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
@@ -4,70 +4,48 @@ module Pikuri
4
4
  class Agent
5
5
  module Listener
6
6
  # Decorator that coalesces high-frequency streaming deltas
7
- # ({Event::AssistantDelta}, {Event::ThinkingDelta}) into at most
8
- # +fps+ events per second before handing them off to an inner
9
- # listener. Non-delta events flow through unmodified, with any
10
- # pending coalesced content either emitted ahead of them
11
- # (default) or dropped silently — see
12
- # +flush_pending_on_non_delta:+.
7
+ # ({Event::AssistantDelta}, {Event::ThinkingDelta}) into at most +fps+
8
+ # events/sec before forwarding to an inner listener. Non-delta events flow
9
+ # through unmodified, with any pending coalesced content emitted ahead of
10
+ # them (default) or dropped — see +flush_pending_on_non_delta:+.
13
11
  #
14
- # Useful for renderers whose per-delta repaint cost is higher
15
- # than the wire-arrival rate of provider chunks: a fast provider
16
- # can emit dozens of deltas per second, and a Markdown-rendering
17
- # UI doesn't need every one of them on-screen. +fps: 15+ is a
18
- # reasonable starting point for a TUI.
12
+ # For renderers whose per-delta repaint costs more than provider chunks
13
+ # arrive: a fast provider emits dozens of deltas/sec, and a
14
+ # Markdown-rendering UI doesn't need every one. +fps: 15+ suits a TUI.
19
15
  #
20
16
  # == Tick alignment
21
17
  #
22
- # The first flush is anchored to a monotonic-clock reference
23
- # taken at construction, not to the arrival of the first delta.
24
- # This keeps stream-startup latency from leaking into the
25
- # cadence: with +fps: 1+, if the first delta arrives 0.9s after
26
- # construction, it flushes immediately (the construction-time
27
- # tick has already passed) and the next tick fires 0.1s later
28
- # — preserving the configured rate. After a flush, the schedule
29
- # advances by exactly +1/fps+ per missed tick; a long-idle
30
- # stream doesn't backlog ticks against itself.
18
+ # The first flush is anchored to a monotonic-clock reference taken at
19
+ # construction, not the first delta's arrival — so stream-startup latency
20
+ # doesn't leak into the cadence. After a flush the schedule advances by
21
+ # exactly +1/fps+ per missed tick, so a long-idle stream doesn't backlog
22
+ # ticks against itself.
31
23
  #
32
24
  # == Per-stream buffering
33
25
  #
34
- # Assistant and thinking deltas are buffered independently. A
35
- # tick flushes whichever buffers are non-empty as separate
36
- # coalesced delta events; providers practically don't interleave
37
- # the two modalities within a single tick, but the
38
- # implementation doesn't rely on that.
26
+ # Assistant and thinking deltas buffer independently; a tick flushes
27
+ # whichever are non-empty as separate events.
39
28
  #
40
29
  # == Non-delta handling
41
30
  #
42
- # +flush_pending_on_non_delta: true+ (default) emits any pending
43
- # coalesced content as a delta event ahead of the non-delta
44
- # event so the inner listener sees the complete stream — the
45
- # lossless choice. +false+ drops the pending buffers silently:
46
- # appropriate for inner listeners that re-render the
47
- # authoritative final content on a bookend ({Event::Assistant} /
48
- # {Event::Thinking}), where rendering the trailing 0–66 ms of
49
- # deltas would be wasted CPU before the redraw.
31
+ # +flush_pending_on_non_delta: true+ (default) emits pending content ahead
32
+ # of the non-delta event — lossless. +false+ drops it silently:
33
+ # appropriate for inner listeners that re-render the authoritative final
34
+ # content on a bookend ({Event::Assistant}/{Event::Thinking}), where the
35
+ # trailing 0–66 ms of deltas would be wasted CPU before the redraw.
50
36
  #
51
- # == Threading
52
- #
53
- # No threads, no timers. The decorator advances its tick state
54
- # only when an event arrives, so it's safe to install on any
55
- # listener regardless of which thread the agent emits on, as
56
- # long as delivery is sequential (which the {ListenerList}
57
- # contract guarantees).
37
+ # No threads or timers: tick state advances only when an event arrives, so
38
+ # it's safe on any thread as long as delivery is sequential (the
39
+ # {ListenerList} contract).
58
40
  class RateLimited < Base
59
- # @param inner [Listener::Base] the listener to forward
60
- # (possibly coalesced) events to.
61
- # @param fps [Integer, Float] frames-per-second cap on the
62
- # coalesced delta stream. Must be positive.
63
- # @param flush_pending_on_non_delta [Boolean] +true+
64
- # (default) emits any pending coalesced content as a delta
65
- # before forwarding the non-delta event; +false+ drops the
66
- # pending buffers silently.
67
- # @param clock [Proc] zero-arg returning monotonic
68
- # seconds-since-some-epoch as a +Float+. Injection seam for
69
- # deterministic specs; production uses
70
- # +Process.clock_gettime(Process::CLOCK_MONOTONIC)+.
41
+ # @param inner [Listener::Base] the listener to forward (coalesced) events to.
42
+ # @param fps [Integer, Float] frames-per-second cap on the coalesced
43
+ # delta stream. Must be positive.
44
+ # @param flush_pending_on_non_delta [Boolean] +true+ (default) emits
45
+ # pending content as a delta before a non-delta event; +false+ drops it.
46
+ # @param clock [Proc] zero-arg → monotonic seconds as a +Float+;
47
+ # injection seam for specs (production uses
48
+ # +Process.clock_gettime(Process::CLOCK_MONOTONIC)+).
71
49
  # @raise [ArgumentError] if +fps+ is not positive.
72
50
  def initialize(inner, fps:, flush_pending_on_non_delta: true,
73
51
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
@@ -107,15 +85,13 @@ module Pikuri
107
85
  end
108
86
  end
109
87
 
110
- # Sub-agent variant: wraps the inner listener's sub-agent
111
- # variant (or the inner listener itself when it doesn't define
112
- # +for_sub_agent+) in a fresh +RateLimited+ with the same
113
- # knobs. Returns +nil+ when the inner's +for_sub_agent+
114
- # returned +nil+ — there's nothing to wrap.
88
+ # Sub-agent variant: wraps the inner listener's sub-agent variant (or
89
+ # the inner itself when it has no +for_sub_agent+) in a fresh
90
+ # +RateLimited+ with the same knobs. +nil+ when the inner's returned
91
+ # +nil+.
115
92
  #
116
- # @param params [Hash{Symbol => Object}] forwarded to the
117
- # inner listener's +for_sub_agent+, same protocol as
118
- # {ListenerList#for_sub_agent}
93
+ # @param params [Hash{Symbol => Object}] forwarded to the inner's
94
+ # +for_sub_agent+ (same protocol as {ListenerList#for_sub_agent})
119
95
  # @return [RateLimited, nil]
120
96
  def for_sub_agent(**params)
121
97
  inner_sub = @inner.respond_to?(:for_sub_agent) ? @inner.for_sub_agent(**params) : @inner
@@ -126,9 +102,8 @@ module Pikuri
126
102
  clock: @clock)
127
103
  end
128
104
 
129
- # @return [String] short label for {Agent#to_s}; embeds the
130
- # inner listener's label so the chain reads end-to-end in
131
- # {ListenerList#to_s}
105
+ # @return [String] short label for {Agent#to_s}, embedding the inner
106
+ # listener's so the chain reads end-to-end.
132
107
  def to_s
133
108
  policy = @flush_on_non_delta ? 'flush' : 'drop'
134
109
  "RateLimited(#{@fps}fps, #{policy}, #{@inner})"
@@ -136,9 +111,8 @@ module Pikuri
136
111
 
137
112
  private
138
113
 
139
- # Advance to the next tick strictly after +now+, in one shot.
140
- # Handles the case where deltas paused for several periods —
141
- # a long-idle stream doesn't backlog ticks against the next
114
+ # Advance to the next tick strictly after +now+, in one shot — a stream
115
+ # idle for several periods doesn't backlog ticks against the next
142
116
  # stream's first delta.
143
117
  # @return [void]
144
118
  def tick
@@ -1,103 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rainbow'
4
+ require 'io/console'
4
5
 
5
6
  module Pikuri
6
7
  class Agent
7
8
  module Listener
8
- # Terminal renderer for the normalized event stream: dim grey
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
- # memory / context an extension injected) renders dim grey
14
- # with a +⊕+ marker. {Event::UserTurn} is intentionally silent
15
- # (the terminal user just typed the message, so re-rendering
16
- # it adds nothing); {Event::Tokens} and {Event::ContextCap}
17
- # are silent too (their consumer is {TokenLog}).
9
+ # Terminal renderer for the normalized event stream: dim grey reasoning,
10
+ # assistant Markdown printed raw, cyan tool-call/result lines, yellow
11
+ # fallback notice, red cancelled, magenta model-switch.
12
+ # {Event::UserTurn} / {Event::Tokens} / {Event::ContextCap} are silent
13
+ # (echo, or {TokenLog}'s business); {Event::SystemInjected} renders dim
14
+ # grey with a +⊕+ marker. Any unrecognized event — notably a gem
15
+ # *domain* event — renders generically via its +to_s+ (dim grey, +·+
16
+ # marker), so a new variant shows in the demo without a change here. An
17
+ # {Event::Transient} one instead rewrites a single line in place, and the
18
+ # text of every domain event is defanged through {Pikuri::Sanitizer} —
19
+ # a +to_s+ may carry a filename, a skill name or a server's own words.
18
20
  #
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).
21
+ # Assistant Markdown prints raw, no Markdown-to-ANSI: rendering can't
22
+ # apply to the streaming path anyway (half-built fences/tables), and the
23
+ # one gem for it (tty-markdown) was unmaintained, crashed on
24
+ # ANSI-in-tables, and pulled seven transitive deps — proper rendering
25
+ # belongs to a richer host (the planned pikuri-tui).
30
26
  #
31
- # Optionally prepends a fixed number of leading spaces to
32
- # every rendered line via the +padding:+ kwarg. Sub-agents
33
- # get a fresh padded instance through {#for_sub_agent}
34
- # (dispatched by {ListenerList#for_sub_agent}) so their
35
- # output is visually indented under the parent's stream.
27
+ # +padding:+ prepends a fixed number of leading spaces to every line;
28
+ # sub-agents get a fresh padded instance via {#for_sub_agent}.
36
29
  #
37
30
  # == Streaming mode
38
31
  #
39
- # When constructed with +streaming: true+ (typically because
40
- # the host's {Agent} was constructed with the matching flag):
41
- #
42
- # - {Event::ThinkingDelta} fragments print live in the same
43
- # dim grey as the non-streaming {Event::Thinking}, with no
44
- # trailing newline so the next fragment continues the line.
45
- # - {Event::AssistantDelta} fragments print live the same
46
- # way, uncolored.
47
- # - {Event::Thinking} and {Event::Assistant} bookends print
48
- # a single blank line as a stream terminator, not their
49
- # content (the content already landed via the deltas). The
50
- # terminator gives the next event (tool call, next round,
51
- # final REPL prompt) a clean line to start on.
52
- #
53
- # In non-streaming mode (+streaming: false+, the default),
54
- # the deltas are silently ignored and the bookend events
55
- # render the full text the way they always have.
32
+ # With +streaming: true+ (matching the {Agent}'s flag):
33
+ # {Event::ThinkingDelta} / {Event::AssistantDelta} print live (dim grey /
34
+ # uncolored, no trailing newline), and the {Event::Thinking} /
35
+ # {Event::Assistant} bookends print a single blank line as a terminator,
36
+ # not their content (already landed via deltas). With +streaming: false+
37
+ # (default) the deltas are ignored and the bookends render full text.
56
38
  class Terminal < Base
57
- # Cap, in characters, applied to tool-result content
58
- # rendered to the terminal. Anything longer is truncated
59
- # with a marker that reports the original byte size so the
60
- # reader can tell a 200-char return apart from a 50KB HTML
61
- # dump.
39
+ # Char cap on tool-result content rendered to the terminal; longer is
40
+ # truncated with a marker reporting the original byte size (so a
41
+ # 200-char return is distinguishable from a 50KB dump).
62
42
  MAX_TOOL_RESULT_CHARS = 200
63
43
 
64
- # Padding applied to a sub-agent's rendered stream.
65
- # Absolute, not additive — sub-agent recursion is blocked
66
- # (the sub-agent's tool set excludes +sub_agent+ itself),
67
- # so the depth never exceeds 1 in practice and a fixed
68
- # indent reads cleanly.
44
+ # Padding for a sub-agent's stream. Absolute, not additive — sub-agent
45
+ # recursion is blocked (the sub-agent's toolset excludes +sub_agent+),
46
+ # so depth never exceeds 1 and a fixed indent reads cleanly.
69
47
  SUB_AGENT_PADDING = 2
70
48
 
71
- # @param padding [Integer] non-negative number of leading
72
- # spaces prepended to every rendered line. Defaults to 0;
73
- # sub-agents get a fresh instance with
74
- # {SUB_AGENT_PADDING} via {#for_sub_agent}.
75
- # @param streaming [Boolean] render the chunk-level delta
76
- # stream live. See the class header's "Streaming mode"
77
- # section for the behavior swap. Defaults to +false+.
49
+ # @param padding [Integer] non-negative leading spaces per line
50
+ # (default 0; sub-agents get {SUB_AGENT_PADDING} via {#for_sub_agent}).
51
+ # @param streaming [Boolean] render the chunk-level delta stream live
52
+ # (default +false+; see "Streaming mode").
78
53
  def initialize(padding: 0, streaming: false)
79
54
  super()
80
55
  @padding = padding
81
56
  @streaming = streaming
82
57
  @at_line_start = true
58
+ @transient = false
83
59
  end
84
60
 
85
- # @return [Integer] current padding width; exposed so
86
- # callers can introspect it (and so tests can assert it).
61
+ # @return [Integer] current padding width.
87
62
  attr_reader :padding
88
63
 
89
- # @return [Boolean] +true+ when this Terminal is in
90
- # streaming mode (deltas rendered live; bookends emit a
91
- # terminator newline only).
64
+ # @return [Boolean] +true+ in streaming mode.
92
65
  attr_reader :streaming
93
66
 
94
- # Sub-agent variant: a fresh +Terminal+ at
95
- # {SUB_AGENT_PADDING}, carrying the same +streaming+ flag
96
- # the parent had, so sub-agent output is visually indented
97
- # under the parent's stream and the stream mode stays
98
- # consistent across the agent tree. Called by
99
- # {ListenerList#for_sub_agent}; ignores any params it's
100
- # handed (Terminal has no caller-provided knobs).
67
+ # Sub-agent variant: a fresh +Terminal+ at {SUB_AGENT_PADDING} carrying
68
+ # the parent's +streaming+ flag, so sub-agent output indents under the
69
+ # parent's stream. Ignores any params (Terminal has no caller knobs).
101
70
  #
102
71
  # @return [Terminal]
103
72
  def for_sub_agent(**)
@@ -138,11 +107,16 @@ module Pikuri
138
107
  println(indent(Rainbow("! #{reason}").yellow))
139
108
  in Event::Cancelled
140
109
  println(indent(Rainbow('! cancelled').red))
110
+ in Event::Reset
111
+ println(indent(Rainbow('— context cleared —').color(85, 85, 85)))
112
+ in Event::UserTurn | Event::Tokens | Event::ContextCap
113
+ # Silent: UserTurn echoes just-typed input; Tokens/ContextCap are
114
+ # TokenLog's. (Non-streaming deltas no-op in their branches above.)
141
115
  else
142
- # UserTurn / Tokens / ContextCap silent on the terminal.
143
- # In non-streaming mode the deltas fall through here
144
- # too (the final Thinking / Assistant bookend already
145
- # renders the full text).
116
+ # Any other event — notably a gem *domain* event — renders via its
117
+ # own to_s; core needs no knowledge of the type. A richer host
118
+ # pattern-matches the variants it styles.
119
+ domain_event(event)
146
120
  end
147
121
  end
148
122
 
@@ -158,47 +132,108 @@ module Pikuri
158
132
 
159
133
  private
160
134
 
161
- # +puts+ wrapper that also resets the streaming line-state
162
- # to "at line start" — every full-line print necessarily
163
- # leaves the cursor at column 0, so the next fragment
164
- # printed in streaming mode knows to insert padding before
165
- # its first character. Used by every branch in
166
- # {#on_event} except the delta branches.
135
+ # +puts+ wrapper that resets the streaming line-state to "at line
136
+ # start" (a full-line print leaves the cursor at column 0, so the next
137
+ # streamed fragment knows to insert padding first).
167
138
  #
168
139
  # @param text [String]
169
140
  # @return [void]
170
141
  def println(text)
142
+ clear_transient
171
143
  puts text
172
144
  @at_line_start = true
173
145
  end
174
146
 
175
- # Emit a single newline as a stream terminator and reset
176
- # the line-state. Called on {Event::Thinking} /
177
- # {Event::Assistant} bookends in streaming mode, where the
178
- # content has already been rendered via the delta stream
179
- # and what's left to do is give the next event a clean
180
- # line to start on.
147
+ # A gem domain event, defanged and dimmed — an {Event::Transient} one
148
+ # rewriting a single line rather than appending to the log.
149
+ #
150
+ # @param event [Object] any event variant this listener does not style.
151
+ # @return [void]
152
+ def domain_event(event)
153
+ text = Sanitizer.sanitize(event.to_s.gsub(/\s+/, ' ').strip).text
154
+ return dim_line(text) unless event.is_a?(Event::Transient)
155
+
156
+ transient(text, done: event.done)
157
+ end
158
+
159
+ # @param text [String] sanitized and flattened.
160
+ # @return [void]
161
+ def dim_line(text)
162
+ println(indent(Rainbow("· #{text}").color(85, 85, 85)))
163
+ end
164
+
165
+ # Rewrite the in-place line, or retire it.
166
+ #
167
+ # Off a terminal it degrades to the final state only, as an ordinary line:
168
+ # a stream of +\r+ into a file is one unreadable mega-line, and a cold
169
+ # index emits hundreds of updates.
170
+ #
171
+ # @param text [String] sanitized and flattened.
172
+ # @param done [Boolean] the sequence is over, so the line goes away.
173
+ # @return [void]
174
+ def transient(text, done:)
175
+ unless $stdout.tty?
176
+ dim_line(text) if done
177
+ return
178
+ end
179
+
180
+ clear_transient
181
+ return if done
182
+
183
+ print(Rainbow(cut(indent("· #{text}"))).color(85, 85, 85))
184
+ $stdout.flush
185
+ @transient = true
186
+ @at_line_start = false
187
+ end
188
+
189
+ # Erase the in-place line so ordinary output does not land mid-bar.
190
+ #
191
+ # @return [void]
192
+ def clear_transient
193
+ return unless @transient
194
+
195
+ print("\r\e[K")
196
+ @transient = false
197
+ @at_line_start = true
198
+ end
199
+
200
+ # Fit +text+ in one row, because a line that wraps leaves its tail behind
201
+ # the carriage return that was supposed to erase it.
202
+ #
203
+ # @param text [String]
204
+ # @return [String]
205
+ def cut(text)
206
+ width = begin
207
+ $stdout.winsize[1]
208
+ rescue StandardError
209
+ 80
210
+ end
211
+ text.length < width ? text : "#{text[0, width - 2]}…"
212
+ end
213
+
214
+ # Emit a newline as a stream terminator and reset line-state — the
215
+ # {Event::Thinking}/{Event::Assistant} bookend in streaming mode, where
216
+ # the content already rendered via deltas.
181
217
  #
182
218
  # @return [void]
183
219
  def terminate_stream
220
+ clear_transient
184
221
  puts
185
222
  @at_line_start = true
186
223
  end
187
224
 
188
- # Print one streaming fragment to stdout *without* a
189
- # trailing newline (so the next fragment continues the
190
- # line) and flush so the bytes actually reach the
191
- # terminal. Threads padding through any mid-fragment
192
- # newlines: a fragment that contains "foo\nbar" with
193
- # padding 2 prints +" foo\n bar"+ when the cursor was
194
- # at line start.
225
+ # Print one streaming fragment without a trailing newline (so the next
226
+ # continues the line) and flush. Threads padding through mid-fragment
227
+ # newlines: "foo\nbar" at padding 2 from line start prints
228
+ # +" foo\n bar"+.
195
229
  #
196
- # @param text [String] the fragment to emit; +nil+ / empty
197
- # short-circuits
230
+ # @param text [String] the fragment; +nil+/empty short-circuits
198
231
  # @return [void]
199
232
  def stream_fragment(text)
200
233
  return if text.nil? || text.empty?
201
234
 
235
+ clear_transient
236
+
202
237
  if @padding.zero?
203
238
  print(text)
204
239
  @at_line_start = text.end_with?("\n")
@@ -215,11 +250,9 @@ module Pikuri
215
250
  $stdout.flush
216
251
  end
217
252
 
218
- # Prepend +@padding+ spaces to every line of +text+. Splits
219
- # on +each_line+ rather than a +gsub+ trick so a trailing
220
- # newline in the input doesn't produce a stray padded blank
221
- # line at the end — +puts+ adds the final newline if
222
- # missing, same as before.
253
+ # Prepend +@padding+ spaces to every line of +text+. Uses +each_line+,
254
+ # not a +gsub+, so a trailing newline doesn't produce a stray padded
255
+ # blank line.
223
256
  #
224
257
  # @param text [String]
225
258
  # @return [String]
@@ -230,24 +263,17 @@ module Pikuri
230
263
  text.to_s.each_line.map { |line| prefix + line }.join
231
264
  end
232
265
 
233
- # Flatten whitespace and cap to {MAX_TOOL_RESULT_CHARS}. The
234
- # cap keeps multi-screen dumps (rendered HTML, PDF text)
235
- # from drowning the terminal stream; the byte-count suffix
236
- # on a truncated result distinguishes "tool returned
237
- # exactly this" from "tool returned much more, you're
238
- # seeing a slice."
266
+ # Flatten whitespace and cap to {MAX_TOOL_RESULT_CHARS}; a truncated
267
+ # result's byte-count suffix distinguishes "returned exactly this" from
268
+ # "returned much more, you're seeing a slice."
239
269
  #
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.
270
+ # Whitespace is flattened first (fine — this passive echo is a status
271
+ # line, not an approval artifact), then run through {Pikuri::Sanitizer}
272
+ # so a tool observation can't smuggle an ESC/control byte into this
273
+ # line. Its warnings are dropped (they belong at a confirmation prompt).
247
274
  #
248
275
  # @param content [String] tool observation
249
- # @return [String] single-line display form, possibly
250
- # truncated
276
+ # @return [String] single-line display form, possibly truncated
251
277
  def truncate_tool_result(content)
252
278
  original_bytes = content.to_s.bytesize
253
279
  flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text