samagotchi 0.27.0 → 0.29.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 +65 -1
- data/docs/cli.md +6 -3
- data/docs/configuration.md +17 -3
- data/docs/internals/prompt-caching.md +38 -7
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundle_command.rb +4 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/desktop/macos/App.swift +6 -4
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
- data/lib/samagotchi/desktop/macos.rb +5 -1
- data/lib/samagotchi/engine.rb +5 -4
- data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/llm/openai_chat.rb +40 -4
- data/lib/samagotchi/llm/prompt_cache.rb +31 -5
- data/lib/samagotchi/memory_bundle/installer.rb +14 -0
- data/lib/samagotchi/session.rb +9 -0
- data/lib/samagotchi/session_commands.rb +6 -2
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +26 -13
- data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/tool_activity.rb +32 -6
- data/lib/samagotchi/tools/builtins.rb +10 -7
- data/lib/samagotchi/tools/task_runtime.rb +21 -0
- data/lib/samagotchi/turn_note.rb +11 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_context.rb +17 -4
- data/lib/samagotchi/web/public/app.js +14 -4
- data/lib/samagotchi/web/public/index.html +37 -19
- data/lib/samagotchi/web/public/sessions_list.js +21 -4
- data/lib/samagotchi/web/public/stage_model.js +5 -2
- data/lib/samagotchi/web/public/task_stop.js +24 -1
- data/lib/samagotchi/web/public/turn_model.js +14 -5
- data/lib/samagotchi/web/public/turn_view.js +46 -10
- data/lib/samagotchi/worker.rb +9 -0
- metadata +1 -1
|
@@ -127,40 +127,50 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
127
127
|
.bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
|
|
128
128
|
.bubble.recap.stale{opacity:.7;border-left-style:dashed}
|
|
129
129
|
.activity-body{margin-top:6px}
|
|
130
|
-
.activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
130
|
+
.activity-row{--row-gutter:22px;display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
131
131
|
.activity-row:first-child{border-top:none}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.activity-
|
|
136
|
-
.activity-status
|
|
137
|
-
.activity-status.
|
|
132
|
+
/* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
|
|
133
|
+
no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
|
|
134
|
+
blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
|
|
135
|
+
An exception also shows its word after the title (.activity-state). */
|
|
136
|
+
.activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
|
|
137
|
+
.activity-status.ok::before{content:"\2713";opacity:.85}
|
|
138
|
+
.activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
|
|
139
|
+
.activity-status.stopped::before{content:"\25A0";font-size:10px}
|
|
140
|
+
.activity-status.running::before{content:"";display:inline-block;box-sizing:border-box;width:10px;height:10px;vertical-align:-1px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%}
|
|
141
|
+
.turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s linear infinite}
|
|
142
|
+
.activity-state{flex:none;white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
|
|
143
|
+
:is(.activity-status,.activity-state).running{color:var(--warn)}
|
|
144
|
+
:is(.activity-status,.activity-state).ok{color:var(--ok)}
|
|
145
|
+
:is(.activity-status,.activity-state).error{color:var(--danger)}
|
|
146
|
+
:is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
|
|
147
|
+
:is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
|
|
138
148
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
139
149
|
/* The stop-task button on a running task_wait (task_stop.js): a small ghost
|
|
140
150
|
button in the danger colours, at the row's end and in the stage's now slot. */
|
|
141
151
|
button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
|
|
142
152
|
button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
|
|
143
153
|
.ts-now button.task-stop{align-self:center}
|
|
144
|
-
.activity-params{color:var(--text-muted);
|
|
154
|
+
.activity-params{color:var(--text-muted);overflow-wrap:anywhere}
|
|
145
155
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
146
|
-
.activity-output{margin:4px 0 2px
|
|
156
|
+
.activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
147
157
|
/* A row's status and tool name never wrap (a phone broke them letter by
|
|
148
158
|
letter); the params take what is left. The output goes on its own line
|
|
149
159
|
under the call instead of a column beside it: on a desktop it lines up
|
|
150
|
-
under the tool name (the
|
|
151
|
-
takes the full width and the params break inside the rest of
|
|
152
|
-
line rather than drop to a line of their own. */
|
|
160
|
+
under the tool name (--row-gutter: the 14 px mark slot + the 8 px gap), on
|
|
161
|
+
a phone it takes the full width and the params break inside the rest of
|
|
162
|
+
the first line rather than drop to a line of their own. */
|
|
153
163
|
.activity-row{flex-wrap:wrap}
|
|
154
164
|
.activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
|
|
155
165
|
.activity-params{min-width:0}
|
|
156
166
|
.activity-output{flex:1 1 100%;min-width:0}
|
|
157
167
|
/* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
|
|
158
|
-
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px
|
|
168
|
+
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
|
|
159
169
|
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
160
170
|
.activity-diff>summary:hover{color:var(--text-2)}
|
|
161
171
|
.activity-diff[open]>summary{margin-bottom:4px}
|
|
162
172
|
/* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
|
|
163
|
-
.activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px
|
|
173
|
+
.activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
|
|
164
174
|
.activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
|
|
165
175
|
.activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
|
|
166
176
|
.activity-command-cwd{margin-bottom:2px}
|
|
@@ -218,12 +228,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
218
228
|
.turn-work:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);align-self:flex-start;border-radius:6px;transition:background .12s ease}
|
|
219
229
|
.turn-work:not([open]):hover{background:var(--bg-panel)}
|
|
220
230
|
.turn-work:not([open])>summary{font-size:12px;font-weight:500}
|
|
221
|
-
|
|
222
|
-
.
|
|
231
|
+
/* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
|
|
232
|
+
chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
|
|
233
|
+
negative margin: a child's own margin shorthand can't undo the padding. */
|
|
234
|
+
.gen{--step-indent:14px;margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border)}
|
|
235
|
+
.gen>summary{margin-left:calc(-1 * var(--step-indent));cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
223
236
|
.gen>summary::-webkit-details-marker{display:none}
|
|
224
237
|
.gen>summary::before{content:"▸ ";color:var(--text-faint)}
|
|
225
238
|
.gen[open]>summary::before{content:"▾ "}
|
|
226
|
-
|
|
239
|
+
/* A step with a failed call (status error): its chevron in the danger colour. */
|
|
240
|
+
.gen.has-failed>summary::before{color:var(--danger)}
|
|
241
|
+
.gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px calc(9px + var(--step-indent));margin-left:-9px;margin-right:-9px}
|
|
227
242
|
.gen.live>summary{color:var(--accent);white-space:normal}
|
|
228
243
|
/* The live step's marker: a pulsing accent dot in place of the chevron (the block's own left bar stays the only one). */
|
|
229
244
|
.gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:step-pulse 1.6s ease-in-out infinite}
|
|
@@ -256,6 +271,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
256
271
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen>summary{display:none}
|
|
257
272
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen{margin:0;padding:0;border:0;background:transparent}
|
|
258
273
|
.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
|
|
274
|
+
@media(max-width:600px){.gen{--step-indent:8px}}
|
|
259
275
|
/* A question card asks for input like the composer card, so it wears the same glass (the recipe above); only its warm tint is its own. */
|
|
260
276
|
.bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
|
|
261
277
|
/* Collapsed (resolved) question: one line, like the thinking and activity blocks.
|
|
@@ -432,7 +448,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
432
448
|
.chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
|
|
433
449
|
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
|
|
434
450
|
.thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
|
|
435
|
-
.activity-row .thumbs{flex-basis:100
|
|
451
|
+
.activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
|
|
452
|
+
@media(max-width:600px){.activity-row .thumbs{margin-left:0}}
|
|
436
453
|
#lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
|
|
437
454
|
#lightbox[hidden]{display:none}
|
|
438
455
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
@@ -617,7 +634,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
617
634
|
.ts-defer{display:none}
|
|
618
635
|
.ts-trail-item{max-width:24ch}
|
|
619
636
|
}
|
|
620
|
-
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark{animation:none}.turn-stage.leaving{transition:none}}
|
|
637
|
+
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
|
|
621
638
|
#infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
|
|
622
639
|
#infoBar.visible{display:flex}
|
|
623
640
|
#infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
|
|
@@ -809,6 +826,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
809
826
|
@keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
|
|
810
827
|
@media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting{animation:none}}
|
|
811
828
|
.card .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
|
|
829
|
+
.card .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
|
|
812
830
|
.status.running{background:var(--ok-bg);color:var(--ok-text)}
|
|
813
831
|
.status.idle{background:var(--bg-raised)}
|
|
814
832
|
.status.completed{background:var(--accent-bg);color:var(--accent)}
|
|
@@ -89,12 +89,29 @@ export function waitingBadge(summary) {
|
|
|
89
89
|
return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// "" for
|
|
92
|
+
// A card's badge for a last turn a hook stopped (the same marker as
|
|
93
|
+
// `chi sessions list`'s "[looped]" / "[stopped by X]"): "looped" for
|
|
94
|
+
// loop-guard, "stopped by <name>" for any other hook; null when the last
|
|
95
|
+
// turn ended otherwise.
|
|
96
|
+
// @return null | {text, title}
|
|
97
|
+
export function stoppedBadge(summary) {
|
|
98
|
+
const by = summary?.last_turn?.stopped_by;
|
|
99
|
+
if (!by) return null;
|
|
100
|
+
return by === "loop-guard"
|
|
101
|
+
? { text: "looped", title: "loop-guard stopped the last turn" }
|
|
102
|
+
: { text: `stopped by ${by}`, title: `${by} stopped the last turn` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What the all view's search matches for a session: its waiting badge's words
|
|
106
|
+
// ("waiting", "question", "approval", …) and/or its stopped badge's words
|
|
107
|
+
// ("looped", "stopped by <name>"); "" for one with neither.
|
|
95
108
|
export function waitingSearchText(summary) {
|
|
109
|
+
const parts = [];
|
|
96
110
|
const badge = waitingBadge(summary);
|
|
97
|
-
|
|
111
|
+
if (badge) parts.push(`waiting ${badge.text}`);
|
|
112
|
+
const stopped = stoppedBadge(summary);
|
|
113
|
+
if (stopped) parts.push(stopped.text);
|
|
114
|
+
return parts.join(" ");
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
// The sessions that wait on the user (waitingOn) first, the rest after,
|
|
@@ -11,6 +11,7 @@ const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task
|
|
|
11
11
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
12
12
|
const EXECS = new Set(["execute", "task_create", "task_wait", "task_stop", "delegate", "delegate_result"]);
|
|
13
13
|
const FILE_TOOLS = new Set(["read", "edit", "write"]);
|
|
14
|
+
const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
|
|
14
15
|
const TRAIL = 3;
|
|
15
16
|
// The hand-off waits this long after the stage was last in use.
|
|
16
17
|
export const QUIET_MS = 1500;
|
|
@@ -80,9 +81,11 @@ function rowTitle(row) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
// A slot's full command (the row's view), for its hover; none for a tool
|
|
83
|
-
// without one.
|
|
84
|
+
// without one. A task tool titled by its task's command (the server's
|
|
85
|
+
// title) hovers its params instead: the id line it replaced.
|
|
84
86
|
function withCommand(slot, row) {
|
|
85
|
-
|
|
87
|
+
if (row.view?.command) return { ...slot, command: row.view.command };
|
|
88
|
+
return TASK_TOOLS.has(row.tool) && row.title && row.params ? { ...slot, command: row.params } : slot;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
// A running task_wait's slot: the task it waits on and the row's key, for
|
|
@@ -54,10 +54,24 @@ export function createTaskStopper({
|
|
|
54
54
|
alert = (text) => globalThis.alert(text),
|
|
55
55
|
}) {
|
|
56
56
|
const inFlight = new Set();
|
|
57
|
+
const watchers = new Map(); // key -> Set of fn(busy)
|
|
57
58
|
const keyOf = (taskId) => `${sessionId()}|${taskId}`;
|
|
59
|
+
const notify = (key, busy) => watchers.get(key)?.forEach((fn) => fn(busy));
|
|
58
60
|
return {
|
|
59
61
|
target: (row) => stopTarget(row, features()),
|
|
60
62
|
busy: (taskId) => inFlight.has(keyOf(taskId)),
|
|
63
|
+
// Call +fn+ whenever a stop for +taskId+ starts or ends (the key enters
|
|
64
|
+
// or leaves the in-flight set) — the session's own task, on this session.
|
|
65
|
+
// Returns unwatch. A task drawn in two rows (the stage's "now" slot and
|
|
66
|
+
// the step list) so both buttons stay in step; a watcher for a button
|
|
67
|
+
// that is gone unwatches itself.
|
|
68
|
+
watch(taskId, fn) {
|
|
69
|
+
const key = keyOf(taskId);
|
|
70
|
+
let set = watchers.get(key);
|
|
71
|
+
if (!set) watchers.set(key, (set = new Set()));
|
|
72
|
+
set.add(fn);
|
|
73
|
+
return () => set.delete(fn);
|
|
74
|
+
},
|
|
61
75
|
// true once the worker stopped it. A stop that went through stays in
|
|
62
76
|
// flight: the button goes with its row's completion a moment later.
|
|
63
77
|
async stop(taskId, command = null) {
|
|
@@ -65,11 +79,13 @@ export function createTaskStopper({
|
|
|
65
79
|
const key = keyOf(taskId);
|
|
66
80
|
if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
|
|
67
81
|
inFlight.add(key);
|
|
82
|
+
notify(key, true);
|
|
68
83
|
try {
|
|
69
84
|
await stopTask(id, taskId);
|
|
70
85
|
return true;
|
|
71
86
|
} catch (e) {
|
|
72
87
|
inFlight.delete(key);
|
|
88
|
+
notify(key, false);
|
|
73
89
|
if (!quietFailure(e)) alert(e.message);
|
|
74
90
|
return false;
|
|
75
91
|
}
|
|
@@ -79,7 +95,10 @@ export function createTaskStopper({
|
|
|
79
95
|
|
|
80
96
|
// The button itself (the one DOM part): a click asks, posts, and keeps it
|
|
81
97
|
// disabled while the stop is in flight. +command+ is read at the click;
|
|
82
|
-
// +onChange+ lets a view redraw (the stage's slot).
|
|
98
|
+
// +onChange+ lets a view redraw (the stage's slot). It also watches the
|
|
99
|
+
// task's in-flight state, so a stop started from a second button of the
|
|
100
|
+
// same row (the stage draws one in the "now" slot and one in the step
|
|
101
|
+
// list) disables this one too — a button that is gone unwatched.
|
|
83
102
|
export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
|
|
84
103
|
const btn = document.createElement("button");
|
|
85
104
|
btn.type = "button";
|
|
@@ -87,6 +106,10 @@ export function taskStopButton(stopper, taskId, { command = () => null, onChange
|
|
|
87
106
|
btn.textContent = "stop task";
|
|
88
107
|
btn.title = "Kill this background task; the model is told you stopped it";
|
|
89
108
|
btn.disabled = stopper.busy(taskId);
|
|
109
|
+
const unwatch = stopper.watch?.(taskId, (busy) => {
|
|
110
|
+
if (!btn.isConnected) { unwatch(); return; }
|
|
111
|
+
btn.disabled = busy;
|
|
112
|
+
});
|
|
90
113
|
btn.addEventListener("click", (e) => {
|
|
91
114
|
e.stopPropagation();
|
|
92
115
|
const pending = stopper.stop(taskId, command());
|
|
@@ -159,13 +159,22 @@ 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) {
|
|
170
179
|
const line = String(gen.text || "").trim().split("\n")[0].trim();
|
|
171
180
|
const calls = gen.tools.length;
|
|
@@ -181,7 +190,7 @@ export function genLabel(gen) {
|
|
|
181
190
|
} else {
|
|
182
191
|
return "thinking";
|
|
183
192
|
}
|
|
184
|
-
return calls ? `${head} · ${toolCallsText(
|
|
193
|
+
return calls ? `${head} · ${toolCallsText(gen.tools)}` : head;
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
// The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
|
|
@@ -192,7 +201,7 @@ export function blockSummary(turn, { running = false } = {}) {
|
|
|
192
201
|
const fields = [`${steps} step${steps === 1 ? "" : "s"}`];
|
|
193
202
|
const tally = tallyText(rows, { last: false });
|
|
194
203
|
if (tally) fields.push(tally);
|
|
195
|
-
else if (rows.length) fields.push(toolCallsText(rows
|
|
204
|
+
else if (rows.length) fields.push(toolCallsText(rows));
|
|
196
205
|
if (running) fields.unshift("working");
|
|
197
206
|
return fields.join(" · ");
|
|
198
207
|
}
|
|
@@ -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
|
+
entry.summary.textContent = model.genLabel(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
|
}
|
|
@@ -712,8 +746,9 @@ function reloadRowHtml(r, thumbs) {
|
|
|
712
746
|
const output = full
|
|
713
747
|
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
714
748
|
: "";
|
|
715
|
-
|
|
716
|
-
|
|
749
|
+
const status = statusParts(r.status);
|
|
750
|
+
return `<div class="activity-row" data-key="${escapeHtml(r.key)}">${statusMarkHtml(status)}` +
|
|
751
|
+
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${statusWordHtml(status)}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
717
752
|
`${commandBlockHtml(r.view, r.title)}${output}` +
|
|
718
753
|
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
719
754
|
}
|
|
@@ -775,7 +810,8 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
775
810
|
// The steers the step answered first, where the live rows went.
|
|
776
811
|
const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
|
|
777
812
|
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
|
|
778
|
-
|
|
813
|
+
const failed = model.failedCount(gen.tools) ? " has-failed" : "";
|
|
814
|
+
return `<details class="gen${failed}"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
|
|
779
815
|
});
|
|
780
816
|
parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
|
|
781
817
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -515,7 +515,16 @@ module Samagotchi
|
|
|
515
515
|
unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
|
|
516
516
|
SessionInbox.write_output(@session_dir, response)
|
|
517
517
|
end
|
|
518
|
+
save_after_turn
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The turn's own save: the turn ended fine and its messages are in
|
|
522
|
+
# memory, so a save that fails here (disk full, permissions; the next
|
|
523
|
+
# save writes them) must not take the worker down with it.
|
|
524
|
+
def save_after_turn
|
|
518
525
|
save_session
|
|
526
|
+
rescue SystemCallError, IOError => e
|
|
527
|
+
Log.exception(:worker, "save_failed", e)
|
|
519
528
|
end
|
|
520
529
|
|
|
521
530
|
# Back to the conversation before the failed turn, as the REPL does (so
|