samagotchi 0.33.0 → 0.35.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +6 -1
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +5 -0
  9. data/docs/guardrails.md +35 -3
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +17 -1
  12. data/docs/plugins.md +78 -3
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +15 -9
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  18. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  19. data/lib/samagotchi/broadcast/tags.rb +154 -0
  20. data/lib/samagotchi/broadcast_command.rb +228 -0
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  35. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  36. data/lib/samagotchi/children_status.rb +143 -0
  37. data/lib/samagotchi/config.rb +6 -0
  38. data/lib/samagotchi/cut_policy.rb +58 -0
  39. data/lib/samagotchi/engine.rb +45 -108
  40. data/lib/samagotchi/engine_peers.rb +30 -0
  41. data/lib/samagotchi/git_head.rb +24 -0
  42. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  43. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  44. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  45. data/lib/samagotchi/guardrails/context.rb +32 -0
  46. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  47. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +11 -5
  50. data/lib/samagotchi/kernel_loop.rb +9 -22
  51. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  52. data/lib/samagotchi/log_subscriber.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  54. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  55. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  56. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  57. data/lib/samagotchi/memory_paths.rb +10 -0
  58. data/lib/samagotchi/note_command.rb +13 -13
  59. data/lib/samagotchi/note_delivery.rb +42 -0
  60. data/lib/samagotchi/plugin/api.rb +2 -2
  61. data/lib/samagotchi/plugin/context.rb +4 -4
  62. data/lib/samagotchi/plugin/sessions.rb +45 -1
  63. data/lib/samagotchi/process_group.rb +2 -5
  64. data/lib/samagotchi/session_manager.rb +4 -3
  65. data/lib/samagotchi/sessions_command.rb +3 -2
  66. data/lib/samagotchi/steer_cut.rb +125 -0
  67. data/lib/samagotchi/system_prompt.rb +21 -8
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  69. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  70. data/lib/samagotchi/terminal_ui.rb +14 -0
  71. data/lib/samagotchi/tool_declarations.rb +9 -5
  72. data/lib/samagotchi/tools/builtins.rb +2 -2
  73. data/lib/samagotchi/tools/delegate.rb +103 -14
  74. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  75. data/lib/samagotchi/tools/send_note.rb +5 -11
  76. data/lib/samagotchi/turn_note.rb +9 -1
  77. data/lib/samagotchi/version.rb +1 -1
  78. data/lib/samagotchi/web/app.rb +3 -0
  79. data/lib/samagotchi/web/capabilities.rb +19 -0
  80. data/lib/samagotchi/web/public/activity.js +26 -6
  81. data/lib/samagotchi/web/public/app.js +515 -288
  82. data/lib/samagotchi/web/public/card.js +12 -3
  83. data/lib/samagotchi/web/public/command_view.js +2 -3
  84. data/lib/samagotchi/web/public/families_view.js +171 -0
  85. data/lib/samagotchi/web/public/format.js +52 -0
  86. data/lib/samagotchi/web/public/index.html +94 -21
  87. data/lib/samagotchi/web/public/live_timing.js +154 -0
  88. data/lib/samagotchi/web/public/live_turn.js +44 -0
  89. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  90. data/lib/samagotchi/web/public/strip.js +5 -2
  91. data/lib/samagotchi/web/public/turn_html.js +107 -0
  92. data/lib/samagotchi/web/public/turn_model.js +5 -4
  93. data/lib/samagotchi/web/public/turn_view.js +43 -129
  94. data/lib/samagotchi/web/session_summary.rb +4 -0
  95. data/lib/samagotchi/worker.rb +173 -309
  96. data/lib/samagotchi/worker_inbound.rb +251 -0
  97. data/lib/samagotchi/worker_wakes.rb +120 -0
  98. metadata +24 -1
@@ -9,15 +9,13 @@
9
9
  // A block that holds one gen with no thinking and no tool rows hides its
10
10
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
11
11
  // removed at the end: the answer bubble takes its place.
12
- import { toolName } from "./activity.js";
13
12
  import { leavesBlock } from "./card.js";
14
13
  import { copySourceAttr } from "./copy.js";
15
- import { commandBlockHtml, rowHover } from "./command_view.js";
16
- import { activityDiffHtml } from "./diff_view.js";
17
14
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
18
15
  import { thumbsHtml } from "./images.js";
19
16
  import { shouldFollowScroll } from "./scroll.js";
20
- import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
17
+ import { hookNoticeLabel } from "./turn_events.js";
18
+ import { emptyAnswerHtml, htmlElement, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
21
19
  import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
22
20
  import { createSentenceFeed } from "./sentences.js";
23
21
  import { createTicker } from "./thinking_ticker.js";
@@ -25,81 +23,31 @@ import * as model from "./turn_model.js";
25
23
  import { createHold } from "./hold.js";
26
24
  import { taskCommand, taskStopButton } from "./task_stop.js";
27
25
 
28
- // A row's status (live, or a saved tool record's on reload): running,
29
- // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
30
- // denied it), else ok ("done").
31
- const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
32
-
33
- // A row's status as its parts: the mark's class (CSS draws the glyph), its
34
- // label (aria-label and title) and the word an exception shows after the
35
- // title (empty for done and running). Live and reload share it.
36
- export function statusParts(status) {
37
- const named = NAMED_STATUSES.includes(status);
38
- const cls = named ? status : "ok";
39
- const label = named ? status : "done";
40
- return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
26
+ // The parts of a drawn tool row a user may have changed (the command's
27
+ // raw toggle, an opened diff) or that never change once there (the
28
+ // thumbs): a redraw keeps them as they are.
29
+ const KEPT_PARTS = ["activity-command", "activity-diff", "thumbs"];
30
+
31
+ const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
32
+
33
+ // A live tool row drawn again from its activity row (status, title,
34
+ // output): the reloaded row's markup (toolRowInnerHtml), the kept parts
35
+ // and the stop-task button carried over. +html+ is that markup.
36
+ function redrawRow(el, html) {
37
+ const fresh = htmlElement(`<div>${html}</div>`);
38
+ for (const cls of KEPT_PARTS) {
39
+ const old = childWith(el, cls);
40
+ const drawn = childWith(fresh, cls);
41
+ if (old && drawn) drawn.replaceWith(old);
42
+ }
43
+ const stop = childWith(el, "task-stop");
44
+ el.replaceChildren(...fresh.childNodes);
45
+ if (stop) rowAnchor(el).after(stop);
41
46
  }
42
47
 
43
- function statusMarkHtml({ cls, label }) {
44
- return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
45
- }
46
-
47
- function statusWordHtml({ cls, word }) {
48
- return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
49
- }
50
-
51
- // A live tool row filled from its activity row (status, title, output).
52
- function fillActivityRow(el, row, sessionId) {
53
- const parts = statusParts(row.status);
54
- const statusEl = el.querySelector(".activity-status");
55
- statusEl.className = `activity-status ${parts.cls}`;
56
- statusEl.setAttribute("role", "img");
57
- statusEl.setAttribute("aria-label", parts.label);
58
- statusEl.title = parts.label;
59
- // The exception's word after the title (reloadRowHtml's place); never
60
- // beside a running task_wait's stop button, which needs "running".
61
- let wordEl = el.querySelector(":scope > .activity-state");
62
- if (!parts.word) wordEl?.remove();
63
- else {
64
- if (!wordEl) {
65
- wordEl = document.createElement("span");
66
- (el.querySelector(":scope > .activity-params") || el.querySelector(":scope > .activity-tool")).after(wordEl);
67
- }
68
- wordEl.className = `activity-state ${parts.cls}`;
69
- wordEl.textContent = parts.word;
70
- }
71
- const toolEl = el.querySelector(".activity-tool");
72
- if (toolEl) toolEl.textContent = toolName(row);
73
- const paramsEl = el.querySelector(".activity-params");
74
- if (paramsEl) {
75
- // The title in place of the params; the full command (else the full
76
- // params line) on hover.
77
- paramsEl.textContent = row.title || row.params || "";
78
- paramsEl.title = rowHover(row);
79
- }
80
- const outEl = el.querySelector(".activity-output");
81
- // The call's full command, above its output (running too).
82
- if (row.view?.command && !el.querySelector(".activity-command")) {
83
- if (outEl) outEl.insertAdjacentHTML("beforebegin", commandBlockHtml(row.view, row.title));
84
- else el.insertAdjacentHTML("beforeend", commandBlockHtml(row.view, row.title));
85
- }
86
- if (outEl) {
87
- if (row.status === "running") {
88
- outEl.style.display = "none";
89
- outEl.textContent = "";
90
- outEl.title = "";
91
- } else {
92
- // dispatch prefixes output with "[tool]"; strip that noise for display.
93
- const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
94
- outEl.title = full;
95
- outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
96
- outEl.style.display = full ? "" : "none";
97
- }
98
- }
99
- // The images a tool returned (a reloaded row has them too, reloadRowHtml).
100
- if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
101
- // An edit/write's change, collapsed: "diff +3 −1".
102
- if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
48
+ // Where a row's stop-task button goes: after its title (else its tool).
49
+ function rowAnchor(el) {
50
+ return childWith(el, "activity-params") || childWith(el, "activity-tool");
103
51
  }
104
52
 
105
53
  // The container seams (the stage view composes this view, stage_view.js):
@@ -423,6 +371,11 @@ export function createTurnView({
423
371
  onChange();
424
372
  }
425
373
 
374
+ // A tool row is the reloaded row's markup (turn_html.js toolRowHtml),
375
+ // drawn again when an event changed it.
376
+ const rowHtml = new WeakMap();
377
+ const rowThumbs = (images) => thumbsHtml(sessionId(), images);
378
+
426
379
  function toolEvent(r) {
427
380
  apply(r);
428
381
  const entry = genEls.get(r.gen);
@@ -430,24 +383,18 @@ export function createTurnView({
430
383
  if (!r.gen.text) entry.text.textContent = "";
431
384
  const body = activityEl(r.gen);
432
385
  let el = entry.rows.get(r.row.key);
386
+ const html = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
433
387
  if (!el) {
434
- el = document.createElement("div");
435
- el.className = "activity-row";
436
- el.dataset.key = r.row.key;
437
- for (const [tag, cls] of [["span", "activity-status"], ["span", "activity-tool"], ["span", "activity-params"], ["div", "activity-output"]]) {
438
- const part = document.createElement(tag);
439
- part.className = cls;
440
- el.appendChild(part);
441
- }
442
- el.querySelector(".activity-output").style.display = "none";
388
+ el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
389
+ rowHtml.set(el, html);
443
390
  entry.rows.set(r.row.key, el);
444
391
  const stick = isNearBottom();
445
392
  body.appendChild(el);
446
- fillActivityRow(el, r.row, sessionId());
447
393
  syncTaskStop(el, r.row);
448
394
  if (stick) follow();
449
395
  } else {
450
- fillActivityRow(el, r.row, sessionId());
396
+ if (rowHtml.get(el) !== html) redrawRow(el, html);
397
+ rowHtml.set(el, html);
451
398
  syncTaskStop(el, r.row);
452
399
  }
453
400
  if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
@@ -469,7 +416,7 @@ export function createTurnView({
469
416
  return;
470
417
  }
471
418
  const command = () => taskCommand(turn?.gens, taskId);
472
- el.querySelector(".activity-params").after(taskStopButton(taskStop, taskId, { command }));
419
+ rowAnchor(el).after(taskStopButton(taskStop, taskId, { command }));
473
420
  }
474
421
 
475
422
  // A hook's line (hook_notice) as a row of the current step, above the row
@@ -680,11 +627,11 @@ export function createTurnView({
680
627
  },
681
628
  toolStarted(data) {
682
629
  if (!turn) this.turnStarted({});
683
- toolEvent(model.toolStarted(turn, data));
630
+ toolEvent(model.toolStarted(turn, data, Date.now()));
684
631
  },
685
632
  toolCompleted(data) {
686
633
  if (!turn) this.turnStarted({});
687
- toolEvent(model.toolCompleted(turn, data));
634
+ toolEvent(model.toolCompleted(turn, data, Date.now()));
688
635
  },
689
636
  turnEnded,
690
637
  notice,
@@ -754,35 +701,6 @@ function setLabel(summary, gen) {
754
701
  summary.innerHTML = html;
755
702
  }
756
703
 
757
- // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, the full command (an
758
- // execute/task_create's view), output (its "[tool]" tag dropped, 300 chars, the rest on hover), plus the call's
759
- // duration from its record.
760
- // Params, output and the call's images come from the saved messages'
761
- // parts; without them the row has the record's name, status and duration
762
- // only. +thumbs(images)+ draws the images, as the live row does.
763
- function reloadRowHtml(r, thumbs) {
764
- const shown = r.title || r.params;
765
- const hover = rowHover(r) ? ` title="${escapeHtml(rowHover(r))}"` : "";
766
- const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
767
- const duration = formatDuration(r.duration_ms);
768
- const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
769
- const output = full
770
- ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
771
- : "";
772
- const status = statusParts(r.status);
773
- return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
774
- `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
775
- `${commandBlockHtml(r.view, r.title)}${output}` +
776
- `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
777
- }
778
-
779
- // The notice of a turn that ended with no answer: a muted row like the
780
- // loop's retry row, where the answer would be (live: promote()).
781
- export const EMPTY_ANSWER_CLASS = "bubble hook-notice empty-answer";
782
- function emptyAnswerHtml(item) {
783
- return `<div class="${EMPTY_ANSWER_CLASS}">${escapeHtml(emptyAnswerLine(item))}</div>`;
784
- }
785
-
786
704
  // A reloaded history in the turn view (renderHistory's object path): per
787
705
  // turn the prompt, a collapsed block of its steps when it had any (texts,
788
706
  // and with the server's parts the thinking, params and output, from the
@@ -805,12 +723,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
805
723
  const { body, renderedMarkdown } = messageBodyHtml(m);
806
724
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
807
725
  };
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
- };
726
+ // A user's line, labelled when chi brought it in (a delegate report).
727
+ const userHtml = (m, opts = {}) => userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
814
728
  // The first text after a delegate report merged mid-turn, unless it is
815
729
  // the turn's answer (shown anyway).
816
730
  const reportReplyHtml = (i, group) => {
@@ -829,7 +743,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
829
743
  // A context wake turn starts with its note.
830
744
  const parts = [prompt.role === "note"
831
745
  ? `<div class="bubble note">${noteHtml(prompt)}</div>`
832
- : `<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
746
+ : userHtml(prompt)];
833
747
  const [only] = group.steps;
834
748
  const ended = group.answer !== null || group.emptyAnswer != null;
835
749
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -850,7 +764,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
850
764
  const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
851
765
  // The steers the step answered first, where the live rows went.
852
766
  const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
853
- const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
767
+ const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => toolRowHtml(r, { thumbs })).join("")}</div>` : "";
854
768
  const failed = model.failedCount(gen.tools) ? " has-failed" : "";
855
769
  return `<details class="gen${failed}">${labelHtml(gen)}${steers}${thinking}${text}${tools}</details>`;
856
770
  });
@@ -866,7 +780,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
866
780
  // small model tells its user about a report before its next tool call,
867
781
  // and that step's text would stay folded in the block.
868
782
  parts.push((group.merged || []).map((i) =>
869
- `<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>` +
783
+ userHtml(items[i], { state: "steered", step: items[i].step }) +
870
784
  reportReplyHtml(i, group)).join(""));
871
785
  // A turn that ended with no answer: chi's muted notice where the answer
872
786
  // would be, before the timing, as live.
@@ -49,6 +49,10 @@ module Samagotchi
49
49
  muted_memory_names: Array(session.muted_memory_names),
50
50
  # The session that delegated this one (the `delegate` tool), else nil.
51
51
  parent_id: session.parent_id,
52
+ # Started by the delegate tool (Session#delegate?); a fork has a
53
+ # parent_id too but isn't one. The parent's children chip counts
54
+ # delegates only, as the TUI's segment and /children do.
55
+ delegate: session.delegate?,
52
56
  first_preview: first_preview_for(session),
53
57
  owner: owner&.kind,
54
58
  # The saved recap's first sentence, for the session card.