samagotchi 0.75.0 → 0.76.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session_commands.rb +2 -0
  39. data/lib/samagotchi/session_manager.rb +1 -2
  40. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  41. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  42. data/lib/samagotchi/session_metrics.rb +118 -83
  43. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  44. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  45. data/lib/samagotchi/toolkit.rb +9 -8
  46. data/lib/samagotchi/toolkit_command.rb +2 -2
  47. data/lib/samagotchi/tools/builtins.rb +1 -5
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +3 -0
  50. data/lib/samagotchi/web/public/app.js +4 -3
  51. data/lib/samagotchi/web/public/chains.js +1 -1
  52. data/lib/samagotchi/web/public/composer.js +9 -5
  53. data/lib/samagotchi/web/public/ctx.js +73 -11
  54. data/lib/samagotchi/web/public/index.html +30 -35
  55. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  56. data/lib/samagotchi/web/public/question_cards.js +1 -1
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  59. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  60. data/lib/samagotchi/web/public/timing.js +11 -2
  61. metadata +3 -1
@@ -77,16 +77,17 @@ module Samagotchi
77
77
  super
78
78
  end
79
79
 
80
- # What the prompt carries of it: the declared names, the reachable
81
- # and removed ones (the not-loaded line, find_tools' chi line), when
82
- # either list has any the kit name the line gives, and the kit's
83
- # prompt text. Two equal keys build the same prompt (/toolkit's "no
84
- # change", its cost).
85
- def prompt_key
86
- [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name,
87
- resolved.prompt]
80
+ # What the prompt carries before the kit's prompt text: the declared
81
+ # names, reachable and removed ones (the not-loaded line, find_tools'
82
+ # chi line), and, when either list has any, the kit name the line gives.
83
+ def tools_key
84
+ [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
88
85
  end
89
86
 
87
+ # What the prompt carries of it, including the kit's prompt text. Two
88
+ # equal keys build the same prompt (/toolkit's "no change", its cost).
89
+ def prompt_key = [*tools_key, resolved.prompt]
90
+
90
91
  # What the UIs show (the snapshot, command_ran): nil when it declares
91
92
  # every tool on full (full, full +delegate), which has no chip.
92
93
  # +left_out+ is +reachable+ and +removed+ together; +prompt+ (the
@@ -129,9 +129,9 @@ module Samagotchi
129
129
 
130
130
  # What the confirm says is read again: from the tools on (the front of
131
131
  # every request), or from the kit's prompt text on when only that
132
- # changes (Explained#prompt_key's last part: a kit on full's tools).
132
+ # changes (Explained#tools_key is unchanged: only a kit's prompt text).
133
133
  def reread_line(before, after)
134
- if before.prompt_key[0...-1] == after.prompt_key[0...-1]
134
+ if before.tools_key == after.tools_key
135
135
  "The kit's prompt text is in the system prompt: the prompt is read again from it on."
136
136
  else
137
137
  "The tools are the front of every request: the whole prompt is read again from them on."
@@ -144,11 +144,7 @@ module Samagotchi
144
144
 
145
145
  # The XDG dirs set in +env+, made absolute (nothing for an unset one).
146
146
  def xdg_absolute(env = ENV)
147
- set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
148
- dirs = {}
149
- dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
150
- dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
151
- dirs
147
+ Paths.absolute_xdg(env: env)
152
148
  end
153
149
 
154
150
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.75.0"
4
+ VERSION = "0.76.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1794,6 +1794,9 @@ module Samagotchi
1794
1794
  # before the next turn streams (live over saved).
1795
1795
  context: source["context"],
1796
1796
  tokens: source["tokens"],
1797
+ # The tokens, cost and speeds per model (SessionMetrics'
1798
+ # model_segments; none with one model, or from an older file).
1799
+ model_segments: Array(source["model_segments"]),
1797
1800
  # The memory indexes the session's prompt holds (the ctx tooltip).
1798
1801
  memory_index: source["memory_index"]
1799
1802
  }
@@ -1475,7 +1475,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1475
1475
  // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1476
1476
  // image refused (bad_image, too_large, bad_images) says why below.
1477
1477
  if (e.code === "owned_by_tui") {
1478
- if (sentTo !== selected) return alert(e.message);
1478
+ if (sentTo !== selected) return showToast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
1479
1479
  current.owner = "tui";
1480
1480
  updateComposerMode();
1481
1481
  return;
@@ -1485,7 +1485,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1485
1485
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1486
1486
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1487
1487
  if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1488
- alert(e.message);
1488
+ showToast(cantText("send", e), null, { sticky: true });
1489
1489
  } finally {
1490
1490
  actionBtn.disabled = current.owner === "tui";
1491
1491
  promptEl.focus();
@@ -1502,13 +1502,14 @@ const composerCommands = createComposerCommands({
1502
1502
  refreshHistory: () => refreshHistory(),
1503
1503
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1504
1504
  // As handleSendTurn says a turn's failure after a switch.
1505
- failedElsewhere: (line, text) => alert(`${line}: ${text}`),
1505
+ failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1506
1506
  ownedByTui: () => {
1507
1507
  current.owner = "tui";
1508
1508
  updateComposerMode();
1509
1509
  },
1510
1510
  composer: {
1511
1511
  busy: () => { actionBtn.disabled = true; },
1512
+ text: () => promptEl.value,
1512
1513
  clear: () => clearComposer(),
1513
1514
  done: () => {
1514
1515
  actionBtn.disabled = current.owner === "tui";
@@ -230,7 +230,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
230
230
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
231
231
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
232
232
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
233
- title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">#${n}</span>${
233
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${n}</span>${
234
234
  date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
235
235
  rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
236
236
  });
@@ -60,9 +60,10 @@ export function routeSessionLine(prompt, { commands = [], images = 0, delivery =
60
60
  // followWokenWorker() the command woke the worker: follow its stream
61
61
  // refreshHistory()
62
62
  // failed(line, text) a failed command's bubble
63
- // failedElsewhere(line, text) a command to a session no longer open failed
63
+ // failedElsewhere(line, text, id) a command to session id, no longer open, failed
64
64
  // ownedByTui() a plain terminal holds the session
65
- // composer: {busy(), clear(), done()} the composer's send button and text
65
+ // composer: {busy(), text(), clear(), done()} the composer's send button
66
+ // and text (text(): what it holds now)
66
67
  export function createComposerCommands(deps) {
67
68
  const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, failedElsewhere, ownedByTui,
68
69
  composer } = deps;
@@ -72,14 +73,17 @@ export function createComposerCommands(deps) {
72
73
  // worker, owner and history are left alone then.
73
74
  async function send(line) {
74
75
  const id = session();
76
+ // The composer is shared: a draft typed in a session opened meanwhile
77
+ // stays when the answer comes; only the text sent goes.
78
+ const sent = composer.text();
75
79
  composer.busy();
76
80
  try {
77
81
  await sendCommand(id, line, { clientId });
78
- composer.clear();
82
+ if (composer.text() === sent) composer.clear();
79
83
  refreshHistory();
80
84
  if (session() === id) followWokenWorker();
81
85
  } catch (e) {
82
- if (session() !== id) return failedElsewhere(line, e.message);
86
+ if (session() !== id) return failedElsewhere(line, e.message, id);
83
87
  if (e.code === "owned_by_tui") {
84
88
  ownedByTui();
85
89
  return;
@@ -103,7 +107,7 @@ export function createComposerCommands(deps) {
103
107
  return true;
104
108
  } catch (e) {
105
109
  if (session() === id) failed(line, e.message);
106
- else failedElsewhere(line, e.message);
110
+ else failedElsewhere(line, e.message, id);
107
111
  return false;
108
112
  }
109
113
  }
@@ -133,14 +133,23 @@ export function ctxBarText(pct, windowSource = null) {
133
133
  }
134
134
 
135
135
  // The info bar's / the card's ctx tooltip: the window line under the title
136
- // when there's a number (tokensTipText), or why there's none — "No request
137
- // in this session yet" — when the page has no percent (before any request;
138
- // the page isn't told context.status is off, and the window always resolves,
139
- // so "?" never means 0).
140
- export function ctxBarTip(tokens, memoryIndex, window) {
141
- if (num(window?.pct) === null) return "No request in this session yet";
136
+ // when there's a number (tokensTipText), or why there's none when the page
137
+ // has no percent: "No request in this session yet" before any request (the
138
+ // page isn't told context.status is off, so "?" never means 0); after one,
139
+ // "Context window unknown" over the session's tokens and per-model rows with
140
+ // no window line (a window lookup that failed; a model list without a context
141
+ // length still falls back to context.window_tokens or chi's default). +window.afterSwitch+ (the info
142
+ // bar's; a /model whose new window didn't resolve, no request since:
143
+ // Engine#announce_switched_context! sent usage null) adds "until the next
144
+ // request", which asks for it again.
145
+ export function ctxBarTip(tokens, memoryIndex, window, segments = null) {
146
+ if (num(window?.pct) === null) {
147
+ if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) return "No request in this session yet";
148
+ const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
149
+ return tokensTipText(tokens, why, memoryIndex, null, segments);
150
+ }
142
151
 
143
- return tokensTipText(tokens, undefined, memoryIndex, window);
152
+ return tokensTipText(tokens, undefined, memoryIndex, window, segments);
144
153
  }
145
154
 
146
155
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
@@ -166,14 +175,67 @@ export function ctxWindowText(window) {
166
175
  return `context: ${used}${countK(tokens)} tokens${sourceText}`;
167
176
  }
168
177
 
178
+ // ── A session's rows per model (the timing's modelSegments) ──────────────
179
+ // The words /stats' models: block shares (Formatting#model_segment_*;
180
+ // spec/shared/labels_matrix.json model_segments).
181
+
182
+ // A row's model: its ref; the rows folded past the cap ("3 earlier
183
+ // segments", told by folded_segments); a session's turns from before turns
184
+ // named their model ("earlier turns").
185
+ export function modelSegmentLabel(row) {
186
+ const folded = num(row?.folded_segments);
187
+ if (folded !== null && folded > 0) return `${folded} earlier segments`;
188
+ return row?.model_ref ? String(row.model_ref) : "earlier turns";
189
+ }
190
+
191
+ // "6.0k in / 40 out".
192
+ export function modelSegmentTokensText(row) {
193
+ return `${countK(num(row?.prompt_sum) || 0)} in / ${countK(num(row?.completion_sum) || 0)} out`;
194
+ }
195
+
196
+ // "$0.42", "~$0.12", "$0.42 + ~$0.12"; "" for none.
197
+ export function modelSegmentCostText(row) {
198
+ return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
199
+ }
200
+
201
+ // "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
202
+ // row's model prefilled (its cost an estimate from the configured price,
203
+ // left out without one); "" for none.
204
+ export function modelSegmentPrefillText(row) {
205
+ const tokens = num(row?.prefill_tokens) || 0;
206
+ if (tokens <= 0) return "";
207
+ const cost = costText(row?.prefill_cost_estimate);
208
+ return `switch prefill ${countK(tokens)} uncached prompt${cost ? ` ≈${cost}` : ""}`;
209
+ }
210
+
211
+ // The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
212
+ // turns · in / out · cost", a switch's prefill under its row.
213
+ function modelSegmentLines(segments) {
214
+ if (!Array.isArray(segments) || segments.length < 2) return [];
215
+ const lines = ["per model:"];
216
+ for (const row of segments) {
217
+ const turns = num(row.turns) || 0;
218
+ const parts = [modelSegmentLabel(row), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
219
+ const cost = modelSegmentCostText(row);
220
+ if (cost) parts.push(cost);
221
+ lines.push(` ${parts.join(" · ")}`);
222
+ const prefill = modelSegmentPrefillText(row);
223
+ if (prefill) lines.push(` └ ${prefill}`);
224
+ }
225
+ return lines;
226
+ }
227
+
169
228
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
170
229
  // (in summed over every request, cached with its share, out with the
171
230
  // reasoning part) and its cost, when it has any (an estimated part with
172
231
  // "~", costLine), and the memory indexes
173
232
  // its prompt holds (+memoryIndex+, the snapshot's memory_index); with
174
- // +window+ (ctxWindowText's) the window line under the title. A
175
- // delegated session keeps its own metrics, so these are this session's only.
176
- export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null) {
233
+ // +window+ (ctxWindowText's) the window line under the title; with
234
+ // +segments+ (the timing's modelSegments, ≥ 2) the rows per model above the
235
+ // totals. A delegated session keeps its own metrics, so these are this
236
+ // session's only.
237
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
238
+ segments = null) {
177
239
  const memory = memoryIndexText(memoryIndex);
178
240
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
179
241
  const windowText = ctxWindowText(window);
@@ -186,7 +248,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
186
248
  const parts = [`in ${count(prompt)}`];
187
249
  if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
188
250
  parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
189
- const lines = [title, `tokens: ${parts.join(" · ")}`];
251
+ const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
190
252
  const cost = costLine(tokens);
191
253
  if (cost) lines.push(cost);
192
254
  lines.push("this session only, all requests");
@@ -326,22 +326,35 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
326
326
  .bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
327
327
  .bubble.question[open]>summary .question-said{display:none}
328
328
  .question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
329
- /* Its markdown once the card is first opened (question_cards.js renderSaidOnOpen), as compact as the stage's. */
330
- .question-said-full.markdown{white-space:normal}
331
- .question-said-full.markdown :is(h1,h2,h3,h4,h5,h6){font-size:13.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-2)}
332
- .question-said-full.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
333
- .question-said-full.markdown>:first-child{margin-top:0}
334
- .question-said-full.markdown>:last-child{margin-bottom:0}
335
- .question-said-full.markdown :is(ul,ol){padding-left:1.3em}
336
- .question-said-full.markdown li>:is(ul,ol){margin:0}
337
- .question-said-full.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
338
- .question-said-full.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px}
339
- .question-said-full.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
340
- .question-said-full.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px}
341
- .question-said-full.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12px;line-height:1.45;white-space:pre}
342
- .question-said-full.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
343
- .question-said-full.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:12.5px}
344
- .question-said-full.markdown th{background:var(--bg-inset);font-weight:600}
329
+ .question-said-full.md-compact{
330
+ --md-heading-size:13.5px;--md-heading-color:var(--text-2);
331
+ --md-subheading-size:13.5px;--md-subheading-color:var(--text-2);
332
+ --md-strong-color:currentColor;--md-blockquote-color:currentColor;--md-code-color:currentColor;
333
+ --md-pre-size:12px;--md-cell-size:12.5px
334
+ }
335
+ .ts-asking.md-compact{
336
+ --md-heading-size:14.5px;--md-heading-color:var(--text-bright);
337
+ --md-subheading-size:14px;--md-subheading-color:var(--text-2);
338
+ --md-strong-color:var(--text-bright);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
339
+ --md-pre-size:12.5px;--md-cell-size:13px
340
+ }
341
+ .md-compact:is(.ts-asking,.question-said-full){white-space:normal}
342
+ .md-compact :is(h1,h2,h3,h4,h5,h6){font-size:var(--md-heading-size);font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--md-heading-color)}
343
+ .md-compact :is(h4,h5,h6){font-size:var(--md-subheading-size);color:var(--md-subheading-color)}
344
+ .md-compact :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
345
+ .md-compact>:first-child{margin-top:0}
346
+ .md-compact>:last-child{margin-bottom:0}
347
+ .md-compact :is(ul,ol){padding-left:1.3em}
348
+ .md-compact li>:is(ul,ol){margin:0}
349
+ .md-compact hr{border:0;border-top:1px solid var(--border);margin:8px 0}
350
+ .md-compact blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--md-blockquote-color)}
351
+ .md-compact code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
352
+ .md-compact :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--md-code-color)}
353
+ .md-compact strong{color:var(--md-strong-color)}
354
+ .md-compact pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:var(--md-pre-size);line-height:1.45;white-space:pre}
355
+ .md-compact table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
356
+ .md-compact :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:var(--md-cell-size)}
357
+ .md-compact th{background:var(--bg-inset);font-weight:600}
345
358
  .bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
346
359
  .bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
347
360
  .note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
@@ -730,24 +743,6 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
730
743
  text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
731
744
  its room, read from the top. */
732
745
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
733
- /* Its markdown, compact: about the raw text's metrics, so the swap barely moves the slot. */
734
- .ts-asking.markdown{white-space:normal}
735
- .ts-asking.markdown :is(h1,h2,h3,h4,h5,h6){font-size:14.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-bright)}
736
- .ts-asking.markdown :is(h4,h5,h6){font-size:14px;color:var(--text-2)}
737
- .ts-asking.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
738
- .ts-asking.markdown>:first-child{margin-top:0}
739
- .ts-asking.markdown>:last-child{margin-bottom:0}
740
- .ts-asking.markdown :is(ul,ol){padding-left:1.3em}
741
- .ts-asking.markdown li>:is(ul,ol){margin:0}
742
- .ts-asking.markdown strong{color:var(--text-bright)}
743
- .ts-asking.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
744
- .ts-asking.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-muted)}
745
- .ts-asking.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
746
- .ts-asking.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--text)}
747
- .ts-asking.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12.5px;line-height:1.45;white-space:pre}
748
- .ts-asking.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
749
- .ts-asking.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:13px}
750
- .ts-asking.markdown th{background:var(--bg-inset);font-weight:600}
751
746
  .turn-stage.asking .ts-headline{display:none}
752
747
  @keyframes ts-fade{from{opacity:.35}}
753
748
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
@@ -1031,7 +1026,7 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1031
1026
  .chain-chip .cc-arrow{display:inline-block;margin-right:4px}
1032
1027
  .chain-chip .live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok);margin-right:3px;vertical-align:1px}
1033
1028
  .chain-chip .cc-waiting{font-family:system-ui,sans-serif;font-weight:600}
1034
- .chain-row .cr-day{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
1029
+ .chain-row .cr-n{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
1035
1030
  .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
1036
1031
  /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1037
1032
  .chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
@@ -67,7 +67,8 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
67
67
  const tokens = state.timing.tokens;
68
68
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
69
69
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
70
- budget: state.llmContext?.budget_tokens });
70
+ budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
71
+ state.timing.modelSegments);
71
72
  const speedTextNow = lastSpeedText(tokens);
72
73
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
73
74
  const sessionDuration = sessionDurationNow(state.timing, running);
@@ -450,7 +450,7 @@ export function createQuestionCards({
450
450
  state = "done";
451
451
  // Sanitised on the server (POST /api/render).
452
452
  full.innerHTML = html;
453
- full.classList.add("markdown");
453
+ full.classList.add("markdown", "md-compact");
454
454
  linkRefs(full);
455
455
  });
456
456
  };
@@ -31,6 +31,9 @@ export function emptySessionState() {
31
31
  ctxWindow: null,
32
32
  // Where it came from ("server", "config", … "default": chi's guess).
33
33
  ctxWindowSource: null,
34
+ // A /model's new window didn't resolve and no request has started since
35
+ // (stream_handlers.js context_status): the ctx tooltip says it waits.
36
+ ctxUnknownAfterSwitch: false,
34
37
  status: "",
35
38
  model: "",
36
39
  // What the worker's server said it served for a model name: [served, asked].
@@ -58,7 +58,7 @@ export function createStageSlots({
58
58
  asking.hidden = !text;
59
59
  if (shown.asking === text) return;
60
60
  shown.asking = text;
61
- asking.classList.remove("markdown");
61
+ asking.classList.remove("markdown", "md-compact");
62
62
  asking.textContent = text;
63
63
  asking.scrollTop = 0;
64
64
  if (!text) return;
@@ -76,7 +76,7 @@ export function createStageSlots({
76
76
  if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
77
77
  // Sanitised on the server (POST /api/render), as an answer's HTML is.
78
78
  asking.innerHTML = html;
79
- asking.classList.add("markdown");
79
+ asking.classList.add("markdown", "md-compact");
80
80
  asking.scrollTop = 0;
81
81
  linkRefs(asking);
82
82
  });
@@ -154,7 +154,7 @@ export function createStageSlots({
154
154
  function reset() {
155
155
  for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
156
156
  asking.hidden = true;
157
- asking.classList.remove("markdown");
157
+ asking.classList.remove("markdown", "md-compact");
158
158
  shown.headline = shown.now = shown.trail = shown.asking = null;
159
159
  }
160
160
 
@@ -73,6 +73,9 @@ export function createStreamHandlers(deps) {
73
73
  }
74
74
  },
75
75
  generation_started: (data) => {
76
+ // The request after a /model's unresolved window started: whatever it
77
+ // brings, the ctx tooltip no longer waits for it (ctxBarTip).
78
+ state().ctxUnknownAfterSwitch = false;
76
79
  if (data?.context_window_tokens) {
77
80
  const current = state();
78
81
  current.ctxWindow = data.context_window_tokens;
@@ -91,6 +94,10 @@ export function createStreamHandlers(deps) {
91
94
  if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
92
95
  // The generation's speed and the session's token totals, from the server.
93
96
  current.timing.tokens = generationTokens(current.timing.tokens, data);
97
+ // The rows per model, the server's whole list (the open one with this
98
+ // generation in); an event without them (a replay, an older worker)
99
+ // keeps the page's.
100
+ if (Array.isArray(data?.model_segments)) current.timing.modelSegments = data.model_segments;
94
101
  if (data?.served_model || data?.tokens) updateInfoBar();
95
102
  turnDom.generationCompleted();
96
103
  },
@@ -185,6 +192,7 @@ export function createStreamHandlers(deps) {
185
192
  // then show the prompt once more, marked failed (with why). This tab's own
186
193
  // prompt goes back into an empty composer.
187
194
  prompt_restored: async (data) => {
195
+ const id = selected();
188
196
  keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
189
197
  const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
190
198
  if (refill) composer.refill(refill);
@@ -192,6 +200,9 @@ export function createStreamHandlers(deps) {
192
200
  const failedText = liveTurn.failedText;
193
201
  liveTurn.failedText = null;
194
202
  await resync();
203
+ // Another session was opened during the re-read: its history isn't
204
+ // this one's (the re-read drew nothing either).
205
+ if (selected() !== id) return;
195
206
  // The re-render drew it from the session (failed_turn): drawn again
196
207
  // below with this event's label and images.
197
208
  const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
@@ -207,6 +218,7 @@ export function createStreamHandlers(deps) {
207
218
  // A session command ran in the worker (any client's). One that changed
208
219
  // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
209
220
  command_ran: async (data) => {
221
+ const id = selected();
210
222
  const view = commandView(data, clientId);
211
223
  if (view.modelName && view.modelName !== state().model) {
212
224
  state().model = view.modelName;
@@ -240,7 +252,11 @@ export function createStreamHandlers(deps) {
240
252
  state().toolkit = data.toolkit || null;
241
253
  updateInfoBar();
242
254
  }
243
- if (view.resync) await resync();
255
+ if (view.resync) {
256
+ await resync();
257
+ // Another session was opened during the re-read: the bubble isn't its.
258
+ if (selected() !== id) return;
259
+ }
244
260
  // A card's action that isn't anytime (the step-limit question's
245
261
  // /continue) has no bubble: nothing to remove. An anytime or queued
246
262
  // one's bubble was drawn at its command_queued: filled in here. One
@@ -286,6 +302,21 @@ export function createStreamHandlers(deps) {
286
302
  },
287
303
  context_status: (data) => {
288
304
  const current = state();
305
+ // After /model (Engine#announce_switched_context!): the new model's
306
+ // window, and usage null when it didn't resolve (the % clears, "ctx ?").
307
+ // Only that event carries the key: a null drops the old model's window,
308
+ // so the chunks before the next window count no % against it.
309
+ if (data && "context_window_tokens" in data) {
310
+ current.ctxWindow = data.context_window_tokens || null;
311
+ current.ctxWindowSource = data.context_window_tokens ? data.context_window_source || null : null;
312
+ // The ctx tooltip's "until the next request" (ctxBarTip).
313
+ current.ctxUnknownAfterSwitch = !data.context_window_tokens;
314
+ }
315
+ if (data && "usage" in data && data.usage === null) {
316
+ current.ctxPct = null;
317
+ updateInfoBar();
318
+ return;
319
+ }
289
320
  const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
290
321
  if (pct !== null) {
291
322
  current.ctxPct = pct;
@@ -81,6 +81,10 @@ export function normalizeTiming(data = {}) {
81
81
  // The memory indexes the session's prompt holds (SessionMetrics'
82
82
  // memory_index), for the ctx tooltip.
83
83
  memoryIndex: validRecord(data.memory_index) ? data.memory_index : null,
84
+ // The session's tokens, cost and speeds per model after a /model
85
+ // (SessionMetrics' model_segments; [] with one model), for the ctx
86
+ // tooltip.
87
+ modelSegments: Array.isArray(data.model_segments) ? data.model_segments.filter(validRecord) : [],
84
88
  };
85
89
  }
86
90
 
@@ -91,7 +95,7 @@ export function normalizeTiming(data = {}) {
91
95
  // dates keep arrival order), an unknown tool record at the end (they come
92
96
  // grouped by turn, in call order). startedAt, sessionDurationMs and
93
97
  // activeTurn are the reply's, and so are its tokens (a reply without them
94
- // keeps the page's). `short`: the reply's turn_count says the page
98
+ // keeps the page's) and its model_segments (present: authoritative). `short`: the reply's turn_count says the page
95
99
  // misses a turn (it then reads the whole timing). A reply without
96
100
  // turn_count (an older server: the whole timing) replaces, as before; more
97
101
  // records than the count (a worker died before saving a turn the page saw)
@@ -117,7 +121,12 @@ export function mergeTiming(timing, data = {}) {
117
121
  });
118
122
  const tokens = fresh.tokens || timing?.tokens || null;
119
123
  const memoryIndex = fresh.memoryIndex || timing?.memoryIndex || null;
120
- return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex }, short: turnRecords.length < count };
124
+ // A reply that has the rows is the answer, an empty list too (one model
125
+ // now): a stale list never survives it; one without them (an older
126
+ // server) keeps the page's.
127
+ const modelSegments = data && "model_segments" in data ? fresh.modelSegments : (timing?.modelSegments || []);
128
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex, modelSegments },
129
+ short: turnRecords.length < count };
121
130
  }
122
131
 
123
132
  function replaceById(records, record) {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.75.0
4
+ version: 0.76.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -461,6 +461,8 @@ files:
461
461
  - lib/samagotchi/session_inbox.rb
462
462
  - lib/samagotchi/session_manager.rb
463
463
  - lib/samagotchi/session_metrics.rb
464
+ - lib/samagotchi/session_metrics/model_segment.rb
465
+ - lib/samagotchi/session_metrics/totals.rb
464
466
  - lib/samagotchi/session_model_settings.rb
465
467
  - lib/samagotchi/session_observer.rb
466
468
  - lib/samagotchi/session_retention.rb