samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +7 -1
|
@@ -48,3 +48,62 @@ export function savedCtxPct(context) {
|
|
|
48
48
|
export function cardCtxText(pct) {
|
|
49
49
|
return num(pct) === null ? "" : `${Math.round(pct)}%`;
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
// A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
|
|
53
|
+
// server timings); "" without one. The TUI's /stats says the same
|
|
54
|
+
// (Formatting#speed_text; spec/shared/labels_matrix.json).
|
|
55
|
+
export function speedText(tps, source) {
|
|
56
|
+
const value = num(tps);
|
|
57
|
+
if (value === null || value <= 0) return "";
|
|
58
|
+
const number = value >= 1000 ? `${(Math.round(value / 100) / 10).toFixed(1)}k` : String(Math.round(value));
|
|
59
|
+
return `${source === "estimate" ? "~" : ""}${number} tok/s`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
|
|
63
|
+
// none or zero. As Formatting#cost_text.
|
|
64
|
+
export function costText(cost) {
|
|
65
|
+
const value = num(cost);
|
|
66
|
+
if (value === null || value <= 0) return "";
|
|
67
|
+
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function count(n) {
|
|
71
|
+
return Math.round(n).toLocaleString("en-US");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|
|
75
|
+
// (in summed over every request, cached with its share, out with the
|
|
76
|
+
// reasoning part) and its cost, when it has any. A delegated session keeps
|
|
77
|
+
// its own metrics, so these are this session's only.
|
|
78
|
+
export function tokensTipText(tokens, title = "Context used after the last turn") {
|
|
79
|
+
const prompt = num(tokens?.prompt_sum) || 0;
|
|
80
|
+
const completion = num(tokens?.completion_sum) || 0;
|
|
81
|
+
if (!prompt && !completion) return title;
|
|
82
|
+
const cached = num(tokens.cached_sum) || 0;
|
|
83
|
+
const reasoning = num(tokens.reasoning_sum) || 0;
|
|
84
|
+
const parts = [`in ${count(prompt)}`];
|
|
85
|
+
if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
|
|
86
|
+
parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
|
|
87
|
+
const lines = [title, `tokens: ${parts.join(" · ")}`];
|
|
88
|
+
const cost = costText(tokens.cost_sum);
|
|
89
|
+
if (cost) lines.push(`cost: ${cost}`);
|
|
90
|
+
lines.push("this session only, all requests");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The info bar's speed: the newest generation's decode speed, "" before one.
|
|
95
|
+
export function lastSpeedText(tokens) {
|
|
96
|
+
return speedText(tokens?.last_decode_tps, tokens?.tps_source);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The session's tokens block after a :generation_completed: the event's
|
|
100
|
+
// running totals (the server closed the generation and put them on it),
|
|
101
|
+
// with its speed; an event without them (a replay, an older worker) keeps
|
|
102
|
+
// +previous+.
|
|
103
|
+
export function generationTokens(previous, data) {
|
|
104
|
+
const tokens = data?.tokens;
|
|
105
|
+
if (!tokens || typeof tokens !== "object") return previous ?? null;
|
|
106
|
+
const speed = data.speed;
|
|
107
|
+
if (!speed || num(speed.decode_tps) === null) return tokens;
|
|
108
|
+
return { ...tokens, last_decode_tps: speed.decode_tps, tps_source: speed.source };
|
|
109
|
+
}
|
|
@@ -123,6 +123,12 @@ export function stopSession(id, opts = {}) {
|
|
|
123
123
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Hands the session to a new worker on the newest chi installed: 200
|
|
127
|
+
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
128
|
+
export function restartSession(id, opts = {}) {
|
|
129
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/restart`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
130
|
+
}
|
|
131
|
+
|
|
126
132
|
// Hides the session (and its delegates) from the lists, kept for good; the
|
|
127
133
|
// server stops a live idle worker first and answers 409 while a turn runs.
|
|
128
134
|
export function archiveSession(id, opts = {}) {
|
|
@@ -86,7 +86,9 @@ export function previewForCard(session) {
|
|
|
86
86
|
|
|
87
87
|
const DELETE_PREVIEW_CHARS = 40;
|
|
88
88
|
|
|
89
|
-
// The confirm before deleting a session (the info bar's and a card's).
|
|
89
|
+
// The confirm before deleting a session (the info bar's and a card's). The
|
|
90
|
+
// list's owner can be stale (a worker idle-exits), so a worker is only
|
|
91
|
+
// "stopped if it's still running", never promised.
|
|
90
92
|
export function deleteConfirmText(session) {
|
|
91
93
|
const n = normalize(session.first_preview || session.last_prompt || "");
|
|
92
94
|
const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
|
|
@@ -95,7 +97,7 @@ export function deleteConfirmText(session) {
|
|
|
95
97
|
`Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
|
|
96
98
|
"Its history, notes and images are removed; this can't be undone.",
|
|
97
99
|
];
|
|
98
|
-
if (session.owner === "worker") lines.push("
|
|
100
|
+
if (session.owner === "worker") lines.push("If its worker is still running, it is stopped first.");
|
|
99
101
|
return lines.join("\n");
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -216,3 +218,14 @@ export function recapLabel(turnsSince, stale = false) {
|
|
|
216
218
|
if (n > 1) return `recap · before the last ${n} turns`;
|
|
217
219
|
return stale ? "recap · earlier" : "recap";
|
|
218
220
|
}
|
|
221
|
+
|
|
222
|
+
// The info bar's memory chip on a phone (where the "mem: …" line goes),
|
|
223
|
+
// for a session that used memories: "mem 2", the names its tooltip.
|
|
224
|
+
export function memChipText(names) {
|
|
225
|
+
return `mem ${(names || []).length}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function memChipTitle(names) {
|
|
229
|
+
const list = names || [];
|
|
230
|
+
return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
|
|
231
|
+
}
|
|
@@ -242,6 +242,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
242
242
|
.question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
|
|
243
243
|
.question-error.hidden{display:none}
|
|
244
244
|
.question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
|
|
245
|
+
/* 4.02: a resolved card whose summary already carries the result hides the
|
|
246
|
+
body line; a hand-toggled one keeps it (its expanded body shows it). */
|
|
247
|
+
.question-result.hidden{display:none}
|
|
248
|
+
/* N1: an approval's verdict sits above its command block, not after it. */
|
|
249
|
+
.question-result.verdict{margin-top:0;margin-bottom:8px}
|
|
245
250
|
.bubble.question.cancelled .question-result{color:var(--text-muted)}
|
|
246
251
|
.question-submit{align-self:flex-end;font-size:13px}
|
|
247
252
|
.question-dismiss{font-size:13px;margin-left:8px}
|
|
@@ -298,6 +303,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
298
303
|
.bubble.plugin-card{border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--glass-bg));padding:10px 12px;transition:box-shadow .3s ease}
|
|
299
304
|
.bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
|
|
300
305
|
.bubble.plugin-card.card-updated{box-shadow:inset 0 1px 0 var(--glass-highlight),0 0 0 1px var(--accent-ring),var(--glass-shadow)}
|
|
306
|
+
/* A notice card (card.js isNoticeCard): a one-line row like a resolved question, "▸ check-in: Nudged …"; opened, the line wraps and the card's glass comes back. */
|
|
307
|
+
.bubble.plugin-card.notice:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;align-self:flex-start;max-width:100%;border-radius:6px;transition:background .12s ease}
|
|
308
|
+
.bubble.plugin-card.notice:not([open]):hover{background:var(--bg-panel)}
|
|
309
|
+
.bubble.plugin-card.notice>summary{cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
310
|
+
.bubble.plugin-card.notice>summary::-webkit-details-marker{display:none}
|
|
311
|
+
.bubble.plugin-card.notice>summary::before{content:"▸ ";color:var(--accent)}
|
|
312
|
+
.bubble.plugin-card.notice[open]{padding:6px 10px}
|
|
313
|
+
.bubble.plugin-card.notice[open]>summary{white-space:pre-wrap;font-size:13px;color:var(--text-2)}
|
|
314
|
+
.bubble.plugin-card.notice[open]>summary::before{content:"▾ "}
|
|
315
|
+
.notice-from{margin-top:4px;font-size:11px;color:var(--text-faint)}
|
|
316
|
+
.gen .bubble.plugin-card.notice:not([open]){margin:2px 0;padding:2px 8px}
|
|
301
317
|
.card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
|
|
302
318
|
.card-title{font-size:14px;font-weight:600;color:var(--text-bright)}
|
|
303
319
|
.card-source{font-size:11px;color:var(--text-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
@@ -552,8 +568,17 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
552
568
|
#infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
|
|
553
569
|
#infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
|
|
554
570
|
#infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
|
|
555
|
-
|
|
571
|
+
/* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
|
|
572
|
+
#infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
|
|
573
|
+
/* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
|
|
574
|
+
#infoBar .meta .mem-chip{display:none}
|
|
556
575
|
#infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
|
|
576
|
+
/* The open session's worker runs an older chi: its version, and Restart when it can. */
|
|
577
|
+
#infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
|
|
578
|
+
#infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
|
|
579
|
+
#infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
|
|
580
|
+
#infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
|
|
581
|
+
#infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
|
|
557
582
|
#infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
|
|
558
583
|
#infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
|
|
559
584
|
#infoBar .mem{font-size:12px;color:var(--text-muted)}
|
|
@@ -577,10 +602,12 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
577
602
|
#infoBar .meta>*{flex-shrink:0}
|
|
578
603
|
#infoBar .meta .id{order:-2}
|
|
579
604
|
#infoBar .meta .status{order:-1}
|
|
605
|
+
/* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
|
|
606
|
+
#infoBar .meta .mem-chip{display:inline;order:-1}
|
|
580
607
|
#infoBar .meta .dir{word-break:normal}
|
|
581
608
|
#infoBar button{padding:4px 8px}
|
|
582
609
|
}
|
|
583
|
-
#empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:var(--bg);z-index:1}
|
|
610
|
+
#empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
|
|
584
611
|
#empty.hidden{display:none !important}
|
|
585
612
|
/* Start page (no session): the composer alone, centered; no info bar. */
|
|
586
613
|
body:not(.has-session) #center{justify-content:safe center;background:transparent}
|
|
@@ -650,6 +677,13 @@ body.has-session #scope .scope-where{display:none}
|
|
|
650
677
|
#stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
|
|
651
678
|
#stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
|
|
652
679
|
#stripHide:hover{text-decoration:underline;color:var(--text-2)}
|
|
680
|
+
/* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
|
|
681
|
+
#stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
|
|
682
|
+
@media(max-width:900px){
|
|
683
|
+
body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
|
|
684
|
+
#stripHide{display:none}
|
|
685
|
+
#stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
|
|
686
|
+
}
|
|
653
687
|
body.strip-hidden #stripShow{display:block}
|
|
654
688
|
body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
|
|
655
689
|
#allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
|
|
@@ -729,6 +763,7 @@ a{color:var(--accent)}
|
|
|
729
763
|
<button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
|
|
730
764
|
<div id="scope" hidden></div>
|
|
731
765
|
<button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
|
|
766
|
+
<button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
|
|
732
767
|
<button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
|
|
733
768
|
</div>
|
|
734
769
|
<section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
|
|
@@ -17,7 +17,9 @@ export function pickModel(names, defaultName, stored) {
|
|
|
17
17
|
|
|
18
18
|
export const RECENT_KEY = "chi_model_recent";
|
|
19
19
|
|
|
20
|
-
// The payload's models as rows: {name, host, id, isDefaultHost}
|
|
20
|
+
// The payload's models as rows: {name, host, id, isDefaultHost}, and
|
|
21
|
+
// sampling (the configured request parameters as /model words them) when
|
|
22
|
+
// the model has some. The
|
|
21
23
|
// default host's names are bare (name == id); the server's own default,
|
|
22
24
|
// unshifted when no host lists it, has no host and goes with them, shown
|
|
23
25
|
// by its name.
|
|
@@ -29,7 +31,9 @@ export function modelRows(payload) {
|
|
|
29
31
|
const name = String(m.name);
|
|
30
32
|
const host = m.host ? String(m.host) : "";
|
|
31
33
|
const id = host ? String(m.id || name) : name;
|
|
32
|
-
|
|
34
|
+
const row = { name, host, id, isDefaultHost: !host || name === id };
|
|
35
|
+
if (m.sampling) row.sampling = String(m.sampling);
|
|
36
|
+
return row;
|
|
33
37
|
});
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -59,7 +59,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
59
59
|
const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
|
|
60
60
|
const note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
|
|
61
61
|
const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
|
|
62
|
-
|
|
62
|
+
const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
|
|
63
|
+
return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
|
|
63
64
|
`<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -52,7 +52,8 @@ export function approvalView(pq) {
|
|
|
52
52
|
const command = a.command || null;
|
|
53
53
|
let where = a.cwd || "";
|
|
54
54
|
if (a.repo_root) {
|
|
55
|
-
|
|
55
|
+
// The repository's name (a worktree's main checkout), else the root's folder.
|
|
56
|
+
const repo = a.repo_name || String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
|
|
56
57
|
where += ` (repo ${repo}${a.branch ? `, branch ${a.branch}` : ""})`;
|
|
57
58
|
} else if (where) {
|
|
58
59
|
where += " (not in a repo)";
|
|
@@ -161,6 +162,18 @@ export function approvalAllowed(pq, answer) {
|
|
|
161
162
|
return resultText(pq, { answer }).startsWith("Allowed");
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
// What the card says when an answer or a dismissal didn't go through. 503
|
|
166
|
+
// (the server found no live bridge) reads as the session not running; any
|
|
167
|
+
// other failure — the worker's own (the server maps it to a 502
|
|
168
|
+
// bridge_error) or the connection's — shows what it said, so a worker that
|
|
169
|
+
// failed the request is not mistaken for one that is gone.
|
|
170
|
+
export function answerErrorText(error) {
|
|
171
|
+
const status = error?.status;
|
|
172
|
+
const message = String(error?.message ?? error ?? "");
|
|
173
|
+
if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
|
|
174
|
+
return message;
|
|
175
|
+
}
|
|
176
|
+
|
|
164
177
|
// What a new question does to the card on the page: "same" (the card
|
|
165
178
|
// already shows it: a replay), "keep" (the card is resolved, so it stays in
|
|
166
179
|
// the history and the new one goes under it), "replace" (the card is still
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
17
|
import {
|
|
18
|
-
approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
19
|
-
summaryText,
|
|
18
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
19
|
+
relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
22
22
|
import { clientLabel, continueLine, isOwn } from "./turn_events.js";
|
|
@@ -148,9 +148,7 @@ export function createQuestionCards({
|
|
|
148
148
|
}).catch((e) => {
|
|
149
149
|
submit.disabled = false;
|
|
150
150
|
card.classList.remove("submitting");
|
|
151
|
-
err.textContent =
|
|
152
|
-
? "Session is not running — restart it to answer."
|
|
153
|
-
: e.message;
|
|
151
|
+
err.textContent = answerErrorText(e);
|
|
154
152
|
err.classList.remove("hidden");
|
|
155
153
|
});
|
|
156
154
|
});
|
|
@@ -169,9 +167,7 @@ export function createQuestionCards({
|
|
|
169
167
|
api.dismissQuestion(sessionId(), pq.id).catch((e) => {
|
|
170
168
|
submit.disabled = false;
|
|
171
169
|
dismiss.disabled = false;
|
|
172
|
-
err.textContent =
|
|
173
|
-
? "Session is not running — restart it to answer."
|
|
174
|
-
: e.message;
|
|
170
|
+
err.textContent = answerErrorText(e);
|
|
175
171
|
err.classList.remove("hidden");
|
|
176
172
|
});
|
|
177
173
|
});
|
|
@@ -210,7 +206,7 @@ export function createQuestionCards({
|
|
|
210
206
|
api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
|
|
211
207
|
card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
|
|
212
208
|
card.classList.remove("submitting");
|
|
213
|
-
err.textContent =
|
|
209
|
+
err.textContent = answerErrorText(e);
|
|
214
210
|
err.classList.remove("hidden");
|
|
215
211
|
});
|
|
216
212
|
};
|
|
@@ -320,14 +316,20 @@ export function createQuestionCards({
|
|
|
320
316
|
const note = doc.createElement("div");
|
|
321
317
|
note.className = "question-result";
|
|
322
318
|
note.textContent = result;
|
|
319
|
+
// N1: an approval's verdict goes before its command block, not after it:
|
|
320
|
+
// with the details expanded the command reads like another tool call.
|
|
321
|
+
const allowed = approvalAllowed(pending, answer);
|
|
322
|
+
if (allowed !== null) note.classList.add("verdict");
|
|
323
323
|
if (cancelled) {
|
|
324
324
|
questionEl.classList.add("cancelled");
|
|
325
325
|
} else {
|
|
326
326
|
questionEl.classList.add("answered");
|
|
327
|
-
if (
|
|
327
|
+
if (allowed === false) questionEl.classList.add("denied");
|
|
328
328
|
markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
|
|
329
329
|
}
|
|
330
|
-
questionEl.
|
|
330
|
+
const before = allowed !== null ? questionEl.querySelector(".approval-details") : null;
|
|
331
|
+
if (before) questionEl.insertBefore(note, before);
|
|
332
|
+
else questionEl.appendChild(note);
|
|
331
333
|
questionEl.classList.remove("submitting");
|
|
332
334
|
disableQuestionInputs();
|
|
333
335
|
questionEl.querySelector(".question-relayed")?.classList.add("hidden");
|
|
@@ -335,6 +337,10 @@ export function createQuestionCards({
|
|
|
335
337
|
// unless the user opened or closed it by hand.
|
|
336
338
|
const summary = questionEl.querySelector("summary");
|
|
337
339
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
340
|
+
// 4.02: a reopened card's summary already carries the result (the
|
|
341
|
+
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
342
|
+
// stays for a hand-toggled card, whose expanded body the user looks at.
|
|
343
|
+
if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
|
|
338
344
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
339
345
|
pending = null;
|
|
340
346
|
}
|
|
@@ -10,14 +10,23 @@ export function scopeDir(search) {
|
|
|
10
10
|
|
|
11
11
|
// The same place in the all view: the page without ?dir, keeping the hash.
|
|
12
12
|
// +fromDir+ (the project view's folder) rides along as ?from=, so the all
|
|
13
|
-
// view can link back to it.
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// view can link back to it. +search+ (the page's location.search) keeps
|
|
14
|
+
// its ?view= (VIEW_PARAM) on the new page.
|
|
15
|
+
export function allScopeHref(hash, fromDir = null, search = "") {
|
|
16
|
+
const params = [fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
|
|
17
|
+
return `/${params.length ? `?${params.join("&")}` : ""}${hash || ""}`;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
// A folder's project view, keeping the hash
|
|
19
|
-
export function projectScopeHref(dir, hash) {
|
|
20
|
-
|
|
20
|
+
// A folder's project view, keeping the hash and +search+'s ?view=.
|
|
21
|
+
export function projectScopeHref(dir, hash, search = "") {
|
|
22
|
+
const view = viewParam(search);
|
|
23
|
+
return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// "view=<v>" from a location.search string, or "" without one.
|
|
27
|
+
function viewParam(search) {
|
|
28
|
+
const view = new URLSearchParams(search || "").get("view");
|
|
29
|
+
return view ? `view=${encodeURIComponent(view)}` : "";
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
// A folder for the query string, its slashes left readable (as chi web
|
|
@@ -29,6 +29,10 @@ export function emptySessionState() {
|
|
|
29
29
|
owner: null,
|
|
30
30
|
// The session's commands (its worker's, plugins' too) for the / list.
|
|
31
31
|
commands: [],
|
|
32
|
+
// The chi its live worker runs and what it can do (the session card's
|
|
33
|
+
// worker_version / worker_features): the info bar's stale-worker badge.
|
|
34
|
+
workerVersion: null,
|
|
35
|
+
workerFeatures: [],
|
|
32
36
|
timing: normalizeTiming(),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
@@ -52,6 +52,8 @@ export function normalizeTiming(data = {}) {
|
|
|
52
52
|
turnRecords: Array.isArray(data.turn_records) ? data.turn_records.filter(validRecord) : [],
|
|
53
53
|
toolRecords: Array.isArray(data.tool_records) ? data.tool_records.filter(validRecord) : [],
|
|
54
54
|
activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
|
|
55
|
+
// The session's token sums and speeds (SessionMetrics' tokens block).
|
|
56
|
+
tokens: validRecord(data.tokens) ? data.tokens : null,
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -61,7 +63,8 @@ export function normalizeTiming(data = {}) {
|
|
|
61
63
|
// place; an unknown turn goes in started_at order (ties and unparsable
|
|
62
64
|
// dates keep arrival order), an unknown tool record at the end (they come
|
|
63
65
|
// grouped by turn, in call order). startedAt, sessionDurationMs and
|
|
64
|
-
// activeTurn are the reply's
|
|
66
|
+
// activeTurn are the reply's, and so are its tokens (a reply without them
|
|
67
|
+
// keeps the page's). `short`: the reply's turn_count says the page
|
|
65
68
|
// misses a turn (it then reads the whole timing). A reply without
|
|
66
69
|
// turn_count (an older server: the whole timing) replaces, as before; more
|
|
67
70
|
// records than the count (a worker died before saving a turn the page saw)
|
|
@@ -85,7 +88,8 @@ export function mergeTiming(timing, data = {}) {
|
|
|
85
88
|
fresh.toolRecords.forEach((record) => {
|
|
86
89
|
if (!replaceById(toolRecords, record)) toolRecords.push(record);
|
|
87
90
|
});
|
|
88
|
-
|
|
91
|
+
const tokens = fresh.tokens || timing?.tokens || null;
|
|
92
|
+
return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
function replaceById(records, record) {
|
|
@@ -20,10 +20,69 @@ export function workerGoneText({ turnRunning, stopped = false }) {
|
|
|
20
20
|
return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// How long a turn's answer waits for the after_turn hooks' answer_display
|
|
24
|
+
// before the page stops waiting and pops what it has (4.14).
|
|
25
|
+
export const DISPLAY_WAIT_MS = 20000;
|
|
26
|
+
|
|
27
|
+
// The wait between a turn_completed with display_pending and its
|
|
28
|
+
// answer_display. The worker can die in between (a crash, a stop, an idle
|
|
29
|
+
// exit) and never send one, so the wait also ends on the caller's finish()
|
|
30
|
+
// (workerGone, a re-render) and by the timeout: it must never hold the
|
|
31
|
+
// turn's answer bubble forever.
|
|
32
|
+
// @return {{promise, finish, settled: () => Boolean}}
|
|
33
|
+
export function displayWait({
|
|
34
|
+
timeoutMs = DISPLAY_WAIT_MS, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
|
|
35
|
+
} = {}) {
|
|
36
|
+
let settle = null;
|
|
37
|
+
let done = false;
|
|
38
|
+
const promise = new Promise((resolve) => { settle = resolve; });
|
|
39
|
+
const finish = () => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
done = true;
|
|
42
|
+
clearTimeoutImpl(timer);
|
|
43
|
+
settle();
|
|
44
|
+
};
|
|
45
|
+
const timer = setTimeoutImpl(finish, timeoutMs);
|
|
46
|
+
return { promise, finish, settled: () => done };
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
// This tab's id in the events (`origin.client_id`, turn_enqueued's
|
|
24
|
-
// client_id):
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
// client_id): kept in sessionStorage per tab, so a reload mid-turn still
|
|
51
|
+
// recognizes this tab's own prompt (4.03), which a fresh random id every
|
|
52
|
+
// load labelled "web". Falls back to a fresh id when sessionStorage is
|
|
53
|
+
// unavailable (a private window that refuses it) or holds nonsense.
|
|
54
|
+
export const CLIENT_ID_KEY = "chi_client_id";
|
|
55
|
+
export function newClientId(storage = safeSessionStorage()) {
|
|
56
|
+
const kept = readClientId(storage);
|
|
57
|
+
if (kept) return kept;
|
|
58
|
+
const id = `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
|
|
59
|
+
writeClientId(storage, id);
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeSessionStorage() {
|
|
64
|
+
try {
|
|
65
|
+
return globalThis.sessionStorage || null;
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readClientId(storage) {
|
|
72
|
+
try {
|
|
73
|
+
const value = storage?.getItem(CLIENT_ID_KEY);
|
|
74
|
+
return typeof value === "string" && /^web:/.test(value) ? value : null;
|
|
75
|
+
} catch (_) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeClientId(storage, id) {
|
|
81
|
+
try {
|
|
82
|
+
storage?.setItem(CLIENT_ID_KEY, id);
|
|
83
|
+
} catch (_) {
|
|
84
|
+
// Full or refused storage: the id stays for this page only.
|
|
85
|
+
}
|
|
27
86
|
}
|
|
28
87
|
|
|
29
88
|
export function isOwn(clientId, myId) {
|
|
@@ -175,7 +234,7 @@ const WEB_LOCAL_REPLIES = {
|
|
|
175
234
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
176
235
|
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
177
236
|
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
178
|
-
"/stats": "/stats: not in the web yet; each turn shows its time
|
|
237
|
+
"/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
|
|
179
238
|
};
|
|
180
239
|
|
|
181
240
|
export function webLocalReply(text) {
|
|
@@ -190,6 +249,17 @@ export function startPageReply(text, { images = 0 } = {}) {
|
|
|
190
249
|
return images ? null : webLocalReply(text);
|
|
191
250
|
}
|
|
192
251
|
|
|
252
|
+
// The start page's reply to a first message that needs no session at all
|
|
253
|
+
// (2.29): its own command's reply (startPageReply), or the hint for a
|
|
254
|
+
// command word no command answers (a typo like /modle) — the hint the page
|
|
255
|
+
// shows in a session too, as a command bubble. Decided before the session
|
|
256
|
+
// is created, so a first /stats or /modle leaves none behind. A null here
|
|
257
|
+
// means the message goes to a session (a prompt, or anything with images).
|
|
258
|
+
export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
|
|
259
|
+
if (images) return null;
|
|
260
|
+
return startPageReply(text) || unknownCommandHint(text, commands);
|
|
261
|
+
}
|
|
262
|
+
|
|
193
263
|
// The continue card's answers, as the terminal's continue prompt reads them.
|
|
194
264
|
export function continueLine(choice, reason = "") {
|
|
195
265
|
const why = String(reason || "").trim();
|
|
@@ -257,6 +327,40 @@ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
|
|
|
257
327
|
return restoreAction(event, { myId, sentIds, composerEmpty: true });
|
|
258
328
|
}
|
|
259
329
|
|
|
330
|
+
// The /turn this kept entry was for failed: no ack comes, so nothing would
|
|
331
|
+
// take it back. The whole map goes: this tab sends one turn at a time, so
|
|
332
|
+
// every entry waits for an ack that is now never coming (an older one is
|
|
333
|
+
// left over from a send that failed the same way).
|
|
334
|
+
export function dropEarlyRestores(early) {
|
|
335
|
+
const count = early.size;
|
|
336
|
+
early.clear();
|
|
337
|
+
return count;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The composer's text with a restored prompt put back into it: the restored
|
|
341
|
+
// text alone when nothing is typed, else the typed text with the restored
|
|
342
|
+
// prompt under it (the user typed between the restore and the ack; their
|
|
343
|
+
// text must not be replaced).
|
|
344
|
+
export function restoreInto(refill, current) {
|
|
345
|
+
const restored = String(refill ?? "");
|
|
346
|
+
const typed = String(current ?? "");
|
|
347
|
+
if (!restored) return typed;
|
|
348
|
+
if (!typed.trim()) return restored;
|
|
349
|
+
return `${typed.replace(/\n+$/, "")}\n${restored}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// What the composer holds once a /turn ack lands (4.13): the composer still
|
|
353
|
+
// carries the text that was just sent, so that text goes (it is the history's
|
|
354
|
+
// now) — but text typed since the send stays, and a prompt restored before
|
|
355
|
+
// this ack (restoreOnAck's refill) is put back without replacing it.
|
|
356
|
+
// @param sent the composer's text when the turn was sent
|
|
357
|
+
// @param current the composer's text as the ack lands
|
|
358
|
+
// @param refill the prompt an early restore handed back, if any
|
|
359
|
+
export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
|
|
360
|
+
const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
|
|
361
|
+
return restoreInto(refill, kept);
|
|
362
|
+
}
|
|
363
|
+
|
|
260
364
|
// What an event does to the prompt bubbles, as a list of ops for app.js:
|
|
261
365
|
// add — a bubble for a prompt this tab hasn't shown (state queued /
|
|
262
366
|
// started / steered, labelled by its sender)
|