samagotchi 0.29.0 → 0.31.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +4 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/plugins.md +1 -1
  8. data/docs/sessions.md +11 -4
  9. data/lib/samagotchi/bridge/card_store.rb +1 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/child_reports.rb +214 -0
  19. data/lib/samagotchi/client.rb +35 -4
  20. data/lib/samagotchi/command_steps.rb +35 -15
  21. data/lib/samagotchi/config.rb +5 -0
  22. data/lib/samagotchi/engine.rb +2 -0
  23. data/lib/samagotchi/host_registry.rb +4 -0
  24. data/lib/samagotchi/idle_recap.rb +4 -0
  25. data/lib/samagotchi/kernel_loop.rb +63 -11
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -7
  27. data/lib/samagotchi/llm/errors.rb +9 -0
  28. data/lib/samagotchi/log_subscriber.rb +1 -1
  29. data/lib/samagotchi/model_list_store.rb +117 -0
  30. data/lib/samagotchi/model_profile.rb +43 -0
  31. data/lib/samagotchi/prompt_warmup.rb +30 -5
  32. data/lib/samagotchi/reply_wait.rb +39 -8
  33. data/lib/samagotchi/send_command.rb +5 -3
  34. data/lib/samagotchi/session.rb +31 -5
  35. data/lib/samagotchi/session_inbox.rb +34 -1
  36. data/lib/samagotchi/session_manager.rb +41 -5
  37. data/lib/samagotchi/session_metrics.rb +6 -3
  38. data/lib/samagotchi/steer.rb +39 -7
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  40. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  41. data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
  42. data/lib/samagotchi/terminal_ui.rb +44 -5
  43. data/lib/samagotchi/tool_activity.rb +6 -2
  44. data/lib/samagotchi/tool_call_parser.rb +40 -0
  45. data/lib/samagotchi/tool_declarations.rb +7 -7
  46. data/lib/samagotchi/tools/delegate.rb +26 -13
  47. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  48. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  49. data/lib/samagotchi/tools/execute.rb +3 -1
  50. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  51. data/lib/samagotchi/tools/task_wait.rb +2 -2
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +8 -2
  54. data/lib/samagotchi/web/public/activity.js +2 -1
  55. data/lib/samagotchi/web/public/app.js +21 -11
  56. data/lib/samagotchi/web/public/command_view.js +19 -5
  57. data/lib/samagotchi/web/public/format.js +8 -1
  58. data/lib/samagotchi/web/public/index.html +14 -6
  59. data/lib/samagotchi/web/public/notify.js +4 -2
  60. data/lib/samagotchi/web/public/stage_model.js +18 -0
  61. data/lib/samagotchi/web/public/stage_view.js +16 -11
  62. data/lib/samagotchi/web/public/task_stop.js +11 -2
  63. data/lib/samagotchi/web/public/timing.js +12 -7
  64. data/lib/samagotchi/web/public/turn_events.js +29 -9
  65. data/lib/samagotchi/web/public/turn_model.js +16 -11
  66. data/lib/samagotchi/web/public/turn_view.js +49 -7
  67. data/lib/samagotchi/worker.rb +240 -23
  68. metadata +4 -1
@@ -19,17 +19,26 @@ export function stopTarget(row, features) {
19
19
  return row.view?.task_id || null;
20
20
  }
21
21
 
22
+ // A task_wait row's title without its wait ("npm test · up to 600s").
23
+ const WAIT_SUFFIX = / · up to \d+s$/;
24
+
22
25
  // The command a task_create of this turn started +taskId+ with (its row's
23
- // view), or null: a task from an earlier turn has no row here.
26
+ // view), else a task_wait row's title for it (the server titles one by its
27
+ // task's command, also for a task from an earlier turn: ToolActivity.
28
+ // task_title), or null.
24
29
  export function taskCommand(gens, taskId) {
30
+ let titled = null;
25
31
  for (const gen of gens || []) {
26
32
  for (const row of gen.tools || []) {
33
+ if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
34
+ titled ||= row.title.replace(WAIT_SUFFIX, "");
35
+ }
27
36
  if (row.tool !== "task_create") continue;
28
37
  const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
29
38
  if (id === taskId) return row.view?.command || null;
30
39
  }
31
40
  }
32
- return null;
41
+ return titled;
33
42
  }
34
43
 
35
44
  export function confirmText(taskId, command) {
@@ -24,20 +24,25 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
24
24
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
25
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
26
 
27
- // The line a canceled turn ends with: "✕ canceled (stopped)", live from
27
+ // The line a canceled turn ends with: "■ canceled (stopped)", live from
28
28
  // turn_canceled's reason and after a re-render from the turn record's
29
29
  // cancellation_reason (records saved before it existed: no reason). A
30
- // hook's stop that names who (+by+, cancelled_by): "✕ stopped by loop-guard".
30
+ // hook's stop that names who (+by+, cancelled_by): "■ stopped by loop-guard".
31
+ // Every cancel is a stop someone chose (the user, Ctrl-C, a hook), not a
32
+ // failure: the ■ mark and the stopped colours of a call the Stop cut
33
+ // (STOPPED_LINE_CLASS), not the ✕ of an error.
34
+ export const STOP_MARK = "\u25A0";
35
+ export const STOPPED_LINE_CLASS = "bubble cancel stopped";
31
36
  export function cancelLineText(reason, by) {
32
- if (reason === "hook" && by) return `\u2715 stopped by ${by}`;
33
- return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
37
+ if (reason === "hook" && by) return `${STOP_MARK} stopped by ${by}`;
38
+ return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
34
39
  }
35
40
 
36
- // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
37
- // record is not a canceled one.
41
+ // A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
42
+ // the turn record is not a canceled one.
38
43
  export function cancelLineHtml(record, escape) {
39
44
  if (record?.status !== "canceled") return "";
40
- return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
45
+ return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
41
46
  }
42
47
 
43
48
  export function elapsedSince(startedAt, now = Date.now()) {
@@ -14,10 +14,12 @@ export function turnOutput(data) {
14
14
 
15
15
  // The end line for a turn whose worker went away mid-turn (the footer's
16
16
  // stop, a crash): no turn_canceled comes, so the page ends the turn itself.
17
+ // The footer's stop is the user's, drawn as a cancel is (\u25A0, timing.js
18
+ // cancelLineText); a worker that exited on its own is a failure (\u2715).
17
19
  // null when no turn was running.
18
20
  export function workerGoneText({ turnRunning, stopped = false }) {
19
21
  if (!turnRunning) return null;
20
- return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
22
+ return stopped ? "\u25A0 canceled (session stopped)" : "\u2715 canceled (worker exited)";
21
23
  }
22
24
 
23
25
  // How long a turn's answer waits for the after_turn hooks' answer_display
@@ -91,7 +93,13 @@ export function isOwn(clientId, myId) {
91
93
 
92
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
93
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
94
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
96
+ const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
97
+
98
+ // A delegate child's report comes in as client child:<id8> (ChildReports).
99
+ const REPORT_CLIENT = "child:";
100
+ export function isReportClient(clientId) {
101
+ return String(clientId || "").startsWith(REPORT_CLIENT);
102
+ }
95
103
 
96
104
  // The label on another client's prompt, by its client_id prefix.
97
105
  export function clientLabel(clientId) {
@@ -117,9 +125,10 @@ export function hookNoticeLabel(hook) {
117
125
  }
118
126
 
119
127
  // The row a step gets when the loop asks again after an empty answer, or
120
- // after a plugin cut the generation (stopped_by: the bundle).
121
- export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
122
- const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
128
+ // after a plugin cut the generation (stopped_by: the bundle), or after a
129
+ // corrupt native generation (malformed).
130
+ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy, malformed } = {}) {
131
+ const what = stoppedBy ? `cut by ${stoppedBy}` : malformed ? "malformed answer" : "empty answer";
123
132
  return `↻ ${what}, asking again (${attempt}/${of})`;
124
133
  }
125
134
 
@@ -379,9 +388,14 @@ export function composerAfterAck({ sent = "", current = "", refill = "" } = {})
379
388
  // tag — this tab's own local echo gets its enqueued_id
380
389
  // start — the bubble's turn started (drops the "queued" badge)
381
390
  // steer — the bubble was merged into the running turn
391
+ // report — the merge brought a delegate child's report (origin
392
+ // child:<id8>, no bubble anywhere): its text gets a labelled one
382
393
  // `known(enqueuedId)` says whether a bubble carries that id already;
383
- // `unmatchedMerge` whether the last input_merged had an origin with no bubble.
384
- export function promptOps(event, { myId, known, unmatchedMerge = false }) {
394
+ // `unmatchedMerge` whether the last input_merged had an origin with no
395
+ // bubble ("report": a delegate report's); `wakeStart` whether this is the
396
+ // first merge of a turn chi ran for reports (its bubble starts the turn,
397
+ // no "steered" badge, as a reload shows it).
398
+ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStart = false }) {
385
399
  if (!event) return [];
386
400
  switch (event.type) {
387
401
  case "turn_enqueued": {
@@ -401,12 +415,18 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
401
415
  }
402
416
  return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
403
417
  }
404
- case "input_merged":
405
- return (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
418
+ case "input_merged": {
419
+ const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
406
420
  known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
407
421
  );
422
+ if ((event.origins || []).some((o) => o && !o.enqueued_id && isReportClient(o.client_id))) ops.push({ op: "report" });
423
+ return ops;
424
+ }
408
425
  case "pending_input_merged":
409
426
  if (!unmatchedMerge || !event.content) return [];
427
+ if (unmatchedMerge === "report") {
428
+ return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
429
+ }
410
430
  return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
411
431
  default:
412
432
  return [];
@@ -176,21 +176,26 @@ export function failedCount(rows) {
176
176
  // call's title ("edit lib/a.rb"), else the tools it worked with, else
177
177
  // "thinking"; then its call count and how many failed.
178
178
  export function genLabel(gen) {
179
+ const { head, calls } = genLabelParts(gen);
180
+ return calls ? `${head} · ${calls}` : head;
181
+ }
182
+
183
+ // genLabel in its parts: +head+, +calls+ ("3 tool calls (1 failed)", or ""
184
+ // without calls) and +repeats+: the head is words the open step's body
185
+ // starts with (its narration, its first call's title), so an open step's
186
+ // summary can say only its calls (turn_view.js labelHtml).
187
+ export function genLabelParts(gen) {
179
188
  const line = String(gen.text || "").trim().split("\n")[0].trim();
180
- const calls = gen.tools.length;
189
+ const calls = gen.tools.length ? toolCallsText(gen.tools) : "";
181
190
  const first = gen.tools[0];
182
- let head;
183
- if (line) {
184
- head = cut(line);
185
- } else if (first?.title) {
186
- head = cut(`${toolName(first)} ${first.title}`);
187
- } else if (calls) {
191
+ if (line) return { head: cut(line), calls, repeats: true };
192
+ if (first?.title) return { head: cut(`${toolName(first)} ${first.title}`), calls, repeats: true };
193
+ if (gen.tools.length) {
188
194
  const names = [...new Set(gen.tools.map(toolName))];
189
- head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
190
- } else {
191
- return "thinking";
195
+ const head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
196
+ return { head, calls, repeats: false };
192
197
  }
193
- return calls ? `${head} · ${toolCallsText(gen.tools)}` : head;
198
+ return { head: "thinking", calls: "", repeats: false };
194
199
  }
195
200
 
196
201
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
@@ -14,7 +14,7 @@ import { leavesBlock } from "./card.js";
14
14
  import { copySourceAttr } from "./copy.js";
15
15
  import { commandBlockHtml, rowHover } from "./command_view.js";
16
16
  import { activityDiffHtml } from "./diff_view.js";
17
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
18
18
  import { thumbsHtml } from "./images.js";
19
19
  import { shouldFollowScroll } from "./scroll.js";
20
20
  import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
@@ -399,7 +399,7 @@ export function createTurnView({
399
399
  // an ellipsed label): at close, and again when a late tool result lands
400
400
  // in a step that already closed.
401
401
  function writeClosedLabel(entry, gen) {
402
- entry.summary.textContent = model.genLabel(gen);
402
+ setLabel(entry.summary, gen);
403
403
  entry.el.classList.toggle("has-failed", model.failedCount(gen.tools) > 0);
404
404
  }
405
405
 
@@ -551,7 +551,7 @@ export function createTurnView({
551
551
  }
552
552
  entry.text = document.createElement("div");
553
553
  entry.text.className = "gen-text";
554
- entry.summary.textContent = model.genLabel({ ...gen, text: "" });
554
+ setLabel(entry.summary, { ...gen, text: "" });
555
555
  model.takeAnswer(turn, gen);
556
556
  // Cards and hook notices in a step that goes (below, or with the whole
557
557
  // block for a plain answer) stay: before the answer, out of the block,
@@ -731,6 +731,29 @@ function defaultPlaceAnswer(appendToHistory) {
731
731
  };
732
732
  }
733
733
 
734
+ // A closed step's summary (model.genLabel). When its head is the words the
735
+ // body starts with (the narration's first line, the first call's title)
736
+ // and it has calls, the summary is marked .repeats and its parts are
737
+ // spans: an open step shows only its calls (index.html), so it doesn't say
738
+ // the same words twice. Its text stays genLabel's either way.
739
+ // { repeats, html } (the summary's inside).
740
+ function labelParts(gen) {
741
+ const { head, calls, repeats } = model.genLabelParts(gen);
742
+ if (!repeats || !calls) return { repeats: false, html: escapeHtml(model.genLabel(gen)) };
743
+ return { repeats: true, html: `<span class="gen-head">${escapeHtml(head)}</span><span class="gen-sep"> · </span><span class="gen-calls">${escapeHtml(calls)}</span>` };
744
+ }
745
+
746
+ function labelHtml(gen) {
747
+ const { repeats, html } = labelParts(gen);
748
+ return `<summary${repeats ? ' class="repeats"' : ""}>${html}</summary>`;
749
+ }
750
+
751
+ function setLabel(summary, gen) {
752
+ const { repeats, html } = labelParts(gen);
753
+ summary.classList.toggle("repeats", repeats);
754
+ summary.innerHTML = html;
755
+ }
756
+
734
757
  // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, the full command (an
735
758
  // execute/task_create's view), output (its "[tool]" tag dropped, 300 chars, the rest on hover), plus the call's
736
759
  // duration from its record.
@@ -782,13 +805,28 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
782
805
  const { body, renderedMarkdown } = messageBodyHtml(m);
783
806
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
784
807
  };
808
+ // The live bubble's origin label (app.js makeUserBubble), for a line
809
+ // chi brought in: a delegate report.
810
+ const originLabelHtml = (m) => {
811
+ const label = sourceLabel(m?.source);
812
+ return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
813
+ };
814
+ // The first text after a delegate report merged mid-turn, unless it is
815
+ // the turn's answer (shown anyway).
816
+ const reportReplyHtml = (i, group) => {
817
+ if (items[i]?.source !== "delegate_report") return "";
818
+ const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
819
+ if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
820
+ const { body, renderedMarkdown } = messageBodyHtml(items[j]);
821
+ return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
822
+ };
785
823
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
786
824
  return turnGroups(items, timing).map((group) => {
787
825
  if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
788
826
  if (group.kind === "steer") return steerHtml(group.i, true);
789
827
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
790
828
  const prompt = items[group.user];
791
- const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
829
+ const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
792
830
  const [only] = group.steps;
793
831
  const ended = group.answer !== null || group.emptyAnswer != null;
794
832
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -811,7 +849,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
811
849
  const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
812
850
  const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
813
851
  const failed = model.failedCount(gen.tools) ? " has-failed" : "";
814
- return `<details class="gen${failed}"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
852
+ return `<details class="gen${failed}">${labelHtml(gen)}${steers}${thinking}${text}${tools}</details>`;
815
853
  });
816
854
  parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
817
855
  }
@@ -820,9 +858,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
820
858
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
821
859
  parts.push(loose.map((i) => steerHtml(i, true)).join(""));
822
860
  // The user's lines merged into the turn: the live "steered" bubbles,
823
- // after its steps and before its answer.
861
+ // after its steps and before its answer. A delegate report's is
862
+ // followed by the text that answered it when that isn't the answer: a
863
+ // small model tells its user about a report before its next tool call,
864
+ // and that step's text would stay folded in the block.
824
865
  parts.push((group.merged || []).map((i) =>
825
- `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
866
+ `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}>${originLabelHtml(items[i])}<div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>` +
867
+ reportReplyHtml(i, group)).join(""));
826
868
  // A turn that ended with no answer: chi's muted notice where the answer
827
869
  // would be, before the timing, as live.
828
870
  if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);