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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +4 -2
  5. data/docs/configuration.md +6 -5
  6. data/docs/context.md +15 -6
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/background-tasks.md +2 -2
  9. data/docs/internals/engine.md +10 -5
  10. data/docs/internals/tool-guardrails.md +61 -1
  11. data/docs/plugins.md +24 -19
  12. data/docs/sessions.md +3 -3
  13. data/docs/sub-agent.md +2 -1
  14. data/docs/testing.md +3 -2
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -2
  16. data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
  17. data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
  18. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
  19. data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
  20. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
  21. data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
  22. data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
  23. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
  24. data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
  25. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/config.rb +6 -2
  28. data/lib/samagotchi/context_absorber.rb +35 -21
  29. data/lib/samagotchi/engine.rb +2 -0
  30. data/lib/samagotchi/kernel_loop.rb +10 -0
  31. data/lib/samagotchi/llm_context_stale.rb +2 -2
  32. data/lib/samagotchi/log_subscriber.rb +1 -0
  33. data/lib/samagotchi/peer_sources.rb +1 -1
  34. data/lib/samagotchi/plugin/api.rb +3 -2
  35. data/lib/samagotchi/plugin/peer.rb +35 -16
  36. data/lib/samagotchi/sessions_command.rb +12 -3
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -1
  38. data/lib/samagotchi/tool_activity.rb +19 -1
  39. data/lib/samagotchi/tool_output_cut.rb +321 -0
  40. data/lib/samagotchi/tool_output_files.rb +73 -0
  41. data/lib/samagotchi/tool_runner.rb +46 -12
  42. data/lib/samagotchi/toolkit.rb +9 -2
  43. data/lib/samagotchi/tools/delegate.rb +4 -1
  44. data/lib/samagotchi/tools/delegate_wait.rb +12 -1
  45. data/lib/samagotchi/tools/ref_text.rb +27 -0
  46. data/lib/samagotchi/tools/registry.rb +13 -2
  47. data/lib/samagotchi/tools/task_wait.rb +8 -7
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +5 -7
  50. data/lib/samagotchi/web/message_parts.rb +5 -3
  51. data/lib/samagotchi/web/public/answer_refs.js +1 -0
  52. data/lib/samagotchi/web/public/index.html +1 -0
  53. data/lib/samagotchi/web/public/turn_html.js +55 -3
  54. data/lib/samagotchi/worker.rb +2 -2
  55. data/lib/samagotchi/worker_inbound.rb +3 -0
  56. 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>-review`, where
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, REVIEW ("<peer>-inbox", "-review"), BUNDLE, NAME (the peer, as
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 review source is the peer's answers: its own wake budget and
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::REVIEW, label: peer::NAME if chi.respond_to?(:peer_source)
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::REVIEW} context"
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, review = [peer::INBOX, peer::REVIEW].map { |name| sources.find { |source| source[:name] == name } }
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::REVIEW}): #{review_line(review)}" if review
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 review_line(review)
275
- return "none yet" unless review[:fetched_at]
275
+ def outbox_line(outbox)
276
+ return "none yet" unless outbox[:fetched_at]
276
277
 
277
- [review[:summary], ago(review[:fetched_at])].compact.join(" · ")
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
- source = peer::SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
295
- return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless source
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
- text = substitutions.reduce(File.read(source)) { |skill, (placeholder, value)| skill.gsub(placeholder, value) }
300
- target = File.join(peer_dir, "skills", "chi-peer", "SKILL.md")
301
- yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
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::REVIEW} context; don't wait or poll."
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
- # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
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.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
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? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
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
- return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
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