pikuri-core 0.0.6 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +6 -4
- data/lib/pikuri/agent/chat_transport.rb +128 -24
- data/lib/pikuri/agent/configurator.rb +47 -107
- data/lib/pikuri/agent/context_window_detector.rb +80 -70
- 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 +46 -30
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +125 -163
- data/lib/pikuri/agent/extension.rb +121 -83
- data/lib/pikuri/agent/extension_context.rb +120 -0
- 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 +147 -110
- data/lib/pikuri/agent/listener/token_log.rb +116 -108
- data/lib/pikuri/agent/listener.rb +23 -36
- data/lib/pikuri/agent/listener_list.rb +26 -57
- data/lib/pikuri/agent/synthesizer.rb +76 -92
- data/lib/pikuri/agent.rb +939 -642
- 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 +157 -0
- 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 +70 -15
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +70 -84
- data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
- data/lib/pikuri/tool/search/engines.rb +249 -93
- data/lib/pikuri/tool/search/exa.rb +75 -97
- 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 +121 -26
- 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 -86
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +18 -8
|
@@ -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,102 +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
|
-
# with a +⊕+ marker.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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.
|
|
17
20
|
#
|
|
18
|
-
# Assistant Markdown
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# tables — doesn't render), the gem hadn't shipped a release
|
|
24
|
-
# since 2023 (its known ANSI-in-table crashes forced a
|
|
25
|
-
# rescue-and-degrade carve-out here), and it pulled seven
|
|
26
|
-
# transitive gems into the audit surface. Raw Markdown is
|
|
27
|
-
# perfectly readable in a terminal; proper rendering belongs
|
|
28
|
-
# 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).
|
|
29
26
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# get a fresh padded instance through {#for_sub_agent}
|
|
33
|
-
# (dispatched by {ListenerList#for_sub_agent}) so their
|
|
34
|
-
# 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}.
|
|
35
29
|
#
|
|
36
30
|
# == Streaming mode
|
|
37
31
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# - {Event::AssistantDelta} fragments print live the same
|
|
45
|
-
# way, uncolored.
|
|
46
|
-
# - {Event::Thinking} and {Event::Assistant} bookends print
|
|
47
|
-
# a single blank line as a stream terminator, not their
|
|
48
|
-
# content (the content already landed via the deltas). The
|
|
49
|
-
# terminator gives the next event (tool call, next round,
|
|
50
|
-
# final REPL prompt) a clean line to start on.
|
|
51
|
-
#
|
|
52
|
-
# In non-streaming mode (+streaming: false+, the default),
|
|
53
|
-
# the deltas are silently ignored and the bookend events
|
|
54
|
-
# 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.
|
|
55
38
|
class Terminal < Base
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# reader can tell a 200-char return apart from a 50KB HTML
|
|
60
|
-
# 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).
|
|
61
42
|
MAX_TOOL_RESULT_CHARS = 200
|
|
62
43
|
|
|
63
|
-
# Padding
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# so the depth never exceeds 1 in practice and a fixed
|
|
67
|
-
# 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.
|
|
68
47
|
SUB_AGENT_PADDING = 2
|
|
69
48
|
|
|
70
|
-
# @param padding [Integer] non-negative
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# @param streaming [Boolean] render the chunk-level delta
|
|
75
|
-
# stream live. See the class header's "Streaming mode"
|
|
76
|
-
# 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").
|
|
77
53
|
def initialize(padding: 0, streaming: false)
|
|
78
54
|
super()
|
|
79
55
|
@padding = padding
|
|
80
56
|
@streaming = streaming
|
|
81
57
|
@at_line_start = true
|
|
58
|
+
@transient = false
|
|
82
59
|
end
|
|
83
60
|
|
|
84
|
-
# @return [Integer] current padding width
|
|
85
|
-
# callers can introspect it (and so tests can assert it).
|
|
61
|
+
# @return [Integer] current padding width.
|
|
86
62
|
attr_reader :padding
|
|
87
63
|
|
|
88
|
-
# @return [Boolean] +true+
|
|
89
|
-
# streaming mode (deltas rendered live; bookends emit a
|
|
90
|
-
# terminator newline only).
|
|
64
|
+
# @return [Boolean] +true+ in streaming mode.
|
|
91
65
|
attr_reader :streaming
|
|
92
66
|
|
|
93
|
-
# Sub-agent variant: a fresh +Terminal+ at
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# under the parent's stream and the stream mode stays
|
|
97
|
-
# consistent across the agent tree. Called by
|
|
98
|
-
# {ListenerList#for_sub_agent}; ignores any params it's
|
|
99
|
-
# 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).
|
|
100
70
|
#
|
|
101
71
|
# @return [Terminal]
|
|
102
72
|
def for_sub_agent(**)
|
|
@@ -131,15 +101,22 @@ module Pikuri
|
|
|
131
101
|
println(indent(Rainbow("→ #{name}(#{args})").cyan))
|
|
132
102
|
in Event::ToolResult(content:)
|
|
133
103
|
println(indent(Rainbow("= #{truncate_tool_result(content)}").cyan))
|
|
104
|
+
in Event::ModelSwitched(from:, to:)
|
|
105
|
+
println(indent(Rainbow("⇄ model: #{from.model} → #{to.model}").magenta))
|
|
134
106
|
in Event::FallbackNotice(reason:)
|
|
135
107
|
println(indent(Rainbow("! #{reason}").yellow))
|
|
136
108
|
in Event::Cancelled
|
|
137
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.)
|
|
138
115
|
else
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
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)
|
|
143
120
|
end
|
|
144
121
|
end
|
|
145
122
|
|
|
@@ -155,47 +132,108 @@ module Pikuri
|
|
|
155
132
|
|
|
156
133
|
private
|
|
157
134
|
|
|
158
|
-
# +puts+ wrapper that
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
# printed in streaming mode knows to insert padding before
|
|
162
|
-
# its first character. Used by every branch in
|
|
163
|
-
# {#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).
|
|
164
138
|
#
|
|
165
139
|
# @param text [String]
|
|
166
140
|
# @return [void]
|
|
167
141
|
def println(text)
|
|
142
|
+
clear_transient
|
|
168
143
|
puts text
|
|
169
144
|
@at_line_start = true
|
|
170
145
|
end
|
|
171
146
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
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.
|
|
178
217
|
#
|
|
179
218
|
# @return [void]
|
|
180
219
|
def terminate_stream
|
|
220
|
+
clear_transient
|
|
181
221
|
puts
|
|
182
222
|
@at_line_start = true
|
|
183
223
|
end
|
|
184
224
|
|
|
185
|
-
# Print one streaming fragment
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
# newlines: a fragment that contains "foo\nbar" with
|
|
190
|
-
# padding 2 prints +" foo\n bar"+ when the cursor was
|
|
191
|
-
# 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"+.
|
|
192
229
|
#
|
|
193
|
-
# @param text [String] the fragment
|
|
194
|
-
# short-circuits
|
|
230
|
+
# @param text [String] the fragment; +nil+/empty short-circuits
|
|
195
231
|
# @return [void]
|
|
196
232
|
def stream_fragment(text)
|
|
197
233
|
return if text.nil? || text.empty?
|
|
198
234
|
|
|
235
|
+
clear_transient
|
|
236
|
+
|
|
199
237
|
if @padding.zero?
|
|
200
238
|
print(text)
|
|
201
239
|
@at_line_start = text.end_with?("\n")
|
|
@@ -212,11 +250,9 @@ module Pikuri
|
|
|
212
250
|
$stdout.flush
|
|
213
251
|
end
|
|
214
252
|
|
|
215
|
-
# Prepend +@padding+ spaces to every line of +text+.
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
# line at the end — +puts+ adds the final newline if
|
|
219
|
-
# 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.
|
|
220
256
|
#
|
|
221
257
|
# @param text [String]
|
|
222
258
|
# @return [String]
|
|
@@ -227,19 +263,20 @@ module Pikuri
|
|
|
227
263
|
text.to_s.each_line.map { |line| prefix + line }.join
|
|
228
264
|
end
|
|
229
265
|
|
|
230
|
-
# Flatten whitespace and cap to {MAX_TOOL_RESULT_CHARS}
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
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."
|
|
269
|
+
#
|
|
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).
|
|
236
274
|
#
|
|
237
275
|
# @param content [String] tool observation
|
|
238
|
-
# @return [String] single-line display form, possibly
|
|
239
|
-
# truncated
|
|
276
|
+
# @return [String] single-line display form, possibly truncated
|
|
240
277
|
def truncate_tool_result(content)
|
|
241
278
|
original_bytes = content.to_s.bytesize
|
|
242
|
-
flattened = content.to_s.gsub(/\s+/, ' ').strip
|
|
279
|
+
flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text
|
|
243
280
|
return flattened if flattened.length <= MAX_TOOL_RESULT_CHARS
|
|
244
281
|
|
|
245
282
|
"#{flattened[0, MAX_TOOL_RESULT_CHARS]}… (#{original_bytes} bytes total)"
|