samagotchi 0.24.0 → 0.26.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 +80 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +4 -3
- data/docs/configuration.md +22 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +120 -0
- data/docs/plugins.md +18 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +14 -4
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +73 -11
- data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/kernel_loop.rb +60 -6
- data/lib/samagotchi/llm/chat_loop.rb +18 -4
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +104 -0
- 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_view.rb +40 -4
- 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_note.rb +26 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +74 -3
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +45 -13
- data/lib/samagotchi/web/public/stage_model.js +5 -1
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/turn_view.js +3 -3
- data/lib/samagotchi/worker.rb +3 -0
- metadata +7 -1
|
@@ -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;
|
|
@@ -119,10 +139,8 @@ module Samagotchi
|
|
|
119
139
|
|
|
120
140
|
# A tool call from the snapshot: it has no action text, only the tool.
|
|
121
141
|
def snapshot_tool_line(part)
|
|
122
|
-
params = part[:params].to_s.strip
|
|
123
|
-
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
124
142
|
status = part[:status].to_s
|
|
125
|
-
"#{paint("tool>", 36)} #{part[:tool]}#{
|
|
143
|
+
"#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
|
|
126
144
|
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
127
145
|
end
|
|
128
146
|
|
|
@@ -385,6 +403,9 @@ module Samagotchi
|
|
|
385
403
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
386
404
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
387
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}"
|
|
388
409
|
context = snapshot[:context] || {}
|
|
389
410
|
if context[:used_tokens]
|
|
390
411
|
pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
|
|
@@ -400,14 +421,15 @@ module Samagotchi
|
|
|
400
421
|
lines.join("\n")
|
|
401
422
|
end
|
|
402
423
|
|
|
403
|
-
# ", cached 4864 (93%), reasoning 212" for the tokens
|
|
404
|
-
# 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.
|
|
405
426
|
def token_breakdown_text(tokens)
|
|
406
427
|
cached = tokens[:cached_sum].to_i
|
|
407
428
|
reasoning = tokens[:reasoning_sum].to_i
|
|
408
429
|
prompt = tokens[:prompt_sum].to_i
|
|
409
430
|
text = +""
|
|
410
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?
|
|
411
433
|
text << ", reasoning #{reasoning}" if reasoning.positive?
|
|
412
434
|
text
|
|
413
435
|
end
|
|
@@ -173,6 +173,8 @@ module Samagotchi
|
|
|
173
173
|
# -p without --non-interactive drops into the REPL, which can answer
|
|
174
174
|
# an approval; --non-interactive can't, so an approval there denies.
|
|
175
175
|
@engine.interface = @non_interactive ? :non_interactive : :repl
|
|
176
|
+
# A --non-interactive run exits after its turn: no turn-end warm-up.
|
|
177
|
+
@engine.next_turn_waiting = -> { true } if @non_interactive
|
|
176
178
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
177
179
|
# A recap written while a continue offer waits says the turn stopped
|
|
178
180
|
# unfinished. (Answering at the prompt is typing: activity already.)
|
|
@@ -0,0 +1,104 @@
|
|
|
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 (the bundle that stopped it,
|
|
59
|
+
# "hook" when unknown); a user's Stop is not kept.
|
|
60
|
+
keep(event, finish_reason: "cancelled", stopped_by: (event[:stopped_by] || "hook").to_s) if event[:reason].to_s == "hook"
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def reset
|
|
69
|
+
@tail = +""
|
|
70
|
+
@chars = 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add(thinking)
|
|
74
|
+
return if thinking.empty?
|
|
75
|
+
|
|
76
|
+
@chars += thinking.length
|
|
77
|
+
@tail << thinking
|
|
78
|
+
@tail = @tail[-TAIL_CHARS..] if @tail.length > 2 * TAIL_CHARS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def completed(event)
|
|
82
|
+
stopped_by = event[:stopped_by]
|
|
83
|
+
return reset unless stopped_by || event[:finish_reason].to_s == "length"
|
|
84
|
+
|
|
85
|
+
keep(event, finish_reason: event[:finish_reason].to_s, stopped_by: stopped_by&.to_s, stop_reason: event[:stop_reason])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Writes the record and forgets the generation (the native loop's cut
|
|
89
|
+
# with no retry left reports :generation_cancelled after it too).
|
|
90
|
+
def keep(event, **fields)
|
|
91
|
+
return reset if @chars.zero?
|
|
92
|
+
|
|
93
|
+
record = { at: @clock.call.utc.iso8601(3), turn_id: @turn_id, iteration: event[:iteration], **fields,
|
|
94
|
+
thinking_chars: @chars, tail: @tail.length > TAIL_CHARS ? @tail[-TAIL_CHARS..] : @tail }.compact
|
|
95
|
+
reset
|
|
96
|
+
dir = @session_dir.call
|
|
97
|
+
return unless dir
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(dir)
|
|
100
|
+
lines = self.class.read(dir).last(MAX_RECORDS - 1).map { |kept| JSON.generate(kept) }
|
|
101
|
+
AtomicFile.write(File.join(dir, FILE), (lines + [JSON.generate(record)]).join("\n") << "\n")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -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: "What this command does, in a few words (what, not why). Shown to the user as the step's title, so include it on every call."
|
|
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
|
data/lib/samagotchi/tool_view.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "command_steps"
|
|
3
4
|
require_relative "tools/execute"
|
|
4
5
|
require_relative "tools/task_create"
|
|
5
6
|
|
|
@@ -12,9 +13,14 @@ module Samagotchi
|
|
|
12
13
|
# 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
|
|
13
14
|
#
|
|
14
15
|
# +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
|
|
15
|
-
# +chars+ is then its full length.
|
|
16
|
-
|
|
17
|
-
|
|
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 }
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
class ToolView
|
|
@@ -23,6 +29,11 @@ module Samagotchi
|
|
|
23
29
|
|
|
24
30
|
TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
|
|
25
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
|
+
|
|
26
37
|
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
27
38
|
# empty command
|
|
28
39
|
def self.for(tool_name, call)
|
|
@@ -33,8 +44,33 @@ module Samagotchi
|
|
|
33
44
|
|
|
34
45
|
cwd = call[:cwd].to_s.strip
|
|
35
46
|
truncated = command.length > COMMAND_LIMIT
|
|
47
|
+
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
36
48
|
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
37
|
-
truncated: truncated, chars: truncated ? command.length : nil
|
|
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/, "")}…"
|
|
38
74
|
end
|
|
39
75
|
end
|
|
40
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)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require_relative "output_guardrails"
|
|
5
|
+
require_relative "shell"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Tools
|
|
@@ -90,7 +91,7 @@ module Samagotchi
|
|
|
90
91
|
started = monotonic_time
|
|
91
92
|
deadline = started + timeout_sec
|
|
92
93
|
|
|
93
|
-
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
|
+
Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
95
|
stdin.close
|
|
95
96
|
readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
|
|
96
97
|
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Tools
|
|
5
5
|
# The tools a session offers the model, in declaration order: the
|
|
6
|
-
# built-ins (Tools::Builtins),
|
|
6
|
+
# built-ins (Tools::Builtins) as registered, then the ones bundles add,
|
|
7
|
+
# by bundle and name. That order doesn't depend on which plugin
|
|
8
|
+
# finished its init first or re-registered a changed tool, so the
|
|
9
|
+
# tool list (part of the prompt's cached prefix) is the same in every
|
|
10
|
+
# session with the same tools. The
|
|
7
11
|
# native prompts and the chat path's tools: are rendered from #schemas,
|
|
8
12
|
# and KernelLoop#dispatch runs a call through its entry's handler.
|
|
9
13
|
class Registry
|
|
@@ -43,13 +47,16 @@ module Samagotchi
|
|
|
43
47
|
def key?(name) = @entries.key?(name.to_s)
|
|
44
48
|
|
|
45
49
|
# @return [Array<String>] in declaration order
|
|
46
|
-
def names =
|
|
50
|
+
def names = entries.map(&:name)
|
|
47
51
|
|
|
48
52
|
# @return [Array<Entry>] in declaration order
|
|
49
|
-
def entries
|
|
53
|
+
def entries
|
|
54
|
+
core, added = @entries.values.partition(&:core?)
|
|
55
|
+
core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
|
|
56
|
+
end
|
|
50
57
|
|
|
51
58
|
# @return [Array<Hash>] the schemas, in declaration order
|
|
52
|
-
def schemas =
|
|
59
|
+
def schemas = entries.map(&:schema)
|
|
53
60
|
|
|
54
61
|
def freeze
|
|
55
62
|
@entries.freeze
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
# The shell a model's (or the user's `!`) command runs in: execute,
|
|
8
|
+
# task_create and the shell bang. Non-login (a login shell re-sources
|
|
9
|
+
# profile files, which can rebuild PATH and shadow the inherited toolchain).
|
|
10
|
+
#
|
|
11
|
+
# macOS's /bin/sh is bash 3.2, which can't parse an apostrophe in a
|
|
12
|
+
# heredoc inside "$( )" -- the usual `git commit -m "$(cat <<'EOF' ...`.
|
|
13
|
+
# There, zsh in sh emulation runs instead (it reads no rc files in that
|
|
14
|
+
# mode), with the options that keep bash habits working: `{1..3}` brace
|
|
15
|
+
# expansion, unquoted `( )` in `[[ =~ ]]`, BASH_REMATCH, and echo
|
|
16
|
+
# expanding `\t` as macOS's sh does. Elsewhere (Linux) /bin/sh as before.
|
|
17
|
+
module Shell
|
|
18
|
+
ZSH = "/bin/zsh"
|
|
19
|
+
ZSH_SH_MODE = [ZSH, "--emulate", "sh", "+o", "ignore_braces", "+o", "sh_glob", "-o", "bash_rematch",
|
|
20
|
+
"+o", "bsd_echo"].freeze
|
|
21
|
+
SH = ["/bin/sh"].freeze
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the argv that runs +command+
|
|
24
|
+
def self.argv(command) = [*program, "-c", command]
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the shell and its options, without -c
|
|
27
|
+
def self.program(host_os: RbConfig::CONFIG["host_os"], zsh: File.executable?(ZSH))
|
|
28
|
+
host_os.to_s.include?("darwin") && zsh ? ZSH_SH_MODE : SH
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "shell"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Tools
|
|
@@ -49,11 +50,10 @@ module Samagotchi
|
|
|
49
50
|
output_io.sync = true
|
|
50
51
|
|
|
51
52
|
wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
|
|
52
|
-
# Non-login shell
|
|
53
|
-
# files, which can rebuild PATH and shadow the inherited toolchain.
|
|
53
|
+
# Non-login, the shell Execute uses too (Shell).
|
|
54
54
|
pid = Process.spawn(
|
|
55
55
|
spawn_env,
|
|
56
|
-
|
|
56
|
+
*Shell.argv(wrapped_command),
|
|
57
57
|
chdir: resolved_cwd,
|
|
58
58
|
out: output_io,
|
|
59
59
|
err: output_io,
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# The one-line system note a turn leaves in the conversation when it ends
|
|
5
7
|
# without an answer: failed before the model replied, cancelled, or over
|
|
@@ -19,6 +21,12 @@ module Samagotchi
|
|
|
19
21
|
TASK_COMMAND_CHARS = 60
|
|
20
22
|
RETRY_NUDGE = :retry_nudge
|
|
21
23
|
EMPTY_ANSWER = :empty_answer
|
|
24
|
+
# An empty step's thinking kept on the marker: its tail, so a model
|
|
25
|
+
# that thought for minutes doesn't put megabytes in the session file.
|
|
26
|
+
STEP_CHARS = 20_000
|
|
27
|
+
# A step's native thinking starts with one of these; the cut keeps it,
|
|
28
|
+
# so the UIs still find the thinking (MessageParts).
|
|
29
|
+
THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
|
|
22
30
|
|
|
23
31
|
module_function
|
|
24
32
|
|
|
@@ -75,16 +83,32 @@ module Samagotchi
|
|
|
75
83
|
# UIs, which draw the turn's notice and steps from it after a reload:
|
|
76
84
|
# +retries+ the empty-answer retries spent, +steps+ the empty
|
|
77
85
|
# generations as model messages (their thinking; the loops keep them out
|
|
78
|
-
# of the conversation, where they'd be empty assistant turns)
|
|
86
|
+
# of the conversation, where they'd be empty assistant turns), each
|
|
87
|
+
# text cut to its last STEP_CHARS. Never
|
|
79
88
|
# sent: the payload builders send a system message's content, and the
|
|
80
89
|
# copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
|
|
81
90
|
def empty(retries: 0, steps: [])
|
|
82
91
|
marker = { retries: retries.to_i }
|
|
83
|
-
marker[:steps] = steps.map { |step| step
|
|
92
|
+
marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
|
|
84
93
|
message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
|
|
85
94
|
.merge(EMPTY_ANSWER => marker)
|
|
86
95
|
end
|
|
87
96
|
|
|
97
|
+
def capped_step(step)
|
|
98
|
+
step.slice(:role, :content, :thinking).to_h do |key, value|
|
|
99
|
+
[key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The last STEP_CHARS of +text+ after a line saying how much was cut
|
|
104
|
+
# (and the thinking's opening tag when it had one); short text as is.
|
|
105
|
+
def tail_of(text)
|
|
106
|
+
return text if text.length <= STEP_CHARS
|
|
107
|
+
|
|
108
|
+
open = THINK_OPENS.find { |tag| text.start_with?(tag) }
|
|
109
|
+
"#{open}[… #{text.length - STEP_CHARS} earlier characters cut]\n#{text[-STEP_CHARS..]}"
|
|
110
|
+
end
|
|
111
|
+
|
|
88
112
|
# The empty-answer marker of +entry+ (either key type), or nil.
|
|
89
113
|
def empty_answer(entry)
|
|
90
114
|
return nil unless note?(entry)
|
|
@@ -133,6 +133,12 @@ module Samagotchi
|
|
|
133
133
|
true
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# @return [Boolean] whether #steer_next_turn queued anything a turn has
|
|
137
|
+
# yet to begin with
|
|
138
|
+
def carried_steers?
|
|
139
|
+
@lock.synchronize { !@carried_steers.empty? }
|
|
140
|
+
end
|
|
141
|
+
|
|
136
142
|
# Drop what #steer_next_turn queued and no turn began with (a no-op
|
|
137
143
|
# once #begin! took it).
|
|
138
144
|
# @return [Array<Hash>] the steers dropped ({text:, source:})
|