samagotchi 0.43.0 → 0.45.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 +89 -1
- data/docs/cli.md +29 -7
- data/docs/configuration.md +18 -0
- data/docs/context.md +2 -1
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -13
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +38 -19
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +50 -4
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +2 -1
|
@@ -30,10 +30,12 @@ export function moveSelection(index, count, delta) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// One row's parts: the name, its description, and a note (the bundle it
|
|
33
|
-
// comes from; "mid-turn too" for an anytime one
|
|
33
|
+
// comes from; "mid-turn too" for an anytime one, "show: mid-turn too" for
|
|
34
|
+
// one whose show form is), "" when there is none.
|
|
34
35
|
export function commandRow(command) {
|
|
35
36
|
const notes = [];
|
|
36
37
|
if (command.source && command.source !== "core") notes.push(command.source);
|
|
37
38
|
if (command.anytime) notes.push("mid-turn too");
|
|
39
|
+
else if (command.mid_turn === "depends") notes.push("show: mid-turn too");
|
|
38
40
|
return { name: command.name, description: command.description || "", note: notes.join(" · ") };
|
|
39
41
|
}
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
// the kernel resolved for this generation (:generation_started carries it as
|
|
3
3
|
// context_window_tokens); llama.cpp's stream payloads report token counts
|
|
4
4
|
// but no n_ctx, so without it only a precomputed pct or an n_ctx works.
|
|
5
|
+
// `budgetTokens` is the session's LLM context budget (llm_context's
|
|
6
|
+
// budget_tokens): a percentage this file computes counts against it when it
|
|
7
|
+
// is smaller than the window, as the kernel's :context_status does
|
|
8
|
+
// (ContextStatus#counted_against), so the meter reads the same from either.
|
|
5
9
|
|
|
6
10
|
function num(v) {
|
|
7
11
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
// What a fill counts against: the window, or the budget when one is set
|
|
15
|
+
// and smaller; null without a window.
|
|
16
|
+
export function countedTokens(windowTokens, budgetTokens = null) {
|
|
17
|
+
const window = num(windowTokens);
|
|
18
|
+
if (!window || window <= 0) return null;
|
|
19
|
+
const budget = num(budgetTokens);
|
|
20
|
+
return budget && budget > 0 ? Math.min(budget, window) : window;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
// Tokens in context after this chunk: prompt + generated so far.
|
|
11
24
|
function usedTokens(p) {
|
|
12
25
|
if (!p || typeof p !== "object") return null;
|
|
@@ -18,30 +31,35 @@ function usedTokens(p) {
|
|
|
18
31
|
return (prompt ?? 0) + (completion ?? 0);
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
export function extractCtxPct(data, windowTokens = null) {
|
|
34
|
+
export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
|
|
22
35
|
if (!data || typeof data !== "object") return null;
|
|
23
36
|
if (num(data.ctx_pct) !== null) return data.ctx_pct;
|
|
24
37
|
if (num(data.est_pct) !== null) return data.est_pct;
|
|
25
38
|
if (num(data.ctxPct) !== null) return data.ctxPct;
|
|
26
39
|
// The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
|
|
27
40
|
if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
|
|
28
|
-
if (data.context_window_tokens && data.total_tokens)
|
|
41
|
+
if (data.context_window_tokens && data.total_tokens) {
|
|
42
|
+
const against = countedTokens(data.context_window_tokens, budgetTokens);
|
|
43
|
+
if (against) return (data.total_tokens / against) * 100;
|
|
44
|
+
}
|
|
29
45
|
const p = data.payload || data;
|
|
30
46
|
if (num(p.ctx_pct) !== null) return p.ctx_pct;
|
|
31
|
-
const
|
|
47
|
+
const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
|
|
32
48
|
const used = usedTokens(p);
|
|
33
|
-
if (!
|
|
34
|
-
return (used /
|
|
49
|
+
if (!against || used === null) return null;
|
|
50
|
+
return (used / against) * 100;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
// The saved context (the session's timing.context: used_tokens after the
|
|
38
54
|
// last counted turn, the window now): the meter's value on load, before a
|
|
39
|
-
// turn streams
|
|
40
|
-
|
|
55
|
+
// turn streams, counted against +budgetTokens+ (the session's llm_context
|
|
56
|
+
// budget_tokens) when it is smaller, as live. null when either count is
|
|
57
|
+
// missing.
|
|
58
|
+
export function savedCtxPct(context, budgetTokens = null) {
|
|
41
59
|
const used = num(context?.used_tokens);
|
|
42
|
-
const
|
|
43
|
-
if (used === null || !
|
|
44
|
-
return (used /
|
|
60
|
+
const against = countedTokens(context?.window_tokens, budgetTokens);
|
|
61
|
+
if (used === null || !against) return null;
|
|
62
|
+
return (used / against) * 100;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
// A session card's quiet ctx: "12%", or "" when the list row has none.
|
|
@@ -99,18 +117,27 @@ export function ctxBarText(pct, windowSource = null) {
|
|
|
99
117
|
return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
// The ctx tooltip's window line ({tokens:, source:, pct:} the
|
|
103
|
-
// window, where it came from
|
|
104
|
-
// 128.0k tokens (server)", as /stats
|
|
120
|
+
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
121
|
+
// session's window, where it came from, the meter's value and the LLM
|
|
122
|
+
// context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
|
|
123
|
+
// names the source; under a budget smaller than the window the meter's
|
|
124
|
+
// percentage is of the budget: "context: ~41.0k of the 64.0k budget,
|
|
125
|
+
// 128.0k window (server)"; "" without a window.
|
|
105
126
|
export function ctxWindowText(window) {
|
|
106
127
|
const tokens = num(window?.tokens);
|
|
107
128
|
if (!tokens || tokens <= 0) return "";
|
|
129
|
+
const against = countedTokens(tokens, window.budget);
|
|
108
130
|
const pct = num(window.pct);
|
|
109
|
-
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
110
131
|
const source = window.source === "default"
|
|
111
132
|
? "chi's default, a guess: set window_tokens for this model"
|
|
112
133
|
: window.source;
|
|
113
|
-
|
|
134
|
+
const sourceText = source ? ` (${source})` : "";
|
|
135
|
+
if (against < tokens) {
|
|
136
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * against)} of `;
|
|
137
|
+
return `context: ${used}the ${countK(against)} budget, ${countK(tokens)} window${sourceText}`;
|
|
138
|
+
}
|
|
139
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
140
|
+
return `context: ${used}${countK(tokens)} tokens${sourceText}`;
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|
|
@@ -172,6 +172,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
172
172
|
.activity-output{flex:1 1 100%;min-width:0}
|
|
173
173
|
/* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
|
|
174
174
|
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
|
|
175
|
+
/* A reloaded row's ✂ mark (an LLM context edit on its output), one line above the output. */
|
|
176
|
+
.activity-edit{flex:1 1 100%;min-width:0;margin:2px 0 0 var(--row-gutter);font-size:12px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:help}
|
|
175
177
|
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
176
178
|
.activity-diff>summary:hover{color:var(--text-2)}
|
|
177
179
|
.activity-diff[open]>summary{margin-bottom:4px}
|
|
@@ -212,6 +214,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
212
214
|
.activity-params{flex:1 1 0}
|
|
213
215
|
.activity-output{margin-left:0}
|
|
214
216
|
.activity-diff{margin-left:0}
|
|
217
|
+
.activity-edit{margin-left:0}
|
|
215
218
|
.activity-row>.activity-command{margin-left:0}
|
|
216
219
|
}
|
|
217
220
|
.bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
|
|
@@ -299,6 +302,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
299
302
|
.note-text{white-space:pre-wrap;max-height:8em;overflow:auto}
|
|
300
303
|
.bubble.command.busy{border-left-color:var(--warn)}
|
|
301
304
|
.bubble.command.failed{border-left-color:var(--danger)}
|
|
305
|
+
.bubble.command.queued{border-left-style:dashed}
|
|
306
|
+
.bubble.command.queued .command-output{color:var(--text-faint)}
|
|
302
307
|
.command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
|
|
303
308
|
.command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
|
|
304
309
|
.bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
|
|
@@ -374,6 +379,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
374
379
|
.gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
|
|
375
380
|
.gen .activity-body>.hook-notice:first-child{border-top:none}
|
|
376
381
|
.gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
382
|
+
.hook-notice.llm-context{border-left-style:dashed;cursor:help}
|
|
377
383
|
/* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
|
|
378
384
|
.steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
|
|
379
385
|
.steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
|
|
@@ -608,7 +614,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
608
614
|
.ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
|
|
609
615
|
.ts-trail-item.warn{color:var(--warn-text)}
|
|
610
616
|
.ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
|
|
611
|
-
.ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
|
|
617
|
+
.ts-trail-item.info,.ts-trail-item.steer,.ts-trail-item.edit{color:var(--accent)}
|
|
612
618
|
.ts-trail-item.none{font-style:italic}
|
|
613
619
|
/* A card waits: the tool row and trail give it their room. */
|
|
614
620
|
.turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
|
|
@@ -179,9 +179,9 @@ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION,
|
|
|
179
179
|
`<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
|
|
180
180
|
`placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
|
|
181
181
|
`<div class="ctx-pop-error llmctx-error" hidden></div>` +
|
|
182
|
-
`<div class="ctx-pop-meta">${when}${running ? " A turn is running:
|
|
182
|
+
`<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
|
|
183
183
|
`<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
|
|
184
|
-
`<button type="submit" class="llmctx-set"
|
|
184
|
+
`<button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
const EDGE = 16;
|
|
@@ -263,8 +263,6 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
263
263
|
if (!data || session() !== openFor) return close();
|
|
264
264
|
const now = pop.querySelector(".llmctx-now");
|
|
265
265
|
if (now) now.textContent = nowText(data);
|
|
266
|
-
const set = pop.querySelector(".llmctx-set");
|
|
267
|
-
if (set) set.disabled = running();
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
el.addEventListener("click", (e) => {
|
|
@@ -198,15 +198,29 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
|
|
|
198
198
|
// history (it is idle-only).
|
|
199
199
|
// What flashes in the trail for a few seconds while the turn runs (the
|
|
200
200
|
// rows themselves go into their step inside the closed cloud): a hook's
|
|
201
|
-
// notice, warn or info, as its row reads,
|
|
202
|
-
//
|
|
203
|
-
//
|
|
201
|
+
// notice, warn or info, as its row reads, the ✂ row of an applied LLM
|
|
202
|
+
// context edit (kind "edit"), a steer-cut row (a message cut in on a
|
|
203
|
+
// thinking-only generation; kind "steer"), and a plugin's nudge (steer).
|
|
204
|
+
// The row's leading mark ("✂ ", "↪ ") goes: FLASH_MARKS's stands in.
|
|
205
|
+
// chi's own asking-again row (a notice with a line and no hook or cls)
|
|
206
|
+
// does not: the status line tells that already. null: nothing flashes.
|
|
207
|
+
// A hookless notice's cls names its flash: [cls, trail kind, the leading
|
|
208
|
+
// mark the line loses].
|
|
209
|
+
const HOOKLESS_FLASH = [
|
|
210
|
+
["llm-context", "edit", /^✂ /],
|
|
211
|
+
["steer-cut", "steer", /^↪ /],
|
|
212
|
+
];
|
|
204
213
|
export function flashOf(what, data = {}) {
|
|
205
214
|
if (what === "steer") {
|
|
206
215
|
const sender = steerSender(data.source);
|
|
207
216
|
return { kind: "steer", text: sender ? `${sender} nudged the model` : "nudged the model" };
|
|
208
217
|
}
|
|
209
|
-
if (what !== "notice"
|
|
218
|
+
if (what !== "notice") return null;
|
|
219
|
+
if (!data.hook) {
|
|
220
|
+
const flash = HOOKLESS_FLASH.find(([cls]) => cls === data.cls);
|
|
221
|
+
if (!flash || !data.line) return null;
|
|
222
|
+
return { kind: flash[1], text: data.line.replace(flash[2], "") };
|
|
223
|
+
}
|
|
210
224
|
return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
|
|
211
225
|
}
|
|
212
226
|
|
|
@@ -28,9 +28,9 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
28
28
|
// and never holds the hand-off (a turn cut short can leave it open).
|
|
29
29
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
30
30
|
const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
|
|
31
|
-
// The mark of a flash in the trail: a warn notice, a hook's info notice,
|
|
32
|
-
// plugin's nudge (
|
|
33
|
-
const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
|
|
31
|
+
// The mark of a flash in the trail: a warn notice, a hook's info notice,
|
|
32
|
+
// an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
|
|
33
|
+
const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
|
|
34
34
|
|
|
35
35
|
function node(tag, cls, parent = null) {
|
|
36
36
|
const el = document.createElement(tag);
|
|
@@ -156,6 +156,39 @@ export function steerCutLine({ source } = {}) {
|
|
|
156
156
|
return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// The ✂ row of a batch of LLM context edits the server applied
|
|
160
|
+
// (llm_context_edited, LLMContextNotice): its line as the server built
|
|
161
|
+
// it ("✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens"),
|
|
162
|
+
// so the web and the TUI say the same.
|
|
163
|
+
export function llmContextLine({ text } = {}) {
|
|
164
|
+
return text || "✂ LLM context edited";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The ✂ row's hover: what each group did, one line per output (its id,
|
|
168
|
+
// tool and title), and what the batch costs ("~": chars/4).
|
|
169
|
+
export function llmContextHover({ groups = [], freed_tokens: freed, tail_tokens: tail, staged, moment } = {}) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
const output = (item) => [item.id, item.tool, item.title].filter(Boolean).join(" ");
|
|
172
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
173
|
+
const items = Array.isArray(group.items) ? group.items : [];
|
|
174
|
+
if (group.kind === "stale") {
|
|
175
|
+
lines.push("Stubbed as stale:");
|
|
176
|
+
for (const item of items) lines.push(` ${output(item)}${item.note ? ` — ${item.note}` : ""}`);
|
|
177
|
+
} else if (group.kind === "forget") {
|
|
178
|
+
lines.push(`Forgotten by the ${group.by || "model"}: ${group.note || ""}`);
|
|
179
|
+
for (const item of items) lines.push(` ${output(item)}${item.kept ? ` · lines ${item.kept} kept` : ""}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const cost = [];
|
|
183
|
+
if (Number(freed) > 0) cost.push(`frees ~${freed} tokens`);
|
|
184
|
+
if (Number(tail) > 0) cost.push(`the server reads ~${tail} tokens again`);
|
|
185
|
+
if (cost.length) lines.push(cost.join("; "));
|
|
186
|
+
if (Number(staged) > 0) lines.push(`${staged} more staged until the turn ends`);
|
|
187
|
+
lines.push(moment === "turn_end" ? "Sent from the next turn on." : "Sent from the next request on.");
|
|
188
|
+
lines.push("The session keeps the originals.");
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
159
192
|
// The one muted line for a turn that ended with no answer (turn_completed's
|
|
160
193
|
// turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
|
|
161
194
|
// it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
|
|
@@ -170,6 +203,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
|
|
|
170
203
|
export function noticeLine(entry = {}) {
|
|
171
204
|
if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
|
|
172
205
|
if (entry.type === "steer_cut") return steerCutLine(entry);
|
|
206
|
+
if (entry.type === "llm_context_edited") return llmContextLine(entry);
|
|
173
207
|
return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
|
|
174
208
|
}
|
|
175
209
|
|
|
@@ -305,18 +339,26 @@ export function continueLine(choice, reason = "") {
|
|
|
305
339
|
// tab's own), `resync` says the conversation changed (!rollback, !cmd, a
|
|
306
340
|
// continue answered no) and must be read again. An anytime command's
|
|
307
341
|
// (`anytime`, D8) bubble is drawn at its command_queued, before the cards
|
|
308
|
-
// it shows; its command_ran fills that bubble in (`commandId`).
|
|
342
|
+
// it shows; its command_ran fills that bubble in (`commandId`). So is a
|
|
343
|
+
// command queued for after the running turn (`queued`): its command_queued
|
|
344
|
+
// (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
|
|
345
|
+
// dropped (styled as failed).
|
|
346
|
+
export const QUEUED_TEXT = "queued: runs after this turn";
|
|
347
|
+
|
|
309
348
|
export function commandView(event, myId) {
|
|
310
349
|
const own = isOwn(event.client_id, myId);
|
|
350
|
+
const waiting = event.waits === "turn_end";
|
|
311
351
|
return {
|
|
312
352
|
label: own ? null : clientLabel(event.client_id),
|
|
313
353
|
line: event.line || "",
|
|
314
|
-
text: event.output || "",
|
|
354
|
+
text: event.output || (waiting ? QUEUED_TEXT : ""),
|
|
315
355
|
busy: event.status === "busy",
|
|
316
|
-
failed: event.status === "error",
|
|
356
|
+
failed: event.status === "error" || event.status === "dropped",
|
|
317
357
|
resync: (event.changed || []).includes("messages"),
|
|
318
358
|
modelName: event.model_name || null,
|
|
319
359
|
anytime: event.anytime === true,
|
|
360
|
+
queued: waiting || event.queued === true,
|
|
361
|
+
waiting,
|
|
320
362
|
commandId: event.command_id || null,
|
|
321
363
|
// A card's action (Nudge…): no bubble unless it says something back.
|
|
322
364
|
hidden: event.card === true && !event.output && event.status !== "error",
|
|
@@ -464,7 +506,7 @@ export function replaysDrawnTurn(event, turnRecords = []) {
|
|
|
464
506
|
// A `notice` part carries its event as it came (TurnNotice). The TUI replays
|
|
465
507
|
// a turn the same way (TurnAccumulator.replay_events); spec/shared/
|
|
466
508
|
// turn_snapshot.json pins both.
|
|
467
|
-
export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
|
|
509
|
+
export function snapshotEvents({ current_turn: turn = null, queued = [], queued_commands: commands = [], started_at = undefined } = {}) {
|
|
468
510
|
const events = [];
|
|
469
511
|
if (turn) {
|
|
470
512
|
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at }, turn.images));
|
|
@@ -542,6 +584,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
542
584
|
for (const entry of queued || []) {
|
|
543
585
|
events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt }, entry.images));
|
|
544
586
|
}
|
|
587
|
+
// Commands waiting for the turn's end: their bubbles, as live.
|
|
588
|
+
for (const entry of commands || []) {
|
|
589
|
+
events.push({ type: "command_queued", command_id: entry.command_id, client_id: entry.client_id, line: entry.line, waits: "turn_end" });
|
|
590
|
+
}
|
|
545
591
|
return events;
|
|
546
592
|
}
|
|
547
593
|
|
|
@@ -33,12 +33,43 @@ function statusWordHtml({ cls, word }) {
|
|
|
33
33
|
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// A reloaded row's ✂ mark: what an LLM context edit saved on its output
|
|
37
|
+
// sends the model instead (MessageParts.edit_marks): a stale stub and
|
|
38
|
+
// what it frees, a forget's note (or the output whose row carries it) and
|
|
39
|
+
// its kept lines, a forget still staged. The live rows get none (a reload
|
|
40
|
+
// shows them).
|
|
41
|
+
export function llmEditLine(edit) {
|
|
42
|
+
if (!edit || typeof edit !== "object") return "";
|
|
43
|
+
const kept = edit.kept ? ` · lines ${edit.kept} kept` : "";
|
|
44
|
+
if (edit.kind === "forget") {
|
|
45
|
+
if (edit.staged) return `✂ forget staged${kept}`;
|
|
46
|
+
return `${edit.with ? `✂ forgotten with ${edit.with}` : `✂ forgotten: ${edit.note || ""}`}${kept}`;
|
|
47
|
+
}
|
|
48
|
+
if (edit.kind === "stale") {
|
|
49
|
+
const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
|
|
50
|
+
return `✂ stubbed: ${edit.note || "stale"}${tokens}`;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compactTokens(n) {
|
|
56
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens` : `${n} tokens`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function llmEditHtml(row) {
|
|
60
|
+
const line = llmEditLine(row.edit);
|
|
61
|
+
if (!line) return "";
|
|
62
|
+
const hover = `${row.tool_id ? `${row.tool_id}: ` : ""}${line}\nThe model is sent this instead of the output; the session keeps it.`;
|
|
63
|
+
return `<div class="activity-edit" title="${escapeHtml(hover)}">${escapeHtml(line)}</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
// A tool row: its status mark, tool, title (else params; the full command,
|
|
37
67
|
// else the full params line, on hover), the word an exception shows, the
|
|
38
68
|
// call's duration, the full command (an execute/task_create's view, also
|
|
39
69
|
// while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
|
|
40
70
|
// the rest on hover), its images (+thumbs+(images) draws them) and an
|
|
41
|
-
// edit's collapsed diff
|
|
71
|
+
// edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
|
|
72
|
+
// part with nothing to say is left out.
|
|
42
73
|
export function toolRowHtml(row, { thumbs = () => "" } = {}) {
|
|
43
74
|
return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
|
|
44
75
|
}
|
|
@@ -55,7 +86,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
|
55
86
|
const status = statusParts(row.status);
|
|
56
87
|
return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
|
|
57
88
|
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
58
|
-
`${commandBlockHtml(row.view, row.title)}${output}` +
|
|
89
|
+
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
|
|
59
90
|
`${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
|
|
60
91
|
}
|
|
61
92
|
|
|
@@ -422,15 +422,17 @@ export function createTurnView({
|
|
|
422
422
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
423
423
|
// of the call it judged; false with no running turn (app.js shows the
|
|
424
424
|
// bubble then). data.line is a whole row of chi's own (an empty-answer
|
|
425
|
-
// retry), shown as is.
|
|
425
|
+
// retry), shown as is; data.cls a class of its own and data.title its
|
|
426
|
+
// hover (a ✂ row's).
|
|
426
427
|
function notice(data) {
|
|
427
428
|
const r = turn && model.notice(turn, data);
|
|
428
429
|
if (!r) return false;
|
|
429
430
|
const entry = genEls.get(r.gen);
|
|
430
431
|
if (!r.gen.text) entry.text.textContent = "";
|
|
431
432
|
const el = document.createElement("div");
|
|
432
|
-
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
433
|
+
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
433
434
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
435
|
+
if (data.title) el.title = data.title;
|
|
434
436
|
const stick = isNearBottom();
|
|
435
437
|
activityEl(r.gen).appendChild(el);
|
|
436
438
|
if (stick) follow();
|
|
@@ -297,7 +297,7 @@ module Samagotchi
|
|
|
297
297
|
|
|
298
298
|
entry.owner = owner
|
|
299
299
|
entry.live = live
|
|
300
|
-
summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir)
|
|
300
|
+
summary = SessionSummary.build(entry.session, owner: owner, session_dir: dir, registry: hub_registry)
|
|
301
301
|
# A new worker (pid) or a new Bridge for the same session is an
|
|
302
302
|
# event for the page even when the summary reads the same: it
|
|
303
303
|
# reconnects its stream on it.
|
|
@@ -307,6 +307,14 @@ module Samagotchi
|
|
|
307
307
|
emit("session", session: summary)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
+
# One HostRegistry for the hub's lifetime: every session's ctx_pct may
|
|
311
|
+
# need its llm_context budget resolved, and the registry's config read
|
|
312
|
+
# is what would otherwise repeat per scan and per session. Built on
|
|
313
|
+
# the first summary that needs it.
|
|
314
|
+
def hub_registry
|
|
315
|
+
@hub_registry ||= HostRegistry.new
|
|
316
|
+
end
|
|
317
|
+
|
|
310
318
|
def drop(id)
|
|
311
319
|
entry = @sessions.delete(id)
|
|
312
320
|
emit("session_gone", id: id) if entry&.summary
|
|
@@ -4,6 +4,8 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../recap_store"
|
|
5
5
|
require_relative "../archive_store"
|
|
6
6
|
require_relative "../session_metrics"
|
|
7
|
+
require_relative "../llm_context_strategy"
|
|
8
|
+
require_relative "../host_registry"
|
|
7
9
|
require_relative "../bridge_client"
|
|
8
10
|
require_relative "../worker_sidecar"
|
|
9
11
|
require_relative "../bridge/pending_card"
|
|
@@ -26,12 +28,18 @@ module Samagotchi
|
|
|
26
28
|
# @param root_cache [Hash, nil] ProjectScope.root_for's cache, shared
|
|
27
29
|
# across one listing (a session saved before project_root existed
|
|
28
30
|
# looks its project up)
|
|
31
|
+
# @param registry [HostRegistry, nil] the hosts to resolve the
|
|
32
|
+
# session's llm_context budget against (its ctx_pct counts against
|
|
33
|
+
# the smaller of that budget and the window, as the live meter
|
|
34
|
+
# does). nil: one is built here; a caller that builds many summaries
|
|
35
|
+
# (the session hub) keeps one and passes it.
|
|
29
36
|
# @return [Hash] symbol keys
|
|
30
|
-
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil
|
|
37
|
+
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
|
|
38
|
+
registry: nil)
|
|
31
39
|
used = Array(session.used_memory_names)
|
|
32
40
|
up = bridge_up?(session_dir, owner)
|
|
33
41
|
sidecar = up ? WorkerSidecar.read(session_dir) : nil
|
|
34
|
-
saved = SessionMetrics.saved_summary(session_dir)
|
|
42
|
+
saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
|
|
35
43
|
{
|
|
36
44
|
id: session.id,
|
|
37
45
|
status: status,
|
|
@@ -86,6 +94,15 @@ module Samagotchi
|
|
|
86
94
|
}
|
|
87
95
|
end
|
|
88
96
|
|
|
97
|
+
# The budget resolver SessionMetrics.saved_summary takes: a callable,
|
|
98
|
+
# so the session's llm_context budget (config + the registry's index,
|
|
99
|
+
# no network) is resolved only for a session that has a saved context
|
|
100
|
+
# to count. One registry per caller (the hub) or one built here.
|
|
101
|
+
def budget_resolver(session, registry)
|
|
102
|
+
-> { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
|
|
103
|
+
end
|
|
104
|
+
private_class_method :budget_resolver
|
|
105
|
+
|
|
89
106
|
# status is turn state (idle/running). The live worker's snapshot is the
|
|
90
107
|
# truth; on disk, a "running" with no live owner was left by a worker
|
|
91
108
|
# that died mid-turn.
|