samagotchi 0.69.0 → 0.71.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 +73 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +6 -5
- data/docs/context.md +15 -6
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/engine.md +10 -5
- data/docs/internals/tool-guardrails.md +61 -1
- data/docs/plugins.md +24 -19
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +2 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +6 -2
- data/lib/samagotchi/context_absorber.rb +35 -21
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +10 -0
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +35 -16
- data/lib/samagotchi/sessions_command.rb +12 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -1
- data/lib/samagotchi/tool_activity.rb +19 -1
- data/lib/samagotchi/tool_output_cut.rb +321 -0
- data/lib/samagotchi/tool_output_files.rb +73 -0
- data/lib/samagotchi/tool_runner.rb +46 -12
- data/lib/samagotchi/toolkit.rb +9 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +12 -1
- data/lib/samagotchi/tools/ref_text.rb +27 -0
- data/lib/samagotchi/tools/registry.rb +13 -2
- data/lib/samagotchi/tools/task_wait.rb +8 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -7
- data/lib/samagotchi/web/message_parts.rb +5 -3
- data/lib/samagotchi/web/public/answer_refs.js +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/turn_html.js +55 -3
- data/lib/samagotchi/worker.rb +2 -2
- data/lib/samagotchi/worker_inbound.rb +3 -0
- metadata +4 -1
|
@@ -12,7 +12,7 @@ module Samagotchi
|
|
|
12
12
|
# docs/plugins.md): a live link between a chi session and another agent's
|
|
13
13
|
# session the user works with. The peer pairs (its chi-peer skill,
|
|
14
14
|
# scripts/pair): it attaches the `<peer>-inbox` context, whose hint says
|
|
15
|
-
# where to send (a subclass's #check_hint!), and `<peer>-
|
|
15
|
+
# where to send (a subclass's #check_hint!), and `<peer>-outbox`, where
|
|
16
16
|
# its answers come back as pushes that wake chi.
|
|
17
17
|
#
|
|
18
18
|
# The `<peer>_send` tool sends a part of the work (a plan step, a diff, a
|
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
# `From your thinking:` label.
|
|
41
41
|
#
|
|
42
42
|
# A subclass (the bundle's Plugin) sets the strings, as constants:
|
|
43
|
-
# INBOX,
|
|
43
|
+
# INBOX, OUTBOX ("<peer>-inbox", "-outbox"), BUNDLE, NAME (the peer, as
|
|
44
44
|
# the model and user call it: "Claude"), APP (its product: "Claude
|
|
45
45
|
# Code"), COMMAND ("/claude"), TOOL, TOOL_DESCRIPTION, TOOL_PARAMS,
|
|
46
46
|
# USAGE, STATUS_CARD, SKILL_CARD, CHECKED_ON and SCRIPTS_DIRS (beside the
|
|
@@ -90,9 +90,9 @@ module Samagotchi
|
|
|
90
90
|
end
|
|
91
91
|
# A chi without chi.annotation (before 0.66) loads the bundle without the pill.
|
|
92
92
|
chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate" if chi.respond_to?(:annotation)
|
|
93
|
-
# The
|
|
93
|
+
# The outbox source is the peer's answers: its own wake budget and
|
|
94
94
|
# line (context.peer_turns). A chi before 0.67 has no peer_source.
|
|
95
|
-
chi.peer_source peer::
|
|
95
|
+
chi.peer_source peer::OUTBOX, label: peer::NAME if chi.respond_to?(:peer_source)
|
|
96
96
|
chi.on(:before_turn) do |event|
|
|
97
97
|
@lock.synchronize { @before_turn = Array(event[:messages]) }
|
|
98
98
|
end
|
|
@@ -172,7 +172,7 @@ module Samagotchi
|
|
|
172
172
|
|
|
173
173
|
def send_text(ctx, kind, text)
|
|
174
174
|
"#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; #{self.class::NAME}'s answer comes as the " \
|
|
175
|
-
"#{self.class::
|
|
175
|
+
"#{self.class::OUTBOX} context"
|
|
176
176
|
rescue SendError => e
|
|
177
177
|
"#{self.class::COMMAND}: #{e.message}"
|
|
178
178
|
end
|
|
@@ -240,7 +240,7 @@ module Samagotchi
|
|
|
240
240
|
peer = self.class
|
|
241
241
|
id = ctx.session_id
|
|
242
242
|
sources = id ? sources(ctx) : []
|
|
243
|
-
inbox,
|
|
243
|
+
inbox, outbox = [peer::INBOX, peer::OUTBOX].map { |name| sources.find { |source| source[:name] == name } }
|
|
244
244
|
hint = inbox && inbox[:hint].to_s.strip
|
|
245
245
|
paired = !(hint.nil? || hint.empty?)
|
|
246
246
|
lines = if paired
|
|
@@ -250,7 +250,8 @@ module Samagotchi
|
|
|
250
250
|
"#{peer::NAME} \"pair with chi #{id.to_s[0, 8]}\"."]
|
|
251
251
|
end
|
|
252
252
|
lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
|
|
253
|
-
lines << "last answer (#{peer::
|
|
253
|
+
lines << "last answer (#{peer::OUTBOX}): #{outbox_line(outbox)}" if outbox
|
|
254
|
+
lines << "skill: **differs from this bundle's**: #{peer::COMMAND} install-skill (Replace), then pair again" if skill_differs?(ctx)
|
|
254
255
|
lines << transport_line
|
|
255
256
|
ctx.card(id: peer::STATUS_CARD, title: "#{peer::BUNDLE}: #{paired ? "paired" : "not paired"}",
|
|
256
257
|
body: lines.join("\n\n"), actions: [{ label: "Refresh", command: peer::COMMAND },
|
|
@@ -271,10 +272,10 @@ module Samagotchi
|
|
|
271
272
|
"#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
|
|
272
273
|
end
|
|
273
274
|
|
|
274
|
-
def
|
|
275
|
-
return "none yet" unless
|
|
275
|
+
def outbox_line(outbox)
|
|
276
|
+
return "none yet" unless outbox[:fetched_at]
|
|
276
277
|
|
|
277
|
-
[
|
|
278
|
+
[outbox[:summary], ago(outbox[:fetched_at])].compact.join(" · ")
|
|
278
279
|
end
|
|
279
280
|
|
|
280
281
|
def ago(stamp)
|
|
@@ -291,14 +292,32 @@ module Samagotchi
|
|
|
291
292
|
# /<peer> install-skill [--yes]
|
|
292
293
|
def install_skill(ctx, yes:)
|
|
293
294
|
peer = self.class
|
|
294
|
-
|
|
295
|
-
return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless
|
|
295
|
+
target, text = skill_target_and_text(ctx)
|
|
296
|
+
return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless text
|
|
297
|
+
|
|
298
|
+
yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Where install-skill writes the peer's skill and what it writes:
|
|
302
|
+
# [target, text]; the text nil when the bundle has no skill file.
|
|
303
|
+
def skill_target_and_text(ctx)
|
|
304
|
+
target = File.join(peer_dir, "skills", "chi-peer", "SKILL.md")
|
|
305
|
+
source = self.class::SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
|
|
306
|
+
return [target, nil] unless source
|
|
296
307
|
|
|
297
308
|
substitutions = { PAIR_PLACEHOLDER => File.join(File.dirname(source), "pair"),
|
|
298
309
|
PAYLOADS_PLACEHOLDER => tilde(ctx.data_dir) }.merge(skill_substitutions(ctx))
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
[target, substitutions.reduce(File.read(source)) { |skill, (placeholder, value)| skill.gsub(placeholder, value) }]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Whether the peer's installed skill isn't what install-skill would
|
|
314
|
+
# write (an older bundle's, or the user's edit): the status card says
|
|
315
|
+
# so. Not installed, or unreadable: no.
|
|
316
|
+
def skill_differs?(ctx)
|
|
317
|
+
target, text = skill_target_and_text(ctx)
|
|
318
|
+
text && File.file?(target) && File.read(target) != text
|
|
319
|
+
rescue SystemCallError
|
|
320
|
+
false
|
|
302
321
|
end
|
|
303
322
|
|
|
304
323
|
def skill_card(ctx, target, text)
|
|
@@ -351,7 +370,7 @@ module Samagotchi
|
|
|
351
370
|
def follow_up(kind)
|
|
352
371
|
case kind
|
|
353
372
|
when "context" then "#{self.class::NAME} takes it in; it answers only if something in it is wrong."
|
|
354
|
-
else "#{self.class::NAME}'s answer comes as an update of the #{self.class::
|
|
373
|
+
else "#{self.class::NAME}'s answer comes as an update of the #{self.class::OUTBOX} context; don't wait or poll."
|
|
355
374
|
end
|
|
356
375
|
end
|
|
357
376
|
|
|
@@ -37,7 +37,8 @@ module Samagotchi
|
|
|
37
37
|
archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
|
|
38
38
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
39
39
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
40
|
-
clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
40
|
+
clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
41
|
+
--all: every session, not only test and scratch ones (by the age and count limits, as prune)
|
|
41
42
|
stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
|
|
42
43
|
a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
|
|
43
44
|
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
@@ -107,6 +108,8 @@ module Samagotchi
|
|
|
107
108
|
return parsed if parsed.is_a?(Integer)
|
|
108
109
|
|
|
109
110
|
@opts = integers(parsed.options)
|
|
111
|
+
return usage_error(@opts) if @opts.is_a?(String)
|
|
112
|
+
|
|
110
113
|
case @sub
|
|
111
114
|
when "list" then list
|
|
112
115
|
when "stop" then stop(parsed.args)
|
|
@@ -121,9 +124,15 @@ module Samagotchi
|
|
|
121
124
|
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
|
|
122
125
|
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
|
|
123
126
|
|
|
124
|
-
#
|
|
127
|
+
# The options with --days, --keep and --limit as integers, or the usage
|
|
128
|
+
# error's message for one that isn't a whole number (--days=abc must not
|
|
129
|
+
# turn the age limit off).
|
|
130
|
+
# @return [Hash, String]
|
|
125
131
|
def integers(options)
|
|
126
|
-
options.
|
|
132
|
+
bad = options.find { |key, value| INTEGER_OPTIONS.include?(key) && !value.to_s.match?(/\A-?\d+\z/) }
|
|
133
|
+
return "--#{bad[0]} takes a whole number, got #{bad[1].to_s.inspect}" if bad
|
|
134
|
+
|
|
135
|
+
options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? Integer(value, 10) : value] }
|
|
127
136
|
end
|
|
128
137
|
|
|
129
138
|
# The flags a list row carries: a chi scratch session, a test run, an
|
|
@@ -1328,7 +1328,12 @@ module Samagotchi
|
|
|
1328
1328
|
def join_tool_line(part, duration_ms: nil)
|
|
1329
1329
|
output = part[:output]
|
|
1330
1330
|
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1331
|
-
status = output.nil?
|
|
1331
|
+
status = if output.nil?
|
|
1332
|
+
"no result"
|
|
1333
|
+
else
|
|
1334
|
+
ToolActivity.tool_activity_status(ToolActivity.saved_result_text(output), part[:tool],
|
|
1335
|
+
command: command)
|
|
1336
|
+
end
|
|
1332
1337
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1333
1338
|
params: part[:params], status: status,
|
|
1334
1339
|
description: ToolView.description_title(part[:view] || {}) }
|
|
@@ -35,6 +35,14 @@ module Samagotchi
|
|
|
35
35
|
|
|
36
36
|
module_function
|
|
37
37
|
|
|
38
|
+
# A saved tool output's leading "ran as: …" line (ToolRunner#changed_call_line) and "[tool]" lead (the
|
|
39
|
+
# shape of ToolResponse::LEAD), which the live status never sees: it reads the tool's own result.
|
|
40
|
+
SAVED_LEAD = /\A(?:ran as: [^\n]*\n)?\[[^\]\s]+\](?:\n| |\z)/
|
|
41
|
+
|
|
42
|
+
# A saved output (what the model got) as the tool's own text, for tool_activity_status.
|
|
43
|
+
# Without a "[tool]" lead (an older or hand-made output) it stays as it is.
|
|
44
|
+
def saved_result_text(output) = output.to_s.sub(SAVED_LEAD, "")
|
|
45
|
+
|
|
38
46
|
# @param registry [Tools::Registry, nil] the session's tools
|
|
39
47
|
# @param cwd [String, nil] what a file tool's title is relative to (the
|
|
40
48
|
# worker runs in its session's working directory)
|
|
@@ -211,12 +219,16 @@ module Samagotchi
|
|
|
211
219
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
212
220
|
# signal), so the turn tally's "(N failed)" counts it; but not a grep
|
|
213
221
|
# that found nothing (no_match?, given the +command+): that stays "ok".
|
|
222
|
+
# Also for a task_wait whose task failed (exited non-zero or without an
|
|
223
|
+
# exit code): its header's status, never a line of the log's tail.
|
|
214
224
|
# "stopped" for a
|
|
215
225
|
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
216
226
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
217
227
|
# and for a command the user's Stop (or a signal to chi, a session stop)
|
|
218
228
|
# killed or kept from starting: a cancel, not a failure (its text still
|
|
219
229
|
# starts "Error:" for the model).
|
|
230
|
+
# A task_wait's own outcome is read from its header alone: the log's tail
|
|
231
|
+
# can print any line, "status: stopped" included.
|
|
220
232
|
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
221
233
|
text = result.to_s
|
|
222
234
|
return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
|
|
@@ -230,7 +242,13 @@ module Samagotchi
|
|
|
230
242
|
if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
231
243
|
return no_match?(text, command) ? "ok" : "error"
|
|
232
244
|
end
|
|
233
|
-
|
|
245
|
+
|
|
246
|
+
if tool_name == Tools::TaskWait::NAME
|
|
247
|
+
# The header: every line before the log's tail (output_tail:), which can print any line.
|
|
248
|
+
header = text.split(/^output_tail:$/, 2).first.to_s
|
|
249
|
+
return "stopped" if header.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
250
|
+
return "error" if header[/^status: (\S+)$/, 1] == "failed"
|
|
251
|
+
end
|
|
234
252
|
|
|
235
253
|
"ok"
|
|
236
254
|
end
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_output_files"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How ToolRunner cuts a tool's output that is longer than
|
|
7
|
+
# max_tool_output_chars, by the tool's policy (POLICIES, by its built-in
|
|
8
|
+
# name; any other tool gets :head).
|
|
9
|
+
#
|
|
10
|
+
# :head keeps the first chars and ends with head_line ("[cut: N of M
|
|
11
|
+
# chars; read it in parts]"): read (which has line ranges) and every tool
|
|
12
|
+
# with no policy of its own.
|
|
13
|
+
#
|
|
14
|
+
# :head_tail_file (execute) keeps a short head and a long tail, whole
|
|
15
|
+
# lines, with one gap line between them, and saves the whole output
|
|
16
|
+
# (ToolOutputFiles, through the caller's +save+): a test run's verdict and
|
|
17
|
+
# an error are at the end. The gap line names the saved file and the lines
|
|
18
|
+
# left out of it, so the model reads or rg's it instead of running the
|
|
19
|
+
# command again. execute's stdout and stderr are cut each on its own
|
|
20
|
+
# (stdout gets about two thirds of the room), its exit line always kept.
|
|
21
|
+
# The cut text still fits the cap, and comes from the content alone (no
|
|
22
|
+
# clock): the same output gives the same text.
|
|
23
|
+
#
|
|
24
|
+
# :head_tail_ref (task_wait, delegate, delegate_result) cuts the same
|
|
25
|
+
# way, as one block, and saves nothing: the whole is already in a file (a
|
|
26
|
+
# task's log, the child's reply in its output/ folder), which the tool
|
|
27
|
+
# gave with its text (Tools::RefText) and the gap line names.
|
|
28
|
+
module ToolOutputCut
|
|
29
|
+
POLICIES = { "execute" => :head_tail_file, "task_wait" => :head_tail_ref, "delegate" => :head_tail_ref,
|
|
30
|
+
"delegate_result" => :head_tail_ref }.freeze
|
|
31
|
+
|
|
32
|
+
# What a :head_tail_ref tool's file holds, for its gap line.
|
|
33
|
+
REF_WHAT = { "task_wait" => "the task's whole output", "delegate" => "the child's whole reply",
|
|
34
|
+
"delegate_result" => "the child's whole reply" }.freeze
|
|
35
|
+
|
|
36
|
+
# A head cut's last line (head_line), as LLMContextStale recognizes a
|
|
37
|
+
# partial read by it. Kept in sync with head_line by its spec.
|
|
38
|
+
HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
|
|
39
|
+
|
|
40
|
+
# What a web row keeps of a tool's output, on a reload
|
|
41
|
+
# (Web::MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
|
|
42
|
+
# row shows 300 chars and the rest on hover. A longer one keeps its head
|
|
43
|
+
# and tail (preview).
|
|
44
|
+
WEB_PREVIEW_CHARS = 2000
|
|
45
|
+
|
|
46
|
+
# The head's chars at most (execute.cut_head_chars): never more than a
|
|
47
|
+
# quarter of the room; the tail gets the rest.
|
|
48
|
+
HEAD_CHARS = 1000
|
|
49
|
+
# A tail shorter than this isn't worth it: the cap is too small, and
|
|
50
|
+
# the output gets the head cut.
|
|
51
|
+
MIN_TAIL = 200
|
|
52
|
+
|
|
53
|
+
# An execute that didn't finish: stopped, killed at the limit or
|
|
54
|
+
# handed over (its result starts with an "Error:" or a "Still running"
|
|
55
|
+
# line after the lead), or with no exit line last.
|
|
56
|
+
UNFINISHED = /\A(?:ran as: [^\n]*\n)?\[execute\]\n(?:Error: |Still running at the )/
|
|
57
|
+
|
|
58
|
+
# The marks of a text that is already a head-and-tail preview (execute's
|
|
59
|
+
# own above execute.truncate_at_bytes): its saved copy isn't the whole
|
|
60
|
+
# output.
|
|
61
|
+
PREVIEW_MARK = "[TRUNCATED_PREVIEW_HEAD]"
|
|
62
|
+
# execute's blocks (Tools::Execute#call): stdout, stderr, then the exit
|
|
63
|
+
# line last (a hint in brackets may follow it). A killed or handed-over
|
|
64
|
+
# command has none: its output isn't all there is. A command a signal
|
|
65
|
+
# ended (a segfault, an OOM kill) has an exit line with no number
|
|
66
|
+
# ("exit: "): it ended, so its output is all there is. The JS twin is
|
|
67
|
+
# turn_html.js's EXIT_LINE.
|
|
68
|
+
STDOUT_LABEL = "\nstdout:\n"
|
|
69
|
+
STDERR_LABEL = "\nstderr:\n"
|
|
70
|
+
EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?\z/
|
|
71
|
+
|
|
72
|
+
# The share of the room stdout gets when both blocks are cut.
|
|
73
|
+
STDOUT_SHARE = Rational(2, 3)
|
|
74
|
+
|
|
75
|
+
# A cut part: [from, length] of the output.
|
|
76
|
+
Block = Data.define(:from, :length) do
|
|
77
|
+
def to = from + length
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
# @param tool [String, nil] the call's tool name
|
|
83
|
+
# @return [Symbol] its policy
|
|
84
|
+
def policy(tool) = POLICIES.fetch(tool.to_s, :head)
|
|
85
|
+
|
|
86
|
+
# +output+ cut to +cap+ chars by +tool+'s policy.
|
|
87
|
+
# @param save [#call, nil] (text, dry:) → ToolOutputFiles::Saved, or a
|
|
88
|
+
# String saying why it wasn't saved ("no session", "Errno::ENOSPC");
|
|
89
|
+
# dry: true only says where it would go. It writes only once the
|
|
90
|
+
# head-and-tail cut is known to fit the cap with that file's gap
|
|
91
|
+
# line: a cut that falls back to the head cut saves nothing.
|
|
92
|
+
# @param head_chars [Integer] execute.cut_head_chars
|
|
93
|
+
# @return [Array(String, Object)] the text the model gets, and what
|
|
94
|
+
# +save+ returned (nil when it wasn't called)
|
|
95
|
+
# @param ref [String, nil] :head_tail_ref: the file the whole text is in
|
|
96
|
+
# @param ref_stderr [String, nil] :head_tail_ref: its stderr's file, if apart
|
|
97
|
+
def apply(output, cap:, tool:, save: nil, ref: nil, ref_stderr: nil, head_chars: HEAD_CHARS)
|
|
98
|
+
kind = policy(tool)
|
|
99
|
+
return [head(output, cap), nil] if kind == :head || cap < MIN_TAIL * 3
|
|
100
|
+
|
|
101
|
+
if kind == :head_tail_ref
|
|
102
|
+
text = head_tail(output, cap: cap, blocks: [Block.new(from: 0, length: output.length)], head_chars: head_chars,
|
|
103
|
+
gap: RefGap.new(output, ref, REF_WHAT.fetch(tool.to_s, "the whole output"),
|
|
104
|
+
stderr: ref_stderr))
|
|
105
|
+
return [text || head(output, cap), nil]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
blocks = execute_blocks(output)
|
|
109
|
+
planned = save ? save.call(output, dry: true) : "no session"
|
|
110
|
+
text = head_tail(output, cap: cap, blocks: blocks, head_chars: head_chars, gap: FileGap.new(output, planned))
|
|
111
|
+
return [head(output, cap), nil] unless text
|
|
112
|
+
return [text, planned] unless planned.is_a?(ToolOutputFiles::Saved)
|
|
113
|
+
|
|
114
|
+
saved = save.call(output)
|
|
115
|
+
return [text, saved] if saved.is_a?(ToolOutputFiles::Saved)
|
|
116
|
+
|
|
117
|
+
# The write failed: the line says so instead, if that still fits.
|
|
118
|
+
text = head_tail(output, cap: cap, blocks: blocks, head_chars: head_chars, gap: FileGap.new(output, saved))
|
|
119
|
+
[text || head(output, cap), text ? saved : nil]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unfinished?(output) = output.match?(UNFINISHED) || !output.match?(EXIT_LINE)
|
|
123
|
+
|
|
124
|
+
# +output+ as a web row keeps it, at most +cap+ chars (a reload's
|
|
125
|
+
# MessageParts, the Bridge's snapshot): a read's head (its view numbers
|
|
126
|
+
# the lines from the first), any other tool's head and tail with a gap
|
|
127
|
+
# line, as the model's cut keeps a test run's verdict and exit line.
|
|
128
|
+
def preview(output, cap:, tool:)
|
|
129
|
+
return output if output.length <= cap
|
|
130
|
+
return output[0, cap] if tool.to_s == "read"
|
|
131
|
+
|
|
132
|
+
head_tail(output, cap: cap, blocks: [Block.new(from: 0, length: output.length)], head_chars: HEAD_CHARS,
|
|
133
|
+
gap: RefGap.new(output, nil, nil)) || output[0, cap]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# +output+ cut to +cap+ chars, the cut line included: the model knows
|
|
137
|
+
# what it didn't get. A cap smaller than the line keeps +cap+ chars and
|
|
138
|
+
# the line goes past it.
|
|
139
|
+
def head(output, cap)
|
|
140
|
+
keep = cap - head_line(cap, output.length).length
|
|
141
|
+
keep = cap unless keep.positive?
|
|
142
|
+
"#{output[0, keep]}#{head_line(keep, output.length)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def head_line(kept, total)
|
|
146
|
+
"\n[cut: #{kept} of #{total} chars; read it in parts]"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# +output+ with each of +blocks+ that doesn't fit its share of the room
|
|
150
|
+
# cut to its head and tail, a gap line between them (the first cut's
|
|
151
|
+
# line +gap+.line, the next ones' +gap+.short). nil when the cap leaves
|
|
152
|
+
# too little room.
|
|
153
|
+
def head_tail(output, cap:, blocks:, head_chars:, gap:)
|
|
154
|
+
fixed = output.length - blocks.sum(&:length)
|
|
155
|
+
# Room for the gap lines (their longest numbers) and a newline each.
|
|
156
|
+
room = cap - fixed - (gap.line_max.length + 2) - (blocks.size > 1 ? gap.short_max.length + 2 : 0)
|
|
157
|
+
shares = shares(blocks.map(&:length), room)
|
|
158
|
+
return nil unless shares
|
|
159
|
+
|
|
160
|
+
text = +""
|
|
161
|
+
at = 0
|
|
162
|
+
first = true
|
|
163
|
+
blocks.zip(shares).each do |block, share|
|
|
164
|
+
text << output[at...block.from]
|
|
165
|
+
at = block.to
|
|
166
|
+
body = output[block.from, block.length]
|
|
167
|
+
if body.length <= share
|
|
168
|
+
text << body
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
shown = cut_block(body, share, head_chars)
|
|
173
|
+
return nil unless shown
|
|
174
|
+
|
|
175
|
+
head_part, tail_part = shown
|
|
176
|
+
left_out = Block.new(from: block.from + head_part.length, length: body.length - head_part.length - tail_part.length)
|
|
177
|
+
text << head_part
|
|
178
|
+
text << "\n" unless head_part.end_with?("\n") || head_part.empty?
|
|
179
|
+
text << (first ? gap.line(left_out) : gap.short(left_out)) << "\n" << tail_part
|
|
180
|
+
first = false
|
|
181
|
+
end
|
|
182
|
+
text << output[at..]
|
|
183
|
+
text.length <= cap ? text : nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The room each block gets: stdout STDOUT_SHARE when both need cutting,
|
|
187
|
+
# a block that needs less gives the rest to the other. nil when there
|
|
188
|
+
# isn't room for a useful tail.
|
|
189
|
+
def shares(lengths, room)
|
|
190
|
+
return nil if room < MIN_TAIL
|
|
191
|
+
return [room] if lengths.size == 1
|
|
192
|
+
|
|
193
|
+
first = (room * STDOUT_SHARE).floor
|
|
194
|
+
second = room - first
|
|
195
|
+
if lengths[1] < second
|
|
196
|
+
[room - lengths[1], lengths[1]]
|
|
197
|
+
elsif lengths[0] < first
|
|
198
|
+
[lengths[0], room - lengths[0]]
|
|
199
|
+
else
|
|
200
|
+
[first, second]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# +body+'s head and tail within +share+ chars, each snapped to whole
|
|
205
|
+
# lines when that keeps at least half of it. nil when the tail would
|
|
206
|
+
# be shorter than MIN_TAIL.
|
|
207
|
+
def cut_block(body, share, head_chars)
|
|
208
|
+
head_size = [head_chars, share / 4].min
|
|
209
|
+
tail_size = share - head_size
|
|
210
|
+
return nil if tail_size < MIN_TAIL
|
|
211
|
+
|
|
212
|
+
head_part = body[0, head_size]
|
|
213
|
+
newline = head_part.rindex("\n")
|
|
214
|
+
head_part = head_part[0, newline + 1] if newline && newline + 1 >= head_size / 2
|
|
215
|
+
tail_part = body[-tail_size..]
|
|
216
|
+
newline = tail_part.index("\n")
|
|
217
|
+
tail_part = tail_part[(newline + 1)..] if newline && tail_part.length - newline - 1 >= tail_size / 2
|
|
218
|
+
[head_part, tail_part]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# execute's stdout and stderr bodies, each cut on its own; any other
|
|
222
|
+
# text (no "stdout:"/"stderr:" block: an error, a hand-over) is one.
|
|
223
|
+
def execute_blocks(output)
|
|
224
|
+
ends = output.match(EXIT_LINE)&.begin(0) || output.length
|
|
225
|
+
stdout_at = output.index(STDOUT_LABEL)
|
|
226
|
+
stderr_at = output.rindex(STDERR_LABEL, ends - STDERR_LABEL.length)
|
|
227
|
+
stderr_at = nil if stderr_at && stdout_at && stderr_at < stdout_at
|
|
228
|
+
blocks = []
|
|
229
|
+
if stdout_at
|
|
230
|
+
from = stdout_at + STDOUT_LABEL.length
|
|
231
|
+
blocks << Block.new(from: from, length: [(stderr_at || ends) - from, 0].max)
|
|
232
|
+
end
|
|
233
|
+
blocks << Block.new(from: stderr_at + STDERR_LABEL.length, length: ends - stderr_at - STDERR_LABEL.length) if stderr_at
|
|
234
|
+
blocks.empty? ? [Block.new(from: 0, length: output.length)] : blocks
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The gap line of a :head_tail_ref cut: the file the whole text is in.
|
|
238
|
+
class RefGap
|
|
239
|
+
def initialize(output, ref, what, stderr: nil)
|
|
240
|
+
@output = output
|
|
241
|
+
@ref = ref.to_s
|
|
242
|
+
@what = what
|
|
243
|
+
@stderr = stderr.to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def line(left_out)
|
|
247
|
+
also = @stderr.empty? ? "" : " (its stderr in #{@stderr})"
|
|
248
|
+
where = @ref.empty? ? "" : "; #{@what} is in #{@ref}#{also}: read it with start_line/end_line or rg it"
|
|
249
|
+
"[cut: #{left_out.length} of #{@output.length} chars not shown#{where}]"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def short(left_out) = "[cut: #{left_out.length} chars not shown]"
|
|
253
|
+
def line_max = line(Block.new(from: 0, length: @output.length))
|
|
254
|
+
def short_max = short(Block.new(from: 0, length: @output.length))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The gap lines of a :head_tail_file cut: where the whole output is
|
|
258
|
+
# saved, and which of its lines the cut left out.
|
|
259
|
+
class FileGap
|
|
260
|
+
# @param saved [ToolOutputFiles::Saved, String] the saved file, or
|
|
261
|
+
# why there is none
|
|
262
|
+
def initialize(output, saved)
|
|
263
|
+
@output = output
|
|
264
|
+
@saved = saved.is_a?(String) ? nil : saved
|
|
265
|
+
@reason = saved.is_a?(String) ? saved : nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The first cut's line.
|
|
269
|
+
def line(left_out)
|
|
270
|
+
return "[cut: #{left_out.length} of #{@output.length} chars not shown; #{unsaved_note}]" unless @saved
|
|
271
|
+
|
|
272
|
+
"[cut: #{lines_text(left_out)} of the saved file not shown (#{left_out.length} of #{@output.length} chars); " \
|
|
273
|
+
"#{saved_note}]"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A further cut's line, the file named by the first.
|
|
277
|
+
def short(left_out)
|
|
278
|
+
return "[cut: #{left_out.length} chars not shown]" unless @saved
|
|
279
|
+
|
|
280
|
+
"[cut: #{lines_text(left_out)} of the saved file not shown (#{left_out.length} chars)]"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The longest line and short line it can write (budgeting).
|
|
284
|
+
def line_max = line(widest)
|
|
285
|
+
def short_max = short(widest)
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def widest = Block.new(from: 0, length: @output.length)
|
|
290
|
+
|
|
291
|
+
def lines_text(left_out)
|
|
292
|
+
return "lines #{lines_total}-#{lines_total}" if left_out.from.zero? && left_out.length == @output.length
|
|
293
|
+
|
|
294
|
+
first = @output[0, left_out.from].count("\n") + 1
|
|
295
|
+
last = @output[0, left_out.to].count("\n")
|
|
296
|
+
last += 1 unless left_out.to.zero? || @output[left_out.to - 1] == "\n"
|
|
297
|
+
"lines #{first}-#{[last, first].max}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def lines_total = @saved.lines
|
|
301
|
+
|
|
302
|
+
def where = "#{@saved.path} (#{@saved.bytes} bytes, #{@saved.lines} lines)"
|
|
303
|
+
|
|
304
|
+
def saved_note
|
|
305
|
+
if @output.include?(PREVIEW_MARK)
|
|
306
|
+
"this preview is saved in #{where}; the full output wasn't kept: read the preview with " \
|
|
307
|
+
"start_line/end_line or rg it"
|
|
308
|
+
elsif ToolOutputCut.unfinished?(@output)
|
|
309
|
+
"the output it printed is saved in #{where}: read it with start_line/end_line or rg it"
|
|
310
|
+
else
|
|
311
|
+
"the whole output is saved in #{where}: read it with start_line/end_line or rg it; don't rerun the command"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def unsaved_note
|
|
316
|
+
"the whole output wasn't saved (#{@reason}); to look past this, rerun with the output sent to a file " \
|
|
317
|
+
"(cmd > /tmp/out.txt 2>&1) and read that"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "atomic_file"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The whole output of a tool call ToolRunner cut (ToolOutputCut's
|
|
9
|
+
# :head_tail_file), saved in the session's folder, tool_output/
|
|
10
|
+
# (sessions/<id>/tool_output/; not output/, the session's reply outbox),
|
|
11
|
+
# so the model reads or rg's it instead of running the command again.
|
|
12
|
+
#
|
|
13
|
+
# The name comes from the content (<tool>-<sha256[0,12]>.txt): the same
|
|
14
|
+
# output gives the same path, so the cut text is the same each time
|
|
15
|
+
# (loop-guard's repeat check, the prompt cache). The files go with the
|
|
16
|
+
# session folder; a session keeps its newest MAX_FILES.
|
|
17
|
+
module ToolOutputFiles
|
|
18
|
+
DIR = "tool_output"
|
|
19
|
+
MAX_FILES = 50
|
|
20
|
+
|
|
21
|
+
# Where a saved output is, and its size.
|
|
22
|
+
Saved = Data.define(:path, :bytes, :lines)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param session_dir [String] the session's folder (Session.session_dir)
|
|
27
|
+
# @param text [String] the output, saved byte for byte
|
|
28
|
+
# @param tool [String] the tool's name, the file name's start
|
|
29
|
+
# @return [Saved]
|
|
30
|
+
# @raise [SystemCallError] when it can't be written
|
|
31
|
+
def save(session_dir, text, tool:)
|
|
32
|
+
path = planned(session_dir, text, tool: tool).path
|
|
33
|
+
dir = File.dirname(path)
|
|
34
|
+
FileUtils.mkdir_p(dir)
|
|
35
|
+
# Already there (the same output again): newest now, not rewritten.
|
|
36
|
+
# An empty one (cut short) is written again.
|
|
37
|
+
if File.size?(path)
|
|
38
|
+
FileUtils.touch(path)
|
|
39
|
+
else
|
|
40
|
+
AtomicFile.write(path, text)
|
|
41
|
+
end
|
|
42
|
+
prune(dir)
|
|
43
|
+
planned(session_dir, text, tool: tool)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Where #save would put +text+, and its size, with nothing written: the
|
|
47
|
+
# cut checks its gap line fits before anything is saved.
|
|
48
|
+
# @return [Saved]
|
|
49
|
+
def planned(session_dir, text, tool:)
|
|
50
|
+
path = File.join(session_dir, DIR, "#{file_tool(tool)}-#{Digest::SHA256.hexdigest(text)[0, 12]}.txt")
|
|
51
|
+
Saved.new(path: path, bytes: text.bytesize, lines: line_count(text))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The lines a text has, a last line with no newline counted.
|
|
55
|
+
def line_count(text)
|
|
56
|
+
text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A tool name safe in a file name (a plugin's or an MCP tool's).
|
|
60
|
+
def file_tool(tool)
|
|
61
|
+
name = tool.to_s.downcase.gsub(/[^a-z0-9_-]/, "_")
|
|
62
|
+
name.empty? ? "tool" : name
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keeps the newest MAX_FILES files of +dir+, by mtime.
|
|
66
|
+
def prune(dir)
|
|
67
|
+
files = Dir.glob(File.join(dir, "*.txt"))
|
|
68
|
+
return if files.size <= MAX_FILES
|
|
69
|
+
|
|
70
|
+
files.sort_by { |f| [-File.mtime(f).to_f, f] }.drop(MAX_FILES).each { |f| FileUtils.rm_f(f) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|