Class: Pikuri::Skill::PathActivationListener

Inherits:
Agent::Listener::Base
  • Object
show all
Defined in:
lib/pikuri/skill/path_activation_listener.rb

Overview

Watches the tool stream for a file the model is about to touch, and promotes the +paths:+-gated skills that file fires:

listener = PathActivationListener.new(
activation: activation,
root: project_root,
on_promote: ->(skills, path) { ext.announce(skills, path) }
)

Nothing fires twice: Activation#activate answers with the skills that were not already in play, and on_promote is called only when that list is non-empty.

Implementation details

TOUCH_TOOLS is an allowlist of tools, not a convention about argument names. read, write and edit agree on path — but so do grep and glob, whose path is a search root, and one repo-root grep would otherwise activate half the catalog at once. That is the same supplied-vs-walked line the Denylist seam draws for the credential denylist, from the other direction.

Activation reads the model's arguments, at before_tool_call, so a read of a path that does not exist, or a write the human then rejects, promotes anyway. Deliberate: over-firing costs a few hundred bytes, under-firing withholds the skill at the moment it was wanted. It is also what buys the in-turn landing — an enqueue here drains as soon as the running tool batch is answered, with no extra round-trip.

The path is model-supplied, so it is resolved against root lexically — no realpath, or a write to a file that does not exist yet would never match — and anything landing outside the root is dropped rather than matched against the wrong tree.

Constant Summary collapse

TOUCH_TOOLS =

Tools whose path argument names a file the model means to touch.

%w[read write edit].freeze

Instance Method Summary collapse

Constructor Details

#initialize(activation:, root:, on_promote:) ⇒ PathActivationListener

Returns a new instance of PathActivationListener.

Parameters:

  • activation (Activation)

    the conversation's gate state; also the source of the catalog to match against

  • root (Pathname)

    absolute workspace root

  • on_promote (Proc)

    called as (skills, path) with the newly activated skills and the workspace-relative path that fired them



49
50
51
52
53
54
# File 'lib/pikuri/skill/path_activation_listener.rb', line 49

def initialize(activation:, root:, on_promote:)
  super()
  @activation = activation
  @root = root
  @on_promote = on_promote
end

Instance Method Details

#for_sub_agentnil

A sub-agent must never run this: it holds the parent's activation, so a child reading a file would mutate the parent's gate state and enqueue onto the parent's interloper. Sub-agents get no promotions at all — Activation::Sealed is the other half of that.

Returns:

  • (nil)

    always, opting out of the sub-agent listener list



78
# File 'lib/pikuri/skill/path_activation_listener.rb', line 78

def for_sub_agent(**) = nil

#on_event(event) ⇒ void

This method returns an undefined value.

Parameters:

  • event (Pikuri::Agent::Event::ToolCall, Object)

    anything else is ignored



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/pikuri/skill/path_activation_listener.rb', line 59

def on_event(event)
  return unless event.is_a?(Pikuri::Agent::Event::ToolCall)
  return unless TOUCH_TOOLS.include?(event.name)

  path = workspace_relative(event.arguments['path'] || event.arguments[:path])
  return if path.nil?

  fired = @activation.catalog.list.select { |skill| PathMatcher.match?(skill.paths, path) }
  promoted = @activation.activate(fired)
  @on_promote.call(promoted, path) unless promoted.empty?
  nil
end