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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -1
- data/docs/cli.md +4 -4
- data/docs/hooks.md +1 -1
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +35 -15
- data/lib/samagotchi/engine.rb +4 -3
- data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
- data/lib/samagotchi/host_registry.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +27 -7
- data/lib/samagotchi/llm/chat_loop.rb +12 -6
- data/lib/samagotchi/memory_bundle/installer.rb +14 -0
- data/lib/samagotchi/model_list_store.rb +117 -0
- data/lib/samagotchi/model_profile.rb +43 -0
- data/lib/samagotchi/send_command.rb +5 -3
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_commands.rb +6 -2
- data/lib/samagotchi/session_manager.rb +9 -2
- data/lib/samagotchi/session_metrics.rb +6 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
- data/lib/samagotchi/terminal_ui.rb +35 -5
- data/lib/samagotchi/tool_activity.rb +38 -8
- data/lib/samagotchi/tools/builtins.rb +10 -7
- data/lib/samagotchi/tools/delegate.rb +14 -10
- data/lib/samagotchi/tools/execute.rb +3 -1
- data/lib/samagotchi/tools/task_runtime.rb +21 -0
- data/lib/samagotchi/turn_note.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -1
- data/lib/samagotchi/web/public/app.js +23 -11
- data/lib/samagotchi/web/public/command_view.js +19 -5
- data/lib/samagotchi/web/public/index.html +51 -25
- data/lib/samagotchi/web/public/sessions_list.js +21 -4
- data/lib/samagotchi/web/public/stage_model.js +23 -2
- data/lib/samagotchi/web/public/stage_view.js +16 -11
- data/lib/samagotchi/web/public/task_stop.js +11 -2
- data/lib/samagotchi/web/public/timing.js +12 -7
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_model.js +29 -15
- data/lib/samagotchi/web/public/turn_view.js +70 -11
- data/lib/samagotchi/worker.rb +24 -7
- 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
|
-
|
|
192
|
+
const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
|
|
193
193
|
|
|
194
194
|
drawHeadline(slots.headline);
|
|
195
|
-
drawNow(slots.tool,
|
|
195
|
+
drawNow(slots.tool, slots.quiet);
|
|
196
196
|
drawTrail(slots.trail);
|
|
197
197
|
drawTicks(slots.ticks);
|
|
198
198
|
const calls = slots.ticks.length;
|
|
199
|
-
|
|
200
|
-
|
|
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,
|
|
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
|
|
266
|
-
const el = node("span", `ts-trail-item${
|
|
267
|
-
el.textContent = `${
|
|
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),
|
|
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
|
|
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: "
|
|
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): "
|
|
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
|
|
33
|
-
return
|
|
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
|
|
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="
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
return
|
|
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 ${
|
|
45
|
-
statusEl.
|
|
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
|
|
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
|
|
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
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|