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
@@ -7,6 +7,9 @@ require_relative "guardrails"
7
7
  require_relative "vision_context"
8
8
  require_relative "log"
9
9
  require_relative "edit_preview"
10
+ require_relative "tool_output_cut"
11
+ require_relative "tool_output_files"
12
+ require_relative "tools/output_guardrails"
10
13
  require_relative "tools/tool_path"
11
14
  require_relative "memory_bundle/index_sync"
12
15
  require_relative "memory_bundle/index_size"
@@ -16,8 +19,8 @@ module Samagotchi
16
19
  # tool_call_completed events, the guardrail gate (before_tool_call hooks
17
20
  # and their veto), dispatch through KernelLoop, the after_tool_call hook
18
21
  # and the output cap. Both loops feed the model `capped_output:` (cut to
19
- # the cap with a "[cut: N of M chars …]" line), the text the event and
20
- # the hook get too.
22
+ # the cap by the tool's ToolOutputCut policy, with a "[cut: …]" line),
23
+ # the text the event and the hook get too.
21
24
  class ToolRunner
22
25
  # More images in one result are left out, each with a line (a tool
23
26
  # that returns many screenshots can't flood the context).
@@ -38,6 +41,7 @@ module Samagotchi
38
41
 
39
42
  # @param call_index [Integer] 1-based position of the call in its batch
40
43
  # @return [Hash] output:, capped_output:, truncated:, activity:,
44
+ # output_file: (the saved whole output of a cut execute result),
41
45
  # images: (refs) when the tool returned images the model gets to see, and
42
46
  # shown_params: the params line the live row showed, and shown_label:
43
47
  # its label ("chrome: screenshot"), only for a tool that isn't built in
@@ -109,7 +113,8 @@ module Samagotchi
109
113
  output = "#{changed}\n#{output}"
110
114
  end
111
115
  truncated = !max_tool_output_chars.nil? && output.length > max_tool_output_chars
112
- capped = truncated ? cut(output, max_tool_output_chars) : output
116
+ capped, saved = truncated ? cut(call, output, max_tool_output_chars, result) : output
117
+ output_file = saved.path if saved.is_a?(ToolOutputFiles::Saved)
113
118
 
114
119
  # The call's outcome as its activity line has it (worked out from the
115
120
  # full output: ok, error, blocked, stopped); a dispatcher that raised
@@ -120,6 +125,7 @@ module Samagotchi
120
125
  completed[:images] = images if images&.any?
121
126
  completed[:diff] = diff if diff
122
127
  completed[:waited_ms] = waited_ms if waited_ms
128
+ completed[:output_file] = output_file if output_file
123
129
  # Also here: a UI that missed the start (a replay gap) builds its row
124
130
  # from this event (its title is in the activity).
125
131
  completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
@@ -135,22 +141,50 @@ module Samagotchi
135
141
  run[:shown_label] = label if label
136
142
  run[:refs] = refs unless refs.empty?
137
143
  run[:diff] = diff if diff
144
+ run[:output_file] = output_file if output_file
138
145
  run
139
146
  end
140
147
 
141
148
  private
142
149
 
143
- # +output+ cut to +cap+ chars, the cut line included: the model knows
144
- # what it didn't get. A cap smaller than the line keeps +cap+ chars and
145
- # the line goes past it.
146
- def cut(output, cap)
147
- keep = cap - cut_line(cap, output.length).length
148
- keep = cap unless keep.positive?
149
- "#{output[0, keep]}#{cut_line(keep, output.length)}"
150
+ # +output+ cut to +cap+ by its tool's policy (ToolOutputCut); an
151
+ # execute result keeps its tail too, its whole output saved in the
152
+ # session's folder.
153
+ # @param result [Hash] the dispatch's: its ref_path is the file the
154
+ # whole text is in (a task's log, a child's reply: Tools::RefText),
155
+ # for task_wait and delegate; ref_stderr_path, a task's stderr log
156
+ # @return [Array(String, Object)] the text, and the saved file (or why
157
+ # there is none) when it was cut that way
158
+ def cut(call, output, cap, result)
159
+ ToolOutputCut.apply(output, cap: cap, tool: call[:name], save: ->(text, dry: false) { save_output(call, text, dry: dry) },
160
+ ref: result[:ref_path], ref_stderr: result[:ref_stderr_path],
161
+ head_chars: Tools::OutputGuardrails.config_positive_int("execute.cut_head_chars",
162
+ ToolOutputCut::HEAD_CHARS))
150
163
  end
151
164
 
152
- def cut_line(kept, total)
153
- "\n[cut: #{kept} of #{total} chars; read it in parts]"
165
+ # The whole output in the session's tool_output folder
166
+ # (ToolOutputFiles), or why it isn't saved: no session (a bare kernel)
167
+ # or the error's class. Never fails the call. +dry+: where it would go,
168
+ # nothing written (ToolOutputFiles.planned).
169
+ # @return [ToolOutputFiles::Saved, String]
170
+ def save_output(call, text, dry: false)
171
+ dir = session_dir
172
+ return "no session" if dir.to_s.empty?
173
+ return ToolOutputFiles.planned(dir, text, tool: call[:name]) if dry
174
+
175
+ ToolOutputFiles.save(dir, text, tool: call[:name])
176
+ rescue SystemCallError => e
177
+ Log.warn(:turn, "tool_output_save_failed", tool: call[:name], error: e.class.name)
178
+ e.class.name
179
+ end
180
+
181
+ # The session's folder (KernelLoop#session_dir), nil for a bare kernel
182
+ # or a lookup that failed.
183
+ def session_dir
184
+ @kernel.respond_to?(:session_dir) ? @kernel.session_dir&.call : nil
185
+ rescue StandardError => e
186
+ Log.warn(:turn, "tool_output_save_failed", error: e.class.name)
187
+ nil
154
188
  end
155
189
 
156
190
  # edit/write only: the file just before the call runs. The row diffs it
@@ -230,13 +230,17 @@ module Samagotchi
230
230
  base.changed(add: kit.add, remove: kit.remove).with(name: name)
231
231
  end
232
232
 
233
+ # The keys a config kit entry takes.
234
+ KIT_KEYS = %w[base add remove].freeze
235
+
233
236
  # The kits known: the built-ins, then config's toolkit.kits, then the
234
237
  # plugins' (chi.toolkit). A config kit with a built-in's name is left
235
238
  # out (one it can't redefine) with a line in +errors+, and so is one
236
- # that isn't a {base:, add:, remove:} map.
239
+ # that isn't a {base:, add:, remove:} map. One with another key (a
240
+ # typo, a tools: list) still loads, with a line naming the key.
237
241
  # @param config [Hash, nil] toolkit.kits
238
242
  # @param plugins [Array<Kit>]
239
- # @param errors [Array<String>] gets a line per kit left out
243
+ # @param errors [Array<String>] gets a line per kit left out or unknown key
240
244
  # @return [Hash{String => Kit}]
241
245
  def self.kits(config: nil, plugins: [], errors: [])
242
246
  kits = BUILTIN_KITS.dup
@@ -248,6 +252,9 @@ module Samagotchi
248
252
  errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
249
253
  else
250
254
  spec = spec.transform_keys(&:to_s)
255
+ (spec.keys - KIT_KEYS).each do |key|
256
+ errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:)"
257
+ end
251
258
  kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
252
259
  source: "config")
253
260
  end
@@ -66,7 +66,10 @@ module Samagotchi
66
66
  return child_id if child_id.start_with?("Error:")
67
67
 
68
68
  note = warning ? "Warning: #{warning}\n" : ""
69
- return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
69
+ if wait
70
+ result = DelegateWait.call(child_id, peers: peers, timeout: timeout)
71
+ return RefText.wrap(note + result, result.respond_to?(:ref_path) ? result.ref_path : nil)
72
+ end
70
73
 
71
74
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
72
75
  "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
@@ -7,6 +7,7 @@ require_relative "output_guardrails"
7
7
  require_relative "peers"
8
8
  require_relative "delegate_relay"
9
9
  require_relative "delegate_cursor"
10
+ require_relative "ref_text"
10
11
 
11
12
  module Samagotchi
12
13
  # Loaded on first use: child_reports requires this file (a circular
@@ -78,7 +79,8 @@ module Samagotchi
78
79
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
79
80
  outcomes.concat(relayed_outcomes.delete(key) || [])
80
81
  advance(peers.session_id, child_id, wait, state_dir: sd)
81
- return with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
82
+ text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
83
+ return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
82
84
  end
83
85
 
84
86
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
@@ -104,6 +106,15 @@ module Samagotchi
104
106
  })
105
107
  end
106
108
 
109
+ # The child's reply file a :done wait read (its output/ file), which
110
+ # ToolRunner's cut of a long reply names (ToolOutputCut's
111
+ # :head_tail_ref); nil for any other outcome.
112
+ def reply_path(child_id, wait, state_dir:)
113
+ return nil unless wait.status == :done && wait.file
114
+
115
+ File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), wait.file)
116
+ end
117
+
107
118
  # The relayed approvals' outcome lines, after the status line.
108
119
  def with_outcomes(text, outcomes)
109
120
  return text if outcomes.empty?
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # A tool's text that shows part of a file kept elsewhere in whole: a
6
+ # task's log (task_wait's output tail), a child's reply file (delegate,
7
+ # delegate_result). It is the text itself (a String), like
8
+ # Plugin::ToolResult; #ref_path is the file, which ToolRunner's cut
9
+ # names when the text is longer than max_tool_output_chars
10
+ # (ToolOutputCut's :head_tail_ref), instead of saving a copy;
11
+ # #stderr_path, a handed-over execute's stderr log, the cut names too.
12
+ class RefText < String
13
+ attr_reader :ref_path, :stderr_path
14
+
15
+ def initialize(text, ref_path, stderr_path: nil)
16
+ @ref_path = ref_path
17
+ @stderr_path = stderr_path.to_s.empty? ? nil : stderr_path
18
+ super(text.to_s)
19
+ end
20
+
21
+ # +text+ as a RefText of +ref_path+; +text+ as it is with no path.
22
+ def self.wrap(text, ref_path, stderr_path: nil)
23
+ ref_path.to_s.empty? ? text : new(text, ref_path, stderr_path: stderr_path)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -179,13 +179,24 @@ module Samagotchi
179
179
  return true if @index.sourced
180
180
  return false if toolkit&.removed&.include?(@index.call_name)
181
181
 
182
- @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
182
+ index_reaches?(toolkit)
183
183
  end
184
184
 
185
+ # Whether the index's tools would reach something under +toolkit+,
186
+ # removed or not: a source registered at load, or a built-in the
187
+ # toolkit leaves out without removing it.
188
+ def index_reaches?(toolkit)
189
+ @index.sourced || @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
190
+ end
191
+
192
+ # Not declared by +toolkit+ under +layers+. An index tool counts only
193
+ # when the toolkit removes it and it would reach something (full with
194
+ # --no-tool call_tool and no MCP server leaves nothing out).
185
195
  def left_out?(entry, layers, toolkit)
186
196
  return false unless entry.offered?(layers) && !declared?(entry, toolkit)
197
+ return true unless index_entry?(entry)
187
198
 
188
- !index_entry?(entry) || toolkit&.removed&.include?(entry.name) == true
199
+ toolkit&.removed&.include?(entry.name) == true && index_reaches?(toolkit)
189
200
  end
190
201
 
191
202
  # A built-in +toolkit+ leaves out of a turn under +layers+ without
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "task_runtime"
4
+ require_relative "ref_text"
4
5
 
5
6
  module Samagotchi
6
7
  module Tools
@@ -79,13 +80,11 @@ module Samagotchi
79
80
  end
80
81
  private_class_method :cancelled_response
81
82
 
82
- # A task the user stopped also says so, with the tail of its output:
83
- # the model sees where the job was cut off.
83
+ # Always give the log's last lines: a test run's summary, an error. A
84
+ # task the user stopped also says so.
84
85
  def self.finished_response(record, tail_lines)
85
- return format_response(record) unless record.stopped_by_user?
86
-
87
- format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
88
- extra: [TaskRuntime::USER_STOP_NOTE])
86
+ extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
87
+ format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
89
88
  end
90
89
  private_class_method :finished_response
91
90
 
@@ -121,7 +120,9 @@ module Samagotchi
121
120
  lines << output_tail.rstrip unless output_tail.empty?
122
121
  stderr_tail = stderr_tail(record, tail_lines)
123
122
  lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
124
- lines.join("\n")
123
+ # The whole log is at output_path (stderr at stderr_path): a cut of
124
+ # this text names them.
125
+ RefText.wrap(lines.join("\n"), record.output_path, stderr_path: record.stderr_path)
125
126
  end
126
127
  private_class_method :format_response
127
128
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.69.0"
4
+ VERSION = "0.71.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -896,9 +896,9 @@ module Samagotchi
896
896
  # session part is what the page reads of it (id, status, used memory
897
897
  # names, the model notes its prompt carried: the first turn's build or
898
898
  # a /model records them). ?turn_id=: that turn's answer (a queued
899
- # turn's page re-reads after the next one started). ?recent=1 (the page since this
900
- # release): the timing's records are the newest turn's (#recent_timing),
901
- # which the page merges by id; without it, the whole timing as before.
899
+ # turn's page re-reads after the next one started). The timing's records
900
+ # are the newest turn's (#recent_timing), which the page merges by id
901
+ # (the page still sends ?recent=1, which is accepted and not needed).
902
902
  def handle_tail_read(req, id)
903
903
  return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
904
904
 
@@ -919,9 +919,7 @@ module Samagotchi
919
919
  end
920
920
  session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
921
921
  live_metrics = state && state["metrics"]
922
- # Without recent=1: a tab opened before this release (it replaces its
923
- # timing with the reply's). Remove this branch after the next release.
924
- timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
922
+ timing = recent_timing(id, live_metrics)
925
923
  json_response(200, {
926
924
  tail: true,
927
925
  session: session_json,
@@ -1703,7 +1701,7 @@ module Samagotchi
1703
1701
  turn_count: (live && turn_count(live_metrics)) || turns.size)
1704
1702
  end
1705
1703
 
1706
- # The timing a ?tail=1&recent=1 read sends: a live worker's recent
1704
+ # The timing a ?tail=1 read sends: a live worker's recent
1707
1705
  # records as its metrics hold them (the newest finished turn's and
1708
1706
  # the unsaved ones, the running turn's finished calls), no
1709
1707
  # analytics.json read; with no worker the disk's, trimmed to the
@@ -10,6 +10,7 @@ require_relative "../image_store"
10
10
  require_relative "../llm_context_edit"
11
11
  require_relative "../token_usage"
12
12
  require_relative "../row_refs"
13
+ require_relative "../tool_output_cut"
13
14
 
14
15
  module Samagotchi
15
16
  module Web
@@ -50,8 +51,9 @@ module Samagotchi
50
51
  # so an output that holds the joiner itself stays whole.
51
52
  JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
52
53
  # The row shows 300 chars and the rest on hover; a read of a big file
53
- # would otherwise ride along with every reload.
54
- OUTPUT_MAX = 2000
54
+ # would otherwise ride along with every reload. A longer one keeps its
55
+ # head and tail (ToolOutputCut.preview).
56
+ OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
55
57
 
56
58
  QWEN_THINK = %r{<think>(.*?)</think>}m
57
59
  QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
@@ -246,7 +248,7 @@ module Samagotchi
246
248
  part.merge!(ToolRowFields.for(name, call, cwd: cwd))
247
249
  part[:label] = label if label.is_a?(String) && !label.empty?
248
250
  unless output.nil?
249
- part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
251
+ part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
250
252
  part[:output_truncated] = true if output.length > OUTPUT_MAX
251
253
  end
252
254
  shots = Array(images).select { |image| image.is_a?(Hash) }
@@ -67,6 +67,7 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
67
67
  if (typeof message.html === "string") {
68
68
  bubble.classList.add("markdown");
69
69
  bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ // The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
70
71
  bubble.innerHTML = message.html;
71
72
  }
72
73
  setAnswerRefs(bubble, message.display_refs);
@@ -162,6 +162,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
162
162
  .activity-alias{color:var(--text-faint);white-space:nowrap}
163
163
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
164
164
  .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
165
+ .activity-output.tail{display:flex;flex-direction:column-reverse}
165
166
  /* A row's status and tool name never wrap (a phone broke them letter by
166
167
  letter); the params take what is left. The output goes on its own line
167
168
  under the call instead of a column beside it: on a desktop it lines up
@@ -11,6 +11,7 @@ import { activityDiffTemplate } from "./diff_view.js";
11
11
  import { clip, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
+ import { handedOverId } from "./task_stop.js";
14
15
  import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
15
16
  import { emptyAnswerLine } from "./turn_events.js";
16
17
 
@@ -73,12 +74,63 @@ function llmEditTemplate(row) {
73
74
  return html`<div class="activity-edit" title=${hover}>${line}</div>`;
74
75
  }
75
76
 
77
+ // What a done row shows of its output inline: its first INLINE_CHARS, else
78
+ // for an execute's result and a task_wait with its log's last lines its
79
+ // last ones (a test run's summary, a failing exit line). An execute drops a
80
+ // bare success "exit: 0" (the mark says it) and, with no stderr block, the
81
+ // lone "stdout:" label. A guardrail deny, an approval refused or an
82
+ // execute's error has its reason first, a hand-over its task_id: the head.
83
+ // A gate-corrected call's `ran as:` line shows on hover only.
84
+ const INLINE_CHARS = 300;
85
+ // The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
86
+ // signal-killed command's has no number ("exit: ").
87
+ const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
88
+ // A bare success exit line; group 1 is the hint line after it, if any.
89
+ const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
90
+ const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
91
+ const OUTPUT_TAIL = /^output_tail:$/m;
92
+ // A gate-corrected call's first line (ToolRunner#changed_call_line) and the "[tool]" lead after it, which
93
+ // the row's lead strip misses (it isn't first). The inline text drops both; the hover keeps them.
94
+ const RAN_AS_LEAD = /^ran as: [^\n]*\n(?:\[[^\]\s]+\](?:\n| ))?/;
95
+
96
+ export function inlineOutput(tool, full) {
97
+ const text = String(full ?? "").replace(RAN_AS_LEAD, "");
98
+ if (!tailMode(tool, text)) return clip(text, INLINE_CHARS + 1);
99
+ let shown = text;
100
+ if (tool === "execute") {
101
+ shown = shown.replace(SUCCESS_EXIT_LINE, (_, hint) => hint || "");
102
+ if (!text.includes("\nstderr:\n") && shown.startsWith("stdout:\n")) shown = shown.slice("stdout:\n".length);
103
+ }
104
+ shown = shown.replace(/\n+$/, "");
105
+ return tailClip(shown.trim() ? shown : text);
106
+ }
107
+
108
+ // Whether a row's output shows its end (its div opens scrolled to the end).
109
+ export function tailMode(tool, text) {
110
+ if (tool === "task_wait") return OUTPUT_TAIL.test(text);
111
+ if (tool !== "execute" || handedOverId(text)) return false;
112
+ return EXECUTE_BLOCK.test(text) || EXIT_LINE.test(text);
113
+ }
114
+
115
+ // The text's end, as clip keeps its start: whole when it fits, else "…" and
116
+ // its last INLINE_CHARS, never from half an emoji, from a line's start when
117
+ // that keeps at least half of them (ToolOutputCut.cut_block's tail rule).
118
+ function tailClip(text) {
119
+ if (text.length <= INLINE_CHARS + 1) return text;
120
+ let kept = text.slice(-INLINE_CHARS);
121
+ const first = kept.charCodeAt(0);
122
+ if (first >= 0xdc00 && first <= 0xdfff) kept = kept.slice(1);
123
+ const nl = kept.indexOf("\n");
124
+ if (nl >= 0 && kept.length - nl - 1 >= INLINE_CHARS / 2) kept = kept.slice(nl + 1);
125
+ return `\u2026${kept}`;
126
+ }
127
+
76
128
  // A tool row: its status mark, tool, title (else params; the full command,
77
129
  // else the full params line, on hover), the word an exception shows, the
78
130
  // call's duration, the full command (an execute/task_create's view, also
79
131
  // while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
80
- // the rest on hover; a read of the file as-is also its numbered lines,
81
- // closed: read_view.js), its images (+thumbs+(images) draws them) and an
132
+ // an execute/task_wait's last ones (inlineOutput), the rest on hover; a read
133
+ // of the file as-is also its numbered lines, closed: read_view.js), its images (+thumbs+(images) draws them) and an
82
134
  // edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
83
135
  // part with nothing to say is left out. A reload renders toolRowTemplate;
84
136
  // the live view renders toolRowInnerTemplate into its own row element
@@ -107,7 +159,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
107
159
  const duration = formatDuration(row.duration_ms);
108
160
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
109
161
  const output = full
110
- ? html`<div class="activity-output" title=${full}>${clip(full, 301)}</div>`
162
+ ? html`<div class=${tailMode(row.tool, full) ? "activity-output tail" : "activity-output"} title=${full}>${inlineOutput(row.tool, full)}</div>`
111
163
  : nothing;
112
164
  const status = statusParts(row.status, { noMatch: !!row.no_match });
113
165
  return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
@@ -496,13 +496,13 @@ module Samagotchi
496
496
  # turn from context:<name> whose first message is the wake note already
497
497
  # in the conversation (named by +turn_id+), on the same rules as a
498
498
  # delegate report's wake turn: it counts toward session.max_wakes (a
499
- # peer's answer toward context.peer_turns too), and a failure goes back
499
+ # peer's answer toward context.peer_turns too, unless it agrees), and a failure goes back
500
500
  # to before the turn (the note stays, as a plain one:
501
501
  # WorkerInbound#unmark_wake) and pauses wakes until a human's input.
502
502
  # +context_wake+: a WorkerInbound::ContextWake.
503
503
  def run_context_wake_turn(context_wake)
504
504
  name = context_wake.name
505
- Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer?))
505
+ Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer_turn?))
506
506
  @turn_flow.before_prompt_turn
507
507
  run_engine_turn(nil, continue: true, origin: context_wake.origin,
508
508
  id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
@@ -47,6 +47,9 @@ module Samagotchi
47
47
  def name = delivery.name
48
48
  # For a peer agent's answer (its own budget, WorkerWakes#peer_open?).
49
49
  def peer? = !delivery.peer.nil?
50
+ # Whether the wake counts toward context.peer_turns: a peer's answer
51
+ # that agrees closes the round and counts toward session.max_wakes only.
52
+ def peer_turn? = peer? && !delivery.agreed
50
53
  def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
51
54
  end
52
55
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.69.0
4
+ version: 0.71.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -504,6 +504,8 @@ files:
504
504
  - lib/samagotchi/tool_call_parser.rb
505
505
  - lib/samagotchi/tool_declarations.rb
506
506
  - lib/samagotchi/tool_ids.rb
507
+ - lib/samagotchi/tool_output_cut.rb
508
+ - lib/samagotchi/tool_output_files.rb
507
509
  - lib/samagotchi/tool_response.rb
508
510
  - lib/samagotchi/tool_row_fields.rb
509
511
  - lib/samagotchi/tool_runner.rb
@@ -531,6 +533,7 @@ files:
531
533
  - lib/samagotchi/tools/output_guardrails.rb
532
534
  - lib/samagotchi/tools/peers.rb
533
535
  - lib/samagotchi/tools/read.rb
536
+ - lib/samagotchi/tools/ref_text.rb
534
537
  - lib/samagotchi/tools/register_reminder.rb
535
538
  - lib/samagotchi/tools/registry.rb
536
539
  - lib/samagotchi/tools/send_note.rb