pikuri-core 0.0.7 → 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 +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  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 +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  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 +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  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 +45 -67
  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 +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  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 +100 -24
  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 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -2,231 +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).
22
- #
23
- # == Sealed for loop narration; gems add domain events
24
- #
25
- # "Sealed" applies to the *loop-narration* vocabulary: the
26
- # variants below are the complete set, all emitted by {Agent},
27
- # and new chat-loop observability belongs here, not in a gem.
28
- # Gems may define their own *domain* events in their own
29
- # namespace (e.g. +Pikuri::Tasks::ListChanged+) and emit them
30
- # via {ExtensionContext#emit_event}; they ride the same stream.
31
- # Listeners must no-op on variants they don't recognize —
32
- # {Listener::Base#on_event}'s default plus +case+-fallthrough
33
- # give that for free.
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.
34
15
  module Event
35
- # User's input for a turn (+mid_loop: false+, the default) or a
36
- # host-supplied injection delivered while the loop is running
37
- # (+mid_loop: true+, drained from {Control::Interloper}). The
38
- # flag exists so listeners that treat the +UserTurn+ as a turn
39
- # boundary can distinguish a fresh turn from an in-loop
40
- # injection (additional context for the same turn, not a new
41
- # one). Controls themselves no longer see this event — the
42
- # +Agent+ pokes their +reset!+ / +tick!+ entry points directly
43
- # at the right boundaries.
44
- #
45
- # Emitted in two places: by {Agent#run_loop} at the start of
46
- # each turn (with +mid_loop: false+), and by {Agent}'s
47
- # +after_tool_result+ wiring when a queued
48
- # {Control::Interloper} item drains into the chat history
49
- # (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.
50
22
  UserTurn = Data.define(:content, :mid_loop) do
51
23
  # @param content [String] user-supplied text
52
- # @param mid_loop [Boolean] +false+ for a turn-starting message
53
- # (the default); +true+ when drained from
54
- # {Control::Interloper}
24
+ # @param mid_loop [Boolean] +true+ when drained from
25
+ # {Control::Interloper}; default +false+
55
26
  def initialize(content:, mid_loop: false)
56
27
  super
57
28
  end
58
29
  end
59
30
 
60
- # A system-role block an {Extension#on_user_message} hook
61
- # injected into the chat log — recalled reference (memory
62
- # context, retrieved snippets) tagged +role: :system+ so the
63
- # model reads it as background, not new user input. Carries
64
- # 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.
65
36
  #
66
- # Emitted by {Agent#dispatch_ext_on_user_message}, once per
67
- # extension that returns a non-empty block, at the same site
68
- # that grows the chat log — so the event stream stays a
69
- # faithful mirror of what the model actually sees. Without it
70
- # an injection is invisible: it never surfaces in the stream,
71
- # only as a secondary echo in the assistant's later reasoning.
72
- # {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.
73
40
  SystemInjected = Data.define(:content)
74
41
 
75
- # Assistant reasoning ("thinking") block, extracted from the
76
- # +thinking.text+ field on a +RubyLLM::Message+ with role
77
- # +:assistant+. Emitted by {Agent}'s +after_message+ wiring;
78
- # empty +thinking.text+ is filtered at the dispatch site so
79
- # 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.
80
45
  Thinking = Data.define(:content)
81
46
 
82
- # Assistant Markdown content, extracted from a +RubyLLM::Message+
83
- # with role +:assistant+. Emitted by {Agent}'s +after_message+
84
- # wiring; empty +content+ is filtered at the dispatch site
85
- # (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).
86
50
  Assistant = Data.define(:content)
87
51
 
88
- # Streaming fragment of an assistant reasoning block, pulled
89
- # off a +RubyLLM::Chunk+ during a streaming completion.
90
- # Emitted by the per-chunk streaming block {Agent#run_loop}
91
- # passes to +Chat#complete+ when the agent's +streaming:+
92
- # flag is on; empty fragments are filtered at the dispatch
93
- # site.
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.
94
55
  #
95
- # Preview-only, not authoritative: the {Thinking} event
96
- # emitted from +after_message+ at the end of the round-trip
97
- # is the final reasoning text. Providers may normalize
98
- # whitespace, and Anthropic thinking blocks include a
99
- # signature that never appears in deltas, so
100
- # +concat(deltas) == final.content+ is not guaranteed.
101
- #
102
- # == Ordering
103
- #
104
- # Per round-trip: all {ThinkingDelta}s (and {AssistantDelta}s)
105
- # for a round arrive before that round's {Thinking} /
106
- # {Assistant} / {Tokens} bookend, because the streaming block
107
- # fires synchronously inside +Chat#ask+'s SSE read and
108
- # +after_message+ fires once the message is complete. Within
109
- # the delta stream itself, ordering between {ThinkingDelta}
110
- # and {AssistantDelta} is provider-dependent (in practice
111
- # non-interleaved on Anthropic and OpenAI reasoning models,
112
- # 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.
113
62
  ThinkingDelta = Data.define(:content)
114
63
 
115
- # Streaming fragment of an assistant Markdown content block,
116
- # pulled off a +RubyLLM::Chunk+ during a streaming
117
- # completion. Emitted by the per-chunk streaming block
118
- # {Agent#run_loop} passes to +Chat#complete+ when the
119
- # agent's +streaming:+ flag is on; empty fragments are
120
- # filtered at the dispatch site.
121
- #
122
- # Preview-only, same semantics as {ThinkingDelta}: the
123
- # {Assistant} event emitted from +after_message+ at the end
124
- # of the round-trip is the authoritative final text;
125
- # listeners that need an exact concat of fragments should
126
- # consume {Assistant} instead. Per-round-trip ordering is
127
- # guaranteed; per-modality ordering within the delta stream
128
- # 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.
129
67
  AssistantDelta = Data.define(:content)
130
68
 
131
- # A tool invocation the LLM has requested but not yet observed.
132
- # Arguments are the raw hash ruby_llm parsed from the model's
133
- # +tool_calls+ JSON — no validation has run yet. Emitted by
134
- # {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+.
135
72
  ToolCall = Data.define(:name, :arguments)
136
73
 
137
- # The observation a tool produced, as returned by {Tool#run}.
138
- # Recoverable failures arrive here as +"Error: ..."+ strings
139
- # (per the pikuri error convention), not as exceptions.
140
- # 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+.
141
77
  ToolResult = Data.define(:content)
142
78
 
143
- # Provider-reported token usage for a single assistant turn,
144
- # copied off a +RubyLLM::Message+'s +tokens+ block. Emitted by
145
- # {Agent}'s +after_message+ wiring on every assistant turn,
146
- # including pure tool-call turns where {Assistant} would have
147
- # been filtered for empty content (those are exactly the turns
148
- # where context-window growth matters most).
149
- #
150
- # All counts are +Integer, nil+. +nil+ means the provider did not
151
- # report that field — common with local llama.cpp / Ollama
152
- # servers that leave parts of the OpenAI +usage+ block empty.
153
- # Listeners treat +nil+ as zero.
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).
154
83
  #
155
- # The fields +input+, +cached+, and +cache_creation+ are
156
- # **exclusive portions of this turn's full prompt** under the
157
- # shape ruby_llm exposes for llama.cpp and Anthropic: they sum
158
- # to the total prompt size processed on this request. OpenAI
159
- # proper nests +cached_tokens+ inside its +prompt_tokens+
160
- # instead — if pikuri ever talks there directly, the sum formula
161
- # 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.
162
91
  #
163
92
  # - +input+ — newly-processed (uncached) prompt tokens this turn.
164
- # - +output+ — tokens in this single assistant reply.
165
- # - +cached+ — portion of this turn's prompt served from the
166
- # provider's prompt cache. Still counts against the context
167
- # window (caching is a speed/cost optimization, not a context-
168
- # savings mechanism).
169
- # - +cache_creation+ — portion of this turn's prompt written
170
- # into the prompt cache. Anthropic-specific; usually +nil+ on
171
- # OpenAI-compatible local servers.
172
- # - +thinking+ — extended-thinking (Anthropic) or reasoning
173
- # (OpenAI o-series) tokens produced on this turn. +nil+ on
174
- # providers without a reasoning channel.
175
- # - +model_id+ — provider-side model name as reported on the
176
- # response; useful when a process targets multiple models.
177
- #
178
- # == Computing "current context window size"
179
- #
180
- # +input + cached + cache_creation+ is the size of the prompt
181
- # processed on this turn. Add +output+ to get tokens consumed by
182
- # the conversation *through* this turn — this turn's prompt plus
183
- # its reply, both of which the model will re-process on the next
184
- # turn. That's what climbs toward
185
- # +RubyLLM::ContextLengthExceededError+ and is the snapshot
186
- # {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.
187
99
  Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking, :model_id)
188
100
 
189
- # Model's resolved context-window cap. Emitted at construction
190
- # by {Agent#initialize}, and again after every model switch
191
- # (see {Agent#run_loop}'s +transport:+) since the cap is a
192
- # property of the model. Carries +nil+ when no source produced
193
- # a value (a non-llama server with no explicit cap). Listeners
194
- # that care — {Listener::TokenLog} renders +ctx=<used>/<cap>+
195
- # when set, +ctx=<used>+ when +nil+ — pick the value off this
196
- # event and cache it; non-caring listeners ignore. A second
197
- # ContextCap simply overwrites the first; the conversation is
198
- # not re-baselined (a switch keeps the running context size).
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.
199
104
  ContextCap = Data.define(:cap)
200
105
 
201
- # The agent switched to a different model mid-conversation,
202
- # emitted by {Agent#run_loop} (via +apply_transport!+) just
203
- # before the matching {ContextCap} for the new model. Carries
204
- # the old and new {Agent::ChatTransport}s verbatim — unformatted
205
- # by design, so each chrome presents them its own way (a TUI
206
- # adds ANSI off +.model+, a web client adds CSS). The cap rides
207
- # on the paired {ContextCap}, not here, so {Listener::TokenLog}
208
- # needs no awareness of this event (its existing {ContextCap}
209
- # arm picks up the new cap); a renderer wanting "switched to X
210
- # (128k)" on one line correlates the two.
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.
211
111
  ModelSwitched = Data.define(:from, :to)
212
112
 
213
- # Out-of-band notice that the agent had to take a rescue path.
214
- # Emitted by {Agent#run_loop} when {Control::StepLimit} trips
215
- # and the synthesizer fallback runs; carries the reason string
216
- # the listener should surface. Lets listeners (Terminal, future
217
- # web UI) surface the divergence to the user before the
218
- # synthesizer's own assistant output flows through.
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.
219
117
  FallbackNotice = Data.define(:reason)
220
118
 
221
- # Out-of-band notice that the user cancelled the in-flight turn
222
- # via {Control::Cancellable}. Emitted by {Agent#run_loop} just
223
- # before the +Cancellable::Cancelled+ exception re-raises out of
224
- # the loop, so listeners (Terminal renderer, structured
225
- # recorders) can mark the turn as user-aborted. Unlike
226
- # {FallbackNotice}, no recovery follows — the exception is
227
- # re-raised and the caller is expected to return control to the
228
- # 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.
229
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)
230
165
  end
231
166
  end
232
167
  end
@@ -3,120 +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
- # Each phase receives that phase's context object: +configure+
14
- # gets the build-time {Configurator}; +bind+ and
15
- # +on_user_message+ get the runtime {ExtensionContext} — the
16
- # capability facade for everything that acts on the live agent
17
- # (domain-event emission, raw tool registration, sub-agent
18
- # listener derivation), with the agent itself readable via
19
- # {ExtensionContext#agent}.
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.
20
19
  #
21
- # Mix this module into an extension class to inherit empty
22
- # default implementations of all three hooks; override the ones
23
- # you need. Extensions that don't +include+ this module still
24
- # work *if they define all three methods themselves* — the Agent
25
- # and Configurator call them by name with no +respond_to?+ guard,
26
- # so a missing one raises. The module exists to make the protocol
27
- # *explicit* and to give "I want to implement just +configure+"
28
- # extensions free no-op +bind+ / +on_user_message+ defaults (and
29
- # any other combination).
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).
30
22
  #
31
23
  # == Example
32
24
  #
33
25
  # class MyExtension
34
26
  # include Pikuri::Agent::Extension
35
- #
36
- # def configure(c)
37
- # c.append_system_prompt("Always be polite.")
38
- # end
39
- #
40
- # # bind not overridden — inherits the empty default
27
+ # def system_prompt_snippets = ["Always be polite."]
28
+ # # configure / bind not overridden — inherit the empty defaults
41
29
  # end
42
30
  #
43
- # See +Pikuri::Mcp::Extension+ and +Pikuri::Skill::Extension+
44
- # (once those land in Steps 2-3 of the gem-split refactor — see
45
- # IDEAS.md §"Extension protocol design") for the canonical
46
- # worked implementations.
31
+ # See +Pikuri::Mcp::Extension+ / +Pikuri::Skill::Extension+ for worked
32
+ # implementations.
47
33
  module Extension
48
34
  # Called immediately by {Configurator#add_extension} during the
49
- # +Agent.new+ block, with the parent agent's {Configurator}.
50
- # Runs exactly once per extension instance, on the parent agent
51
- # only — sub-agents do not re-run +configure+. The default is a
52
- # no-op; override when you need to install *agent-agnostic*
53
- # state. Things you typically do here:
54
- #
55
- # * append snippets to the system prompt via
56
- # {Configurator#append_system_prompt}
57
- # * register tools via {Configurator#add_tool}
58
- # * register listeners via {Configurator#add_listener}
59
- # * register parent-only +on_close+ handlers via
60
- # {Configurator#on_close} (for cleanup of resources the
61
- # extension created in +configure+)
62
- # * read the agent's transport / cancellable / etc. via the
63
- # 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
64
43
  #
65
44
  # @param c [Configurator] the parent agent's Configurator
66
45
  # @return [void]
67
46
  def configure(c); end
68
47
 
69
- # Called by {Agent#initialize} after the block returns and the
70
- # chat is fully wired, with the agent's {ExtensionContext} as
71
- # the argument. Fires once per agent the extension was
72
- # registered to via {Configurator#add_extension} — in the
73
- # typical setup that's the parent agent only, since sub-agents
74
- # do not inherit extensions. The default is a no-op; override
75
- # when you need to install state keyed to the live agent.
76
- # Things you typically do here:
77
- #
78
- # * register dynamic tools via {ExtensionContext#add_raw_tool}
79
- # (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
80
- # whose +execute+ closure needs the context so activations
81
- # register on the right chat)
82
- # * wire domain-event emission via
83
- # {ExtensionContext#emit_event} (e.g. +Pikuri::Tasks::Extension+
84
- # arms its list's +on_change+ here)
85
- # * register per-agent +on_close+ handlers via
86
- # {ExtensionContext#on_close}
87
- # * stash the +ctx+ if the extension's tools need to act on
88
- # this specific agent later
89
- #
90
- # @param ctx [ExtensionContext] capability facade for the
91
- # live, fully wired agent
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
92
58
  # @return [void]
93
59
  def bind(ctx); end
94
60
 
95
- # Optional per-turn hook fired by the {Agent} after a user-message
96
- # is added to the chat. The
97
- # default is a no-op returning +nil+; override and return {String}
98
- # to emit a `:system` message with that text.
99
- #
100
- # == Append-only, never mutate
101
- #
102
- # The Agent only ever *appends* the returned block at the tail; it never
103
- # rewrites or removes an earlier one. Mutating mid-log would bust the
104
- # provider prefix cache for every message after the edit. Stale blocks
105
- # ride the existing context-window machinery, not a per-turn rewrite.
106
- #
107
- # == Not inherited by sub-agents
108
- #
109
- # Like the rest of the extension surface, this fires on the parent agent
110
- # only — sub-agents do not inherit extensions, so a persona's turns are
111
- # never prefetched or recorded by the parent's memory.
112
- #
113
- # @param ctx [ExtensionContext] capability facade for the live
114
- # agent whose turn this is — same instance +bind+ received
115
- # @param content [String] the user message (initial or interloper) about
116
- # to be sent to the model
117
- # @return [String, nil] an optional block of text to be injected verbatim as
118
- # a system-role message (after the user message), or +nil+ to inject nothing
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
119
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)
99
+ # @return [void]
100
+ def on_conversation_reset(ctx); end
101
+
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
120
144
  end
121
145
  end
122
146
  end