samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -14,7 +14,10 @@
14
14
 
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
- import { approvalAllowed, approvalView, nextCardAction, relayedToView, resultText, summaryText } from "./question_card.js";
17
+ import {
18
+ approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction, relayedToView, resultText,
19
+ summaryText,
20
+ } from "./question_card.js";
18
21
  import { sessionHash } from "./route.js";
19
22
  import { clientLabel, continueLine, isOwn } from "./turn_events.js";
20
23
 
@@ -50,8 +53,11 @@ export function createQuestionCards({
50
53
  removeHintIfEmpty();
51
54
 
52
55
  const approval = approvalView(pq);
56
+ const stepLimit = isContinue(pq);
57
+ // The step-limit question stands for the old continue card.
58
+ if (stepLimit) dropContinueCard();
53
59
  const card = doc.createElement("details");
54
- card.className = approval ? "bubble question approval" : "bubble question";
60
+ card.className = approval ? "bubble question approval" : stepLimit ? "bubble question step-limit" : "bubble question";
55
61
  card.dataset.qid = pq.id;
56
62
  // Open while pending: the question must be actionable.
57
63
  card.open = true;
@@ -77,7 +83,9 @@ export function createQuestionCards({
77
83
  }
78
84
 
79
85
  const options = Array.isArray(pq.options) ? pq.options : [];
80
- if (options.length) {
86
+ if (stepLimit) {
87
+ renderStepLimitActions(card, pq, options);
88
+ } else if (options.length) {
81
89
  const list = doc.createElement("div");
82
90
  list.className = "question-options";
83
91
  const inputType = pq.multi_select ? "checkbox" : "radio";
@@ -98,6 +106,14 @@ export function createQuestionCards({
98
106
  card.appendChild(list);
99
107
  }
100
108
 
109
+ if (stepLimit) {
110
+ const follow = isNearBottom();
111
+ place(card);
112
+ questionEl = card;
113
+ if (follow) scrollToEnd();
114
+ return;
115
+ }
116
+
101
117
  let freeformInput = null;
102
118
  if (pq.allow_freeform) {
103
119
  freeformInput = doc.createElement("input");
@@ -168,6 +184,49 @@ export function createQuestionCards({
168
184
  if (follow) scrollToEnd();
169
185
  }
170
186
 
187
+ // The step-limit question's actions: a button per option (Continue,
188
+ // Stop), the reason (it goes with Stop) and no dismiss: the turn waits
189
+ // on one or the other. A Continue with a reason in the box is refused
190
+ // here, as the worker would.
191
+ function renderStepLimitActions(card, pq, options) {
192
+ const reason = doc.createElement("input");
193
+ reason.type = "text";
194
+ reason.className = "question-freeform continue-reason";
195
+ reason.placeholder = CONTINUE_REASON_PLACEHOLDER;
196
+ const err = doc.createElement("div");
197
+ err.className = "question-error hidden";
198
+ const row = doc.createElement("div");
199
+ row.className = "continue-actions";
200
+ const send = (label) => {
201
+ const freeform = label === "Continue" ? "" : reason.value.trim();
202
+ if (label === "Continue" && reason.value.trim()) {
203
+ err.textContent = "Continue takes no reason: clear it, or Stop with it.";
204
+ err.classList.remove("hidden");
205
+ return;
206
+ }
207
+ err.classList.add("hidden");
208
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
209
+ card.classList.add("submitting");
210
+ api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
211
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
212
+ card.classList.remove("submitting");
213
+ err.textContent = /503|not_live/.test(e.message) ? "Session is not running — restart it to answer." : e.message;
214
+ err.classList.remove("hidden");
215
+ });
216
+ };
217
+ options.forEach((label, i) => {
218
+ const button = doc.createElement("button");
219
+ button.className = i === 0 ? "question-choice continue-yes" : "question-choice continue-no ghost";
220
+ button.dataset.value = String(label);
221
+ button.textContent = String(label);
222
+ button.addEventListener("click", () => send(String(label)));
223
+ row.appendChild(button);
224
+ });
225
+ if (pq.allow_freeform) row.appendChild(reason);
226
+ card.appendChild(row);
227
+ card.appendChild(err);
228
+ }
229
+
171
230
  // The tool, the command (or paths, or args) as code, where and why; no
172
231
  // code box when there is nothing to show.
173
232
  function renderApprovalDetails(view) {
@@ -282,6 +341,9 @@ export function createQuestionCards({
282
341
 
283
342
  function markQuestionSelection(sel) {
284
343
  if (!questionEl || !sel.length) return;
344
+ questionEl.querySelectorAll(".question-choice").forEach((button) => {
345
+ if (sel.includes(button.dataset.value)) button.classList.add("selected");
346
+ });
285
347
  questionEl.querySelectorAll(".question-option input").forEach((input) => {
286
348
  if (sel.includes(input.value)) {
287
349
  input.checked = true;
@@ -317,8 +379,17 @@ export function createQuestionCards({
317
379
  dropped: "Dropped: a new prompt came",
318
380
  };
319
381
 
382
+ // The old card goes: the step-limit question asks the same (a worker that
383
+ // posts it announces continue_offered too, for older pages).
384
+ function dropContinueCard() {
385
+ continueEl?.remove();
386
+ continueEl = null;
387
+ }
388
+
320
389
  function renderContinueCard(offer) {
321
390
  if (continueEl?.isConnected) return;
391
+ // A step-limit question is up: it is the card (an older worker has none).
392
+ if (isContinue(pending)) return;
322
393
  removeHintIfEmpty();
323
394
  const card = doc.createElement("div");
324
395
  card.className = "bubble continue";
@@ -70,6 +70,7 @@ export function withChildrenAfterParents(list) {
70
70
  const WAITING_BADGES = {
71
71
  question: { text: "question", title: "Waiting for your answer" },
72
72
  approval: { text: "approval", title: "Waiting for your approval" },
73
+ continue: { text: "out of steps", title: "The turn hit its step limit: continue or stop it" },
73
74
  card: { text: "needs you", title: "A card in the running turn waits for you" },
74
75
  };
75
76
 
@@ -360,6 +360,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
360
360
  // never above the session panel (the dock is absolute: a tall composer or
361
361
  // a phone's keyboard would push the stage's top out) ────────────────────
362
362
 
363
+ // Scroll the stage so +slot+'s end shows (it is the scroller's child).
364
+ function revealSlot(slot) {
365
+ if (stage.hidden || slot.hidden) return;
366
+ const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
367
+ if (end > scroller.scrollTop) scroller.scrollTop = end;
368
+ }
369
+
363
370
  function fitCap() {
364
371
  if (stage.hidden) return;
365
372
  const rest = dockEl.offsetHeight - stage.offsetHeight;
@@ -593,8 +600,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
593
600
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
594
601
  slot.appendChild(el);
595
602
  slot.hidden = false;
596
- if (stick || el.matches?.(PENDING) || el.querySelector?.(ASKS)) slot.scrollTop = slot.scrollHeight;
603
+ const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
604
+ if (stick || waits) slot.scrollTop = slot.scrollHeight;
597
605
  scheduleRefresh();
606
+ // A card that waits for the user is brought into the stage's view: in
607
+ // a short window the prompt and headline fill it and the card sat
608
+ // below, out of sight (after the refresh, which hides the live slots).
609
+ if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
598
610
  return true;
599
611
  },
600
612
  // Where the live timing line goes (the status row), null: the history.
@@ -30,3 +30,14 @@ export function stripShowParts(sessions) {
30
30
  waiting: waitingN ? `${waitingN} waiting` : "",
31
31
  };
32
32
  }
33
+
34
+ // A window this short hides the strip by itself: at ~500-700 px the stage
35
+ // is capped low and a card that waits there (a question, an approval) is
36
+ // cut off below the strip.
37
+ export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
38
+
39
+ // Whether the strip is hidden: the user's own hide (saved), or a short
40
+ // window unless the user showed it (the pill) since it got short.
41
+ export function stripAutoHidden({ saved, short, shownWhileShort }) {
42
+ return !!saved || (!!short && !shownWhileShort);
43
+ }
@@ -55,6 +55,47 @@ export function normalizeTiming(data = {}) {
55
55
  };
56
56
  }
57
57
 
58
+ // A turn-end re-read's timing (+data+, the server's ?tail=1&recent=1: the
59
+ // newest turn's records and turn_count) merged into the page's (+timing+,
60
+ // normalizeTiming's shape). Records by id: a known one is replaced in
61
+ // place; an unknown turn goes in started_at order (ties and unparsable
62
+ // dates keep arrival order), an unknown tool record at the end (they come
63
+ // grouped by turn, in call order). startedAt, sessionDurationMs and
64
+ // activeTurn are the reply's. `short`: the reply's turn_count says the page
65
+ // misses a turn (it then reads the whole timing). A reply without
66
+ // turn_count (an older server: the whole timing) replaces, as before; more
67
+ // records than the count (a worker died before saving a turn the page saw)
68
+ // are kept.
69
+ // @return {{timing: object, short: boolean}}
70
+ export function mergeTiming(timing, data = {}) {
71
+ const fresh = normalizeTiming(data || {});
72
+ const count = data?.turn_count;
73
+ if (typeof count !== "number" || !Number.isFinite(count)) return { timing: fresh, short: false };
74
+ const turnRecords = (timing?.turnRecords || []).slice();
75
+ fresh.turnRecords.forEach((record) => {
76
+ if (replaceById(turnRecords, record)) return;
77
+ const started = Date.parse(record.started_at);
78
+ const at = Number.isFinite(started)
79
+ ? turnRecords.findIndex((r) => Date.parse(r?.started_at) > started)
80
+ : -1;
81
+ if (at < 0) turnRecords.push(record);
82
+ else turnRecords.splice(at, 0, record);
83
+ });
84
+ const toolRecords = (timing?.toolRecords || []).slice();
85
+ fresh.toolRecords.forEach((record) => {
86
+ if (!replaceById(toolRecords, record)) toolRecords.push(record);
87
+ });
88
+ return { timing: { ...fresh, turnRecords, toolRecords }, short: turnRecords.length < count };
89
+ }
90
+
91
+ function replaceById(records, record) {
92
+ if (record?.id == null) return false;
93
+ const at = records.findIndex((r) => r?.id === record.id);
94
+ if (at < 0) return false;
95
+ records[at] = record;
96
+ return true;
97
+ }
98
+
58
99
  export function turnRecordAt(timing, index) {
59
100
  const records = timing?.turnRecords;
60
101
  return Array.isArray(records) ? records[index] || null : null;
@@ -185,37 +226,51 @@ function recordRows(tools, iteration) {
185
226
  }
186
227
 
187
228
  // A turn whose saved messages carry their parts (the server's ?parts=1):
188
- // every assistant message is one generation, so the k-th is iteration k
189
- // and its calls pair with that iteration's records by position. The last
190
- // message is the answer when it has text and made no call (and the turn
191
- // is no canceled multi-step one); its thinking stays behind as a step, as
192
- // it does live.
229
+ // every assistant message is one step. Its calls take the turn's records by
230
+ // tool, in call order (each the next record of its tool, consumed with the
231
+ // ones it skipped), not by iteration: an empty retry spends an iteration
232
+ // that left no message (as the TUI's join, attached_loop.rb join_duration).
233
+ // The last message is the answer when it has text and made no call (and the
234
+ // turn is no canceled multi-step one); its thinking stays behind as a step,
235
+ // as it does live.
193
236
  function partsTurn(g, items, record, tools, stepsOnly = false) {
194
237
  const last = g.texts[g.texts.length - 1];
195
238
  const lastItem = items[last];
196
239
  const answer = !stepsOnly && String(lastItem.content ?? "") !== "" && !lastItem.parts?.tools?.length ? last : null;
240
+ const pool = [...tools].sort((a, b) =>
241
+ (Number(a.iteration) || 0) - (Number(b.iteration) || 0) || (Number(a.call_index) || 0) - (Number(b.call_index) || 0));
197
242
  const steps = g.texts.map((i, k) => {
198
243
  const iteration = k + 1;
199
244
  const parts = items[i].parts || {};
200
- const records = recordRows(tools, iteration);
201
245
  const rows = Array.isArray(parts.tools)
202
246
  ? parts.tools.map((call, j) => {
203
- const r = records.find((row) => row.key === `${iteration}:${j + 1}`);
247
+ const r = takeRecord(pool, call.tool);
204
248
  return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
205
249
  })
206
- : records;
250
+ : [];
207
251
  const thinking = String(parts.thinking || "");
208
252
  if (i === answer) return thinking ? { i: null, iteration, thinking, tools: [] } : null;
209
253
  return { i, iteration, thinking, tools: rows };
210
254
  }).filter(Boolean);
211
- // Records past the saved messages (a generation that saved nothing).
212
- const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
213
- for (let iteration = g.texts.length + 1; iteration <= maxIteration; iteration += 1) {
214
- steps.push({ i: null, iteration, thinking: "", tools: recordRows(tools, iteration) });
255
+ // Records no saved message took (a generation that saved nothing): a
256
+ // step per iteration of theirs, after the messages'.
257
+ let iteration = g.texts.length;
258
+ for (const n of [...new Set(pool.map((r) => Number(r.iteration) || 0))]) {
259
+ iteration += 1;
260
+ const rows = recordRows(pool, n).map((row) => ({ ...row, key: `${iteration}:${row.key.split(":")[1]}` }));
261
+ steps.push({ i: null, iteration, thinking: "", tools: rows });
215
262
  }
216
263
  return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
217
264
  }
218
265
 
266
+ // The next record of +tool+ in +pool+ (call order), removed with the ones
267
+ // before it; undefined when there is none (the pool stays as it was).
268
+ function takeRecord(pool, tool) {
269
+ const at = pool.findIndex((r) => String(r.tool) === String(tool));
270
+ if (at < 0) return undefined;
271
+ return pool.splice(0, at + 1)[at];
272
+ }
273
+
219
274
  // Append +el+ to the history, above +timingEl+ while it is a running turn's
220
275
  // live timing line, so that line stays under the turn's activity, thinking
221
276
  // and answer.
@@ -97,9 +97,73 @@ export function initWaitLine({ tasks } = {}) {
97
97
  return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
98
98
  }
99
99
 
100
- // A composer line for the worker's command route rather than a prompt.
101
- export function isCommandLine(text) {
102
- return /^[/!]/.test(String(text || "").trim());
100
+ // Whether a composer line in an open session is one of its commands
101
+ // (+commands+: the session GET's listing), as the terminals decide
102
+ // (Commands::Registry#lookup): a "!" line with something after the "!", or
103
+ // a listed name alone or followed by a space; a terminal's own (/stats,
104
+ // /exit) too, which the page answers itself (webLocalReply). An unknown
105
+ // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
106
+ export function sessionCommandLine(text, commands) {
107
+ const line = String(text || "").trim();
108
+ if (/^!\s*\S/.test(line)) return true;
109
+ if (!line.startsWith("/")) return false;
110
+ if (webLocalReply(line)) return true;
111
+ return (Array.isArray(commands) ? commands : [])
112
+ .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
113
+ }
114
+
115
+ // A line that is one word: a slash and a name, no spaces, no second slash
116
+ // (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
117
+ // and `/usr/bin/env` are not (they are prompts).
118
+ const UNKNOWN_COMMAND_WORD = /^\/[A-Za-z][A-Za-z0-9_-]*$/;
119
+
120
+ // The hint for a command word no command answers (a typo like `/modle`),
121
+ // or null when the line is not one: `Unknown command /modle. Did you mean
122
+ // /model? /help lists the commands.` The "Did you mean" part needs a close
123
+ // name among the session's commands and the page's own (webLocalReply).
124
+ export function unknownCommandHint(text, commands) {
125
+ const line = String(text || "").trim();
126
+ if (!UNKNOWN_COMMAND_WORD.test(line)) return null;
127
+ if (sessionCommandLine(line, commands)) return null;
128
+ const names = (Array.isArray(commands) ? commands : [])
129
+ .map((c) => (c && typeof c.name === "string" ? c.name : null))
130
+ .filter(Boolean)
131
+ .concat(Object.keys(WEB_LOCAL_REPLIES));
132
+ const close = closestName(line, names);
133
+ return `Unknown command ${line}. ${close ? `Did you mean ${close}? ` : ""}/help lists the commands.`;
134
+ }
135
+
136
+ // The closest name within an optimal string alignment distance of 2, or
137
+ // null. Ties go to the first in the list.
138
+ function closestName(line, names) {
139
+ let best = null;
140
+ let bestDistance = 3;
141
+ for (const name of names) {
142
+ const distance = osaDistance(line, name);
143
+ if (distance < bestDistance) {
144
+ best = name;
145
+ bestDistance = distance;
146
+ }
147
+ }
148
+ return best;
149
+ }
150
+
151
+ // Optimal string alignment distance: Levenshtein, with an adjacent
152
+ // transposition counting as 1 (so `/modle` is 1 from `/model`, as Ruby's
153
+ // DidYouMean reads it).
154
+ function osaDistance(a, b) {
155
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
156
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
157
+ for (let i = 1; i <= a.length; i += 1) {
158
+ for (let j = 1; j <= b.length; j += 1) {
159
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
160
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
161
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
162
+ rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
163
+ }
164
+ }
165
+ }
166
+ return rows[a.length][b.length];
103
167
  }
104
168
 
105
169
  // The terminal's own commands (a button in the web, or none yet): the
@@ -119,6 +183,13 @@ export function webLocalReply(text) {
119
183
  return WEB_LOCAL_REPLIES[name] || null;
120
184
  }
121
185
 
186
+ // The start page's first message when the page answers it itself
187
+ // (webLocalReply: /stats, /exit …): shown there, with no session made just
188
+ // to hold the reply. With images it is a message, as in a session.
189
+ export function startPageReply(text, { images = 0 } = {}) {
190
+ return images ? null : webLocalReply(text);
191
+ }
192
+
122
193
  // The continue card's answers, as the terminal's continue prompt reads them.
123
194
  export function continueLine(choice, reason = "") {
124
195
  const why = String(reason || "").trim();
@@ -0,0 +1,15 @@
1
+ // An open tab keeps its JS across a `chi web` restart (its event streams
2
+ // just reconnect), so after an upgrade old page code talks to a new server.
3
+ // The page knows the version that served it (body data-version); each
4
+ // GET /api/events snapshot names the one serving now.
5
+
6
+ // The toast's text when the chi web serving now is another version than
7
+ // the one that served this page, else null: also when either side doesn't
8
+ // say (an older chi web, a page from before data-version), and when the
9
+ // toast was already shown for this version (+shown+: the reconnects'
10
+ // snapshots carry it again).
11
+ export function updateNotice(loaded, served, shown = null) {
12
+ const known = (v) => typeof v === "string" && v !== "";
13
+ if (!known(loaded) || !known(served) || loaded === served || served === shown) return null;
14
+ return `chi was updated to ${served}`;
15
+ }
@@ -12,6 +12,8 @@ require_relative "session_manager"
12
12
  require_relative "archive_store"
13
13
  require_relative "log"
14
14
  require_relative "turn_flow"
15
+ require_relative "continue_offer"
16
+ require_relative "iteration_limit"
15
17
  require_relative "session_commands"
16
18
  require_relative "model_profile"
17
19
 
@@ -44,8 +46,6 @@ module Samagotchi
44
46
  # How much of a command's output goes into its :command_ran.
45
47
  COMMAND_OUTPUT_LIMIT = 4096
46
48
  BUSY_OUTPUT = "busy: wait for the turn to end"
47
- DEFAULT_MAX_ITERATIONS = 100
48
- NO_INTERRUPT_MAX_ITERATIONS = 1000 # as Engine::NO_INTERRUPT_MAX_ITERATIONS
49
49
 
50
50
  # Wakes the worker loop. Whoever queues work writes it first and wakes
51
51
  # after, and #wait drains every wake before the loop looks for work: a
@@ -108,6 +108,13 @@ module Samagotchi
108
108
  # an empty session until the first turn.
109
109
  @engine.session = @session
110
110
  @turn_flow = TurnFlow.new(engine: @engine)
111
+ # Its question's answer is queued as the matching /continue (the
112
+ # Bridge exists by the time anyone answers).
113
+ @continue_offer = ContinueOffer.new(engine: @engine, turn_flow: @turn_flow, run_turn: method(:run_engine_turn),
114
+ max_iterations: method(:max_iterations),
115
+ queue_command: lambda { |line, client_id:|
116
+ @bridge.queue_command(line, client_id: client_id, card: true)
117
+ })
111
118
  # A recap written while a continue offer waits says the turn stopped
112
119
  # unfinished (before the idle jobs start).
113
120
  @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
@@ -173,7 +180,7 @@ module Samagotchi
173
180
  absorb_notes
174
181
 
175
182
  if (prompt = take_initial_prompt)
176
- run_prompt(prompt, nil)
183
+ run_prompt(prompt, nil) unless initial_command(prompt)
177
184
  next
178
185
  end
179
186
 
@@ -203,7 +210,10 @@ module Samagotchi
203
210
  ensure
204
211
  # The anytime commands finish and the plugins' services stop (a
205
212
  # server process), whatever the way out; the Bridge last, so a
206
- # command's command_ran still reaches its UI on a crash.
213
+ # command's command_ran still reaches its UI on a crash. A
214
+ # step-limit question stays in the file (a save here would write
215
+ # over a stop's status): with no live worker the lists don't read
216
+ # it as waiting, and the next worker drops it (drop_dead_question).
207
217
  @engine&.shutdown
208
218
  @bridge&.stop
209
219
  end
@@ -269,6 +279,29 @@ module Samagotchi
269
279
  prompt
270
280
  end
271
281
 
282
+ # A session command sent as a message (chi send -m "/model x", a web
283
+ # page's first message) runs as the command, as the Bridge does for a
284
+ # POST /turn; an unknown /word stays a prompt. Queued: the loop's next
285
+ # pass runs it.
286
+ # @return [Boolean] whether +text+ was one
287
+ def queue_as_command(text, origin)
288
+ return false unless @engine.command_registry.command?(text.to_s)
289
+
290
+ @bridge.queue_command(text, client_id: origin&.dig(:client_id))
291
+ true
292
+ end
293
+
294
+ # The first prompt as a command: the session was saved as running for a
295
+ # turn that won't run.
296
+ # @return [Boolean] whether it was one
297
+ def initial_command(prompt)
298
+ return false unless queue_as_command(prompt, nil)
299
+
300
+ @session.status = Session::STATUS_IDLE
301
+ save_session
302
+ true
303
+ end
304
+
272
305
  # Add the queued context notes to the conversation (between turns only,
273
306
  # on this thread), save, then delete their files: a crash before the
274
307
  # delete leaves them claimed, and Engine#add_context_note skips a note
@@ -293,7 +326,10 @@ module Samagotchi
293
326
 
294
327
  begin
295
328
  message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
296
- run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || []) unless message.to_s.strip.empty?
329
+ return if message.to_s.strip.empty?
330
+ return if Array(images).empty? && queue_as_command(message, origin)
331
+
332
+ run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
297
333
  ensure
298
334
  FileUtils.rm_f(claimed_file)
299
335
  end
@@ -304,7 +340,7 @@ module Samagotchi
304
340
  # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
305
341
  def run_prompt(prompt, origin, no_interrupt: false, images: [])
306
342
  user_input(origin&.dig(:client_id))
307
- drop_continue_offer(origin)
343
+ @continue_offer.drop(origin)
308
344
  @turn_flow.before_prompt_turn
309
345
  @merged_this_turn = []
310
346
  run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
@@ -313,7 +349,7 @@ module Samagotchi
313
349
  # The Engine announced :turn_failed (with the error's one line).
314
350
  restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
315
351
  else
316
- after_turn(result, no_interrupt: no_interrupt)
352
+ @continue_offer.after_turn(result, no_interrupt: no_interrupt)
317
353
  end
318
354
  end
319
355
  end
@@ -329,16 +365,16 @@ module Samagotchi
329
365
  @engine.clear_due_reminder_names!
330
366
  return false unless @engine.reminders_due?
331
367
 
332
- drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
368
+ @continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
333
369
  # A failure has no prompt to hand back (the Engine announced
334
370
  # :turn_failed). The offer went before the turn
335
- # (drop_continue_offer); either way the rollback window closes.
371
+ # (ContinueOffer#drop); either way the rollback window closes.
336
372
  run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
337
- max_iterations: DEFAULT_MAX_ITERATIONS) { @turn_flow.after_reminder_turn }
373
+ max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
338
374
  true
339
375
  end
340
376
 
341
- def max_iterations(no_interrupt) = no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : DEFAULT_MAX_ITERATIONS
377
+ def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
342
378
 
343
379
  # @return [Boolean] whether any command ran
344
380
  def run_queued_commands
@@ -372,22 +408,18 @@ module Samagotchi
372
408
  end
373
409
 
374
410
  def run_command(command)
375
- awaiting = @turn_flow.awaiting_continue?
411
+ awaiting = @continue_offer.awaiting?
376
412
  result, shown = run_command_line(command)
377
- resolved = awaiting && result.decision && result.decision != :invalid
413
+ resolved = @continue_offer.resolved?(awaiting, result)
378
414
  @engine.synchronize_events do
379
- if resolved
380
- @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
381
- end
415
+ @continue_offer.announce_resolved(result, command) if resolved
382
416
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
383
417
  shown.each { |event| @engine.announce(event) }
384
418
  end
385
419
  save_session unless Array(result.changed).empty?
386
420
  user_input(command[:client_id]) if resolved
387
- # An offer answered without a turn ("no") is activity: the recap
388
- # written at the offer (it says the turn stopped) gets rewritten.
389
- @engine.record_activity if resolved && !result.resume
390
- run_continue_turn(command) if result.resume
421
+ @continue_offer.after_command(result, resolved: resolved)
422
+ @continue_offer.run_continue_turn(command) if result.resume
391
423
  end
392
424
 
393
425
  # @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
@@ -440,22 +472,6 @@ module Samagotchi
440
472
  @engine.announce(event)
441
473
  end
442
474
 
443
- # The continue offer was answered yes: resume the conversation without a
444
- # user message, with the iteration limit the offer's turn had. A failure
445
- # keeps the offer, as the REPL does ("continue prompt preserved").
446
- def run_continue_turn(command)
447
- offer = @turn_flow.offer
448
- @turn_flow.before_continue_turn
449
- run_engine_turn(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
450
- max_iterations: max_iterations(offer[:no_interrupt])) do |result, error|
451
- if error
452
- @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
453
- else
454
- after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
455
- end
456
- end
457
- end
458
-
459
475
  # One Engine turn, the same for a prompt, a reminder and a continue:
460
476
  # the session shows as running to readers of the file (the web's
461
477
  # session list; the Engine resets it to idle when it ends), commands
@@ -476,31 +492,13 @@ module Samagotchi
476
492
  end
477
493
  yield result, error
478
494
  response = result&.output
479
- SessionInbox.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
480
- save_session
481
- end
482
-
483
- # TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
484
- # the continue offer of a turn that ran out of iterations, which every
485
- # UI hears about.
486
- def after_turn(result, continue: false, no_interrupt: false)
487
- outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
488
- return unless outcome == :continue_offered || outcome == :continue_cancelled
489
-
490
- offer = @turn_flow.offer
491
- @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
492
- end
493
-
494
- # A prompt (or a reminder turn) taken while a continue is offered
495
- # replaces the answer (D2): the offer goes, the partial turn stays.
496
- def drop_continue_offer(origin)
497
- return unless @turn_flow.awaiting_continue?
498
-
499
- @engine.synchronize_events do
500
- @turn_flow.drop_offer!
501
- @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
495
+ # A turn that ran out of iterations and asks whether to continue left
496
+ # no reply: its text is what came before its last tool calls, and a
497
+ # wait (chi send --wait) gets the question instead.
498
+ unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
499
+ SessionInbox.write_output(@session_dir, response)
502
500
  end
503
- @engine.record_activity
501
+ save_session
504
502
  end
505
503
 
506
504
  # Back to the conversation before the failed turn, as the REPL does (so
@@ -42,7 +42,13 @@ module Samagotchi
42
42
  return nil unless sidecar&.port&.positive?
43
43
 
44
44
  begin
45
- Socket.tcp(host, sidecar.port, connect_timeout: timeout).close
45
+ # Not Socket.tcp: with connect_timeout on macOS 26 it hands back the
46
+ # socket of a refused connect (the kernel's second connect() says
47
+ # EISCONN, SO_ERROR is unchecked), so a dead worker's port read as
48
+ # live. Ruby bug #22223, fixed in 3.4.11 / 4.0.7, not in 3.3.
49
+ # TCPSocket checks SO_ERROR and raises ECONNREFUSED on every Ruby,
50
+ # so this stays even once 3.3 is dropped.
51
+ TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
46
52
  sidecar
47
53
  rescue StandardError
48
54
  FileUtils.rm_f(path(session_dir)) if unlink