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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- 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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
|
35
|
-
#
|
|
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
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
#
|
|
112
|
-
# +
|
|
113
|
-
#
|
|
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
|
-
#
|
|
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}
|
|
130
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
32
|
-
#
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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
|
|
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+
|
|
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
|
-
#
|
|
96
|
-
#
|
|
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
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
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
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
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
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
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
|
|
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+.
|
|
219
|
-
#
|
|
220
|
-
#
|
|
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}
|
|
234
|
-
#
|
|
235
|
-
#
|
|
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
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
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
|