samagotchi 0.23.0 → 0.24.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/cli.md +6 -1
  4. data/docs/configuration.md +9 -0
  5. data/docs/hooks.md +20 -6
  6. data/docs/plugins.md +1 -1
  7. data/docs/sub-agent.md +4 -3
  8. data/lib/samagotchi/bridge/card_store.rb +4 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  11. data/lib/samagotchi/bridge.rb +7 -0
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/config.rb +4 -0
  16. data/lib/samagotchi/engine.rb +80 -1
  17. data/lib/samagotchi/generation_phase.rb +83 -0
  18. data/lib/samagotchi/guardrails/shell_lex.rb +111 -3
  19. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  20. data/lib/samagotchi/idle_recap.rb +4 -2
  21. data/lib/samagotchi/kernel_loop.rb +19 -9
  22. data/lib/samagotchi/llm/chat_loop.rb +20 -10
  23. data/lib/samagotchi/llm/openai_chat.rb +3 -0
  24. data/lib/samagotchi/log_subscriber.rb +3 -2
  25. data/lib/samagotchi/prompt.rb +4 -1
  26. data/lib/samagotchi/steer.rb +103 -8
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  29. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  30. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  31. data/lib/samagotchi/terminal_ui.rb +5 -0
  32. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  33. data/lib/samagotchi/tool_runner.rb +7 -0
  34. data/lib/samagotchi/tool_view.rb +40 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/waiting_steer.rb +64 -0
  37. data/lib/samagotchi/web/message_parts.rb +5 -0
  38. data/lib/samagotchi/web/public/activity.js +5 -0
  39. data/lib/samagotchi/web/public/app.js +2 -1
  40. data/lib/samagotchi/web/public/command_view.js +28 -0
  41. data/lib/samagotchi/web/public/copy.js +7 -0
  42. data/lib/samagotchi/web/public/index.html +8 -0
  43. data/lib/samagotchi/web/public/stage_model.js +11 -4
  44. data/lib/samagotchi/web/public/stage_view.js +5 -2
  45. data/lib/samagotchi/web/public/turn_events.js +15 -1
  46. data/lib/samagotchi/web/public/turn_view.js +15 -6
  47. data/lib/samagotchi/worker.rb +4 -1
  48. metadata +5 -1
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A message for the running turn that may cut its generation but came too
5
+ # early (the generation hadn't streamed only thinking for long enough):
6
+ # it waits here until the thinking passes steer.cut_after (the Engine
7
+ # checks on each thinking chunk), or until a drain hands the message to
8
+ # the model at a boundary, or the turn ends. One cut covers every waiting
9
+ # message: the first one's source names it.
10
+ #
11
+ # The epoch counts the drains that took input. A caller reads it before
12
+ # it queues its message; a drain after that may have taken the message
13
+ # already, so the message doesn't wait (it may be in the model's prompt).
14
+ # Written from the UIs' threads and the turn's; its own Mutex.
15
+ class WaitingSteer
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @source = nil
19
+ @epoch = 0
20
+ end
21
+
22
+ # @return [Integer] the drains that took input so far
23
+ def epoch
24
+ @mutex.synchronize { @epoch }
25
+ end
26
+
27
+ # +source+'s message waits for a cut, unless a drain took input since
28
+ # +epoch+. Returns whether it waits.
29
+ def wait!(source, epoch: nil)
30
+ @mutex.synchronize do
31
+ next false if epoch && epoch != @epoch
32
+
33
+ @source ||= source.to_s
34
+ true
35
+ end
36
+ end
37
+
38
+ def waiting?
39
+ @mutex.synchronize { !@source.nil? }
40
+ end
41
+
42
+ # The waiting source, taken (nil when none waits).
43
+ def take
44
+ @mutex.synchronize do
45
+ source = @source
46
+ @source = nil
47
+ source
48
+ end
49
+ end
50
+
51
+ # A drain took input: what waited is delivered.
52
+ def delivered!
53
+ @mutex.synchronize do
54
+ @epoch += 1
55
+ @source = nil
56
+ end
57
+ end
58
+
59
+ # The turn ended, or a cut happened: nothing waits.
60
+ def clear!
61
+ @mutex.synchronize { @source = nil }
62
+ end
63
+ end
64
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../model_profile"
4
4
  require_relative "../tool_call_parser"
5
5
  require_relative "../tool_activity"
6
+ require_relative "../tool_view"
6
7
  require_relative "../llm/native_tool_normalizer"
7
8
  require_relative "../tools/builtins"
8
9
  require_relative "../image_store"
@@ -158,6 +159,10 @@ module Samagotchi
158
159
  part = { tool: name, params: params.to_s }
159
160
  title = ToolActivity.tool_title(name, call, cwd: cwd)
160
161
  part[:title] = title if title
162
+ # Built from the saved call (never saved itself), so older sessions
163
+ # get it too.
164
+ view = ToolView.for(name, call)&.to_h
165
+ part[:view] = view if view
161
166
  part[:label] = label if label.is_a?(String) && !label.empty?
162
167
  unless output.nil?
163
168
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
@@ -50,6 +50,7 @@ export function addStarted(model, event = {}) {
50
50
  row.label = event.label || row.label;
51
51
  row.params = event.params || row.params;
52
52
  row.title = event.title || row.title;
53
+ row.view = event.view || row.view;
53
54
  return { row, created: false };
54
55
  }
55
56
  row = {
@@ -63,6 +64,9 @@ export function addStarted(model, event = {}) {
63
64
  // project-relative path, a command without its "cd … &&"); rows show it
64
65
  // in place of the params when there is one.
65
66
  title: event.title || null,
67
+ // The call's full arguments for the expanded row and the hovers (the
68
+ // server's ToolView: an execute/task_create's command, its cwd).
69
+ view: event.view || null,
66
70
  status: ROW_STATUS.RUNNING,
67
71
  output: "",
68
72
  output_truncated: false,
@@ -87,6 +91,7 @@ export function addCompleted(model, event = {}) {
87
91
  label: event.label,
88
92
  params: event.activity?.params || "",
89
93
  title: event.activity?.title,
94
+ view: event.view,
90
95
  });
91
96
  row = synthesized.row;
92
97
  }
@@ -45,7 +45,7 @@ import {
45
45
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
46
46
  } from "./timing.js";
47
47
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
48
- import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
+ import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
49
49
 
50
50
  const $ = (s) => document.querySelector(s);
51
51
  const topStripEl = $("#topStrip");
@@ -1861,6 +1861,7 @@ const streamHandlers = {
1861
1861
  // The loop asks again after an empty answer: a row of the empty step
1862
1862
  // (a reload puts it back from the snapshot's cards).
1863
1863
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1864
+ steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
1864
1865
  // The provider is asked again after an error, or the turn waits for
1865
1866
  // plugins' setup: a note on the live timing line until it streams.
1866
1867
  generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
@@ -0,0 +1,28 @@
1
+ // A tool call's full command in a richer UI than the TUI's params line:
2
+ // the server's view (ToolView: an execute/task_create's command uncut up to
3
+ // 8,000 chars, the call's cwd: argument). Pure HTML builders, shared by the
4
+ // live row (turn_view.js fillActivityRow) and the reloaded one
5
+ // (reloadRowHtml), so the two can't differ.
6
+ import { copyButtonHtml } from "./copy.js";
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // What a row's title hovers: the full command when the call has one,
10
+ // else the params line when the row shows a title in its place.
11
+ export function rowHover(row) {
12
+ if (row?.view?.command) return row.view.command;
13
+ return row?.title && row?.params ? row.params : "";
14
+ }
15
+
16
+ // The expanded row's command block: "in <cwd>" when the call gave one (as
17
+ // given: execute's may be relative to the project root), the command as
18
+ // written (whitespace kept, its own scroll), a copy button, and a line
19
+ // saying how much a cut command left out. "" for a call without a view.
20
+ export function commandBlockHtml(view) {
21
+ const command = view?.command;
22
+ if (!command) return "";
23
+ const cwd = view.cwd ? `<div class="activity-command-cwd">in <span>${escapeHtml(view.cwd)}</span></div>` : "";
24
+ const cut = view.truncated
25
+ ? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
26
+ : "";
27
+ return `<div class="activity-command code-wrap">${cwd}<pre><code>${escapeHtml(command)}</code></pre>${copyButtonHtml("code")}${cut}</div>`;
28
+ }
@@ -88,6 +88,13 @@ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"
88
88
  const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
89
89
  const CODE = ".bubble.output pre";
90
90
 
91
+ // The button as markup, for a builder that returns HTML (a tool row's
92
+ // command block, command_view.js); the same click handler answers it.
93
+ export function copyButtonHtml(kind) {
94
+ const title = kind === "code" ? "Copy code" : "Copy";
95
+ return `<button type="button" class="copy-btn copy-${kind}" title="${title}" aria-label="${title}">${ICON}</button>`;
96
+ }
97
+
91
98
  function makeButton(doc, kind) {
92
99
  const btn = doc.createElement("button");
93
100
  btn.type = "button";
@@ -134,10 +134,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
134
134
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
135
135
  .activity-diff>summary:hover{color:var(--text-2)}
136
136
  .activity-diff[open]>summary{margin-bottom:4px}
137
+ /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
138
+ .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px 60px}
139
+ .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
140
+ .activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
141
+ .activity-command-cwd{margin-bottom:2px}
142
+ .activity-command-cwd>span{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-muted)}
143
+ .activity-command-cut{margin-top:2px}
137
144
  @media(max-width:600px){
138
145
  .activity-params{flex:1 1 0}
139
146
  .activity-output{margin-left:0}
140
147
  .activity-diff{margin-left:0}
148
+ .activity-row>.activity-command{margin-left:0}
141
149
  }
142
150
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
143
151
  .bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
@@ -78,6 +78,12 @@ function rowTitle(row) {
78
78
  return row.title || row.params || "";
79
79
  }
80
80
 
81
+ // A slot's full command (the row's view), for its hover; none for a tool
82
+ // without one.
83
+ function withCommand(slot, row) {
84
+ return row.view?.command ? { ...slot, command: row.view.command } : slot;
85
+ }
86
+
81
87
  // While the first step has no thinking and no tool rows the turn is plain:
82
88
  // its text streams in the stage's answer slot, as the turn view's
83
89
  // chrome-less block streams like a bubble.
@@ -98,9 +104,10 @@ const NO_ANSWER = "no answer";
98
104
  // headline the newest narration sentence (+narration+, the view's sentence
99
105
  // feed), else the newest thinking one (+thinking+, its ticker),
100
106
  // else "…";
101
- // tool the running call ({name, title, kind}) or null;
107
+ // tool the running call ({name, title, kind}, and command: its full
108
+ // command when it has one) or null;
102
109
  // trail the last three finished calls, newest last (a file's title
103
- // without its dir);
110
+ // without its dir; command as for tool);
104
111
  // ticks one per call: {kind, status, step (the gen's index)}.
105
112
  // +pending+: a card waits for the user; +ended+: the turn's end kind;
106
113
  // +emptyAnswer+: a completed turn ended with no answer
@@ -123,13 +130,13 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
123
130
  else if (thinking) headline = { text: thinking, thinking: true };
124
131
  const trail = rows.filter(({ row }) => row.status !== "running").slice(-TRAIL).map(({ row }) => {
125
132
  const title = rowTitle(row);
126
- return { name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status };
133
+ return withCommand({ name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status }, row);
127
134
  });
128
135
  return {
129
136
  phase,
130
137
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
131
138
  headline,
132
- tool: running ? { name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) } : null,
139
+ tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
133
140
  trail,
134
141
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
135
142
  };
@@ -195,6 +195,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
195
195
  const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
196
196
  chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
197
197
  miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
198
+ miniTool.title = (collapsed && slots.tool?.command) || "";
198
199
 
199
200
  live.hidden = plain || !!endKind;
200
201
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
@@ -222,7 +223,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
222
223
 
223
224
  function drawNow(tool, gen) {
224
225
  const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
225
- const key = tool ? `run|${tool.name}|${tool.title}` : `quiet|${quiet}`;
226
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}` : `quiet|${quiet}`;
226
227
  if (shown.now === key) return;
227
228
  shown.now = key;
228
229
  now.replaceChildren();
@@ -233,7 +234,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
233
234
  name.textContent = tool.name;
234
235
  const title = node("span", "ts-title", now);
235
236
  title.textContent = tool.title;
236
- title.title = tool.title;
237
+ // The full command when the call has one (the title is its first line).
238
+ title.title = tool.command || tool.title;
237
239
  } else {
238
240
  const word = node("span", "ts-title quiet", now);
239
241
  word.textContent = quiet;
@@ -251,6 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
251
253
  const failed = item.status === "error";
252
254
  const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
253
255
  el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
256
+ if (item.command) el.title = item.command;
254
257
  }
255
258
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
256
259
  }
@@ -123,6 +123,17 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
123
123
  return `↻ ${what}, asking again (${attempt}/${of})`;
124
124
  }
125
125
 
126
+ // The row a step gets when a message for the running turn cut a generation
127
+ // that was only thinking (steer_cut; source "" is the user's, as is an
128
+ // unknown one). The TUI words it the same (spec/shared/labels_matrix.json).
129
+ const STEER_CUT_FOR = {
130
+ chi_send: "a message sent with chi send",
131
+ parent_agent: "the parent agent's message",
132
+ };
133
+ export function steerCutLine({ source } = {}) {
134
+ return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
135
+ }
136
+
126
137
  // The one muted line for a turn that ended with no answer (turn_completed's
127
138
  // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
128
139
  // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
@@ -136,6 +147,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
136
147
  // notice as "<label>: text", the loop's asking-again row as its line.
137
148
  export function noticeLine(entry = {}) {
138
149
  if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
150
+ if (entry.type === "steer_cut") return steerCutLine(entry);
139
151
  return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
140
152
  }
141
153
 
@@ -451,7 +463,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
451
463
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
452
464
  if (part.label) call.label = part.label;
453
465
  const title = part.title ? { title: part.title } : {};
454
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
466
+ const view = part.view ? { view: part.view } : {};
467
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...view });
455
468
  if (part.status !== "running") {
456
469
  // The activity as the live event had it; action and duration_ms
457
470
  // only in snapshots from a worker that keeps them (the TUI's
@@ -463,6 +476,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
463
476
  };
464
477
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
465
478
  if (part.diff) completed.diff = part.diff;
479
+ if (part.view) completed.view = part.view;
466
480
  events.push(withImages(completed, part.images));
467
481
  }
468
482
  break;
@@ -12,6 +12,7 @@
12
12
  import { toolName } from "./activity.js";
13
13
  import { leavesBlock } from "./card.js";
14
14
  import { copySourceAttr } from "./copy.js";
15
+ import { commandBlockHtml, rowHover } from "./command_view.js";
15
16
  import { activityDiffHtml } from "./diff_view.js";
16
17
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
18
  import { thumbsHtml } from "./images.js";
@@ -45,11 +46,17 @@ function fillActivityRow(el, row, sessionId) {
45
46
  if (toolEl) toolEl.textContent = toolName(row);
46
47
  const paramsEl = el.querySelector(".activity-params");
47
48
  if (paramsEl) {
48
- // The title in place of the params; the full params line on hover.
49
+ // The title in place of the params; the full command (else the full
50
+ // params line) on hover.
49
51
  paramsEl.textContent = row.title || row.params || "";
50
- paramsEl.title = row.title && row.params ? row.params : "";
52
+ paramsEl.title = rowHover(row);
51
53
  }
52
54
  const outEl = el.querySelector(".activity-output");
55
+ // The call's full command, above its output (running too).
56
+ if (row.view?.command && !el.querySelector(".activity-command")) {
57
+ if (outEl) outEl.insertAdjacentHTML("beforebegin", commandBlockHtml(row.view));
58
+ else el.insertAdjacentHTML("beforeend", commandBlockHtml(row.view));
59
+ }
53
60
  if (outEl) {
54
61
  if (row.status === "running") {
55
62
  outEl.style.display = "none";
@@ -667,14 +674,15 @@ function defaultPlaceAnswer(appendToHistory) {
667
674
  };
668
675
  }
669
676
 
670
- // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
671
- // 300 chars, the rest on hover), plus the call's duration from its record.
677
+ // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, the full command (an
678
+ // execute/task_create's view), output (its "[tool]" tag dropped, 300 chars, the rest on hover), plus the call's
679
+ // duration from its record.
672
680
  // Params, output and the call's images come from the saved messages'
673
681
  // parts; without them the row has the record's name, status and duration
674
682
  // only. +thumbs(images)+ draws the images, as the live row does.
675
683
  function reloadRowHtml(r, thumbs) {
676
684
  const shown = r.title || r.params;
677
- const hover = r.title && r.params ? ` title="${escapeHtml(r.params)}"` : "";
685
+ const hover = rowHover(r) ? ` title="${escapeHtml(rowHover(r))}"` : "";
678
686
  const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
679
687
  const duration = formatDuration(r.duration_ms);
680
688
  const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
@@ -682,7 +690,8 @@ function reloadRowHtml(r, thumbs) {
682
690
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
683
691
  : "";
684
692
  return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
685
- `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
693
+ `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
694
+ `${commandBlockHtml(r.view)}${output}` +
686
695
  `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
687
696
  }
688
697
 
@@ -7,6 +7,7 @@ require_relative "session"
7
7
  require_relative "session_inbox"
8
8
  require_relative "turn_note"
9
9
  require_relative "context_note"
10
+ require_relative "steer"
10
11
  require_relative "worker_idle_exit"
11
12
  require_relative "session_manager"
12
13
  require_relative "archive_store"
@@ -571,7 +572,9 @@ module Samagotchi
571
572
  @engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
572
573
  @merged_this_turn.concat(merged)
573
574
  end
574
- merged.map(&:first)
575
+ # Each line keeps its sender (Steer.source_for_client): a chi send or
576
+ # a delegating parent's line is saved and shown to the model as theirs.
577
+ merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) }
575
578
  end
576
579
  end
577
580
 
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.23.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -223,6 +223,7 @@ files:
223
223
  - lib/samagotchi/engine.rb
224
224
  - lib/samagotchi/events.rb
225
225
  - lib/samagotchi/gem_update.rb
226
+ - lib/samagotchi/generation_phase.rb
226
227
  - lib/samagotchi/guardrail_wiring.rb
227
228
  - lib/samagotchi/guardrails.rb
228
229
  - lib/samagotchi/guardrails/approval.rb
@@ -380,6 +381,7 @@ files:
380
381
  - lib/samagotchi/tool_declarations.rb
381
382
  - lib/samagotchi/tool_response.rb
382
383
  - lib/samagotchi/tool_runner.rb
384
+ - lib/samagotchi/tool_view.rb
383
385
  - lib/samagotchi/tools/args.rb
384
386
  - lib/samagotchi/tools/ask_user_question.rb
385
387
  - lib/samagotchi/tools/builtin_calls.rb
@@ -420,6 +422,7 @@ files:
420
422
  - lib/samagotchi/version.rb
421
423
  - lib/samagotchi/vision_context.rb
422
424
  - lib/samagotchi/vision_support.rb
425
+ - lib/samagotchi/waiting_steer.rb
423
426
  - lib/samagotchi/web/app.rb
424
427
  - lib/samagotchi/web/info_probe.rb
425
428
  - lib/samagotchi/web/lan.rb
@@ -434,6 +437,7 @@ files:
434
437
  - lib/samagotchi/web/public/chunk_router.js
435
438
  - lib/samagotchi/web/public/command_complete.js
436
439
  - lib/samagotchi/web/public/command_list.js
440
+ - lib/samagotchi/web/public/command_view.js
437
441
  - lib/samagotchi/web/public/composer_size.js
438
442
  - lib/samagotchi/web/public/copy.js
439
443
  - lib/samagotchi/web/public/ctx.js