samagotchi 0.44.0 → 0.46.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  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/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. metadata +4 -1
@@ -255,8 +255,13 @@ export function servedModelDiffers(asked, served) {
255
255
  }
256
256
 
257
257
  // The session's model for the info bar: the served model with a marker when
258
- // the server serves another one than +servedFor+ (the name asked).
259
- export function modelLabel(model, served, servedFor) {
258
+ // the server serves another one than +servedFor+ (the name asked), unless
259
+ // +expectedBy+ names the host whose hosts.<name>.models served: lists it.
260
+ export function modelLabel(model, served, servedFor, expectedBy = null) {
261
+ if (expectedBy && servedModelDiffers(servedFor, served)) {
262
+ const name = model || "";
263
+ return { text: name.slice(0, 40), title: `${name}; served: ${served} (expected: hosts.${expectedBy}.models)`, mismatch: false };
264
+ }
260
265
  if (servedModelDiffers(servedFor, served)) {
261
266
  return { text: `${served.slice(0, 40)} ⚠`, title: `served: ${served}; asked for ${model}`, mismatch: true };
262
267
  }
@@ -302,6 +302,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
302
302
  .note-text{white-space:pre-wrap;max-height:8em;overflow:auto}
303
303
  .bubble.command.busy{border-left-color:var(--warn)}
304
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)}
305
307
  .command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
306
308
  .command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
307
309
  .bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
@@ -612,7 +614,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
612
614
  .ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
613
615
  .ts-trail-item.warn{color:var(--warn-text)}
614
616
  .ts-trail-item.stopped{color:var(--warn-text);opacity:.8}
615
- .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)}
616
618
  .ts-trail-item.none{font-style:italic}
617
619
  /* A card waits: the tool row and trail give it their room. */
618
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) => {
@@ -20,7 +20,8 @@ export const RECENT_KEY = "chi_model_recent";
20
20
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
21
  // sampling (the configured request parameters as /model words them) when
22
22
  // the model has some, and llm_context (the LLM context it starts under,
23
- // LLMContextStrategy::Explained#summary) when the server sent one. The
23
+ // LLMContextStrategy::Explained#summary) when the server sent one, and
24
+ // configured when the host declares the id (hosts.<name>.models). The
24
25
  // default host's names are bare (name == id); the server's own default,
25
26
  // unshifted when no host lists it, has no host and goes with them, shown
26
27
  // by its name.
@@ -34,15 +35,18 @@ export function modelRows(payload) {
34
35
  const id = host ? String(m.id || name) : name;
35
36
  const row = { name, host, id, isDefaultHost: !host || name === id };
36
37
  if (m.sampling) row.sampling = String(m.sampling);
38
+ if (m.configured === true) row.configured = true;
37
39
  if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
38
40
  return row;
39
41
  });
40
42
  }
41
43
 
42
- const byId = (a, b) => a.id.localeCompare(b.id, "en", { sensitivity: "base" });
44
+ const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
45
+ a.id.localeCompare(b.id, "en", { sensitivity: "base" });
43
46
 
44
47
  // The rows under their hosts, [{host, isDefault, rows}]: the default host
45
- // first, the others in the server's order, ids A-Z inside a host.
48
+ // first, the others in the server's order; inside a host the configured
49
+ // ids first, each part A-Z.
46
50
  export function groupRows(rows) {
47
51
  const groups = [];
48
52
  const find = (key) => groups.find((g) => g.key === key);
@@ -61,6 +65,22 @@ export function groupRows(rows) {
61
65
  }));
62
66
  }
63
67
 
68
+ // The word after a row's id: "default" for the default model, else
69
+ // "config" for an id the host declares (hosts.<name>.models), else "".
70
+ export function rowNote(row, defaultName) {
71
+ if (row.name === defaultName) return "default";
72
+ return row.configured ? "config" : "";
73
+ }
74
+
75
+ // A row's tooltip: its configured sampling, and where a configured id
76
+ // comes from; "" when neither.
77
+ export function rowTitle(row) {
78
+ const parts = [];
79
+ if (row.sampling) parts.push(`sampling: ${row.sampling}`);
80
+ if (row.configured) parts.push(`served by config (hosts.${row.host}.models)`);
81
+ return parts.join("; ");
82
+ }
83
+
64
84
  const SEPARATORS = "/-:._ ";
65
85
  const atSegmentStart = (text, i) => i === 0 || SEPARATORS.includes(text[i - 1]);
66
86
 
@@ -12,7 +12,7 @@
12
12
  import { escapeHtml } from "./format.js";
13
13
  import { moveSelection } from "./command_complete.js";
14
14
  import {
15
- MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows,
15
+ MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
16
16
  } from "./model_pick.js";
17
17
 
18
18
  const PAGE = 8;
@@ -57,9 +57,11 @@ export function createModelPicker({ button, panel, input, list, composer, center
57
57
 
58
58
  function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
59
59
  const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
- const note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
60
+ const noteText = rowNote(row, defaultName);
61
+ const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
61
62
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
- const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
63
+ const titleText = rowTitle(row);
64
+ const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
63
65
  return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
64
66
  `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
65
67
  }
@@ -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);
@@ -560,6 +560,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
560
560
  },
561
561
  generationStarted: (data) => view.generationStarted(data),
562
562
  chunk: (data) => view.chunk(data),
563
+ generationRestarted: (data) => view.generationRestarted(data),
563
564
  generationCompleted: () => view.generationCompleted(),
564
565
  toolStarted: (data) => view.toolStarted(data),
565
566
  toolCompleted: (data) => view.toolCompleted(data),
@@ -209,8 +209,9 @@ export function noticeLine(entry = {}) {
209
209
 
210
210
  // The live turn's status while the provider is asked again after an error
211
211
  // (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
212
- export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
213
- const why = status || String(error_class || "").split("::").pop();
212
+ export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class, restarted } = {}) {
213
+ // restarted: a dropped stream's step asked again (the chat loop's).
214
+ const why = restarted ? "stream dropped" : status || String(error_class || "").split("::").pop();
214
215
  const delay = Number(next_delay);
215
216
  const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
216
217
  const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
@@ -339,18 +340,26 @@ export function continueLine(choice, reason = "") {
339
340
  // tab's own), `resync` says the conversation changed (!rollback, !cmd, a
340
341
  // continue answered no) and must be read again. An anytime command's
341
342
  // (`anytime`, D8) bubble is drawn at its command_queued, before the cards
342
- // it shows; its command_ran fills that bubble in (`commandId`).
343
+ // it shows; its command_ran fills that bubble in (`commandId`). So is a
344
+ // command queued for after the running turn (`queued`): its command_queued
345
+ // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
346
+ // dropped (styled as failed).
347
+ export const QUEUED_TEXT = "queued: runs after this turn";
348
+
343
349
  export function commandView(event, myId) {
344
350
  const own = isOwn(event.client_id, myId);
351
+ const waiting = event.waits === "turn_end";
345
352
  return {
346
353
  label: own ? null : clientLabel(event.client_id),
347
354
  line: event.line || "",
348
- text: event.output || "",
355
+ text: event.output || (waiting ? QUEUED_TEXT : ""),
349
356
  busy: event.status === "busy",
350
- failed: event.status === "error",
357
+ failed: event.status === "error" || event.status === "dropped",
351
358
  resync: (event.changed || []).includes("messages"),
352
359
  modelName: event.model_name || null,
353
360
  anytime: event.anytime === true,
361
+ queued: waiting || event.queued === true,
362
+ waiting,
354
363
  commandId: event.command_id || null,
355
364
  // A card's action (Nudge…): no bubble unless it says something back.
356
365
  hidden: event.card === true && !event.output && event.status !== "error",
@@ -498,7 +507,7 @@ export function replaysDrawnTurn(event, turnRecords = []) {
498
507
  // A `notice` part carries its event as it came (TurnNotice). The TUI replays
499
508
  // a turn the same way (TurnAccumulator.replay_events); spec/shared/
500
509
  // turn_snapshot.json pins both.
501
- export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
510
+ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_commands: commands = [], started_at = undefined } = {}) {
502
511
  const events = [];
503
512
  if (turn) {
504
513
  events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at }, turn.images));
@@ -576,6 +585,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
576
585
  for (const entry of queued || []) {
577
586
  events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt }, entry.images));
578
587
  }
588
+ // Commands waiting for the turn's end: their bubbles, as live.
589
+ for (const entry of commands || []) {
590
+ events.push({ type: "command_queued", command_id: entry.command_id, client_id: entry.client_id, line: entry.line, waits: "turn_end" });
591
+ }
579
592
  return events;
580
593
  }
581
594
 
@@ -12,6 +12,8 @@
12
12
  // a chunk with no iteration goes to the current gen, unless that one is
13
13
  // textDone or already has tool rows (an older server's replay), which
14
14
  // opens a new gen.
15
+ // - generation_retrying with restarted (a dropped stream's step asked
16
+ // again) empties its gen's thinking and text.
15
17
  // - generation_completed marks the current gen textDone; its tool rows
16
18
  // follow it (the kernel emits them after).
17
19
  // - tool events go to the gen of their iteration, else to the current gen,
@@ -105,6 +107,20 @@ export function chunk(turn, { text = "", thinking = "", iteration = null } = {})
105
107
  return r;
106
108
  }
107
109
 
110
+ // A step asked again after its stream dropped (generation_retrying with
111
+ // restarted): the open gen of +iteration+ (else the current one) loses what
112
+ // it streamed; null when there is none.
113
+ export function generationRestarted(turn, iteration = null) {
114
+ const current = currentGen(turn);
115
+ const gen = iteration == null ? current
116
+ : [...turn.gens].reverse().find((g) => !g.closed && g.iteration === iteration) ?? null;
117
+ if (!gen) return null;
118
+ gen.thinking = "";
119
+ gen.text = "";
120
+ gen.textDone = false;
121
+ return { gen, opened: false, closed: null };
122
+ }
123
+
108
124
  export function generationCompleted(turn) {
109
125
  const gen = currentGen(turn);
110
126
  if (gen) gen.textDone = true;
@@ -615,6 +615,25 @@ export function createTurnView({
615
615
  apply(r);
616
616
  scheduleFlush(r.gen);
617
617
  },
618
+ // A dropped stream's step asked again (generation_retrying with
619
+ // restarted): its gen's thinking and narration so far go, the step
620
+ // shows "…" until the new stream's text comes.
621
+ generationRestarted(data) {
622
+ if (!turn) return;
623
+ const r = model.generationRestarted(turn, data?.iteration ?? null);
624
+ const entry = r && genEls.get(r.gen);
625
+ if (!entry) return;
626
+ dirty.delete(r.gen);
627
+ if (entry.thinkingWrap) {
628
+ if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
629
+ entry.ticker.close();
630
+ entry.thinkingWrap.remove();
631
+ }
632
+ Object.assign(entry, { thinking: null, thinkingWrap: null, thinkingLine: null, ticker: null, thinkingWritten: 0,
633
+ box: null, feed: createSentenceFeed() });
634
+ entry.text.textContent = "…";
635
+ onChange();
636
+ },
618
637
  generationCompleted() {
619
638
  if (!turn) return;
620
639
  const r = model.generationCompleted(turn);
@@ -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.