samagotchi 0.84.0 → 0.86.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -13,6 +13,13 @@ import { linkedLine } from "./notice_links.js";
13
13
  import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
14
14
  import { drawCommandBubble } from "./commands.js";
15
15
 
16
+ export function removeQueuedBubble(bubble) {
17
+ if (!bubble) return;
18
+ const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
19
+ bubble.remove();
20
+ if (slot && slot.childElementCount === 0) slot.remove();
21
+ }
22
+
16
23
  // A prompt that started a turn: not a line steered into a running one.
17
24
  export const TURN_PROMPT = ".bubble.user:not(.steered)";
18
25
 
@@ -205,6 +212,8 @@ export function createBubbles({
205
212
  setBubbleState(bubble, null);
206
213
  started = bubble;
207
214
  }
215
+ } else if (op.op === "remove") {
216
+ removeQueuedBubble(bubbleById(op.enqueuedId));
208
217
  } else if (op.op === "steer") {
209
218
  const bubble = bubbleById(op.enqueuedId);
210
219
  if (bubble) setBubbleState(bubble, "steered");
@@ -1,26 +1,15 @@
1
1
  // Pure routing for one :generation_chunk event into the two render lanes.
2
2
  //
3
- // Every :generation_chunk the server sends carries additive `text` (visible
4
- // prose, thinking + tool_call blocks removed) and `thinking` (thinking-only)
5
- // fields beside the raw `content`: the native loop splits its stream per
6
- // generation for every profile (Gemma's thought channel included), the chat
7
- // loop gets reasoning apart from the answer. We use those fields and ignore raw
8
- // `content`; a chunk without them (an older server) falls back to raw
9
- // `content` as text.
10
- //
11
- // Presence is checked with `typeof === "string"` (not truthiness) so an empty
12
- // `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
13
- // wire instead of accidentally falling back to raw `content`.
3
+ // Every :generation_chunk the server sends carries `text` (visible prose,
4
+ // thinking + tool_call blocks removed) and `thinking` (thinking-only): the
5
+ // native loop splits its stream per generation for every profile (Gemma's
6
+ // thought channel included), the chat loop gets reasoning apart from the
7
+ // answer. The raw stream text and the provider's payload stay in the worker
8
+ // (Bridge::ClientEvent); a missing or non-string field is an empty lane.
14
9
  export function routeChunk(data) {
15
10
  data = data || {};
16
- if (typeof data.text === "string" || typeof data.thinking === "string") {
17
- return {
18
- text: typeof data.text === "string" ? data.text : "",
19
- thinking: typeof data.thinking === "string" ? data.thinking : "",
20
- };
21
- }
22
11
  return {
23
- text: typeof data.content === "string" ? data.content : "",
24
- thinking: "",
12
+ text: typeof data.text === "string" ? data.text : "",
13
+ thinking: typeof data.thinking === "string" ? data.thinking : "",
25
14
  };
26
15
  }
@@ -110,6 +110,7 @@ const WEB_LOCAL_REPLIES = {
110
110
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
111
111
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
112
112
  "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
113
+ "/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
113
114
  "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
114
115
  };
115
116
 
@@ -1,7 +1,10 @@
1
- // Context-window usage (%) from a stream event. `windowTokens` is the window
2
- // the kernel resolved for this generation (:generation_started carries it as
3
- // context_window_tokens); llama.cpp's stream payloads report token counts
4
- // but no n_ctx, so without it only a precomputed pct or an n_ctx works.
1
+ // Context-window usage (%) from a stream event: a :context_status's
2
+ // estimated_pct, or a :generation_chunk's `counts` (chi's own counts, which
3
+ // the worker's Bridge works out from the provider's payload; the payload
4
+ // itself never reaches a client). `windowTokens` is the window the kernel
5
+ // resolved for this generation (:generation_started carries it as
6
+ // context_window_tokens); a chunk's counts carry a window only when the server
7
+ // reported one (llama.cpp's /completion doesn't), and that one wins.
5
8
  // `budgetTokens` is the session's LLM context budget (llm_context's
6
9
  // budget_tokens): a percentage this file computes counts against it when it
7
10
  // is smaller than the window, as the kernel's :context_status does
@@ -20,32 +23,15 @@ function countedTokens(windowTokens, budgetTokens = null) {
20
23
  return budget && budget > 0 ? Math.min(budget, window) : window;
21
24
  }
22
25
 
23
- // Tokens in context after this chunk: prompt + generated so far.
24
- function usedTokens(p) {
25
- if (!p || typeof p !== "object") return null;
26
- if (num(p.n_past)) return p.n_past;
27
- if (num(p.usage?.total_tokens)) return p.usage.total_tokens;
28
- const prompt = num(p.usage?.prompt_tokens) ?? num(p.tokens_evaluated) ?? num(p.timings?.prompt_n);
29
- const completion = num(p.usage?.completion_tokens) ?? num(p.tokens_predicted) ?? num(p.timings?.predicted_n);
30
- if (prompt === null && completion === null) return null;
31
- return (prompt ?? 0) + (completion ?? 0);
32
- }
33
-
34
26
  export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
35
27
  if (!data || typeof data !== "object") return null;
36
- if (num(data.ctx_pct) !== null) return data.ctx_pct;
37
- if (num(data.est_pct) !== null) return data.est_pct;
38
- if (num(data.ctxPct) !== null) return data.ctxPct;
39
28
  // The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
40
29
  if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
41
- if (data.context_window_tokens && data.total_tokens) {
42
- const against = countedTokens(data.context_window_tokens, budgetTokens);
43
- if (against) return (data.total_tokens / against) * 100;
44
- }
45
- const p = data.payload || data;
46
- if (num(p.ctx_pct) !== null) return p.ctx_pct;
47
- const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
48
- const used = usedTokens(p);
30
+ // A chunk's counts: tokens in context after it (prompt + generated so far).
31
+ const counts = data.counts;
32
+ if (!counts || typeof counts !== "object") return null;
33
+ const used = num(counts.total_tokens);
34
+ const against = countedTokens(num(counts.context_window_tokens) || num(windowTokens), budgetTokens);
49
35
  if (!against || used === null) return null;
50
36
  return (used / against) * 100;
51
37
  }
@@ -169,6 +169,17 @@ export function stopTask(id, taskId, opts = {}) {
169
169
  });
170
170
  }
171
171
 
172
+ // Takes back a message queued for after the running turn (the × on its
173
+ // bubble): 200 {status, enqueued_id, prompt}; 409 already_started /
174
+ // not_queued, 404 unknown_enqueued_id, 503 no live worker.
175
+ export function dequeueTurn(id, enqueuedId, { clientId = null } = {}, opts = {}) {
176
+ return api(`/api/sessions/${encodeURIComponent(id)}/turn/${encodeURIComponent(enqueuedId)}/dequeue`, {
177
+ method: "POST",
178
+ body: JSON.stringify(clientId ? { client_id: clientId } : {}),
179
+ ...opts,
180
+ });
181
+ }
182
+
172
183
  // Hands the session to a new worker on the newest chi installed: 200
173
184
  // {status, from_version, version}, or 409 with the reason in words (detail).
174
185
  export function restartSession(id, opts = {}) {
@@ -120,6 +120,12 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
120
120
  .bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
121
121
  .bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
122
122
  .bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
123
+ /* The × on a message queued for after the turn (unqueue.js): only once the bubble has its enqueued_id; a 32px tap target around a small mark */
124
+ .bubble.user .state-badge button.unqueue{position:relative;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin:0 -3px 0 0;padding:0;border:0;border-radius:50%;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
125
+ .bubble.user .state-badge button.unqueue::before{content:"\00d7"}
126
+ .bubble.user .state-badge button.unqueue::after{content:"";position:absolute;inset:-8px}
127
+ .bubble.user .state-badge button.unqueue:hover:not(:disabled){background:var(--danger-bg-dim);color:var(--danger-text);filter:none}
128
+ .bubble.user:not([data-enqueued-id]) .state-badge button.unqueue{display:none}
123
129
  .bubble.user .origin-label{font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
124
130
  .bubble.user .user-message{flex:1;min-width:0}
125
131
  .bubble.user blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-3);margin:2px 0 6px}
@@ -847,6 +853,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
847
853
  .turn-stage .turn-work.outline>.gen.folded{display:block}
848
854
  .turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
849
855
  @media(max-width:600px){
856
+ .bubble.user.queued[data-delivery="queue"]{flex-wrap:wrap}
857
+ .bubble.user.queued[data-delivery="queue"] .origin-label,.bubble.user.queued[data-delivery="queue"] .state-badge{order:0}
858
+ .bubble.user.queued[data-delivery="queue"] .user-message{order:1;flex-basis:100%}
850
859
  .ts-status{padding-left:10px}
851
860
  .ts-scroll{padding:8px 10px 10px}
852
861
  .ts-foot{padding:0 10px 10px}
@@ -410,6 +410,10 @@ export function createStageView({
410
410
  }
411
411
  }
412
412
 
413
+ function ensureQueuedSlot() {
414
+ if (!queuedSlot.isConnected) stage.appendChild(queuedSlot);
415
+ }
416
+
413
417
  function emptySlots() {
414
418
  for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
415
419
  slotsView.reset();
@@ -535,6 +539,7 @@ export function createStageView({
535
539
  // The lines still queued behind this turn went into the history with
536
540
  // the turn before (the hand-off at this turn_started, or an earlier
537
541
  // one): back below this turn's card, as a reload places them.
542
+ ensureQueuedSlot();
538
543
  queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
539
544
  view.turnStarted(data || {});
540
545
  show();
@@ -616,6 +621,7 @@ export function createStageView({
616
621
  const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
617
622
  if (where === "history") return false;
618
623
  if (where === "queued") {
624
+ ensureQueuedSlot();
619
625
  queuedSlot.appendChild(el);
620
626
  scheduleRefresh();
621
627
  return true;
@@ -61,7 +61,7 @@ export function createStreamHandlers(deps) {
61
61
  return {
62
62
  generation_chunk: (data) => {
63
63
  // Route the chunk's bytes to the text bubble and the thinking block (see
64
- // chunk_router.js). Raw `content` is only a fallback for an older server.
64
+ // chunk_router.js).
65
65
  const { text, thinking } = routeChunk(data);
66
66
  liveTurn.timing.setNote("");
67
67
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
@@ -116,6 +116,9 @@ export function createStreamHandlers(deps) {
116
116
  showSteers(data.steers);
117
117
  },
118
118
  turn_enqueued: (data) => applyPromptOps(data),
119
+ // A queued message taken back before it ran (the × here or in another
120
+ // tab, /unqueue in a terminal): its bubble goes, in every tab.
121
+ turn_dequeued: (data) => applyPromptOps(data),
119
122
  turn_started: (data) => keepFollowing(historyEl, () => {
120
123
  if (stream.takeGuess()) {
121
124
  // A replay of a turn the render drew: re-read and stream from there.
@@ -247,6 +247,8 @@ export function initWaitLine({ tasks } = {}) {
247
247
  // tag — this tab's own local echo gets its enqueued_id
248
248
  // start — the bubble's turn started (drops the "queued" badge)
249
249
  // steer — the bubble was merged into the running turn
250
+ // remove — the queued message was taken back before it ran (turn_dequeued,
251
+ // whoever removed it): its bubble goes
250
252
  // report — the merge brought a delegate child's report (origin
251
253
  // child:<id8>, no bubble anywhere): its text gets a labelled one
252
254
  // `known(enqueuedId)` says whether a bubble carries that id already;
@@ -276,6 +278,8 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
276
278
  }
277
279
  return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
278
280
  }
281
+ case "turn_dequeued":
282
+ return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
279
283
  case "input_merged": {
280
284
  const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
281
285
  known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
@@ -184,9 +184,17 @@ export function badgeText(state, delivery = null) {
184
184
  return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
185
185
  }
186
186
 
187
+ // A message queued for after the turn (delivery queue) can be taken back
188
+ // while it waits: the badge carries its ×, shown once the bubble has its
189
+ // enqueued_id (style.css; an echo before its ack has none) and handled by
190
+ // unqueue.js. Not a next-step line: a step boundary may merge it any moment.
187
191
  export function stateBadgeTemplate(state, delivery = null) {
188
192
  const text = badgeText(state, delivery);
189
- return text ? html`<span class="state-badge">${text}</span>` : nothing;
193
+ if (!text) return nothing;
194
+ const remove = state === "queued" && delivery === "queue"
195
+ ? html`<button type="button" class="unqueue" title="Remove from queue" aria-label="Remove from queue"></button>`
196
+ : nothing;
197
+ return html`<span class="state-badge">${text}${remove}</span>`;
190
198
  }
191
199
 
192
200
  // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
@@ -0,0 +1,56 @@
1
+ // The × on a message queued for after the running turn (its badge,
2
+ // turn_html.js stateBadgeTemplate): POST …/turn/:enqueued_id/dequeue
3
+ // (Bridge#handle_dequeue_turn) takes it back before it runs. The bubble
4
+ // goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
5
+ // here at the 200 already. Its text comes back (edit): into an empty
6
+ // composer, to change and send again; over a draft only in a toast (the
7
+ // draft stays). Its images don't come back. A refusal keeps it and says why
8
+ // in a toast. No confirm: nothing is lost. Pure but for the
9
+ // injected calls, so `node --test` covers it; app.js binds onClick to the
10
+ // turn roots.
11
+ import { dequeueTurn as postDequeue } from "./data.js";
12
+ import { removeQueuedBubble } from "./bubbles.js";
13
+
14
+ // Why a removal didn't happen, in words.
15
+ export function unqueueFailureText(error) {
16
+ if (error?.code === "already_started") return "Not removed: it already started";
17
+ if (error?.code === "not_queued") return "Not removed: it goes in at the running turn's next step";
18
+ if (error?.status === 404 && error?.code === "unknown_enqueued_id") return "Not removed: it is already gone";
19
+ return `Not removed: ${error?.message || "the worker did not answer"}`;
20
+ }
21
+
22
+ // The removed text in a toast, clipped.
23
+ const SHOWN = 200;
24
+ function clipped(text) {
25
+ const line = String(text || "").replace(/\s+/g, " ").trim();
26
+ return line.length > SHOWN ? `${line.slice(0, SHOWN)}\u2026` : line;
27
+ }
28
+
29
+ // +sessionId+ is read at each click (the open session); +clientId+ is this
30
+ // tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
31
+ // composer when it is empty and says whether it did.
32
+ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
33
+ // A click on a queued bubble's ×; any other click is left alone.
34
+ async function onClick(e) {
35
+ const button = e.target?.closest?.("button.unqueue");
36
+ if (!button || button.disabled) return null;
37
+ const bubble = button.closest(".bubble.user");
38
+ const enqueuedId = bubble?.dataset.enqueuedId;
39
+ const id = sessionId();
40
+ if (!enqueuedId || !id) return null;
41
+ e.stopPropagation?.();
42
+ button.disabled = true;
43
+ try {
44
+ const result = await dequeue(id, enqueuedId, { clientId });
45
+ removeQueuedBubble(bubble);
46
+ const text = String(result?.prompt || "");
47
+ if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
48
+ return result;
49
+ } catch (error) {
50
+ if (button.isConnected) button.disabled = false;
51
+ toast(unqueueFailureText(error));
52
+ return null;
53
+ }
54
+ }
55
+ return { onClick };
56
+ }
@@ -120,6 +120,9 @@ module Samagotchi
120
120
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
121
121
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
122
122
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
123
+ config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
124
+ Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
125
+ end
123
126
  port = resolve_port(port)
124
127
  host = resolve_host(host)
125
128
  url ||= "http://#{url_host(host)}:#{port}/"
@@ -166,6 +169,7 @@ module Samagotchi
166
169
  warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
167
170
  false
168
171
  ensure
172
+ config_restart_unsubscribe&.call
169
173
  hub&.stop
170
174
  Samagotchi::Log.info(:web, "stop") if app
171
175
  end
@@ -229,6 +229,9 @@ module Samagotchi
229
229
  # The running turn, on disk at its iteration boundaries.
230
230
  @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
231
231
  @engine = build_engine
232
+ @config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
233
+ @engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
234
+ end
232
235
  settle_interrupted_turn
233
236
  # Before the Bridge serves anything: a UI joining a resumed worker's
234
237
  # stream gets the session's history and status in its snapshot, not
@@ -278,8 +281,9 @@ module Samagotchi
278
281
  context_absorber: @context_absorber, wakes: @wakes,
279
282
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
280
283
  stopped: -> { stopped_on_disk? },
281
- queue_command: lambda { |line, client_id, after_file|
282
- @bridge.queue_command(line, client_id: client_id, after_file: after_file)
284
+ queue_command: lambda { |line, client_id, after_file, enqueued_id|
285
+ @bridge.queue_command(line, client_id: client_id, after_file: after_file,
286
+ enqueued_id: enqueued_id)
283
287
  })
284
288
  # /model's default is the config's, as in the REPL; the Engine started
285
289
  # on the session's model. nil when there is no default.model.
@@ -414,6 +418,7 @@ module Samagotchi
414
418
  # The context poller first: a command it runs goes (its process
415
419
  # group) before the Engine does.
416
420
  def teardown
421
+ @config_restart_unsubscribe&.call
417
422
  @context_poller&.stop
418
423
  drop_queued_commands
419
424
  @engine&.shutdown
@@ -453,9 +458,10 @@ module Samagotchi
453
458
 
454
459
  # A turn an earlier worker died in (TurnCheckpoint's mark in the file):
455
460
  # ended now as canceled, with its note, and saved, before the Bridge
456
- # serves the session (TurnCheckpoint.settle).
461
+ # serves the session (TurnCheckpoint.settle). Its queued id's wait
462
+ # (chi send --queue --wait) reads the end it records in the session dir.
457
463
  def settle_interrupted_turn
458
- mark = TurnCheckpoint.settle(@session)
464
+ mark = TurnCheckpoint.settle(@session, session_dir: @session_dir)
459
465
  return unless mark
460
466
 
461
467
  Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
@@ -787,7 +793,9 @@ module Samagotchi
787
793
  # the cards and notices it showed, held: they follow its command_ran,
788
794
  # as its output
789
795
  def run_command_line(command)
790
- @engine.holding_announcements { command_result(command) { @commands.run(command.line, confirm: confirm?(command)) } }
796
+ @engine.holding_announcements do
797
+ command_result(command) { @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command)) }
798
+ end
791
799
  end
792
800
 
793
801
  # Whether +command+ asks before a costly /thinking or /reasoning-history
@@ -795,6 +803,11 @@ module Samagotchi
795
803
  # question; chi send, a delegate, a plugin and a card's action apply it.
796
804
  def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
797
805
 
806
+ # Whether +command+ is a parent agent's (chi answer's marker, which chi
807
+ # send and a parent's attach give a /continue yes too): held to
808
+ # guardrails.parent_answers (SessionCommands#continue_answer).
809
+ def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
810
+
798
811
  # The block's Result for +command+; one that raised, or found no
799
812
  # command for the line, is an error Result saying so.
800
813
  # @return [SessionCommands::Result]
@@ -866,6 +879,7 @@ module Samagotchi
866
879
  # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
867
880
  # line that applies it (the TUI's prefill, the web's Apply button)
868
881
  def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
882
+ TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
869
883
  text = output.to_s
870
884
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
871
885
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -70,9 +70,9 @@ module Samagotchi
70
70
  # @param wakes [WorkerWakes] the wake budget a context wake asks
71
71
  # @param awaiting_continue [#call] whether a continue offer waits
72
72
  # @param stopped [#call] whether the session was stopped on disk
73
- # @param queue_command [#call] (line, client_id, after_file) queues a
74
- # session command for the loop's next pass; after_file is the input
75
- # file it came in, or nil
73
+ # @param queue_command [#call] (line, client_id, after_file, enqueued_id)
74
+ # queues a session command for the loop's next pass; after_file is the
75
+ # input file it came in, or nil
76
76
  def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
77
77
  stopped:, queue_command:)
78
78
  @session = session
@@ -228,7 +228,7 @@ module Samagotchi
228
228
  def queue_as_command(text, origin, after_file = nil)
229
229
  return false unless @engine.command_registry.command?(text.to_s)
230
230
 
231
- @queue_command.call(text, origin&.dig(:client_id), after_file)
231
+ @queue_command.call(text, origin&.dig(:client_id), after_file, origin&.dig(:enqueued_id))
232
232
  true
233
233
  end
234
234
 
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.84.0
4
+ version: 0.86.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -124,6 +124,7 @@ files:
124
124
  - bin/chi
125
125
  - docs/architecture.md
126
126
  - docs/broadcast.md
127
+ - docs/bundle-authoring.md
127
128
  - docs/cli.md
128
129
  - docs/configuration.md
129
130
  - docs/context.md
@@ -162,6 +163,7 @@ files:
162
163
  - lib/samagotchi/bridge.rb
163
164
  - lib/samagotchi/bridge/bounded_queue.rb
164
165
  - lib/samagotchi/bridge/card_store.rb
166
+ - lib/samagotchi/bridge/client_event.rb
165
167
  - lib/samagotchi/bridge/event_id.rb
166
168
  - lib/samagotchi/bridge/pending_card.rb
167
169
  - lib/samagotchi/bridge/ring_buffer.rb
@@ -179,6 +181,9 @@ files:
179
181
  - lib/samagotchi/broadcast/triage_model.rb
180
182
  - lib/samagotchi/broadcast_command.rb
181
183
  - lib/samagotchi/bundle_command.rb
184
+ - lib/samagotchi/bundle_command/check.rb
185
+ - lib/samagotchi/bundle_command/scaffold.rb
186
+ - lib/samagotchi/bundle_command/test_run.rb
182
187
  - lib/samagotchi/bundle_needs.rb
183
188
  - lib/samagotchi/bundle_session_settings.rb
184
189
  - lib/samagotchi/bundles/agents-md/manifest.yml
@@ -218,6 +223,8 @@ files:
218
223
  - lib/samagotchi/bundles/loop-guard/plugin.rb
219
224
  - lib/samagotchi/bundles/mcp/manifest.yml
220
225
  - lib/samagotchi/bundles/mcp/plugin.rb
226
+ - lib/samagotchi/bundles/ruby/manifest.yml
227
+ - lib/samagotchi/bundles/ruby/plugin.rb
221
228
  - lib/samagotchi/bundles/skills/manifest.yml
222
229
  - lib/samagotchi/bundles/skills/plugin.rb
223
230
  - lib/samagotchi/bundles/slow-commands/manifest.yml
@@ -301,7 +308,7 @@ files:
301
308
  - lib/samagotchi/guardrails/model_size.rb
302
309
  - lib/samagotchi/guardrails/outside.rb
303
310
  - lib/samagotchi/guardrails/parent_approvals.rb
304
- - lib/samagotchi/guardrails/parent_continue.rb
311
+ - lib/samagotchi/guardrails/parent_policy.rb
305
312
  - lib/samagotchi/guardrails/protected_paths.rb
306
313
  - lib/samagotchi/guardrails/read_only_shell.rb
307
314
  - lib/samagotchi/guardrails/rm_targets.rb
@@ -391,6 +398,7 @@ files:
391
398
  - lib/samagotchi/memory_bundle/plugin_ref.rb
392
399
  - lib/samagotchi/memory_bundle/profile.rb
393
400
  - lib/samagotchi/memory_bundle/provenance.rb
401
+ - lib/samagotchi/memory_bundle/shas.rb
394
402
  - lib/samagotchi/memory_bundle/shipped_update.rb
395
403
  - lib/samagotchi/memory_bundle/source.rb
396
404
  - lib/samagotchi/memory_bundle/status.rb
@@ -519,6 +527,7 @@ files:
519
527
  - lib/samagotchi/testing/fake_http.rb
520
528
  - lib/samagotchi/testing/helpers.rb
521
529
  - lib/samagotchi/testing/host.rb
530
+ - lib/samagotchi/testing/install.rb
522
531
  - lib/samagotchi/testing/minitest.rb
523
532
  - lib/samagotchi/testing/recorder.rb
524
533
  - lib/samagotchi/testing/recording_api.rb
@@ -691,6 +700,7 @@ files:
691
700
  - lib/samagotchi/web/public/turn_html.js
692
701
  - lib/samagotchi/web/public/turn_model.js
693
702
  - lib/samagotchi/web/public/turn_view.js
703
+ - lib/samagotchi/web/public/unqueue.js
694
704
  - lib/samagotchi/web/public/update_notice.js
695
705
  - lib/samagotchi/web/public/vendor/directive-helpers.js
696
706
  - lib/samagotchi/web/public/vendor/directive.js
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../config"
4
-
5
- module Samagotchi
6
- module Guardrails
7
- # Whether a parent agent may answer Continue to a session's step-limit
8
- # question (kind "continue", ContinueOffer): turn.parent_continue,
9
- # config.yml only, default true. Continue grants no permission (every
10
- # tool call still meets the guardrails), it spends time and tokens;
11
- # false makes parents stop-only. Checked by `chi answer` before it posts
12
- # and by the worker's question desk for an answer marked as chi
13
- # answer's, the way ParentApprovals is.
14
- module ParentContinue
15
- KEY = "turn.parent_continue"
16
- KIND = "continue"
17
- CONTINUE = "Continue"
18
-
19
- module_function
20
-
21
- # @return [Boolean] this process's setting
22
- def allowed?
23
- Config.get(KEY) != false
24
- end
25
-
26
- # Why a parent may not give this answer, or nil.
27
- # @param pending [PendingQuestion] the pending question
28
- # @param selected [Array<String>] the selected labels
29
- # @return [Symbol, nil] :stop_only
30
- def refusal(pending, selected, allowed: allowed?)
31
- return nil if allowed
32
- return nil unless pending.kind.to_s == KIND
33
-
34
- Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
35
- end
36
-
37
- def message
38
- "a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
39
- "(--option Stop --text WHY), or tell your user it waits"
40
- end
41
- end
42
- end
43
- end