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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/configuration.md +18 -0
  5. data/docs/context.md +2 -1
  6. data/docs/internals/context-telemetry.md +4 -1
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/plugins.md +4 -2
  9. data/docs/sessions.md +8 -4
  10. data/lib/samagotchi/bridge/card_store.rb +20 -9
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  12. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/commands/registry.rb +54 -9
  19. data/lib/samagotchi/context_status.rb +18 -5
  20. data/lib/samagotchi/engine.rb +8 -3
  21. data/lib/samagotchi/host_registry.rb +28 -3
  22. data/lib/samagotchi/kernel_loop.rb +24 -9
  23. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  24. data/lib/samagotchi/llm_context_forget.rb +6 -2
  25. data/lib/samagotchi/llm_context_notice.rb +96 -0
  26. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  27. data/lib/samagotchi/model_notes.rb +8 -6
  28. data/lib/samagotchi/model_overlay.rb +3 -2
  29. data/lib/samagotchi/model_profile.rb +71 -2
  30. data/lib/samagotchi/plugin/context.rb +19 -7
  31. data/lib/samagotchi/self_report.rb +31 -2
  32. data/lib/samagotchi/session_commands.rb +24 -7
  33. data/lib/samagotchi/session_manager.rb +19 -1
  34. data/lib/samagotchi/session_metrics.rb +39 -6
  35. data/lib/samagotchi/sessions_command.rb +15 -5
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
  39. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  40. data/lib/samagotchi/terminal_ui.rb +84 -12
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -13
  43. data/lib/samagotchi/web/message_parts.rb +90 -11
  44. data/lib/samagotchi/web/public/app.js +38 -19
  45. data/lib/samagotchi/web/public/command_complete.js +3 -1
  46. data/lib/samagotchi/web/public/ctx.js +42 -15
  47. data/lib/samagotchi/web/public/index.html +7 -1
  48. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  49. data/lib/samagotchi/web/public/stage_model.js +18 -4
  50. data/lib/samagotchi/web/public/stage_view.js +3 -3
  51. data/lib/samagotchi/web/public/turn_events.js +50 -4
  52. data/lib/samagotchi/web/public/turn_html.js +33 -2
  53. data/lib/samagotchi/web/public/turn_view.js +4 -2
  54. data/lib/samagotchi/web/session_hub.rb +9 -1
  55. data/lib/samagotchi/web/session_summary.rb +19 -2
  56. data/lib/samagotchi/worker.rb +156 -20
  57. data/lib/samagotchi/worker_inbound.rb +6 -5
  58. 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), "" when there is none.
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) return (data.total_tokens / data.context_window_tokens) * 100;
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 window = num(p.n_ctx) || num(windowTokens);
47
+ const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
32
48
  const used = usedTokens(p);
33
- if (!window || window <= 0 || used === null) return null;
34
- return (used / window) * 100;
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. null when either count is missing.
40
- export function savedCtxPct(context) {
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 window = num(context?.window_tokens);
43
- if (used === null || !window || window <= 0) return null;
44
- return (used / window) * 100;
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 session's
103
- // window, where it came from and the meter's value): "context: ~41.0k of
104
- // 128.0k tokens (server)", as /stats names the source; "" without a window.
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
- return `context: ${used}${countK(tokens)} tokens${source ? ` (${source})` : ""}`;
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: set it when it ends." : ""}</div>` +
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"${running ? " disabled" : ""}>Set</button></div></form>`;
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, and a plugin's nudge (steer).
202
- // chi's own asking-again row (a notice with a line and no hook) does not:
203
- // the status line tells that already. null: nothing flashes.
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" || !data.hook) return null;
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, a
32
- // plugin's nudge (steer).
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. A part with nothing to say is left out.
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.