samagotchi 0.28.0 → 0.30.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/hooks.md +1 -1
  5. data/docs/plugins.md +1 -1
  6. data/docs/sessions.md +3 -3
  7. data/lib/samagotchi/bundle_command.rb +4 -2
  8. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  10. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  11. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/command_steps.rb +35 -15
  14. data/lib/samagotchi/engine.rb +4 -3
  15. data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
  16. data/lib/samagotchi/host_registry.rb +4 -0
  17. data/lib/samagotchi/kernel_loop.rb +27 -7
  18. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  19. data/lib/samagotchi/memory_bundle/installer.rb +14 -0
  20. data/lib/samagotchi/model_list_store.rb +117 -0
  21. data/lib/samagotchi/model_profile.rb +43 -0
  22. data/lib/samagotchi/send_command.rb +5 -3
  23. data/lib/samagotchi/session.rb +5 -0
  24. data/lib/samagotchi/session_commands.rb +6 -2
  25. data/lib/samagotchi/session_manager.rb +9 -2
  26. data/lib/samagotchi/session_metrics.rb +6 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  28. data/lib/samagotchi/terminal_ui.rb +35 -5
  29. data/lib/samagotchi/tool_activity.rb +38 -8
  30. data/lib/samagotchi/tools/builtins.rb +10 -7
  31. data/lib/samagotchi/tools/delegate.rb +14 -10
  32. data/lib/samagotchi/tools/execute.rb +3 -1
  33. data/lib/samagotchi/tools/task_runtime.rb +21 -0
  34. data/lib/samagotchi/turn_note.rb +10 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/activity.js +2 -1
  37. data/lib/samagotchi/web/public/app.js +23 -11
  38. data/lib/samagotchi/web/public/command_view.js +19 -5
  39. data/lib/samagotchi/web/public/index.html +51 -25
  40. data/lib/samagotchi/web/public/sessions_list.js +21 -4
  41. data/lib/samagotchi/web/public/stage_model.js +23 -2
  42. data/lib/samagotchi/web/public/stage_view.js +16 -11
  43. data/lib/samagotchi/web/public/task_stop.js +11 -2
  44. data/lib/samagotchi/web/public/timing.js +12 -7
  45. data/lib/samagotchi/web/public/turn_events.js +3 -1
  46. data/lib/samagotchi/web/public/turn_model.js +29 -15
  47. data/lib/samagotchi/web/public/turn_view.js +70 -11
  48. data/lib/samagotchi/worker.rb +24 -7
  49. metadata +2 -1
@@ -11,7 +11,7 @@
11
11
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
12
  // and annotate survive. The rules are pure in stage_model.js.
13
13
  import { createTurnView } from "./turn_view.js";
14
- import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
14
+ import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailMark, trailTone } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
16
  import { taskCommand, taskStopButton } from "./task_stop.js";
17
17
 
@@ -189,20 +189,26 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
189
189
  stage.dataset.phase = slots.phase;
190
190
  mark.textContent = MARKS[slots.phase] || "";
191
191
  phaseEl.textContent = slots.phase;
192
- stepEl.textContent = endKind ? `${slots.step} step${slots.step === 1 ? "" : "s"}` : `step ${slots.step}`;
192
+ const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
193
193
 
194
194
  drawHeadline(slots.headline);
195
- drawNow(slots.tool, gen);
195
+ drawNow(slots.tool, slots.quiet);
196
196
  drawTrail(slots.trail);
197
197
  drawTicks(slots.ticks);
198
198
  const calls = slots.ticks.length;
199
- const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
200
- chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
199
+ // An ended turn says each thing once: its steps in the chip (the label
200
+ // of what the cloud holds), its time in the status row's timing line
201
+ // (the one that moves into the history). The chip keeps the time when
202
+ // that line is elsewhere (a turn the stage didn't hold from its start).
203
+ const elapsed = endKind && !timingSlot.firstChild ? formatDuration(endedAt - startedAt) : "";
204
+ chipLabel.textContent = `${steps} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
201
205
  miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
202
206
  miniTool.title = (collapsed && slots.tool?.command) || "";
203
207
 
204
208
  live.hidden = plain || !!endKind;
205
209
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
210
+ // Running: the step it is on. Ended: the count, unless the chip says it.
211
+ stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
206
212
  promptSlot.hidden = !promptSlot.firstChild;
207
213
  extras.hidden = !extras.firstChild;
208
214
  answer.hidden = !answer.firstChild;
@@ -225,8 +231,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
225
231
  headline.classList.add("fresh");
226
232
  }
227
233
 
228
- function drawNow(tool, gen) {
229
- const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
234
+ function drawNow(tool, quiet) {
230
235
  // A task_wait's stop button, as the row's (task_stop.js): in the key
231
236
  // with its in-flight state, so a redraw keeps a clicked one disabled.
232
237
  const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
@@ -248,7 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
248
253
  const command = () => taskCommand(view.current().turn?.gens, stopId);
249
254
  now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
250
255
  }
251
- } else {
256
+ } else if (quiet) {
252
257
  const word = node("span", "ts-title quiet", now);
253
258
  word.textContent = quiet;
254
259
  }
@@ -262,9 +267,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
262
267
  trail.replaceChildren();
263
268
  if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
264
269
  for (const item of items) {
265
- const failed = item.status === "error";
266
- const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
267
- el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
270
+ const tone = trailTone(item.status);
271
+ const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
272
+ el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
268
273
  if (item.command) el.title = item.command;
269
274
  }
270
275
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
@@ -19,17 +19,26 @@ export function stopTarget(row, features) {
19
19
  return row.view?.task_id || null;
20
20
  }
21
21
 
22
+ // A task_wait row's title without its wait ("npm test · up to 600s").
23
+ const WAIT_SUFFIX = / · up to \d+s$/;
24
+
22
25
  // The command a task_create of this turn started +taskId+ with (its row's
23
- // view), or null: a task from an earlier turn has no row here.
26
+ // view), else a task_wait row's title for it (the server titles one by its
27
+ // task's command, also for a task from an earlier turn: ToolActivity.
28
+ // task_title), or null.
24
29
  export function taskCommand(gens, taskId) {
30
+ let titled = null;
25
31
  for (const gen of gens || []) {
26
32
  for (const row of gen.tools || []) {
33
+ if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
34
+ titled ||= row.title.replace(WAIT_SUFFIX, "");
35
+ }
27
36
  if (row.tool !== "task_create") continue;
28
37
  const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
29
38
  if (id === taskId) return row.view?.command || null;
30
39
  }
31
40
  }
32
- return null;
41
+ return titled;
33
42
  }
34
43
 
35
44
  export function confirmText(taskId, command) {
@@ -24,20 +24,25 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
24
24
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
25
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
26
 
27
- // The line a canceled turn ends with: "✕ canceled (stopped)", live from
27
+ // The line a canceled turn ends with: "■ canceled (stopped)", live from
28
28
  // turn_canceled's reason and after a re-render from the turn record's
29
29
  // cancellation_reason (records saved before it existed: no reason). A
30
- // hook's stop that names who (+by+, cancelled_by): "✕ stopped by loop-guard".
30
+ // hook's stop that names who (+by+, cancelled_by): "■ stopped by loop-guard".
31
+ // Every cancel is a stop someone chose (the user, Ctrl-C, a hook), not a
32
+ // failure: the ■ mark and the stopped colours of a call the Stop cut
33
+ // (STOPPED_LINE_CLASS), not the ✕ of an error.
34
+ export const STOP_MARK = "\u25A0";
35
+ export const STOPPED_LINE_CLASS = "bubble cancel stopped";
31
36
  export function cancelLineText(reason, by) {
32
- if (reason === "hook" && by) return `\u2715 stopped by ${by}`;
33
- return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
37
+ if (reason === "hook" && by) return `${STOP_MARK} stopped by ${by}`;
38
+ return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
34
39
  }
35
40
 
36
- // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
37
- // record is not a canceled one.
41
+ // A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
42
+ // the turn record is not a canceled one.
38
43
  export function cancelLineHtml(record, escape) {
39
44
  if (record?.status !== "canceled") return "";
40
- return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
45
+ return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
41
46
  }
42
47
 
43
48
  export function elapsedSince(startedAt, now = Date.now()) {
@@ -14,10 +14,12 @@ export function turnOutput(data) {
14
14
 
15
15
  // The end line for a turn whose worker went away mid-turn (the footer's
16
16
  // stop, a crash): no turn_canceled comes, so the page ends the turn itself.
17
+ // The footer's stop is the user's, drawn as a cancel is (\u25A0, timing.js
18
+ // cancelLineText); a worker that exited on its own is a failure (\u2715).
17
19
  // null when no turn was running.
18
20
  export function workerGoneText({ turnRunning, stopped = false }) {
19
21
  if (!turnRunning) return null;
20
- return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
22
+ return stopped ? "\u25A0 canceled (session stopped)" : "\u2715 canceled (worker exited)";
21
23
  }
22
24
 
23
25
  // How long a turn's answer waits for the after_turn hooks' answer_display
@@ -159,29 +159,43 @@ function cut(line) {
159
159
  return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
160
160
  }
161
161
 
162
- function toolCallsText(n) {
163
- return `${n} tool call${n === 1 ? "" : "s"}`;
162
+ // "3 tool calls (1 failed)": failed = status error, the tally's predicate
163
+ // (tally.js), so a step's label and the block's summary never disagree.
164
+ function toolCallsText(rows) {
165
+ const n = rows.length;
166
+ const failed = failedCount(rows);
167
+ return `${n} tool call${n === 1 ? "" : "s"}${failed ? ` (${failed} failed)` : ""}`;
168
+ }
169
+
170
+ // A gen's calls that failed (status error; blocked and stopped don't count).
171
+ export function failedCount(rows) {
172
+ return rows.filter((r) => r.status === "error").length;
164
173
  }
165
174
 
166
175
  // The collapsed row of a gen: its narration's first line, else its first
167
176
  // call's title ("edit lib/a.rb"), else the tools it worked with, else
168
- // "thinking"; then its call count.
177
+ // "thinking"; then its call count and how many failed.
169
178
  export function genLabel(gen) {
179
+ const { head, calls } = genLabelParts(gen);
180
+ return calls ? `${head} · ${calls}` : head;
181
+ }
182
+
183
+ // genLabel in its parts: +head+, +calls+ ("3 tool calls (1 failed)", or ""
184
+ // without calls) and +repeats+: the head is words the open step's body
185
+ // starts with (its narration, its first call's title), so an open step's
186
+ // summary can say only its calls (turn_view.js labelHtml).
187
+ export function genLabelParts(gen) {
170
188
  const line = String(gen.text || "").trim().split("\n")[0].trim();
171
- const calls = gen.tools.length;
189
+ const calls = gen.tools.length ? toolCallsText(gen.tools) : "";
172
190
  const first = gen.tools[0];
173
- let head;
174
- if (line) {
175
- head = cut(line);
176
- } else if (first?.title) {
177
- head = cut(`${toolName(first)} ${first.title}`);
178
- } else if (calls) {
191
+ if (line) return { head: cut(line), calls, repeats: true };
192
+ if (first?.title) return { head: cut(`${toolName(first)} ${first.title}`), calls, repeats: true };
193
+ if (gen.tools.length) {
179
194
  const names = [...new Set(gen.tools.map(toolName))];
180
- head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
181
- } else {
182
- return "thinking";
195
+ const head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
196
+ return { head, calls, repeats: false };
183
197
  }
184
- return calls ? `${head} · ${toolCallsText(calls)}` : head;
198
+ return { head: "thinking", calls: "", repeats: false };
185
199
  }
186
200
 
187
201
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
@@ -192,7 +206,7 @@ export function blockSummary(turn, { running = false } = {}) {
192
206
  const fields = [`${steps} step${steps === 1 ? "" : "s"}`];
193
207
  const tally = tallyText(rows, { last: false });
194
208
  if (tally) fields.push(tally);
195
- else if (rows.length) fields.push(toolCallsText(rows.length));
209
+ else if (rows.length) fields.push(toolCallsText(rows));
196
210
  if (running) fields.unshift("working");
197
211
  return fields.join(" · ");
198
212
  }
@@ -30,19 +30,44 @@ import { taskCommand, taskStopButton } from "./task_stop.js";
30
30
  // denied it), else ok ("done").
31
31
  const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
32
32
 
33
- function activityStatusClass(status) {
34
- return NAMED_STATUSES.includes(status) ? status : "ok";
33
+ // A row's status as its parts: the mark's class (CSS draws the glyph), its
34
+ // label (aria-label and title) and the word an exception shows after the
35
+ // title (empty for done and running). Live and reload share it.
36
+ export function statusParts(status) {
37
+ const named = NAMED_STATUSES.includes(status);
38
+ const cls = named ? status : "ok";
39
+ const label = named ? status : "done";
40
+ return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
35
41
  }
36
42
 
37
- function activityStatusLabel(status) {
38
- return NAMED_STATUSES.includes(status) ? status : "done";
43
+ function statusMarkHtml({ cls, label }) {
44
+ return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
45
+ }
46
+
47
+ function statusWordHtml({ cls, word }) {
48
+ return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
39
49
  }
40
50
 
41
51
  // A live tool row filled from its activity row (status, title, output).
42
52
  function fillActivityRow(el, row, sessionId) {
53
+ const parts = statusParts(row.status);
43
54
  const statusEl = el.querySelector(".activity-status");
44
- statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
45
- statusEl.textContent = activityStatusLabel(row.status);
55
+ statusEl.className = `activity-status ${parts.cls}`;
56
+ statusEl.setAttribute("role", "img");
57
+ statusEl.setAttribute("aria-label", parts.label);
58
+ statusEl.title = parts.label;
59
+ // The exception's word after the title (reloadRowHtml's place); never
60
+ // beside a running task_wait's stop button, which needs "running".
61
+ let wordEl = el.querySelector(":scope > .activity-state");
62
+ if (!parts.word) wordEl?.remove();
63
+ else {
64
+ if (!wordEl) {
65
+ wordEl = document.createElement("span");
66
+ (el.querySelector(":scope > .activity-params") || el.querySelector(":scope > .activity-tool")).after(wordEl);
67
+ }
68
+ wordEl.className = `activity-state ${parts.cls}`;
69
+ wordEl.textContent = parts.word;
70
+ }
46
71
  const toolEl = el.querySelector(".activity-tool");
47
72
  if (toolEl) toolEl.textContent = toolName(row);
48
73
  const paramsEl = el.querySelector(".activity-params");
@@ -363,13 +388,21 @@ export function createTurnView({
363
388
  // answer, which reuses the wrap).
364
389
  if (!gen.text && !gen.tools.length && turn.gens.length > 1) unwrapThinking(entry);
365
390
  }
366
- entry.summary.textContent = model.genLabel(gen);
391
+ writeClosedLabel(entry, gen);
367
392
  if (!entry.toggled) entry.el.open = false;
368
393
  // Every generation's text goes into seenContent (the turn output dedupe).
369
394
  const k = normalize(gen.text);
370
395
  if (k) sawText(k);
371
396
  }
372
397
 
398
+ // A closed step's label and its failed mark (a red chevron that survives
399
+ // an ellipsed label): at close, and again when a late tool result lands
400
+ // in a step that already closed.
401
+ function writeClosedLabel(entry, gen) {
402
+ setLabel(entry.summary, gen);
403
+ entry.el.classList.toggle("has-failed", model.failedCount(gen.tools) > 0);
404
+ }
405
+
373
406
  // A step that closes mid-turn (the next one starts) collapses: a warn card
374
407
  // in it would go out of sight until the turn ends. It moves out of the
375
408
  // step, right after it in the block (open while the turn runs), where
@@ -417,6 +450,7 @@ export function createTurnView({
417
450
  fillActivityRow(el, r.row, sessionId());
418
451
  syncTaskStop(el, r.row);
419
452
  }
453
+ if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
420
454
  updateBlockSummary();
421
455
  onChange();
422
456
  }
@@ -517,7 +551,7 @@ export function createTurnView({
517
551
  }
518
552
  entry.text = document.createElement("div");
519
553
  entry.text.className = "gen-text";
520
- entry.summary.textContent = model.genLabel({ ...gen, text: "" });
554
+ setLabel(entry.summary, { ...gen, text: "" });
521
555
  model.takeAnswer(turn, gen);
522
556
  // Cards and hook notices in a step that goes (below, or with the whole
523
557
  // block for a plain answer) stay: before the answer, out of the block,
@@ -697,6 +731,29 @@ function defaultPlaceAnswer(appendToHistory) {
697
731
  };
698
732
  }
699
733
 
734
+ // A closed step's summary (model.genLabel). When its head is the words the
735
+ // body starts with (the narration's first line, the first call's title)
736
+ // and it has calls, the summary is marked .repeats and its parts are
737
+ // spans: an open step shows only its calls (index.html), so it doesn't say
738
+ // the same words twice. Its text stays genLabel's either way.
739
+ // { repeats, html } (the summary's inside).
740
+ function labelParts(gen) {
741
+ const { head, calls, repeats } = model.genLabelParts(gen);
742
+ if (!repeats || !calls) return { repeats: false, html: escapeHtml(model.genLabel(gen)) };
743
+ return { repeats: true, html: `<span class="gen-head">${escapeHtml(head)}</span><span class="gen-sep"> · </span><span class="gen-calls">${escapeHtml(calls)}</span>` };
744
+ }
745
+
746
+ function labelHtml(gen) {
747
+ const { repeats, html } = labelParts(gen);
748
+ return `<summary${repeats ? ' class="repeats"' : ""}>${html}</summary>`;
749
+ }
750
+
751
+ function setLabel(summary, gen) {
752
+ const { repeats, html } = labelParts(gen);
753
+ summary.classList.toggle("repeats", repeats);
754
+ summary.innerHTML = html;
755
+ }
756
+
700
757
  // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, the full command (an
701
758
  // execute/task_create's view), output (its "[tool]" tag dropped, 300 chars, the rest on hover), plus the call's
702
759
  // duration from its record.
@@ -712,8 +769,9 @@ function reloadRowHtml(r, thumbs) {
712
769
  const output = full
713
770
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
714
771
  : "";
715
- return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
716
- `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
772
+ const status = statusParts(r.status);
773
+ return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
774
+ `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
717
775
  `${commandBlockHtml(r.view, r.title)}${output}` +
718
776
  `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
719
777
  }
@@ -775,7 +833,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
775
833
  // The steers the step answered first, where the live rows went.
776
834
  const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
777
835
  const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
778
- return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
836
+ const failed = model.failedCount(gen.tools) ? " has-failed" : "";
837
+ return `<details class="gen${failed}">${labelHtml(gen)}${steers}${thinking}${text}${tools}</details>`;
779
838
  });
780
839
  parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
781
840
  }
@@ -268,7 +268,7 @@ module Samagotchi
268
268
 
269
269
  Log.info(:worker, "dead_question_dropped", id: @session.pending_question[:id])
270
270
  @session.pending_question = nil
271
- @session.save(state_dir: @state_dir)
271
+ save_or_log(:dead_question) { @session.save(state_dir: @state_dir) }
272
272
  end
273
273
 
274
274
  # spawn_session hands the first prompt over in last_prompt, but
@@ -285,7 +285,7 @@ module Samagotchi
285
285
 
286
286
  prompt = @session.last_prompt
287
287
  @session.last_prompt = ""
288
- @session.save(state_dir: @state_dir)
288
+ save_or_log(:initial_prompt) { @session.save(state_dir: @state_dir) }
289
289
  prompt
290
290
  end
291
291
 
@@ -308,14 +308,16 @@ module Samagotchi
308
308
  return false unless queue_as_command(prompt, nil)
309
309
 
310
310
  @session.status = Session::STATUS_IDLE
311
- save_session
311
+ save_or_log(:initial_command) { save_session }
312
312
  true
313
313
  end
314
314
 
315
315
  # Add the queued context notes to the conversation (between turns only,
316
316
  # on this thread), save, then delete their files: a crash before the
317
317
  # delete leaves them claimed, and Engine#add_context_note skips a note
318
- # the saved conversation already holds. Not activity: a note alone
318
+ # the saved conversation already holds. A failed save keeps the files
319
+ # too: the next pass claims them again, finds them in the conversation
320
+ # and saves again. Not activity: a note alone
319
321
  # neither starts a turn nor keeps an idle worker up.
320
322
  def absorb_notes
321
323
  files = SessionInbox.find_new_note_files(@session_dir)
@@ -326,7 +328,8 @@ module Samagotchi
326
328
  note = SessionInbox.read_note(file)
327
329
  @engine.add_context_note(@session, note) if note
328
330
  end
329
- @session.save(state_dir: @state_dir)
331
+ return unless save_or_log(:notes) { @session.save(state_dir: @state_dir) }
332
+
330
333
  claimed.each { |file| FileUtils.rm_f(file) }
331
334
  end
332
335
 
@@ -426,7 +429,7 @@ module Samagotchi
426
429
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
427
430
  shown.each { |event| @engine.announce(event) }
428
431
  end
429
- save_session unless Array(result.changed).empty?
432
+ save_or_log(:command) { save_session } unless Array(result.changed).empty?
430
433
  user_input(command[:client_id]) if resolved
431
434
  @continue_offer.after_command(result, resolved: resolved)
432
435
  @continue_offer.run_continue_turn(command) if result.resume
@@ -515,7 +518,21 @@ module Samagotchi
515
518
  unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
516
519
  SessionInbox.write_output(@session_dir, response)
517
520
  end
518
- save_session
521
+ save_or_log(:turn) { save_session }
522
+ end
523
+
524
+ # A save between turns (the turn's own, a command's, the notes', the
525
+ # first prompt's): what it saves is in memory, so one that fails (disk
526
+ # full, permissions; the next save writes it) is logged and must not
527
+ # take the worker down with it.
528
+ # @param at [Symbol] which save, for the log
529
+ # @return [Boolean] whether it saved
530
+ def save_or_log(at)
531
+ yield
532
+ true
533
+ rescue SystemCallError, IOError => e
534
+ Log.exception(:worker, "save_failed", e, at: at)
535
+ false
519
536
  end
520
537
 
521
538
  # Back to the conversation before the failed turn, as the REPL does (so
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.28.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -301,6 +301,7 @@ files:
301
301
  - lib/samagotchi/memory_bundle/uninstaller.rb
302
302
  - lib/samagotchi/memory_paths.rb
303
303
  - lib/samagotchi/model_catalog.rb
304
+ - lib/samagotchi/model_list_store.rb
304
305
  - lib/samagotchi/model_overlay.rb
305
306
  - lib/samagotchi/model_profile.rb
306
307
  - lib/samagotchi/model_ref.rb