pikuri-core 0.0.5 → 0.0.7

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -3
  3. data/lib/pikuri/agent/chat_transport.rb +135 -11
  4. data/lib/pikuri/agent/configurator.rb +4 -4
  5. data/lib/pikuri/agent/context_window_detector.rb +103 -52
  6. data/lib/pikuri/agent/control/step_limit.rb +39 -7
  7. data/lib/pikuri/agent/event.rb +43 -16
  8. data/lib/pikuri/agent/extension.rb +31 -17
  9. data/lib/pikuri/agent/extension_context.rb +147 -0
  10. data/lib/pikuri/agent/listener/terminal.rb +30 -37
  11. data/lib/pikuri/agent/listener/token_log.rb +60 -13
  12. data/lib/pikuri/agent/listener.rb +12 -5
  13. data/lib/pikuri/agent/listener_list.rb +7 -17
  14. data/lib/pikuri/agent/synthesizer.rb +93 -67
  15. data/lib/pikuri/agent.rb +358 -403
  16. data/lib/pikuri/extractor/html.rb +303 -0
  17. data/lib/pikuri/extractor/passthrough.rb +64 -0
  18. data/lib/pikuri/extractor.rb +314 -0
  19. data/lib/pikuri/file_type.rb +74 -266
  20. data/lib/pikuri/sanitizer.rb +179 -0
  21. data/lib/pikuri/subprocess.rb +73 -2
  22. data/lib/pikuri/tool/calculator.rb +213 -41
  23. data/lib/pikuri/tool/fetch.rb +10 -9
  24. data/lib/pikuri/tool/parameters.rb +65 -2
  25. data/lib/pikuri/tool/scraper.rb +186 -0
  26. data/lib/pikuri/tool/search/brave.rb +32 -18
  27. data/lib/pikuri/tool/search/duckduckgo.rb +18 -7
  28. data/lib/pikuri/tool/search/engines.rb +72 -49
  29. data/lib/pikuri/tool/search/exa.rb +34 -22
  30. data/lib/pikuri/tool/web_scrape.rb +5 -5
  31. data/lib/pikuri/tool/web_search.rb +45 -26
  32. data/lib/pikuri/version.rb +1 -1
  33. data/lib/pikuri-core.rb +11 -10
  34. metadata +9 -66
  35. data/lib/pikuri/tool/scraper/fetch_error.rb +0 -16
  36. data/lib/pikuri/tool/scraper/html.rb +0 -285
  37. data/lib/pikuri/tool/scraper/pdf.rb +0 -54
  38. data/lib/pikuri/tool/scraper/simple.rb +0 -183
@@ -10,6 +10,14 @@ module Pikuri
10
10
  # is fully constructed, and {#on_user_message} on every user turn
11
11
  # thereafter.
12
12
  #
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}.
20
+ #
13
21
  # Mix this module into an extension class to inherit empty
14
22
  # default implementations of all three hooks; override the ones
15
23
  # you need. Extensions that don't +include+ this module still
@@ -59,25 +67,30 @@ module Pikuri
59
67
  def configure(c); end
60
68
 
61
69
  # 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:
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:
69
77
  #
70
- # * register dynamic tools via {Agent#internal_add_tool}
78
+ # * register dynamic tools via {ExtensionContext#add_raw_tool}
71
79
  # (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
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
77
89
  #
78
- # @param agent [Agent] the live agent, fully wired
90
+ # @param ctx [ExtensionContext] capability facade for the
91
+ # live, fully wired agent
79
92
  # @return [void]
80
- def bind(agent); end
93
+ def bind(ctx); end
81
94
 
82
95
  # Optional per-turn hook fired by the {Agent} after a user-message
83
96
  # is added to the chat. The
@@ -97,12 +110,13 @@ module Pikuri
97
110
  # only — sub-agents do not inherit extensions, so a persona's turns are
98
111
  # never prefetched or recorded by the parent's memory.
99
112
  #
100
- # @param agent [Agent] the live agent whose turn this is
113
+ # @param ctx [ExtensionContext] capability facade for the live
114
+ # agent whose turn this is — same instance +bind+ received
101
115
  # @param content [String] the user message (initial or interloper) about
102
116
  # to be sent to the model
103
117
  # @return [String, nil] an optional block of text to be injected verbatim as
104
118
  # a system-role message (after the user message), or +nil+ to inject nothing
105
- def on_user_message(agent, content); end
119
+ def on_user_message(ctx, content); end
106
120
  end
107
121
  end
108
122
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # Capability facade handed to {Extension#bind} and
6
+ # {Extension#on_user_message} — the runtime counterpart of
7
+ # {Configurator}. Where the Configurator collects *build-time*
8
+ # declarations (tools, listeners, prompt snippets), this object
9
+ # grants the *runtime* capabilities an extension needs once the
10
+ # agent is fully wired: emitting domain events onto the listener
11
+ # stream, registering raw per-agent tools, and deriving sub-agent
12
+ # listener lists.
13
+ #
14
+ # == Why a handed object, not a getter on Agent
15
+ #
16
+ # The {Agent} deliberately exposes NO public path to these
17
+ # capabilities — no +listeners+ reader, no +chat+ reader, no
18
+ # emit method. Holding an agent reference grants read access to
19
+ # its configuration ({Agent#tools}, {Agent#transport}, ...) and
20
+ # nothing more; the write capabilities live here, and the only
21
+ # way to obtain this object is to be an {Extension} receiving a
22
+ # +bind+ / +on_user_message+ call (or to be handed it onward by
23
+ # one, e.g. {Pikuri::SubAgent::SubAgentTool} and
24
+ # {Pikuri::Mcp::Servers::Connect} both capture the context their
25
+ # extension's +bind+ received). Capabilities flow by explicit
26
+ # handoff, never by fetching from a globally reachable object.
27
+ #
28
+ # The usual Ruby caveat applies: nothing here is mechanically
29
+ # sealed (+instance_variable_get+ exists). The boundary is the
30
+ # API contract — same as every seam in CLAUDE.md.
31
+ #
32
+ # == Boundary rule
33
+ #
34
+ # Operations that *act on* the live agent's wiring live here.
35
+ # Passive readers of constructor-given config (+transport+,
36
+ # +id+, +streaming+, +tools+, +cancellable+, ...) stay on
37
+ # {Agent}, reachable via {#agent}. Don't move readers in; don't
38
+ # add capabilities to Agent.
39
+ #
40
+ # == Audit
41
+ #
42
+ # One context per agent, constructed by {Agent#initialize} right
43
+ # before the extension +bind+ sweep. {ListenerList#emit} has
44
+ # exactly two callers: {Agent} (loop narration) and this class
45
+ # (extension domain events). The roster of capability users is
46
+ # +grep -rn 'emit_event\|add_raw_tool\|sub_agent_listeners'
47
+ # pikuri-*/lib/+.
48
+ class ExtensionContext
49
+ # @param agent [Agent] the live, fully wired agent.
50
+ # @param chat [RubyLLM::Chat] the agent's underlying chat —
51
+ # target of {#add_raw_tool}.
52
+ # @param listeners [ListenerList] the agent's listener list —
53
+ # target of {#emit_event} / {#sub_agent_listeners}.
54
+ # @param on_close_sink [Array<Proc>] the agent's live
55
+ # +@on_close_handlers+ array, which {#on_close} appends to —
56
+ # same live-sink shape as {Configurator}'s +on_close_sink:+.
57
+ def initialize(agent:, chat:, listeners:, on_close_sink:)
58
+ @agent = agent
59
+ @chat = chat
60
+ @listeners = listeners
61
+ @on_close_handlers = on_close_sink
62
+ end
63
+
64
+ # @return [Agent] the live agent, for read access to its
65
+ # configuration (tools, transport, id, streaming, ...).
66
+ attr_reader :agent
67
+
68
+ # Emit a domain event onto the agent's listener stream.
69
+ #
70
+ # Core {Event} variants narrate the chat loop and are emitted
71
+ # by {Agent} alone; gems define their own variants (e.g.
72
+ # +Pikuri::Tasks::ListChanged+) in their own namespace and
73
+ # emit them here. Listeners must no-op on variants they don't
74
+ # recognize — {Listener::Base#on_event}'s default and
75
+ # +case+-fallthrough give that for free.
76
+ #
77
+ # Called on the agent's thread (typically from inside a tool's
78
+ # +execute+, where the event lands between {Event::ToolCall}
79
+ # and {Event::ToolResult} in the stream). Listeners doing
80
+ # cross-thread handoff snapshot/serialize inside +on_event+.
81
+ #
82
+ # @param event [Object] an immutable event value (by
83
+ # convention a +Data+ instance).
84
+ # @return [void]
85
+ def emit_event(event)
86
+ @listeners.emit(event)
87
+ nil
88
+ end
89
+
90
+ # Register a raw +RubyLLM::Tool+ subclass on the agent's
91
+ # underlying chat, bypassing the {Pikuri::Tool}
92
+ # strict-validation seam — hence "raw": native pikuri tools
93
+ # should go through {Pikuri::Tool} (registered at build time
94
+ # via {Configurator#add_tool}) so they get {Tool::Parameters}
95
+ # validation and the LLM-actionable +"Error: ..."+ contract.
96
+ # Intended callers: {Pikuri::Mcp::Servers} (MCP tools
97
+ # deliberately bypass — see IDEAS.md §"MCP tools bypass
98
+ # +Pikuri::Tool+ entirely") and
99
+ # {Pikuri::SubAgent::Extension} (the +agent+ tool must
100
+ # register after the parent's tool list is final).
101
+ #
102
+ # The added tool does NOT enter {Agent#tools}, only the chat's
103
+ # tool list. Sub-agents therefore cannot snapshot it — which
104
+ # is the whole point: activation is strictly per-agent, see
105
+ # IDEAS.md §"Per-agent activation, no propagation".
106
+ #
107
+ # @param ruby_llm_tool [Class] subclass of +RubyLLM::Tool+
108
+ # @return [void]
109
+ def add_raw_tool(ruby_llm_tool)
110
+ @chat.with_tool(ruby_llm_tool)
111
+ nil
112
+ end
113
+
114
+ # Derive a listener list for a spawned sub-agent via
115
+ # {ListenerList#for_sub_agent}. Sole intended caller:
116
+ # {Pikuri::SubAgent::SubAgentTool}, once per spawn.
117
+ #
118
+ # The derived list deliberately aliases the parent's listener
119
+ # *instances* where a listener opts to share by reference
120
+ # (stateful sinks like {Listener::InMemoryEventList}) — see
121
+ # {ListenerList#for_sub_agent} for the per-listener semantics.
122
+ #
123
+ # @param params [Hash{Symbol => Object}] forwarded to each
124
+ # listener's +for_sub_agent+ hook (currently +id:+).
125
+ # @return [ListenerList]
126
+ def sub_agent_listeners(**params)
127
+ @listeners.for_sub_agent(**params)
128
+ end
129
+
130
+ # Register a handler called by {Agent#close}. Symmetric to
131
+ # {Configurator#on_close} — same LIFO + per-handler-rescue +
132
+ # idempotent semantics — but available post-construction, so
133
+ # an {Extension}'s +bind+ can install per-agent cleanup keyed
134
+ # to this specific agent (e.g. +Pikuri::Memory::Extension+
135
+ # arms its recorder's bounded flush here).
136
+ #
137
+ # @yield called with no arguments at close time
138
+ # @return [void]
139
+ def on_close(&blk)
140
+ raise ArgumentError, 'on_close requires a block' unless block_given?
141
+
142
+ @on_close_handlers << blk
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
@@ -1,21 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rainbow'
4
- require 'tty-markdown'
5
4
 
6
5
  module Pikuri
7
6
  class Agent
8
7
  module Listener
9
8
  # Terminal renderer for the normalized event stream: dim grey
10
- # reasoning, Markdown-rendered assistant content, cyan tool-
11
- # call and tool-result lines, yellow fallback notice, red
12
- # cancelled notice. An {Event::SystemInjected} block (recalled
9
+ # reasoning, assistant content printed raw (Markdown as-is),
10
+ # cyan tool-call and tool-result lines, yellow fallback
11
+ # notice, red cancelled notice, magenta model-switch notice.
12
+ # An {Event::SystemInjected} block (recalled
13
13
  # memory / context an extension injected) renders dim grey
14
14
  # with a +⊕+ marker. {Event::UserTurn} is intentionally silent
15
15
  # (the terminal user just typed the message, so re-rendering
16
16
  # it adds nothing); {Event::Tokens} and {Event::ContextCap}
17
17
  # are silent too (their consumer is {TokenLog}).
18
18
  #
19
+ # Assistant Markdown deliberately prints raw, with no
20
+ # Markdown-to-ANSI rendering. A renderer (+tty-markdown+)
21
+ # used to sit on the non-streaming path; it was dropped:
22
+ # rendering can never apply to the streaming path anyway
23
+ # (half-finished Markdown — broken code fences, half-built
24
+ # tables — doesn't render), the gem hadn't shipped a release
25
+ # since 2023 (its known ANSI-in-table crashes forced a
26
+ # rescue-and-degrade carve-out here), and it pulled seven
27
+ # transitive gems into the audit surface. Raw Markdown is
28
+ # perfectly readable in a terminal; proper rendering belongs
29
+ # to a richer host (the planned pikuri-tui).
30
+ #
19
31
  # Optionally prepends a fixed number of leading spaces to
20
32
  # every rendered line via the +padding:+ kwarg. Sub-agents
21
33
  # get a fresh padded instance through {#for_sub_agent}
@@ -30,11 +42,8 @@ module Pikuri
30
42
  # - {Event::ThinkingDelta} fragments print live in the same
31
43
  # dim grey as the non-streaming {Event::Thinking}, with no
32
44
  # trailing newline so the next fragment continues the line.
33
- # - {Event::AssistantDelta} fragments print live, *raw* — no
34
- # Markdown render. tty-markdown can't render half-finished
35
- # Markdown (broken code blocks, half-rendered tables), so
36
- # the live stream gives up formatting in exchange for
37
- # liveness.
45
+ # - {Event::AssistantDelta} fragments print live the same
46
+ # way, uncolored.
38
47
  # - {Event::Thinking} and {Event::Assistant} bookends print
39
48
  # a single blank line as a stream terminator, not their
40
49
  # content (the content already landed via the deltas). The
@@ -45,15 +54,6 @@ module Pikuri
45
54
  # the deltas are silently ignored and the bookend events
46
55
  # render the full text the way they always have.
47
56
  class Terminal < Base
48
- # Subsystem logger; set its level with +PIKURI_LOG_TERMINAL+
49
- # or the global +PIKURI_LOG+. Used for the narrow rescue
50
- # around third-party rendering (+tty-markdown+ choking on
51
- # assistant output) — see the CLAUDE.md "secondary to the
52
- # loop" carve-out.
53
- #
54
- # @return [Logger]
55
- LOGGER = Pikuri.logger_for('Terminal')
56
-
57
57
  # Cap, in characters, applied to tool-result content
58
58
  # rendered to the terminal. Anything longer is truncated
59
59
  # with a marker that reports the original byte size so the
@@ -119,7 +119,7 @@ module Pikuri
119
119
  if @streaming
120
120
  terminate_stream
121
121
  else
122
- println(indent(render_markdown(content)))
122
+ println(indent(content))
123
123
  end
124
124
  in Event::ThinkingDelta(content:)
125
125
  stream_fragment(Rainbow(content).color(85, 85, 85)) if @streaming
@@ -132,6 +132,8 @@ module Pikuri
132
132
  println(indent(Rainbow("→ #{name}(#{args})").cyan))
133
133
  in Event::ToolResult(content:)
134
134
  println(indent(Rainbow("= #{truncate_tool_result(content)}").cyan))
135
+ in Event::ModelSwitched(from:, to:)
136
+ println(indent(Rainbow("⇄ model: #{from.model} → #{to.model}").magenta))
135
137
  in Event::FallbackNotice(reason:)
136
138
  println(indent(Rainbow("! #{reason}").yellow))
137
139
  in Event::Cancelled
@@ -228,23 +230,6 @@ module Pikuri
228
230
  text.to_s.each_line.map { |line| prefix + line }.join
229
231
  end
230
232
 
231
- # Render assistant Markdown for the terminal, degrading to
232
- # the raw string when the renderer raises. tty-markdown /
233
- # strings have known bugs around ANSI inside tables (e.g.
234
- # +Strings::Wrap.insert_ansi+ raising +IndexError+); we'd
235
- # rather show ugly Markdown than abort an in-flight
236
- # conversation.
237
- #
238
- # @param content [String] assistant Markdown
239
- # @return [String] rendered ANSI text, or +content+
240
- # unchanged on render failure
241
- def render_markdown(content)
242
- TTY::Markdown.parse(content)
243
- rescue StandardError => e
244
- LOGGER.warn("TTY::Markdown render failed (#{e.class}: #{e.message}); falling back to raw text")
245
- content
246
- end
247
-
248
233
  # Flatten whitespace and cap to {MAX_TOOL_RESULT_CHARS}. The
249
234
  # cap keeps multi-screen dumps (rendered HTML, PDF text)
250
235
  # from drowning the terminal stream; the byte-count suffix
@@ -252,12 +237,20 @@ module Pikuri
252
237
  # exactly this" from "tool returned much more, you're
253
238
  # seeing a slice."
254
239
  #
240
+ # Whitespace is flattened *first* (collapsing real tabs, CRs and
241
+ # newlines into single spaces — fine here, since this passive
242
+ # echo is a status line, not an approval artifact), then the
243
+ # result runs through {Pikuri::Sanitizer} so a tool observation
244
+ # can't smuggle an ESC or other control byte into this cyan line.
245
+ # The sanitizer's warnings are dropped — they belong at a
246
+ # confirmation prompt, not the stream narration.
247
+ #
255
248
  # @param content [String] tool observation
256
249
  # @return [String] single-line display form, possibly
257
250
  # truncated
258
251
  def truncate_tool_result(content)
259
252
  original_bytes = content.to_s.bytesize
260
- flattened = content.to_s.gsub(/\s+/, ' ').strip
253
+ flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text
261
254
  return flattened if flattened.length <= MAX_TOOL_RESULT_CHARS
262
255
 
263
256
  "#{flattened[0, MAX_TOOL_RESULT_CHARS]}… (#{original_bytes} bytes total)"
@@ -6,8 +6,12 @@ module Pikuri
6
6
  # Logs the conversation's context-window consumption per
7
7
  # assistant turn via +Pikuri.logger_for('Tokens')+. Consumes
8
8
  # {Event::Tokens} (one log line per emission) and
9
- # {Event::ContextCap} (one-shot cap, picked off and cached);
10
- # every other event variant is a no-op.
9
+ # {Event::ContextCap} (the cap, picked off and cached
10
+ # refreshed if a later {Event::ContextCap} arrives after a
11
+ # model switch); every other event variant is a no-op. A
12
+ # switch does not reset the running size or message count: the
13
+ # next {Event::Tokens} self-corrects the headline to the new
14
+ # model's count.
11
15
  #
12
16
  # Existence rationale: catch context-window growth before the
13
17
  # provider raises +RubyLLM::ContextLengthExceededError+.
@@ -96,12 +100,19 @@ module Pikuri
96
100
  # listener you get a fresh instance via {#for_sub_agent}.
97
101
  attr_reader :id
98
102
 
99
- # The most recent log line, in the exact format written to
100
- # {LOGGER} (including any +[<id>] + prefix). Empty until
101
- # the first {Event::Tokens} has been processed. Hosts that
103
+ # The most recent status line, *without* the +[<id>] +
104
+ # prefix the prefix is {LOGGER}'s concern (the log
105
+ # stream interleaves agents, so its lines must carry the
106
+ # id inline); consumers of this reader get the id
107
+ # separately ({#id} here, the first callable parameter on
108
+ # {#on_status_line}) and decide the presentation
109
+ # themselves. Empty until the first {Event::Tokens} has
110
+ # been processed. Hosts that
102
111
  # want to surface the current context-window snapshot in
103
112
  # their own UI (e.g. a TUI status footer) read this
104
- # instead of re-implementing the formatting.
113
+ # instead of re-implementing the formatting. Hosts that
114
+ # want to be *pushed* each new line instead of polling
115
+ # set {#on_status_line}.
105
116
  #
106
117
  # Thread safety: a single instance-variable read of a
107
118
  # +String+ — safe to read from any thread; readers may
@@ -112,6 +123,34 @@ module Pikuri
112
123
  # @return [String]
113
124
  attr_reader :status_line
114
125
 
126
+ # Optional status-line observer, +nil+ by default. When
127
+ # set to a callable, it is invoked with the owning
128
+ # agent's {#id} and the freshly formatted {#status_line}
129
+ # after every {Event::Tokens} — the push counterpart to
130
+ # polling {#status_line}, for hosts that stream the line
131
+ # onward (a Sinatra SSE endpoint pushing it to the
132
+ # browser, a websocket, a TUI redraw trigger). Fires on
133
+ # whatever thread runs the owning agent's loop, so the
134
+ # callable must hand off to its sink thread-safely; and
135
+ # like any listener it sits on the loop's path — an
136
+ # exception it raises propagates into the conversation,
137
+ # so handle dead connections inside the callable.
138
+ #
139
+ # Instances derived via {#for_sub_agent} copy the
140
+ # observer set at derivation time, so one callable
141
+ # receives parent and sub-agent lines alike; the +id+
142
+ # parameter (+""+ for the main agent, the generated id
143
+ # for a sub-agent) is what lets the UI route each line to
144
+ # the right status row — the line itself is unprefixed
145
+ # (see {#status_line}). An observer assigned *after*
146
+ # a sub-agent spawned doesn't reach that sub-agent's
147
+ # instance.
148
+ #
149
+ # @return [Proc, nil] called with +(id, status_line)+ —
150
+ # the owning agent's id +String+ and the unprefixed
151
+ # {#status_line} +String+
152
+ attr_accessor :on_status_line
153
+
115
154
  # @param id [String] owning agent's id, prepended to each
116
155
  # log line as +[<id>] + when non-empty. Defaults to +""+
117
156
  # for the main agent.
@@ -122,6 +161,7 @@ module Pikuri
122
161
  @context_window_size = 0
123
162
  @context_window_cap = nil
124
163
  @status_line = ''
164
+ @on_status_line = nil
125
165
  end
126
166
 
127
167
  # Sub-agent variant: a fresh +TokenLog+ with a zeroed
@@ -132,12 +172,17 @@ module Pikuri
132
172
  # prefix; defaults to +""+ when absent. The cap is left
133
173
  # +nil+ here; the sub-agent's {Agent#initialize} emits a
134
174
  # fresh {Event::ContextCap} immediately after construction
135
- # and this listener picks it up off the stream.
175
+ # and this listener picks it up off the stream. The
176
+ # {#on_status_line} observer carries over by reference, so
177
+ # a host streaming the parent's lines sees the sub-agent's
178
+ # too — distinguished by the +id+ the callable receives.
136
179
  #
137
180
  # @param id [String] sub-agent's id
138
181
  # @return [TokenLog]
139
182
  def for_sub_agent(id: '', **)
140
- self.class.new(id: id)
183
+ derived = self.class.new(id: id)
184
+ derived.on_status_line = @on_status_line
185
+ derived
141
186
  end
142
187
 
143
188
  # @param event [Agent::Event]
@@ -162,8 +207,9 @@ module Pikuri
162
207
 
163
208
  private
164
209
 
165
- # Update the snapshot and write one +INFO+ line to the
166
- # subsystem logger.
210
+ # Update the snapshot, write one +INFO+ line to the
211
+ # subsystem logger, and push the line to the status-line
212
+ # observer (when one was given at construction).
167
213
  #
168
214
  # @param tokens [Event::Tokens]
169
215
  # @return [void]
@@ -178,13 +224,14 @@ module Pikuri
178
224
  delta = @context_window_size - prev_ctx
179
225
 
180
226
  @status_line = format_line(input_now, tokens.output.to_i, delta)
181
- LOGGER.info(@status_line)
227
+ prefix = @id.empty? ? '' : "[#{@id}] "
228
+ LOGGER.info("#{prefix}#{@status_line}")
229
+ @on_status_line&.call(@id, @status_line)
182
230
  end
183
231
 
184
232
  def format_line(input, output, delta)
185
233
  sign = delta.negative? ? '-' : '+'
186
- prefix = @id.empty? ? '' : "[#{@id}] "
187
- "#{prefix}msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
234
+ "msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
188
235
  end
189
236
 
190
237
  # +<used>+ when no cap is set, +<used>/<cap>+ when one is.
@@ -11,10 +11,12 @@ module Pikuri
11
11
  # == What lives here, what doesn't
12
12
  #
13
13
  # The directory holds *pure consumers*: code whose only side
14
- # effect is to react to events the +Agent+ has already emitted.
15
- # No listener writes back into the stream — the +Agent+ is the
16
- # only emitter and no listener reaches into ruby_llm's chat
17
- # callbacks. Both responsibilities live in {Agent}.
14
+ # effect is to react to events already emitted. No listener
15
+ # writes back into the stream — emission belongs to the +Agent+
16
+ # (loop narration) and to extensions via
17
+ # {Agent::ExtensionContext#emit_event} (domain events) and no
18
+ # listener reaches into ruby_llm's chat callbacks (that wiring
19
+ # lives in {Agent}).
18
20
  #
19
21
  # Host-facing signal holders — step budget, cancellation flag,
20
22
  # mid-loop user input queue — are *controls*, not listeners.
@@ -45,7 +47,12 @@ module Pikuri
45
47
  # Event::ThinkingDelta, Event::Assistant,
46
48
  # Event::AssistantDelta, Event::ToolCall,
47
49
  # Event::ToolResult, Event::Tokens, Event::ContextCap,
48
- # Event::FallbackNotice, Event::Cancelled]
50
+ # Event::FallbackNotice, Event::Cancelled, Object] one of
51
+ # the core loop-narration variants, or a gem-defined
52
+ # domain event emitted via
53
+ # {Agent::ExtensionContext#emit_event} (e.g.
54
+ # +Pikuri::Tasks::ListChanged+) — match the variants you
55
+ # know, let everything else fall through
49
56
  # @return [void]
50
57
  def on_event(event); end
51
58
  end
@@ -24,10 +24,14 @@ module Pikuri
24
24
  end
25
25
 
26
26
  # Dispatch one event to every listener, in registration order.
27
- # Called exclusively by {Agent} listeners themselves never
28
- # call this; the stream is one-way.
27
+ # Exactly two callers: {Agent} (loop-narration {Event}
28
+ # variants) and {ExtensionContext#emit_event} (extension
29
+ # domain events). Listeners themselves never call this; the
30
+ # stream is one-way.
29
31
  #
30
- # @param event [Agent::Event]
32
+ # @param event [Object] an {Agent::Event} variant or a
33
+ # gem-defined domain event (an immutable value, by
34
+ # convention a +Data+ instance)
31
35
  # @return [void]
32
36
  def emit(event)
33
37
  @listeners.each { |l| l.on_event(event) }
@@ -78,20 +82,6 @@ module Pikuri
78
82
  self.class.new(swapped)
79
83
  end
80
84
 
81
- # Return a new {ListenerList} containing this list's listeners
82
- # plus the given extras, in order. Used by {Synthesizer} and
83
- # other internal consumers to derive a list from an existing
84
- # one. Returns +self+ when +extras+ is empty so the common
85
- # no-op case allocates nothing.
86
- #
87
- # @param extras [Array<Listener::Base>] listeners to append
88
- # @return [ListenerList]
89
- def with(*extras)
90
- return self if extras.empty?
91
-
92
- self.class.new(@listeners + extras)
93
- end
94
-
95
85
  # @example
96
86
  # list.to_s # => "[Terminal, TokenLog(ctx=0.0k)]"
97
87
  #