pikuri-core 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -4
  3. data/lib/pikuri/agent/chat_transport.rb +128 -24
  4. data/lib/pikuri/agent/configurator.rb +47 -107
  5. data/lib/pikuri/agent/context_window_detector.rb +80 -70
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +46 -30
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +125 -163
  11. data/lib/pikuri/agent/extension.rb +121 -83
  12. data/lib/pikuri/agent/extension_context.rb +120 -0
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +147 -110
  16. data/lib/pikuri/agent/listener/token_log.rb +116 -108
  17. data/lib/pikuri/agent/listener.rb +23 -36
  18. data/lib/pikuri/agent/listener_list.rb +26 -57
  19. data/lib/pikuri/agent/synthesizer.rb +76 -92
  20. data/lib/pikuri/agent.rb +939 -642
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +157 -0
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +70 -15
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +70 -84
  38. data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
  39. data/lib/pikuri/tool/search/engines.rb +249 -93
  40. data/lib/pikuri/tool/search/exa.rb +75 -97
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +121 -26
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -86
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +18 -8
@@ -2,204 +2,166 @@
2
2
 
3
3
  module Pikuri
4
4
  class Agent
5
- # Sealed value-object hierarchy describing a single event in the
6
- # +Agent+'s normalized stream. Every listener consumes these through
7
- # one {Listener::Base#on_event} entry point and pattern-matches on
8
- # the variant.
5
+ # Sealed value-object hierarchy for the +Agent+'s normalized event
6
+ # stream. Listeners consume every variant through one
7
+ # {Listener::Base#on_event} and +case+-match on it.
9
8
  #
10
- # Each variant is a +Data.define+ with the minimal fields it needs;
11
- # value equality and pattern-matching support come for free.
12
- #
13
- # == One stream, no side channels
14
- #
15
- # Provider-reported token usage rides as {Tokens}; the detected
16
- # context-window cap rides as a one-shot {ContextCap} emitted by
17
- # {Agent#initialize}; everything else maps to a turn-or-tool-call
18
- # variant. Listeners override a single +on_event+ method and
19
- # +case+-match on the variant they care about. The per-variant
20
- # docs below name the emission site for each (which {Agent}
21
- # callback wires it and what payload it carries).
9
+ # "Sealed" scopes the *loop-narration* vocabulary: the variants below
10
+ # are the complete set, all emitted by {Agent}, and new chat-loop
11
+ # observability belongs here. Gems define their own *domain* events in
12
+ # their own namespace (e.g. +Pikuri::Tasks::ListChanged+) and emit
13
+ # them via {ExtensionContext#emit_event} onto the same stream;
14
+ # listeners must no-op on variants they don't recognize.
22
15
  module Event
23
- # User's input for a turn (+mid_loop: false+, the default) or a
24
- # host-supplied injection delivered while the loop is running
25
- # (+mid_loop: true+, drained from {Control::Interloper}). The
26
- # flag exists so listeners that treat the +UserTurn+ as a turn
27
- # boundary can distinguish a fresh turn from an in-loop
28
- # injection (additional context for the same turn, not a new
29
- # one). Controls themselves no longer see this event — the
30
- # +Agent+ pokes their +reset!+ / +tick!+ entry points directly
31
- # at the right boundaries.
32
- #
33
- # Emitted in two places: by {Agent#run_loop} at the start of
34
- # each turn (with +mid_loop: false+), and by {Agent}'s
35
- # +after_tool_result+ wiring when a queued
36
- # {Control::Interloper} item drains into the chat history
37
- # (with +mid_loop: true+).
16
+ # User input for a turn (+mid_loop: false+, the default) or a host
17
+ # injection delivered mid-loop (+mid_loop: true+, drained from
18
+ # {Control::Interloper}). The flag lets a listener treating
19
+ # +UserTurn+ as a turn boundary tell a fresh turn from an in-loop
20
+ # injection. Emitted by {Agent#run_loop} at turn start, and when an
21
+ # interloper item drains in after a tool batch.
38
22
  UserTurn = Data.define(:content, :mid_loop) do
39
23
  # @param content [String] user-supplied text
40
- # @param mid_loop [Boolean] +false+ for a turn-starting message
41
- # (the default); +true+ when drained from
42
- # {Control::Interloper}
24
+ # @param mid_loop [Boolean] +true+ when drained from
25
+ # {Control::Interloper}; default +false+
43
26
  def initialize(content:, mid_loop: false)
44
27
  super
45
28
  end
46
29
  end
47
30
 
48
- # A system-role block an {Extension#on_user_message} hook
49
- # injected into the chat log — recalled reference (memory
50
- # context, retrieved snippets) tagged +role: :system+ so the
51
- # model reads it as background, not new user input. Carries
52
- # the injected text verbatim.
31
+ # A reference block injected into the chat log — recalled memory, a
32
+ # host-loaded skill, a path-activation promotion, retrieved snippets.
33
+ # Carries the block *unwrapped*, without the +<system-reminder>+
34
+ # envelope {Agent#append_reference_block} sends it under, so a listener
35
+ # renders what the host wrote rather than pikuri's framing.
53
36
  #
54
- # Emitted by {Agent#dispatch_ext_on_user_message}, once per
55
- # extension that returns a non-empty block, at the same site
56
- # that grows the chat log — so the event stream stays a
57
- # faithful mirror of what the model actually sees. Without it
58
- # an injection is invisible: it never surfaces in the stream,
59
- # only as a secondary echo in the assistant's later reasoning.
60
- # {Listener::Terminal} renders it dim grey with a +⊕+ marker.
37
+ # Named for the *role it plays* (background reference, not new user
38
+ # input), not the wire role it lands under — which is +:user+, because a
39
+ # mid-conversation +:system+ message is unportable.
61
40
  SystemInjected = Data.define(:content)
62
41
 
63
- # Assistant reasoning ("thinking") block, extracted from the
64
- # +thinking.text+ field on a +RubyLLM::Message+ with role
65
- # +:assistant+. Emitted by {Agent}'s +after_message+ wiring;
66
- # empty +thinking.text+ is filtered at the dispatch site so
67
- # listeners never see vacuous events.
42
+ # Assistant reasoning ("thinking") block, from +thinking.text+ on a
43
+ # +RubyLLM::Message+. Emitted by +after_message+; empty text is
44
+ # filtered at the dispatch site.
68
45
  Thinking = Data.define(:content)
69
46
 
70
- # Assistant Markdown content, extracted from a +RubyLLM::Message+
71
- # with role +:assistant+. Emitted by {Agent}'s +after_message+
72
- # wiring; empty +content+ is filtered at the dispatch site
73
- # (pure tool-call turns surface {Tokens} only, no +Assistant+).
47
+ # Assistant Markdown content, from a +RubyLLM::Message+. Emitted by
48
+ # +after_message+; empty content is filtered (pure tool-call turns
49
+ # surface {Tokens} only).
74
50
  Assistant = Data.define(:content)
75
51
 
76
- # Streaming fragment of an assistant reasoning block, pulled
77
- # off a +RubyLLM::Chunk+ during a +Chat#ask+ stream. Emitted
78
- # by the per-chunk streaming block {Agent.streaming_block}
79
- # builds and {Agent#run_loop} / {Synthesizer.run} pass to
80
- # +ask+; empty fragments are filtered at the dispatch site.
81
- #
82
- # Preview-only, not authoritative: the {Thinking} event
83
- # emitted from +after_message+ at the end of the round-trip
84
- # is the final reasoning text. Providers may normalize
85
- # whitespace, and Anthropic thinking blocks include a
86
- # signature that never appears in deltas, so
87
- # +concat(deltas) == final.content+ is not guaranteed.
52
+ # Streaming fragment of a reasoning block, off a +RubyLLM::Chunk+
53
+ # when +streaming:+ is on. Emitted per-chunk by {Agent#run_loop};
54
+ # empty fragments filtered.
88
55
  #
89
- # == Ordering
90
- #
91
- # Per round-trip: all {ThinkingDelta}s (and {AssistantDelta}s)
92
- # for a round arrive before that round's {Thinking} /
93
- # {Assistant} / {Tokens} bookend, because the streaming block
94
- # fires synchronously inside +Chat#ask+'s SSE read and
95
- # +after_message+ fires once the message is complete. Within
96
- # the delta stream itself, ordering between {ThinkingDelta}
97
- # and {AssistantDelta} is provider-dependent (in practice
98
- # non-interleaved on Anthropic and OpenAI reasoning models,
99
- # but pikuri does not enforce it).
56
+ # Preview-only: the {Thinking} from +after_message+ is
57
+ # authoritative. +concat(deltas) == final+ is not guaranteed
58
+ # (whitespace normalization, Anthropic signature blocks). All of a
59
+ # round's deltas arrive before that round's
60
+ # {Thinking}/{Assistant}/{Tokens} bookend; {ThinkingDelta} vs
61
+ # {AssistantDelta} interleave order is provider-dependent.
100
62
  ThinkingDelta = Data.define(:content)
101
63
 
102
- # Streaming fragment of an assistant Markdown content block,
103
- # pulled off a +RubyLLM::Chunk+ during a +Chat#ask+ stream.
104
- # Emitted by the per-chunk streaming block
105
- # {Agent.streaming_block} builds and {Agent#run_loop} /
106
- # {Synthesizer.run} pass to +ask+; empty fragments are
107
- # filtered at the dispatch site.
108
- #
109
- # Preview-only, same semantics as {ThinkingDelta}: the
110
- # {Assistant} event emitted from +after_message+ at the end
111
- # of the round-trip is the authoritative final text;
112
- # listeners that need an exact concat of fragments should
113
- # consume {Assistant} instead. Per-round-trip ordering is
114
- # guaranteed; per-modality ordering within the delta stream
115
- # is best-effort.
64
+ # Streaming fragment of assistant Markdown, off a +RubyLLM::Chunk+
65
+ # when +streaming:+ is on. Same preview-only semantics as
66
+ # {ThinkingDelta}; consume {Assistant} for the authoritative text.
116
67
  AssistantDelta = Data.define(:content)
117
68
 
118
- # A tool invocation the LLM has requested but not yet observed.
119
- # Arguments are the raw hash ruby_llm parsed from the model's
120
- # +tool_calls+ JSON — no validation has run yet. Emitted by
121
- # {Agent}'s +before_tool_call+ wiring.
69
+ # A tool the LLM requested but hasn't observed yet. +arguments+ is
70
+ # the raw hash ruby_llm parsed from the model's +tool_calls+ JSON —
71
+ # unvalidated. Emitted by +before_tool_call+.
122
72
  ToolCall = Data.define(:name, :arguments)
123
73
 
124
- # The observation a tool produced, as returned by {Tool#run}.
125
- # Recoverable failures arrive here as +"Error: ..."+ strings
126
- # (per the pikuri error convention), not as exceptions.
127
- # Emitted by {Agent}'s +after_tool_result+ wiring.
74
+ # The observation a tool produced ({Tool#run}'s return). Recoverable
75
+ # failures arrive as +"Error: ..."+ strings, not exceptions. Emitted
76
+ # by +after_tool_result+.
128
77
  ToolResult = Data.define(:content)
129
78
 
130
- # Provider-reported token usage for a single assistant turn,
131
- # copied off a +RubyLLM::Message+'s +tokens+ block. Emitted by
132
- # {Agent}'s +after_message+ wiring on every assistant turn,
133
- # including pure tool-call turns where {Assistant} would have
134
- # been filtered for empty content (those are exactly the turns
135
- # where context-window growth matters most).
79
+ # Provider-reported token usage for one assistant turn, off a
80
+ # +RubyLLM::Message+'s +tokens+ block. Emitted by +after_message+ on
81
+ # every assistant turn (including pure tool-call turns, where
82
+ # context growth matters most).
136
83
  #
137
- # All counts are +Integer, nil+. +nil+ means the provider did not
138
- # report that field — common with local llama.cpp / Ollama
139
- # servers that leave parts of the OpenAI +usage+ block empty.
140
- # Listeners treat +nil+ as zero.
141
- #
142
- # The fields +input+, +cached+, and +cache_creation+ are
143
- # **exclusive portions of this turn's full prompt** under the
144
- # shape ruby_llm exposes for llama.cpp and Anthropic: they sum
145
- # to the total prompt size processed on this request. OpenAI
146
- # proper nests +cached_tokens+ inside its +prompt_tokens+
147
- # instead — if pikuri ever talks there directly, the sum formula
148
- # needs revisiting.
84
+ # Every count is +Integer, nil+; +nil+ means the provider omitted
85
+ # the field (common on local llama.cpp/Ollama) and is treated as
86
+ # zero. +input+/+cached+/+cache_creation+ are *exclusive* slices of
87
+ # this turn's prompt and sum to its total size (llama.cpp +
88
+ # Anthropic shape; OpenAI nests +cached+ inside +input+, so revisit
89
+ # the sum if pikuri ever targets it directly). +cached+ still counts
90
+ # against the context window.
149
91
  #
150
92
  # - +input+ — newly-processed (uncached) prompt tokens this turn.
151
- # - +output+ — tokens in this single assistant reply.
152
- # - +cached+ — portion of this turn's prompt served from the
153
- # provider's prompt cache. Still counts against the context
154
- # window (caching is a speed/cost optimization, not a context-
155
- # savings mechanism).
156
- # - +cache_creation+ — portion of this turn's prompt written
157
- # into the prompt cache. Anthropic-specific; usually +nil+ on
158
- # OpenAI-compatible local servers.
159
- # - +thinking+ — extended-thinking (Anthropic) or reasoning
160
- # (OpenAI o-series) tokens produced on this turn. +nil+ on
161
- # providers without a reasoning channel.
162
- # - +model_id+ — provider-side model name as reported on the
163
- # response; useful when a process targets multiple models.
164
- #
165
- # == Computing "current context window size"
166
- #
167
- # +input + cached + cache_creation+ is the size of the prompt
168
- # processed on this turn. Add +output+ to get tokens consumed by
169
- # the conversation *through* this turn — this turn's prompt plus
170
- # its reply, both of which the model will re-process on the next
171
- # turn. That's what climbs toward
172
- # +RubyLLM::ContextLengthExceededError+ and is the snapshot
173
- # {Listener::TokenLog#context_window_size} tracks.
93
+ # - +output+ — tokens in this reply.
94
+ # - +cached+ — prompt tokens served from the provider's cache.
95
+ # - +cache_creation+ — prompt tokens written to the cache (Anthropic).
96
+ # - +thinking+ — reasoning tokens (Anthropic thinking / OpenAI
97
+ # o-series); +nil+ without a reasoning channel.
98
+ # - +model_id+ — provider-side model name on the response.
174
99
  Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking, :model_id)
175
100
 
176
- # Model's resolved context-window cap. Emitted once by
177
- # {Agent#initialize} immediately after
178
- # {Agent::ContextWindowDetector} runs. Carries +nil+ when no
179
- # source produced a value (custom local model with no override
180
- # and no reachable llama.cpp +/props+). Listeners that care —
181
- # {Listener::TokenLog} renders +ctx=<used>/<cap>+ when set,
182
- # +ctx=<used>+ when +nil+ — pick the value off this event and
183
- # cache it; non-caring listeners ignore.
101
+ # Model's resolved context-window cap (+nil+ if unknown). Emitted at
102
+ # construction and after each model switch. A later ContextCap
103
+ # overwrites the prior; the running context size is not re-baselined.
184
104
  ContextCap = Data.define(:cap)
185
105
 
186
- # Out-of-band notice that the agent had to take a rescue path.
187
- # Emitted by {Agent#run_loop} when {Control::StepLimit} trips
188
- # and the synthesizer fallback runs; carries the reason string
189
- # the listener should surface. Lets listeners (Terminal, future
190
- # web UI) surface the divergence to the user before the
191
- # synthesizer's own assistant output flows through.
106
+ # Agent switched model mid-conversation. Emitted by {Agent#run_loop}
107
+ # just before the new model's {ContextCap}. Carries both
108
+ # {Agent::ChatTransport}s verbatim and unformatted — each chrome
109
+ # presents them its own way. The new cap rides on the paired
110
+ # {ContextCap}, not here.
111
+ ModelSwitched = Data.define(:from, :to)
112
+
113
+ # Out-of-band notice that a rescue path was taken: emitted by
114
+ # {Agent#run_loop} when {Control::StepLimit} trips and the
115
+ # {Synthesizer} fallback runs. Carries the reason to surface before
116
+ # the synthesizer's own output flows through.
192
117
  FallbackNotice = Data.define(:reason)
193
118
 
194
- # Out-of-band notice that the user cancelled the in-flight turn
195
- # via {Control::Cancellable}. Emitted by {Agent#run_loop} just
196
- # before the +Cancellable::Cancelled+ exception re-raises out of
197
- # the loop, so listeners (Terminal renderer, structured
198
- # recorders) can mark the turn as user-aborted. Unlike
199
- # {FallbackNotice}, no recovery follows — the exception is
200
- # re-raised and the caller is expected to return control to the
201
- # user (typically the REPL prompt).
119
+ # Out-of-band notice that the user cancelled the in-flight turn via
120
+ # {Control::Cancellable}. Emitted by {Agent#run_loop} just before the
121
+ # +Cancelled+ exception re-raises — unlike {FallbackNotice}, no
122
+ # recovery follows; the caller returns control to the user.
202
123
  Cancelled = Data.define
124
+
125
+ # Marks a *domain* event whose successor **replaces** it rather than adding
126
+ # to a log: an indexing bar, a download percentage. A chrome that can
127
+ # rewrite a line does; one that cannot shows only the final state.
128
+ #
129
+ # Progress = Data.define(:title, :done) do
130
+ # include Pikuri::Agent::Event::Transient
131
+ # def to_s = done ? "#{title} — done" : title
132
+ # end
133
+ #
134
+ # The contract is two methods, both of which a +Data.define+ already
135
+ # answers: +#to_s+ is the whole rendering — a listener never inspects the
136
+ # fields, which is what keeps core ignorant of a gem's payload — and
137
+ # +#done+ says the sequence is over and its line may go away.
138
+ module Transient
139
+ end
140
+
141
+ # The conversation was cleared by {Agent#clear_conversation}: history
142
+ # is back to the system prompt, controls are reset, and each
143
+ # extension's {Extension#on_conversation_reset} has run. Emitted
144
+ # once, *before* the extension sweep, so pure-consumer listeners can
145
+ # reset display state; extension-owned state is signalled by the
146
+ # extensions' own domain events, not here. No payload.
147
+ Reset = Data.define
148
+
149
+ # A saved conversation was restored by {Agent#load_history!}. Always
150
+ # follows a {Reset}, which is the part that clears display state — this
151
+ # one says the conversation is *not* empty after all, and carries what a
152
+ # listener needs to stop lying about it.
153
+ #
154
+ # +tokens+ is the last assistant message's usage as recorded when the
155
+ # conversation was exported, so a running context readout can pick up
156
+ # where it left off instead of showing +0+ against 40k of restored
157
+ # history. +nil+ when the history holds no assistant turn, or was
158
+ # exported without usage.
159
+ #
160
+ # @!attribute [r] messages
161
+ # @return [Integer] how many messages were restored
162
+ # @!attribute [r] tokens
163
+ # @return [Tokens, nil] usage of the last restored assistant message
164
+ HistoryLoaded = Data.define(:messages, :tokens)
203
165
  end
204
166
  end
205
167
  end
@@ -3,106 +3,144 @@
3
3
  module Pikuri
4
4
  class Agent
5
5
  # The Extension protocol — how hosts bolt extra capabilities
6
- # (system-prompt snippets, tools, lifecycle hooks) onto an
7
- # {Agent}. Extensions are added via {Configurator#add_extension}
8
- # inside the +Agent.new+ block; the Agent then drives three hooks
9
- # on each — {#configure} during the block, {#bind} once the agent
10
- # is fully constructed, and {#on_user_message} on every user turn
11
- # thereafter.
6
+ # (system-prompt sections, tools, lifecycle hooks) onto an {Agent}.
7
+ # Added via {Configurator#add_extension} inside the +Agent.new+ block;
8
+ # the Agent then drives a fixed hook set: {#configure} during the block
9
+ # (gets the {Configurator}), {#bind} once fully constructed,
10
+ # {#on_user_message} per user turn, {#on_conversation_reset} per
11
+ # {Agent#clear_conversation} (these three get the runtime
12
+ # {ExtensionContext}), and {#system_prompt_snippets} whenever the prompt
13
+ # is (re)assembled (no argument — reads state set in +configure+).
12
14
  #
13
- # Mix this module into an extension class to inherit empty
14
- # default implementations of all three hooks; override the ones
15
- # you need. Extensions that don't +include+ this module still
16
- # work *if they define all three methods themselves* — the Agent
17
- # and Configurator call them by name with no +respond_to?+ guard,
18
- # so a missing one raises. The module exists to make the protocol
19
- # *explicit* and to give "I want to implement just +configure+"
20
- # extensions free no-op +bind+ / +on_user_message+ defaults (and
21
- # any other combination).
15
+ # Mix this module in to inherit no-op defaults for every hook, and
16
+ # override what you need. Not including it also works *if the class
17
+ # defines every hook* — the Agent calls them by name with no
18
+ # +respond_to?+ guard, so a missing one raises.
19
+ #
20
+ # The whole surface fires on the *parent agent only* — sub-agents do not
21
+ # inherit extensions (each persona owns its toolset and prompt verbatim).
22
22
  #
23
23
  # == Example
24
24
  #
25
25
  # class MyExtension
26
26
  # include Pikuri::Agent::Extension
27
- #
28
- # def configure(c)
29
- # c.append_system_prompt("Always be polite.")
30
- # end
31
- #
32
- # # bind not overridden — inherits the empty default
27
+ # def system_prompt_snippets = ["Always be polite."]
28
+ # # configure / bind not overridden — inherit the empty defaults
33
29
  # end
34
30
  #
35
- # See +Pikuri::Mcp::Extension+ and +Pikuri::Skill::Extension+
36
- # (once those land in Steps 2-3 of the gem-split refactor — see
37
- # IDEAS.md §"Extension protocol design") for the canonical
38
- # worked implementations.
31
+ # See +Pikuri::Mcp::Extension+ / +Pikuri::Skill::Extension+ for worked
32
+ # implementations.
39
33
  module Extension
40
34
  # Called immediately by {Configurator#add_extension} during the
41
- # +Agent.new+ block, with the parent agent's {Configurator}.
42
- # Runs exactly once per extension instance, on the parent agent
43
- # only — sub-agents do not re-run +configure+. The default is a
44
- # no-op; override when you need to install *agent-agnostic*
45
- # state. Things you typically do here:
46
- #
47
- # * append snippets to the system prompt via
48
- # {Configurator#append_system_prompt}
49
- # * register tools via {Configurator#add_tool}
50
- # * register listeners via {Configurator#add_listener}
51
- # * register parent-only +on_close+ handlers via
52
- # {Configurator#on_close} (for cleanup of resources the
53
- # extension created in +configure+)
54
- # * read the agent's transport / cancellable / etc. via the
55
- # Configurator's +attr_reader+s
35
+ # +Agent.new+ block, once per instance, with the parent's
36
+ # {Configurator}. Default no-op; override to install *agent-agnostic*
37
+ # state — typically:
38
+ #
39
+ # * tools via {Configurator#add_tool}, listeners via
40
+ # {Configurator#add_listener}
41
+ # * parent-only +on_close+ cleanup via {Configurator#on_close}
42
+ # * read transport / cancellable / etc. off the Configurator
56
43
  #
57
44
  # @param c [Configurator] the parent agent's Configurator
58
45
  # @return [void]
59
46
  def configure(c); end
60
47
 
61
- # Called by {Agent#initialize} after the block returns and the
62
- # chat is fully wired, with the live {Agent} as the argument.
63
- # Fires once per agent the extension was registered to via
64
- # {Configurator#add_extension} — in the typical setup that's
65
- # the parent agent only, since sub-agents do not inherit
66
- # extensions. The default is a no-op; override when you need
67
- # to install state keyed to the live agent object. Things
68
- # you typically do here:
69
- #
70
- # * register dynamic tools via {Agent#internal_add_tool}
71
- # (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
72
- # whose +execute+ closure needs the live agent so
73
- # activations register on the right chat)
74
- # * register +on_close+ handlers via {Agent#on_close}
75
- # * stash an +@agent+ reference if the extension's tools need
76
- # to act on this specific agent later
77
- #
78
- # @param agent [Agent] the live agent, fully wired
48
+ # Called by {Agent#initialize} after the block returns and the chat is
49
+ # fully wired, with the agent's {ExtensionContext}. Default no-op;
50
+ # override to install state keyed to the live agent — typically:
51
+ #
52
+ # * dynamic tools via {ExtensionContext#add_raw_tool}
53
+ # * domain-event wiring via {ExtensionContext#emit_event}
54
+ # * per-agent +on_close+ via {ExtensionContext#on_close}
55
+ # * stash +ctx+ if the extension's tools act on this agent later
56
+ #
57
+ # @param ctx [ExtensionContext] capability facade for the live agent
58
+ # @return [void]
59
+ def bind(ctx); end
60
+
61
+ # Optional per-turn hook fired after a user message is added to the
62
+ # chat. Default no-op returning +nil+; override and return a {String} to
63
+ # have it appended after the user turn as a +<system-reminder>+ reference
64
+ # block (see {Agent#append_reference_block} for the envelope and why it
65
+ # is not a +:system+ message).
66
+ #
67
+ # The Agent only ever *appends* the returned block at the tail — never
68
+ # rewrites or removes an earlier one, which would bust the provider
69
+ # prefix cache for everything after the edit. Stale blocks ride the
70
+ # existing context-window machinery, not a per-turn rewrite.
71
+ #
72
+ # @param ctx [ExtensionContext] the live agent whose turn this is (same
73
+ # instance +bind+ received)
74
+ # @param content [String] the user message (initial or interloper)
75
+ # @return [String, nil] text to inject verbatim as a system-role message
76
+ # after the user message, or +nil+ to inject nothing
77
+ def on_user_message(ctx, content); end
78
+
79
+ # Optional hook fired by {Agent#clear_conversation} (a "/clear").
80
+ # Override to drop *conversation-scoped* state; default no-op, so an
81
+ # extension holding only process/infrastructure state opts out by not
82
+ # defining it.
83
+ #
84
+ # That distinction is the point: reset state that only makes sense
85
+ # within one conversation (+Pikuri::Tasks::Extension+ clears its list,
86
+ # the workspace clears its read-record); leave infrastructure alone
87
+ # (MCP subprocesses, a docker-backed server, the memory recorder's
88
+ # queue — a clear is not a quit).
89
+ #
90
+ # Do *not* touch the system prompt here — that refreshes automatically
91
+ # via {#system_prompt_snippets}, which the clear re-pulls. This hook is
92
+ # only for imperative state the prompt machinery can't express. An
93
+ # extension owning a domain event should emit it here so UI listeners
94
+ # see the reset (Tasks fires +Tasks::ListChanged+ with an empty list),
95
+ # synchronously after the Agent's {Event::Reset}.
96
+ #
97
+ # @param ctx [ExtensionContext] the live agent being cleared (same
98
+ # instance +bind+ received)
79
99
  # @return [void]
80
- def bind(agent); end
100
+ def on_conversation_reset(ctx); end
81
101
 
82
- # Optional per-turn hook fired by the {Agent} after a user-message
83
- # is added to the chat. The
84
- # default is a no-op returning +nil+; override and return {String}
85
- # to emit a `:system` message with that text.
86
- #
87
- # == Append-only, never mutate
88
- #
89
- # The Agent only ever *appends* the returned block at the tail; it never
90
- # rewrites or removes an earlier one. Mutating mid-log would bust the
91
- # provider prefix cache for every message after the edit. Stale blocks
92
- # ride the existing context-window machinery, not a per-turn rewrite.
93
- #
94
- # == Not inherited by sub-agents
95
- #
96
- # Like the rest of the extension surface, this fires on the parent agent
97
- # only — sub-agents do not inherit extensions, so a persona's turns are
98
- # never prefetched or recorded by the parent's memory.
99
- #
100
- # @param agent [Agent] the live agent whose turn this is
101
- # @param content [String] the user message (initial or interloper) about
102
- # to be sent to the model
103
- # @return [String, nil] an optional block of text to be injected verbatim as
104
- # a system-role message (after the user message), or +nil+ to inject nothing
105
- def on_user_message(agent, content); end
102
+ # This extension's system-prompt contributions, as text sections.
103
+ # Default none (+[]+); override to contribute one or more.
104
+ #
105
+ # Pull, not push: the {Agent} assembles its prompt by concatenating the
106
+ # base with what every extension returns here — at construction and
107
+ # again on every {Agent#clear_conversation}. So a section computed from
108
+ # live state (a resident memory persona, +MACHINE.md+ from disk) is
109
+ # *recomputed* on clear and stays current; a static section returns the
110
+ # same constant.
111
+ #
112
+ # No argument: read instance state populated in {#configure} (which runs
113
+ # first). Producing that state (starting servers, probing a model)
114
+ # belongs in +configure+; this only *reads* it, so it stays cheap enough
115
+ # for every clear (memoize an expensive one-shot at its source —
116
+ # +Pikuri::Os::SystemInfo#prompt_section+). Sections are joined by the
117
+ # Agent (blank/nil dropped), in registration order.
118
+ #
119
+ # @return [Array<String>] zero or more prompt sections; +[]+ for none
120
+ def system_prompt_snippets = []
121
+
122
+ # What this extension adds to the lethal-trifecta tree
123
+ # ({Pikuri::Trifecta}): legs the wired agent holds beyond its own tools,
124
+ # and child nodes for any sub-agents it introduces. Default none.
125
+ #
126
+ # Only two kinds of extension owe an answer, and both are things per-tool
127
+ # tagging structurally cannot see: one that introduces **sub-agents** (a
128
+ # whole node, plus the gate on the delegation edge) and one that mounts a
129
+ # **foreign tool surface** whose tools carry no legs of their own.
130
+ #
131
+ # +tools+ is the wired agent's tools plus its sub-agent tools, so an
132
+ # extension resolving a persona's +tool_names+ can select the same
133
+ # objects the sub-agent will actually receive — the resolution and the
134
+ # node it produces stay with whoever owns them.
135
+ #
136
+ # Unlike every other hook here, the Agent calls this **guarded by
137
+ # +respond_to?+**: this protocol permits a class that defines all hooks
138
+ # without including the module, and an unguarded new hook would break
139
+ # those. So a non-including extension may simply not have it.
140
+ #
141
+ # @param tools [Array<Pikuri::Tool>] the agent's tools + sub-agent tools
142
+ # @return [Pikuri::Trifecta::Contribution, nil] +nil+ for none
143
+ def trifecta_contribution(tools) = nil
106
144
  end
107
145
  end
108
146
  end