samagotchi 0.23.0 → 0.25.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/CHANGELOG.md +93 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +10 -4
- data/docs/configuration.md +30 -13
- data/docs/hooks.md +21 -7
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +17 -3
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +103 -5
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/chat_loop.rb +37 -13
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +8 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +76 -0
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +96 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +33 -0
- data/lib/samagotchi/web/public/stage_model.js +16 -5
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +9 -1
|
@@ -23,6 +23,7 @@ require_relative "../session_manager"
|
|
|
23
23
|
require_relative "../session_metrics"
|
|
24
24
|
require_relative "../guardrails/parent_approvals"
|
|
25
25
|
require_relative "../tool_activity"
|
|
26
|
+
require_relative "../tool_view"
|
|
26
27
|
require_relative "../web/message_parts"
|
|
27
28
|
|
|
28
29
|
module Samagotchi
|
|
@@ -1109,7 +1110,8 @@ module Samagotchi
|
|
|
1109
1110
|
output = part[:output]
|
|
1110
1111
|
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
1111
1112
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1112
|
-
params: part[:params], status: status
|
|
1113
|
+
params: part[:params], status: status,
|
|
1114
|
+
description: ToolView.description_title(part[:view] || {}) }
|
|
1113
1115
|
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1114
1116
|
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1115
1117
|
end
|
|
@@ -1189,7 +1191,7 @@ module Samagotchi
|
|
|
1189
1191
|
# hook's notice, a retry's line), as they were drawn live.
|
|
1190
1192
|
# Questions are left out: the pending one is asked after the
|
|
1191
1193
|
# replay, answered ones showed no line.
|
|
1192
|
-
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
|
|
1194
|
+
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
|
|
1193
1195
|
end
|
|
1194
1196
|
end
|
|
1195
1197
|
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
@@ -1202,8 +1204,9 @@ module Samagotchi
|
|
|
1202
1204
|
def replay_tool_completed(event)
|
|
1203
1205
|
return @renderer.call({ duration_ms: nil }.merge(event)) if event.dig(:activity, :action)
|
|
1204
1206
|
|
|
1205
|
-
activity = event
|
|
1207
|
+
activity = EventRenderer.described(event) || {}
|
|
1206
1208
|
@screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
|
|
1209
|
+
description: activity[:description],
|
|
1207
1210
|
images: event[:images], diff: event[:diff] }))
|
|
1208
1211
|
end
|
|
1209
1212
|
|
|
@@ -4,6 +4,8 @@ require "monitor"
|
|
|
4
4
|
require_relative "formatting"
|
|
5
5
|
require_relative "../output_formatter"
|
|
6
6
|
require_relative "../turn_tally"
|
|
7
|
+
require_relative "../tool_view"
|
|
8
|
+
require_relative "event_renderer"
|
|
7
9
|
require_relative "thinking_line"
|
|
8
10
|
|
|
9
11
|
module Samagotchi
|
|
@@ -111,7 +113,8 @@ module Samagotchi
|
|
|
111
113
|
@retry = nil
|
|
112
114
|
@waiting_since = nil
|
|
113
115
|
@tool = "running #{event[:tool]}"
|
|
114
|
-
@tally.started(key: tally_key(event), tool: event[:tool], params: event[:params]
|
|
116
|
+
@tally.started(key: tally_key(event), tool: event[:tool], params: event[:params],
|
|
117
|
+
description: ToolView.description_title(event[:view] || {}))
|
|
115
118
|
redraw_status
|
|
116
119
|
end
|
|
117
120
|
end
|
|
@@ -119,7 +122,8 @@ module Samagotchi
|
|
|
119
122
|
def tool_call_feedback_completed(event)
|
|
120
123
|
@lock.synchronize do
|
|
121
124
|
@tally.completed(key: tally_key(event), tool: event[:tool],
|
|
122
|
-
status: event.dig(:activity, :status), params: event.dig(:activity, :params)
|
|
125
|
+
status: event.dig(:activity, :status), params: event.dig(:activity, :params),
|
|
126
|
+
description: EventRenderer.described(event)&.dig(:description))
|
|
123
127
|
end
|
|
124
128
|
end
|
|
125
129
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "formatting"
|
|
4
|
+
require_relative "../tool_view"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class TerminalUI
|
|
@@ -70,8 +71,8 @@ module Samagotchi
|
|
|
70
71
|
@view.tool_call_feedback_completed(event)
|
|
71
72
|
# A replayed row (a join) carries its duration (nil: unknown).
|
|
72
73
|
measured = tool_duration_ms(event)
|
|
73
|
-
render_streamed_tool_activity(event
|
|
74
|
-
|
|
74
|
+
render_streamed_tool_activity(self.class.described(event), duration_ms: event.fetch(:duration_ms, measured),
|
|
75
|
+
images: event[:images], diff: event[:diff])
|
|
75
76
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
76
77
|
@view.generation_feedback_finished
|
|
77
78
|
when :pending_input_merged
|
|
@@ -79,6 +80,9 @@ module Samagotchi
|
|
|
79
80
|
when :empty_answer_retry
|
|
80
81
|
@view.finish_thinking_spinner
|
|
81
82
|
@view.print_line(@view.format_empty_retry_line(event))
|
|
83
|
+
when :steer_cut
|
|
84
|
+
@view.finish_thinking_spinner
|
|
85
|
+
@view.print_line(@view.format_steer_cut_line(event))
|
|
82
86
|
when :turn_completed
|
|
83
87
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
84
88
|
when :turn_canceled
|
|
@@ -187,6 +191,17 @@ module Samagotchi
|
|
|
187
191
|
Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
|
|
188
192
|
end
|
|
189
193
|
|
|
194
|
+
# A tool_call_completed's activity with the command's description:
|
|
195
|
+
# its own, else its view's (a snapshot's replay carries the view, not
|
|
196
|
+
# the description). nil for an event without one.
|
|
197
|
+
def self.described(event)
|
|
198
|
+
activity = event[:activity]
|
|
199
|
+
return activity if activity.nil? || activity[:description] || !event[:view].is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
description = ToolView.description_title(event[:view].transform_keys(&:to_sym))
|
|
202
|
+
description ? activity.merge(description: description) : activity
|
|
203
|
+
end
|
|
204
|
+
|
|
190
205
|
private
|
|
191
206
|
|
|
192
207
|
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
@@ -11,11 +11,21 @@ module Samagotchi
|
|
|
11
11
|
# $stdout is a colour terminal.
|
|
12
12
|
module Formatting
|
|
13
13
|
def format_tool_activity_line(activity, duration_ms: nil)
|
|
14
|
-
params = activity[:params].to_s.strip
|
|
15
|
-
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
16
14
|
status = activity[:status].to_s
|
|
17
15
|
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
18
|
-
"#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{
|
|
16
|
+
"#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{tool_params_suffix(activity)}): " \
|
|
17
|
+
"#{paint(status, status_color(status))}#{elapsed_suffix}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# What a tool line says the call did: the model's description of a
|
|
21
|
+
# command (": List the specs") in place of its cut params, else the
|
|
22
|
+
# params, dim.
|
|
23
|
+
def tool_params_suffix(activity)
|
|
24
|
+
description = activity[:description].to_s.strip
|
|
25
|
+
return ": #{description}" unless description.empty?
|
|
26
|
+
|
|
27
|
+
params = activity[:params].to_s.strip
|
|
28
|
+
params.empty? ? "" : " #{paint(params, 90)}"
|
|
19
29
|
end
|
|
20
30
|
|
|
21
31
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -30,12 +40,22 @@ module Samagotchi
|
|
|
30
40
|
|
|
31
41
|
STEER_PREVIEW = 80
|
|
32
42
|
|
|
43
|
+
# Who a steer names as its sender: a plugin's own label, chi's sender
|
|
44
|
+
# ids in words (the web's format.js steerSender agrees;
|
|
45
|
+
# spec/shared/labels_matrix.json). "": no one named.
|
|
46
|
+
STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin" }.freeze
|
|
47
|
+
|
|
48
|
+
def steer_sender(source)
|
|
49
|
+
STEER_SENDERS.fetch(source.to_s, source.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
33
52
|
# "check-in> nudged: <text>", dim, one line cut: a plugin's steer
|
|
34
53
|
# (Steer) in the running turn or the join's last exchange.
|
|
35
54
|
def format_steer_line(source:, text:)
|
|
36
55
|
first = text.to_s.strip.split("\n").first.to_s
|
|
37
56
|
first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
|
|
38
|
-
|
|
57
|
+
sender = steer_sender(source)
|
|
58
|
+
paint("#{sender.empty? ? "plugin" : sender}> nudged: #{first}", 90)
|
|
39
59
|
end
|
|
40
60
|
|
|
41
61
|
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
|
|
@@ -45,6 +65,15 @@ module Samagotchi
|
|
|
45
65
|
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
46
66
|
end
|
|
47
67
|
|
|
68
|
+
# "↪ cut in for your message": a message for the running turn cut a
|
|
69
|
+
# generation that was only thinking (Engine#cut_for_steer); an empty
|
|
70
|
+
# or unknown source is the user's.
|
|
71
|
+
STEER_CUT_FOR = { "chi_send" => "a message sent with chi send", "parent_agent" => "the parent agent's message" }.freeze
|
|
72
|
+
|
|
73
|
+
def format_steer_cut_line(event)
|
|
74
|
+
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
75
|
+
end
|
|
76
|
+
|
|
48
77
|
# "no answer: the model returned nothing (after 1 retry)", dim like the
|
|
49
78
|
# retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
|
|
50
79
|
def format_empty_answer_line(retries)
|
|
@@ -110,10 +139,8 @@ module Samagotchi
|
|
|
110
139
|
|
|
111
140
|
# A tool call from the snapshot: it has no action text, only the tool.
|
|
112
141
|
def snapshot_tool_line(part)
|
|
113
|
-
params = part[:params].to_s.strip
|
|
114
|
-
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
115
142
|
status = part[:status].to_s
|
|
116
|
-
"#{paint("tool>", 36)} #{part[:tool]}#{
|
|
143
|
+
"#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
|
|
117
144
|
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
118
145
|
end
|
|
119
146
|
|
|
@@ -376,6 +403,9 @@ module Samagotchi
|
|
|
376
403
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
377
404
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
378
405
|
lines << "retries: #{snapshot[:retries]}"
|
|
406
|
+
# A looped session: loop-guard's cuts, or thinking that ran to the cap.
|
|
407
|
+
lines << "thinking cuts: #{snapshot[:cuts].to_i}"
|
|
408
|
+
lines << "output cap hits: #{snapshot[:capped].to_i}"
|
|
379
409
|
context = snapshot[:context] || {}
|
|
380
410
|
if context[:used_tokens]
|
|
381
411
|
pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
|
|
@@ -391,14 +421,15 @@ module Samagotchi
|
|
|
391
421
|
lines.join("\n")
|
|
392
422
|
end
|
|
393
423
|
|
|
394
|
-
# ", cached 4864 (93%), reasoning 212" for the tokens
|
|
395
|
-
# server didn't report are left out.
|
|
424
|
+
# ", cached 4864 (93%), cache writes 312, reasoning 212" for the tokens
|
|
425
|
+
# line; the counts a server didn't report are left out.
|
|
396
426
|
def token_breakdown_text(tokens)
|
|
397
427
|
cached = tokens[:cached_sum].to_i
|
|
398
428
|
reasoning = tokens[:reasoning_sum].to_i
|
|
399
429
|
prompt = tokens[:prompt_sum].to_i
|
|
400
430
|
text = +""
|
|
401
431
|
text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
|
|
432
|
+
text << ", cache writes #{tokens[:cache_write_sum].to_i}" if tokens[:cache_write_sum].to_i.positive?
|
|
402
433
|
text << ", reasoning #{reasoning}" if reasoning.positive?
|
|
403
434
|
text
|
|
404
435
|
end
|
|
@@ -29,6 +29,7 @@ module Samagotchi
|
|
|
29
29
|
def call(event)
|
|
30
30
|
case event[:type]
|
|
31
31
|
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
|
+
when :steer_cut then line(format_steer_cut_line(event))
|
|
32
33
|
when :generation_retrying then line(format_generation_retry_line(event))
|
|
33
34
|
when :turn_completed, :turn_canceled then @turn_over = true
|
|
34
35
|
when :hook_notice
|
|
@@ -1007,7 +1007,12 @@ module Samagotchi
|
|
|
1007
1007
|
# Steering merges text only: a line with images runs as the next turn.
|
|
1008
1008
|
return image_line_waits if ImageInput.extract(line).any?
|
|
1009
1009
|
|
|
1010
|
+
epoch = @engine.input_epoch
|
|
1010
1011
|
@pending_input_queue.push(line.strip)
|
|
1012
|
+
# The user's line cuts a generation that has streamed only thinking
|
|
1013
|
+
# for long (steer.cut_after), now or once it has: the model starts the
|
|
1014
|
+
# step again with it.
|
|
1015
|
+
@engine.cut_for_steer(nil, epoch: epoch)
|
|
1011
1016
|
true
|
|
1012
1017
|
end
|
|
1013
1018
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The thinking a session would otherwise lose: a SessionObserver
|
|
10
|
+
# subscriber (next to SessionMetrics) that keeps the tail of a generation
|
|
11
|
+
# cut mid-stream (a plugin's stop_generation, loop-guard's cut, a steer's
|
|
12
|
+
# cut), stopped with its turn by a plugin (stop_turn), or ended at the
|
|
13
|
+
# provider's output cap (finish_reason length). A cut generation's
|
|
14
|
+
# thinking is dropped from the conversation, and a looping one is what a
|
|
15
|
+
# fixture needs.
|
|
16
|
+
#
|
|
17
|
+
# The tails go to <session dir>/thinking_tails.jsonl, one JSON record a
|
|
18
|
+
# line, the newest MAX_RECORDS kept: next to the session's analytics.json,
|
|
19
|
+
# so an archived session (and a copied session dir) keeps them. Not in the
|
|
20
|
+
# session's messages (nothing there can reach the model, hooks or the
|
|
21
|
+
# web's reload by accident) and not in the turn records (the live snapshot
|
|
22
|
+
# carries those on every /state frame).
|
|
23
|
+
class ThinkingTails
|
|
24
|
+
FILE = "thinking_tails.jsonl"
|
|
25
|
+
# The last chars of a generation's thinking kept: a loop's cycle and
|
|
26
|
+
# some of its lead-in.
|
|
27
|
+
TAIL_CHARS = 20_000
|
|
28
|
+
MAX_RECORDS = 10
|
|
29
|
+
|
|
30
|
+
# @param session_dir [#call] the current session's directory, or nil
|
|
31
|
+
# (no session yet: nothing is written)
|
|
32
|
+
def initialize(session_dir:, clock: -> { Time.now })
|
|
33
|
+
@session_dir = session_dir
|
|
34
|
+
@clock = clock
|
|
35
|
+
@turn_id = nil
|
|
36
|
+
reset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The saved records of +dir+, oldest first (empty without a file).
|
|
40
|
+
# @return [Array<Hash>] string-keyed
|
|
41
|
+
def self.read(dir)
|
|
42
|
+
File.readlines(File.join(dir, FILE), chomp: true).filter_map do |line|
|
|
43
|
+
JSON.parse(line)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(event)
|
|
52
|
+
case event[:type]
|
|
53
|
+
when :turn_started then @turn_id = event[:turn_id]
|
|
54
|
+
when :generation_started, :generation_retrying then reset
|
|
55
|
+
when :generation_chunk then add(event[:thinking].to_s)
|
|
56
|
+
when :generation_completed then completed(event)
|
|
57
|
+
when :generation_cancelled
|
|
58
|
+
# A plugin's stop_turn mid-generation; a user's Stop is not kept.
|
|
59
|
+
keep(event, finish_reason: "cancelled", stopped_by: "hook") if event[:reason].to_s == "hook"
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def reset
|
|
68
|
+
@tail = +""
|
|
69
|
+
@chars = 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add(thinking)
|
|
73
|
+
return if thinking.empty?
|
|
74
|
+
|
|
75
|
+
@chars += thinking.length
|
|
76
|
+
@tail << thinking
|
|
77
|
+
@tail = @tail[-TAIL_CHARS..] if @tail.length > 2 * TAIL_CHARS
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def completed(event)
|
|
81
|
+
stopped_by = event[:stopped_by]
|
|
82
|
+
return reset unless stopped_by || event[:finish_reason].to_s == "length"
|
|
83
|
+
|
|
84
|
+
keep(event, finish_reason: event[:finish_reason].to_s, stopped_by: stopped_by&.to_s, stop_reason: event[:stop_reason])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Writes the record and forgets the generation (the native loop's cut
|
|
88
|
+
# with no retry left reports :generation_cancelled after it too).
|
|
89
|
+
def keep(event, **fields)
|
|
90
|
+
return reset if @chars.zero?
|
|
91
|
+
|
|
92
|
+
record = { at: @clock.call.utc.iso8601(3), turn_id: @turn_id, iteration: event[:iteration], **fields,
|
|
93
|
+
thinking_chars: @chars, tail: @tail.length > TAIL_CHARS ? @tail[-TAIL_CHARS..] : @tail }.compact
|
|
94
|
+
reset
|
|
95
|
+
dir = @session_dir.call
|
|
96
|
+
return unless dir
|
|
97
|
+
|
|
98
|
+
FileUtils.mkdir_p(dir)
|
|
99
|
+
lines = self.class.read(dir).last(MAX_RECORDS - 1).map { |kept| JSON.generate(kept) }
|
|
100
|
+
AtomicFile.write(File.join(dir, FILE), (lines + [JSON.generate(record)]).join("\n") << "\n")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -46,12 +46,16 @@ module Samagotchi
|
|
|
46
46
|
@active = nil
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Feed one raw chunk. Returns the newly-routed deltas for this chunk
|
|
50
|
-
#
|
|
49
|
+
# Feed one raw chunk. Returns the newly-routed deltas for this chunk
|
|
50
|
+
# only, and +tool+: whether the chunk opened, streamed into or closed a
|
|
51
|
+
# tool_call block (its bytes are dropped, but a steer must not cut a
|
|
52
|
+
# generation that writes a tool call: Engine#cut_for_steer).
|
|
53
|
+
# @return [Hash{ text: String, thinking: String, tool: Boolean }]
|
|
51
54
|
def feed(chunk)
|
|
52
55
|
@carry << chunk.to_s
|
|
53
56
|
text = +""
|
|
54
57
|
thinking = +""
|
|
58
|
+
tool = false
|
|
55
59
|
loop do
|
|
56
60
|
if @state == :normal
|
|
57
61
|
hit = earliest_open
|
|
@@ -64,9 +68,11 @@ module Samagotchi
|
|
|
64
68
|
@carry = @carry[hit[:pos]..]
|
|
65
69
|
@active = hit[:block]
|
|
66
70
|
@state = :in_block
|
|
71
|
+
tool ||= @active[:lane] == :drop
|
|
67
72
|
# Drop the open marker itself; its body follows.
|
|
68
73
|
@carry = @carry[@active[:open].length..].to_s
|
|
69
74
|
else
|
|
75
|
+
tool ||= @active[:lane] == :drop && !@carry.empty?
|
|
70
76
|
close_pos = @carry.index(@active[:close])
|
|
71
77
|
if close_pos.nil?
|
|
72
78
|
flush_in_block(thinking)
|
|
@@ -79,7 +85,7 @@ module Samagotchi
|
|
|
79
85
|
@state = :normal
|
|
80
86
|
end
|
|
81
87
|
end
|
|
82
|
-
{ text: text, thinking: thinking }
|
|
88
|
+
{ text: text, thinking: thinking, tool: tool }
|
|
83
89
|
end
|
|
84
90
|
|
|
85
91
|
# Flush any residual state at end of stream (e.g. an unterminated thinking
|
|
@@ -12,15 +12,16 @@ module Samagotchi
|
|
|
12
12
|
# The last chunk can say more: llama.cpp adds a `timings` block with exact
|
|
13
13
|
# speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
|
|
14
14
|
# so it is not read); OpenRouter adds `usage.cost` (USD) and the
|
|
15
|
-
# `prompt_tokens_details.cached_tokens` / `
|
|
16
|
-
#
|
|
15
|
+
# `prompt_tokens_details.cached_tokens` / `cache_write_tokens` /
|
|
16
|
+
# `completion_tokens_details.reasoning_tokens` breakdowns (cache writes:
|
|
17
|
+
# Anthropic models, prompt caching's first request). Those fields are nil when a payload doesn't carry them.
|
|
17
18
|
#
|
|
18
19
|
# from_payload returns nil when no server token counts are present, so a
|
|
19
20
|
# caller can fall back to a chars/4 heuristic. The reported values are
|
|
20
21
|
# cumulative per request, so callers should take the running MAX across
|
|
21
22
|
# chunks (see SessionMetrics).
|
|
22
|
-
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :
|
|
23
|
-
:predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
23
|
+
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
|
|
24
|
+
:reasoning_tokens, :cost, :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
24
25
|
CHARS_PER_TOKEN = 4.0
|
|
25
26
|
|
|
26
27
|
class << self
|
|
@@ -41,11 +42,12 @@ module Samagotchi
|
|
|
41
42
|
)
|
|
42
43
|
return nil unless prompt_tokens || completion_tokens
|
|
43
44
|
|
|
45
|
+
details = hash_at(usage, :prompt_tokens_details)
|
|
44
46
|
new(
|
|
45
47
|
prompt_tokens: prompt_tokens,
|
|
46
48
|
completion_tokens: completion_tokens,
|
|
47
|
-
cached_tokens: first_positive(*both(
|
|
48
|
-
|
|
49
|
+
cached_tokens: first_positive(*both(details, :cached_tokens), *both(timings, :cache_n)),
|
|
50
|
+
cache_write_tokens: first_positive(*both(details, :cache_write_tokens)),
|
|
49
51
|
reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
|
|
50
52
|
cost: number(*both(usage, :cost)),
|
|
51
53
|
predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
|
|
@@ -55,6 +57,16 @@ module Samagotchi
|
|
|
55
57
|
)
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# A request's prompt-cache counts as a :generation_completed event
|
|
61
|
+
# carries them (the log line too): the prompt, the part of it the
|
|
62
|
+
# server reused from its cache, and the part it wrote to it (nil when
|
|
63
|
+
# none: only some remote servers report writes).
|
|
64
|
+
# @return [Hash] {prompt_tokens:, cached_tokens:, cache_write_tokens:}
|
|
65
|
+
def cache_fields(prompt:, cached:, cache_write:)
|
|
66
|
+
{ prompt_tokens: prompt.to_i, cached_tokens: cached.to_i,
|
|
67
|
+
cache_write_tokens: cache_write.to_i.positive? ? cache_write.to_i : nil }
|
|
68
|
+
end
|
|
69
|
+
|
|
58
70
|
# Estimate token count from raw text length using the chars/4 heuristic.
|
|
59
71
|
# @param text [String]
|
|
60
72
|
# @return [Integer]
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "log"
|
|
5
|
+
require_relative "command_steps"
|
|
6
|
+
require_relative "tool_view"
|
|
5
7
|
require_relative "tools/execute"
|
|
6
8
|
require_relative "tools/read"
|
|
7
9
|
require_relative "tools/write"
|
|
@@ -42,15 +44,34 @@ module Samagotchi
|
|
|
42
44
|
}
|
|
43
45
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
44
46
|
event[:title] = title if title
|
|
47
|
+
# The model's own words for a command, which the TUI's tool line
|
|
48
|
+
# shows in place of the cut params.
|
|
49
|
+
description = command_description(tool_name, call)
|
|
50
|
+
event[:description] = description if description
|
|
45
51
|
event
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
# An execute's description, cut as a row's title; nil for the rest.
|
|
55
|
+
def command_description(tool_name, call)
|
|
56
|
+
return nil unless call.is_a?(Hash) && ToolView::TOOLS.include?(tool_name)
|
|
57
|
+
|
|
58
|
+
ToolView.description_title(call)
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
# What the call did, for a web row's one line ("edit lib/a.rb", the
|
|
49
62
|
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
50
|
-
# when it is under it, a command's
|
|
51
|
-
#
|
|
52
|
-
#
|
|
63
|
+
# when it is under it, a command's description (the model's few words),
|
|
64
|
+
# else its first step and how many more (CommandSteps; its first line
|
|
65
|
+
# when it has no steps), a memory's name;
|
|
66
|
+
# nil for the other tools (a plugin's row keeps its preview, the
|
|
67
|
+
# params). +params+ stays the full key=value line (the TUI, the
|
|
68
|
+
# guardrails).
|
|
53
69
|
def tool_title(tool_name, call, cwd: nil)
|
|
70
|
+
if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
|
|
71
|
+
title = command_description(tool_name, call) || steps_title(call[:content])
|
|
72
|
+
return title if title
|
|
73
|
+
end
|
|
74
|
+
|
|
54
75
|
text = case tool_name
|
|
55
76
|
when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
|
|
56
77
|
when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
|
|
@@ -73,6 +94,19 @@ module Samagotchi
|
|
|
73
94
|
# eats the line.
|
|
74
95
|
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
75
96
|
|
|
97
|
+
# "<first step> +N" (N the steps after it), cut to fit
|
|
98
|
+
# TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
|
|
99
|
+
def steps_title(command)
|
|
100
|
+
steps = CommandSteps.parse(command)&.steps
|
|
101
|
+
return nil unless steps
|
|
102
|
+
|
|
103
|
+
first = steps.first.text.gsub(/\s+/, " ")
|
|
104
|
+
more = steps.size > 1 ? " +#{steps.size - 1}" : ""
|
|
105
|
+
room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
|
|
106
|
+
first = "#{first[0, room - 1]}…" if first.length > room
|
|
107
|
+
"#{first}#{more}"
|
|
108
|
+
end
|
|
109
|
+
|
|
76
110
|
def command_title(command)
|
|
77
111
|
line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
|
|
78
112
|
line.sub(LEADING_CD, "")
|
|
@@ -26,6 +26,10 @@ module Samagotchi
|
|
|
26
26
|
type: "string",
|
|
27
27
|
description: "The shell command to run"
|
|
28
28
|
},
|
|
29
|
+
description: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Optional: what this command does, in a few words (what, not why)"
|
|
32
|
+
},
|
|
29
33
|
cwd: {
|
|
30
34
|
type: "string",
|
|
31
35
|
description: "Optional working directory to run in (defaults to the project root). Relative paths are resolved against the project root."
|
|
@@ -425,6 +429,16 @@ module Samagotchi
|
|
|
425
429
|
|
|
426
430
|
module_function
|
|
427
431
|
|
|
432
|
+
# +schema+ without execute's description property: what a session
|
|
433
|
+
# with execute.description off declares (the parameter isn't offered
|
|
434
|
+
# at all). Any other schema as given.
|
|
435
|
+
def without_command_description(schema)
|
|
436
|
+
return schema unless schema[:name] == "execute"
|
|
437
|
+
|
|
438
|
+
parameters = schema[:parameters]
|
|
439
|
+
schema.merge(parameters: parameters.merge(properties: parameters[:properties].except(:description)))
|
|
440
|
+
end
|
|
441
|
+
|
|
428
442
|
# Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, back
|
|
429
443
|
# to back, as Gemma 4's chat template declares tools.
|
|
430
444
|
# @param schemas [Array<Hash>] a Tools::Registry's schemas
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "tool_activity"
|
|
4
|
+
require_relative "tool_view"
|
|
4
5
|
require_relative "guardrails"
|
|
5
6
|
require_relative "vision_context"
|
|
6
7
|
require_relative "log"
|
|
@@ -55,6 +56,9 @@ module Samagotchi
|
|
|
55
56
|
# The worker runs in its session's working directory.
|
|
56
57
|
title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
|
|
57
58
|
started[:title] = title if title
|
|
59
|
+
# The full command for a richer UI (params stays cut at 80).
|
|
60
|
+
view = ToolView.for(call[:name], call)&.to_h
|
|
61
|
+
started[:view] = view if view
|
|
58
62
|
emit(on_stream_event, started)
|
|
59
63
|
|
|
60
64
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
@@ -95,6 +99,9 @@ module Samagotchi
|
|
|
95
99
|
completed[:images] = images if images&.any?
|
|
96
100
|
completed[:diff] = diff if diff
|
|
97
101
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
102
|
+
# Also here: a UI that missed the start (a replay gap) builds its row
|
|
103
|
+
# from this event.
|
|
104
|
+
completed[:view] = view if view
|
|
98
105
|
emit(on_stream_event, completed)
|
|
99
106
|
# After the completed event: a hook's card (check-in, skills) prints
|
|
100
107
|
# under the call's tool row, not before it.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_steps"
|
|
4
|
+
require_relative "tools/execute"
|
|
5
|
+
require_relative "tools/task_create"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What a richer UI shows of a tool call beyond its one-line params: the
|
|
9
|
+
# full command of an execute / task_create (whitespace kept), and the
|
|
10
|
+
# call's own cwd: argument. Built by the server on every path (the live
|
|
11
|
+
# tool_call_started, the bridge's snapshot and replay, a reload from the
|
|
12
|
+
# saved call), so the UIs never parse the call themselves. params and its
|
|
13
|
+
# 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
|
|
14
|
+
#
|
|
15
|
+
# +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
|
|
16
|
+
# +chars+ is then its full length. +cd+ and +steps+: the command as
|
|
17
|
+
# CommandSteps reads it (its leading cd, its steps), both nil when it
|
|
18
|
+
# can't (the fallback: the UI shows the command itself) or the command
|
|
19
|
+
# is cut. +description+: what the model said the command does (execute's
|
|
20
|
+
# optional description), whitespace collapsed, a trailing period gone;
|
|
21
|
+
# nil when it gave none.
|
|
22
|
+
ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description) do
|
|
23
|
+
def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ToolView
|
|
27
|
+
# The longest command a view carries (the spike's longest was 3,799).
|
|
28
|
+
COMMAND_LIMIT = 8_000
|
|
29
|
+
|
|
30
|
+
TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
|
|
31
|
+
|
|
32
|
+
# The longest description a view carries; a row's title shows the
|
|
33
|
+
# first TITLE_LIMIT characters of it.
|
|
34
|
+
DESCRIPTION_LIMIT = 300
|
|
35
|
+
TITLE_LIMIT = 60
|
|
36
|
+
|
|
37
|
+
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
38
|
+
# empty command
|
|
39
|
+
def self.for(tool_name, call)
|
|
40
|
+
return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
command = call[:content].to_s
|
|
43
|
+
return nil if command.strip.empty?
|
|
44
|
+
|
|
45
|
+
cwd = call[:cwd].to_s.strip
|
|
46
|
+
truncated = command.length > COMMAND_LIMIT
|
|
47
|
+
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
48
|
+
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
49
|
+
truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
|
|
50
|
+
description: description(call))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The call's description as a view shows it, nil for none.
|
|
54
|
+
def self.description(call)
|
|
55
|
+
text = call[:description]
|
|
56
|
+
return nil unless text.is_a?(String)
|
|
57
|
+
|
|
58
|
+
text = text.gsub(/\s+/, " ").strip.sub(/(?<!\.)\.\z/, "")
|
|
59
|
+
return nil if text.empty?
|
|
60
|
+
|
|
61
|
+
text.length > DESCRIPTION_LIMIT ? "#{text[0, DESCRIPTION_LIMIT - 1]}…" : text
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A row's title from the call's description: cut to TITLE_LIMIT, at a
|
|
65
|
+
# word when one ends in its second half. nil for none.
|
|
66
|
+
def self.description_title(call)
|
|
67
|
+
text = description(call)
|
|
68
|
+
return text if text.nil? || text.length <= TITLE_LIMIT
|
|
69
|
+
|
|
70
|
+
cut = text[0, TITLE_LIMIT - 1]
|
|
71
|
+
space = cut.rindex(" ")
|
|
72
|
+
cut = cut[0, space] if space && space >= TITLE_LIMIT / 2
|
|
73
|
+
"#{cut.sub(/[\s,;:.-]+\z/, "")}…"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -142,11 +142,14 @@ module Samagotchi
|
|
|
142
142
|
-> { !!peers&.cancelled? }
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# @param command_description [Boolean] false: execute declares no
|
|
146
|
+
# description parameter (execute.description off)
|
|
145
147
|
# @return [Registry] a new registry with the built-ins, in
|
|
146
148
|
# TOOL_SCHEMAS order (an Engine's own, which bundles add to)
|
|
147
|
-
def registry
|
|
149
|
+
def registry(command_description: true)
|
|
148
150
|
by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
|
|
149
151
|
ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
|
|
152
|
+
schema = ToolDeclarations.without_command_description(schema) unless command_description
|
|
150
153
|
klass = by_name.fetch(schema[:name])
|
|
151
154
|
handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
|
|
152
155
|
registry.register(klass::NAME, schema: schema, handler: handler)
|