samagotchi 0.17.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/architecture.md +25 -2
  4. data/docs/cli.md +34 -8
  5. data/docs/configuration.md +4 -2
  6. data/docs/guardrails.md +51 -8
  7. data/docs/hooks.md +4 -1
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +3 -2
  10. data/docs/sessions.md +6 -5
  11. data/docs/sub-agent.md +42 -4
  12. data/lib/samagotchi/answer_command.rb +23 -2
  13. data/lib/samagotchi/answer_tail.rb +52 -0
  14. data/lib/samagotchi/bridge.rb +49 -8
  15. data/lib/samagotchi/bridge_client.rb +18 -3
  16. data/lib/samagotchi/bundle_command.rb +108 -2
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/commands/registry.rb +28 -0
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/continue_offer.rb +177 -0
  24. data/lib/samagotchi/engine.rb +42 -14
  25. data/lib/samagotchi/guardrails/outside.rb +46 -0
  26. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  27. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  28. data/lib/samagotchi/guardrails/rules.rb +17 -5
  29. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  30. data/lib/samagotchi/guardrails/targets.rb +30 -13
  31. data/lib/samagotchi/guardrails.rb +2 -0
  32. data/lib/samagotchi/iteration_limit.rb +38 -0
  33. data/lib/samagotchi/kernel_loop.rb +2 -1
  34. data/lib/samagotchi/llm/backend.rb +2 -1
  35. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  36. data/lib/samagotchi/llm/native_backend.rb +2 -1
  37. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  38. data/lib/samagotchi/parent_report.rb +39 -9
  39. data/lib/samagotchi/question_desk.rb +205 -46
  40. data/lib/samagotchi/reply_wait.rb +10 -4
  41. data/lib/samagotchi/send_command.rb +2 -1
  42. data/lib/samagotchi/session.rb +3 -2
  43. data/lib/samagotchi/session_commands.rb +3 -2
  44. data/lib/samagotchi/session_manager.rb +4 -1
  45. data/lib/samagotchi/sessions_command.rb +3 -2
  46. data/lib/samagotchi/system_prompt.rb +1 -1
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
  48. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  49. data/lib/samagotchi/terminal_ui.rb +11 -2
  50. data/lib/samagotchi/tool_declarations.rb +2 -2
  51. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  52. data/lib/samagotchi/tools/edit.rb +6 -1
  53. data/lib/samagotchi/tools/execute.rb +5 -4
  54. data/lib/samagotchi/tools/read.rb +2 -1
  55. data/lib/samagotchi/turn_flow.rb +8 -7
  56. data/lib/samagotchi/turn_note.rb +7 -0
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +187 -42
  59. data/lib/samagotchi/web/public/app.js +122 -25
  60. data/lib/samagotchi/web/public/data.js +12 -13
  61. data/lib/samagotchi/web/public/index.html +6 -1
  62. data/lib/samagotchi/web/public/notify.js +5 -2
  63. data/lib/samagotchi/web/public/question_card.js +28 -1
  64. data/lib/samagotchi/web/public/question_cards.js +74 -3
  65. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  66. data/lib/samagotchi/web/public/timing.js +41 -0
  67. data/lib/samagotchi/web/public/turn_events.js +61 -0
  68. data/lib/samagotchi/web/public/update_notice.js +15 -0
  69. data/lib/samagotchi/worker.rb +30 -58
  70. data/lib/samagotchi/worker_sidecar.rb +6 -3
  71. metadata +8 -1
@@ -23,17 +23,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
23
23
  );
24
24
  }
25
25
 
26
- // opts.dir: the folder the chat starts in (else the server's cwd).
27
- // dir: the folder the chat starts in; model: the model it starts on (as
28
- // GET /api/models spells it); either left out means the server's default.
29
- export function createSession(prompt, { dir, model, ...opts } = {}) {
30
- const body = { prompt, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
31
- return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
32
- }
33
-
34
26
  // A session with no first turn: the page's first message is sent into it
35
- // as a turn (with its images, once they are uploaded). preview: that
36
- // message, which names the session until its turn is saved.
27
+ // as a turn (with its images, once they are uploaded). dir: the folder the
28
+ // chat starts in; model: the model it starts on (as GET /api/models spells
29
+ // it); either left out means the server's default. preview: that message,
30
+ // which names the session until its turn is saved.
37
31
  export function createIdleSession({ dir, model, preview, ...opts } = {}) {
38
32
  const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
39
33
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
@@ -61,11 +55,16 @@ export function uploadImage(id, file, name, opts = {}) {
61
55
  }
62
56
 
63
57
  // opts.tail: the lighter answer for the end of a turn (the session, its
64
- // timing and the last answer only), not the whole history.
58
+ // timing and the last answer only), not the whole history; with
59
+ // opts.recent its timing holds the newest turn's records and turn_count
60
+ // (mergeTiming), and opts.turnId asks for that turn's answer.
61
+ // opts.timing: the whole timing alone (when the merge came up short).
65
62
  // opts.parts: each saved message with what it did (thinking, tool calls
66
63
  // and their output), for the turn view's reload.
67
- export function getSession(id, { tail, parts, cards, ...opts } = {}) {
68
- const query = cards ? "?cards=1" : tail ? "?tail=1" : parts ? "?parts=1" : "";
64
+ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
65
+ let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
66
+ if (tail && recent) query += "&recent=1";
67
+ if (tail && turnId) query += `&turn_id=${encodeURIComponent(turnId)}`;
69
68
  return api(`/api/sessions/${id}${query}`, opts);
70
69
  }
71
70
 
@@ -224,6 +224,9 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
224
224
  .continue-context{font-size:12px;color:var(--text-3);margin-bottom:8px;white-space:pre-wrap}
225
225
  .continue-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
226
226
  .continue-reason{flex:1;min-width:140px;background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:8px;color:var(--text);padding:6px 8px}
227
+ /* The step-limit question (kind continue): the continue card's buttons inside a question card; the picked one stays marked once answered. */
228
+ .bubble.question.step-limit .continue-actions{margin-top:2px}
229
+ .bubble.question.step-limit .question-choice.selected{outline:2px solid var(--ok);outline-offset:1px;opacity:1}
227
230
  .bubble.question.submitting{opacity:.7}
228
231
  .bubble.question.answered{border-left-color:var(--ok)}
229
232
  .bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
@@ -666,9 +669,11 @@ body.all-view #center, body.all-view #topStrip{display:none}
666
669
  .card.archived{opacity:.6}
667
670
  .card.archived:hover{opacity:.85}
668
671
  .card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
669
- #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
672
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
670
673
  #toast[hidden]{display:none}
671
674
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
675
+ #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
676
+ #toast .toast-close:hover{color:var(--text)}
672
677
  #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
673
678
  .card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
674
679
  .card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
@@ -15,13 +15,15 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  // A finished turn shorter than this was watched: no "done".
16
16
  export const DONE_MIN_SECONDS = 10;
17
17
 
18
- const questionReason = (q) => (q.kind === "approval" ? "approval" : "question");
18
+ // The step-limit question (kind "continue": a turn ran out of iterations
19
+ // and asks whether to go on) has its own reason: "out of steps".
20
+ const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
19
21
 
20
22
  // What a session waits on the user for right now, from one summary: an open
21
23
  // question or approval, or a running turn's card with actions (check-in's
22
24
  // Nudge / Keep going / Stop asks as a question does). The session cards
23
25
  // mark and lift these; attentionFor notifies when one opens.
24
- // @return null | {reason: "question"|"approval"|"card", id}
26
+ // @return null | {reason: "question"|"approval"|"continue"|"card", id}
25
27
  export function waitingOn(summary) {
26
28
  const q = summary?.pending_question;
27
29
  if (q && q.id) return { reason: questionReason(q), id: q.id };
@@ -137,6 +139,7 @@ export function attentionText(session, attention) {
137
139
  const body = {
138
140
  question: "needs an answer",
139
141
  approval: "needs approval",
142
+ continue: "hit its step limit",
140
143
  card: "needs you",
141
144
  failed: "turn failed",
142
145
  done: `done in ${seconds} s`
@@ -8,6 +8,23 @@ export function isApproval(pq) {
8
8
  return !!pq && pq.kind === "approval";
9
9
  }
10
10
 
11
+ // The step-limit question (kind "continue"): a turn ran out of iterations.
12
+ // Its card has a button per option (Continue, Stop) and a reason that goes
13
+ // with Stop; it can't be dismissed.
14
+ export function isContinue(pq) {
15
+ return !!pq && pq.kind === "continue";
16
+ }
17
+
18
+ export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
19
+
20
+ // Why a step-limit question closed unanswered (its question_cancelled reason).
21
+ const CONTINUE_CLOSED = {
22
+ dropped: "Dropped: a new prompt came",
23
+ answered: "Answered with /continue",
24
+ superseded: "Set aside for another question",
25
+ replaced: "Asked again",
26
+ };
27
+
11
28
  // A delegate's approval relayed to this session's user (the approval
12
29
  // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
13
30
  // task, or null.
@@ -79,6 +96,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
79
96
  const approval = isApproval(pq);
80
97
  const relay = relayView(pq);
81
98
  if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
99
+ if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
82
100
  if (cancelled) {
83
101
  const label = approval ? "Denied" : "Cancelled";
84
102
  return reason ? `${label} (${reason})` : label;
@@ -97,6 +115,14 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
97
115
  return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
98
116
  }
99
117
 
118
+ function continueResultText({ answer, cancelled, reason }) {
119
+ if (cancelled) return CONTINUE_CLOSED[reason] || (reason ? `Closed (${reason})` : "Closed");
120
+ const sel = answer && Array.isArray(answer.selected) ? answer.selected : [];
121
+ const freeform = answer && answer.freeform ? String(answer.freeform) : "";
122
+ if (sel[0] === "Continue") return "Continued";
123
+ return freeform ? `Stopped: ${freeform}` : "Stopped";
124
+ }
125
+
100
126
  // Shorten a line of text for the card's summary (the collapsed one-liner).
101
127
  export function truncate(text, n = 80) {
102
128
  const s = String(text ?? "");
@@ -115,7 +141,8 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
115
141
  const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
116
142
  return truncate(relay ? `${relay.who}: ${text}` : text, 120);
117
143
  }
118
- const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
144
+ const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
145
+ : isContinue(pq) ? "Step limit" : "Question";
119
146
  return truncate(pq.header || label);
120
147
  }
121
148
 
@@ -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
 
@@ -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;
@@ -112,6 +112,60 @@ export function sessionCommandLine(text, commands) {
112
112
  .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
113
113
  }
114
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];
167
+ }
168
+
115
169
  // The terminal's own commands (a button in the web, or none yet): the
116
170
  // reply the page shows itself (they never go to the worker, which doesn't
117
171
  // know them), or null.
@@ -129,6 +183,13 @@ export function webLocalReply(text) {
129
183
  return WEB_LOCAL_REPLIES[name] || null;
130
184
  }
131
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
+
132
193
  // The continue card's answers, as the terminal's continue prompt reads them.
133
194
  export function continueLine(choice, reason = "") {
134
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? }
@@ -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
@@ -330,7 +340,7 @@ module Samagotchi
330
340
  # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
331
341
  def run_prompt(prompt, origin, no_interrupt: false, images: [])
332
342
  user_input(origin&.dig(:client_id))
333
- drop_continue_offer(origin)
343
+ @continue_offer.drop(origin)
334
344
  @turn_flow.before_prompt_turn
335
345
  @merged_this_turn = []
336
346
  run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
@@ -339,7 +349,7 @@ module Samagotchi
339
349
  # The Engine announced :turn_failed (with the error's one line).
340
350
  restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
341
351
  else
342
- after_turn(result, no_interrupt: no_interrupt)
352
+ @continue_offer.after_turn(result, no_interrupt: no_interrupt)
343
353
  end
344
354
  end
345
355
  end
@@ -355,16 +365,16 @@ module Samagotchi
355
365
  @engine.clear_due_reminder_names!
356
366
  return false unless @engine.reminders_due?
357
367
 
358
- drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
368
+ @continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
359
369
  # A failure has no prompt to hand back (the Engine announced
360
370
  # :turn_failed). The offer went before the turn
361
- # (drop_continue_offer); either way the rollback window closes.
371
+ # (ContinueOffer#drop); either way the rollback window closes.
362
372
  run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
363
- max_iterations: DEFAULT_MAX_ITERATIONS) { @turn_flow.after_reminder_turn }
373
+ max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
364
374
  true
365
375
  end
366
376
 
367
- 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)
368
378
 
369
379
  # @return [Boolean] whether any command ran
370
380
  def run_queued_commands
@@ -398,22 +408,18 @@ module Samagotchi
398
408
  end
399
409
 
400
410
  def run_command(command)
401
- awaiting = @turn_flow.awaiting_continue?
411
+ awaiting = @continue_offer.awaiting?
402
412
  result, shown = run_command_line(command)
403
- resolved = awaiting && result.decision && result.decision != :invalid
413
+ resolved = @continue_offer.resolved?(awaiting, result)
404
414
  @engine.synchronize_events do
405
- if resolved
406
- @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
407
- end
415
+ @continue_offer.announce_resolved(result, command) if resolved
408
416
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
409
417
  shown.each { |event| @engine.announce(event) }
410
418
  end
411
419
  save_session unless Array(result.changed).empty?
412
420
  user_input(command[:client_id]) if resolved
413
- # An offer answered without a turn ("no") is activity: the recap
414
- # written at the offer (it says the turn stopped) gets rewritten.
415
- @engine.record_activity if resolved && !result.resume
416
- 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
417
423
  end
418
424
 
419
425
  # @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
@@ -466,22 +472,6 @@ module Samagotchi
466
472
  @engine.announce(event)
467
473
  end
468
474
 
469
- # The continue offer was answered yes: resume the conversation without a
470
- # user message, with the iteration limit the offer's turn had. A failure
471
- # keeps the offer, as the REPL does ("continue prompt preserved").
472
- def run_continue_turn(command)
473
- offer = @turn_flow.offer
474
- @turn_flow.before_continue_turn
475
- run_engine_turn(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
476
- max_iterations: max_iterations(offer[:no_interrupt])) do |result, error|
477
- if error
478
- @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
479
- else
480
- after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
481
- end
482
- end
483
- end
484
-
485
475
  # One Engine turn, the same for a prompt, a reminder and a continue:
486
476
  # the session shows as running to readers of the file (the web's
487
477
  # session list; the Engine resets it to idle when it ends), commands
@@ -502,31 +492,13 @@ module Samagotchi
502
492
  end
503
493
  yield result, error
504
494
  response = result&.output
505
- SessionInbox.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
506
- save_session
507
- end
508
-
509
- # TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
510
- # the continue offer of a turn that ran out of iterations, which every
511
- # UI hears about.
512
- def after_turn(result, continue: false, no_interrupt: false)
513
- outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
514
- return unless outcome == :continue_offered || outcome == :continue_cancelled
515
-
516
- offer = @turn_flow.offer
517
- @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
518
- end
519
-
520
- # A prompt (or a reminder turn) taken while a continue is offered
521
- # replaces the answer (D2): the offer goes, the partial turn stays.
522
- def drop_continue_offer(origin)
523
- return unless @turn_flow.awaiting_continue?
524
-
525
- @engine.synchronize_events do
526
- @turn_flow.drop_offer!
527
- @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)
528
500
  end
529
- @engine.record_activity
501
+ save_session
530
502
  end
531
503
 
532
504
  # Back to the conversation before the failed turn, as the REPL does (so
@@ -42,9 +42,12 @@ module Samagotchi
42
42
  return nil unless sidecar&.port&.positive?
43
43
 
44
44
  begin
45
- # Not Socket.tcp: with connect_timeout (Ruby 3.4, macOS 26) it hands
46
- # back the socket of a refused connect (SO_ERROR set), so a dead
47
- # worker's port read as live. TCPSocket raises ECONNREFUSED.
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.
48
51
  TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
49
52
  sidecar
50
53
  rescue StandardError