samagotchi 0.75.0 → 0.77.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -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 +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -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,78 @@ 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
+ // A ref past +max+ characters with its middle cut to one "…":
212
+ // the tooltip is a native title, which grows as wide as its longest line.
213
+ // The host's start and the model's end stay; the model chip has the full ref.
214
+ const TIP_REF_MAX = 40;
215
+ export function shortRef(text, max = TIP_REF_MAX) {
216
+ const chars = [...text];
217
+ if (chars.length <= max) return text;
218
+ const head = Math.floor((max - 1) / 3);
219
+ return `${chars.slice(0, head).join("")}…${chars.slice(chars.length - (max - 1 - head)).join("")}`;
220
+ }
221
+
222
+ // The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
223
+ // turns · in / out · cost", a switch's prefill under its row.
224
+ function modelSegmentLines(segments) {
225
+ if (!Array.isArray(segments) || segments.length < 2) return [];
226
+ const lines = ["per model:"];
227
+ for (const row of segments) {
228
+ const turns = num(row.turns) || 0;
229
+ const parts = [shortRef(modelSegmentLabel(row)), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
230
+ const cost = modelSegmentCostText(row);
231
+ if (cost) parts.push(cost);
232
+ lines.push(` ${parts.join(" · ")}`);
233
+ const prefill = modelSegmentPrefillText(row);
234
+ if (prefill) lines.push(` └ ${prefill}`);
235
+ }
236
+ return lines;
237
+ }
238
+
169
239
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
170
240
  // (in summed over every request, cached with its share, out with the
171
241
  // reasoning part) and its cost, when it has any (an estimated part with
172
242
  // "~", costLine), and the memory indexes
173
243
  // 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) {
244
+ // +window+ (ctxWindowText's) the window line under the title; with
245
+ // +segments+ (the timing's modelSegments, ≥ 2) the rows per model above the
246
+ // totals. A delegated session keeps its own metrics, so these are this
247
+ // session's only.
248
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
249
+ segments = null) {
177
250
  const memory = memoryIndexText(memoryIndex);
178
251
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
179
252
  const windowText = ctxWindowText(window);
@@ -186,7 +259,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
186
259
  const parts = [`in ${count(prompt)}`];
187
260
  if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
188
261
  parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
189
- const lines = [title, `tokens: ${parts.join(" · ")}`];
262
+ const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
190
263
  const cost = costLine(tokens);
191
264
  if (cost) lines.push(cost);
192
265
  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}
@@ -1126,8 +1121,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1126
1121
  .card.archived:hover{opacity:.85}
1127
1122
  .card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
1128
1123
  /* bottom: above the session's composer card when one shows (--toast-bottom, app.js placeToast). */
1129
- #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1124
+ #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1130
1125
  #toast[hidden]{display:none}
1126
+ #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1131
1127
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1132
1128
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1133
1129
  #toast .toast-close:hover{color:var(--text)}
@@ -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);
@@ -0,0 +1,53 @@
1
+ // Where the page's rendered markdown and its refs go (moved out of app.js):
2
+ // a text as its markdown HTML, the `path:line` refs in one rendered text,
3
+ // a final answer's rendering and refs, and the refs across the page. The
4
+ // page's state comes in as getters (capabilities, roots, currentDir), read
5
+ // at each call.
6
+ export function createMarkdownPlace({
7
+ capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
8
+ pairAnswerRefs,
9
+ }) {
10
+ // A text the page already shows as its markdown HTML (POST /api/render,
11
+ // sanitised there), or null: markdown off here (no request), or a failed
12
+ // read (the raw text stays).
13
+ function renderMarkdown(text) {
14
+ if (capabilities().display !== true) return Promise.resolve(null);
15
+ return renderText(text)
16
+ .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
17
+ .catch(() => null);
18
+ }
19
+
20
+ // The `path:line` refs in one rendered text (the stage's asking slot, a
21
+ // kept reply), as linkTextRefs links the answers'.
22
+ function linkRefsIn(el) {
23
+ linkRefs(el, touchedFiles(roots()), currentDir());
24
+ }
25
+
26
+ // The turn's answer as its rendered markdown and with its plugin refs,
27
+ // once it is final (the turn-end read, an answer_display re-read:
28
+ // answer_refs.js applyFinalAnswer); then the refs.
29
+ function applyFinalMarkdown(messages) {
30
+ applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
31
+ linkTextRefs();
32
+ }
33
+
34
+ // The refs on the answers' and closed steps' texts, on this page: first
35
+ // each answer's own (a plugin's, answer_refs.js), then the `path:line`
36
+ // refs that name a file the session's rows (and cards) touched
37
+ // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
38
+ // turn's end and after a reload's render. A ref already made stays as it
39
+ // is.
40
+ function linkTextRefs() {
41
+ const pageRoots = roots();
42
+ const touched = touchedFiles(pageRoots);
43
+ for (const root of pageRoots) {
44
+ for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
45
+ }
46
+ if (!touched.size) return;
47
+ for (const root of pageRoots) {
48
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, currentDir());
49
+ }
50
+ }
51
+
52
+ return { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs };
53
+ }
@@ -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,11 +73,18 @@ 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
+ const current = state();
79
+ let contextChanged = current.ctxUnknownAfterSwitch !== false;
80
+ current.ctxUnknownAfterSwitch = false;
76
81
  if (data?.context_window_tokens) {
77
- const current = state();
82
+ const source = data.context_window_source || null;
83
+ contextChanged ||= current.ctxWindow !== data.context_window_tokens || current.ctxWindowSource !== source;
78
84
  current.ctxWindow = data.context_window_tokens;
79
- current.ctxWindowSource = data.context_window_source || null;
85
+ current.ctxWindowSource = source;
80
86
  }
87
+ if (contextChanged) updateInfoBar();
81
88
  liveTurn.timing.setNote("");
82
89
  // The step's clock (the composer's delivery line): the page's own at a
83
90
  // live start; a replay's comes from the snapshot's generation age,
@@ -91,6 +98,10 @@ export function createStreamHandlers(deps) {
91
98
  if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
92
99
  // The generation's speed and the session's token totals, from the server.
93
100
  current.timing.tokens = generationTokens(current.timing.tokens, data);
101
+ // The rows per model, the server's whole list (the open one with this
102
+ // generation in); an event without them (a replay, an older worker)
103
+ // keeps the page's.
104
+ if (Array.isArray(data?.model_segments)) current.timing.modelSegments = data.model_segments;
94
105
  if (data?.served_model || data?.tokens) updateInfoBar();
95
106
  turnDom.generationCompleted();
96
107
  },
@@ -185,6 +196,7 @@ export function createStreamHandlers(deps) {
185
196
  // then show the prompt once more, marked failed (with why). This tab's own
186
197
  // prompt goes back into an empty composer.
187
198
  prompt_restored: async (data) => {
199
+ const id = selected();
188
200
  keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
189
201
  const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
190
202
  if (refill) composer.refill(refill);
@@ -192,6 +204,9 @@ export function createStreamHandlers(deps) {
192
204
  const failedText = liveTurn.failedText;
193
205
  liveTurn.failedText = null;
194
206
  await resync();
207
+ // Another session was opened during the re-read: its history isn't
208
+ // this one's (the re-read drew nothing either).
209
+ if (selected() !== id) return;
195
210
  // The re-render drew it from the session (failed_turn): drawn again
196
211
  // below with this event's label and images.
197
212
  const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
@@ -207,6 +222,7 @@ export function createStreamHandlers(deps) {
207
222
  // A session command ran in the worker (any client's). One that changed
208
223
  // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
209
224
  command_ran: async (data) => {
225
+ const id = selected();
210
226
  const view = commandView(data, clientId);
211
227
  if (view.modelName && view.modelName !== state().model) {
212
228
  state().model = view.modelName;
@@ -240,7 +256,11 @@ export function createStreamHandlers(deps) {
240
256
  state().toolkit = data.toolkit || null;
241
257
  updateInfoBar();
242
258
  }
243
- if (view.resync) await resync();
259
+ if (view.resync) {
260
+ await resync();
261
+ // Another session was opened during the re-read: the bubble isn't its.
262
+ if (selected() !== id) return;
263
+ }
244
264
  // A card's action that isn't anytime (the step-limit question's
245
265
  // /continue) has no bubble: nothing to remove. An anytime or queued
246
266
  // one's bubble was drawn at its command_queued: filled in here. One
@@ -286,6 +306,21 @@ export function createStreamHandlers(deps) {
286
306
  },
287
307
  context_status: (data) => {
288
308
  const current = state();
309
+ // After /model (Engine#announce_switched_context!): the new model's
310
+ // window, and usage null when it didn't resolve (the % clears, "ctx ?").
311
+ // Only that event carries the key: a null drops the old model's window,
312
+ // so the chunks before the next window count no % against it.
313
+ if (data && "context_window_tokens" in data) {
314
+ current.ctxWindow = data.context_window_tokens || null;
315
+ current.ctxWindowSource = data.context_window_tokens ? data.context_window_source || null : null;
316
+ // The ctx tooltip's "until the next request" (ctxBarTip).
317
+ current.ctxUnknownAfterSwitch = !data.context_window_tokens;
318
+ }
319
+ if (data && "usage" in data && data.usage === null) {
320
+ current.ctxPct = null;
321
+ updateInfoBar();
322
+ return;
323
+ }
289
324
  const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
290
325
  if (pct !== null) {
291
326
  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) {
@@ -0,0 +1,69 @@
1
+ import { html, nothing, render } from "./vendor/lit-html.js";
2
+
3
+ // The page's toasts (app.js showToast): a stack in #toast, newest last, at
4
+ // most MAX_TOASTS. Each item has its own 6 s timer, or none when sticky: a
5
+ // sticky one stays until its action, its ✕ or a link in it is clicked, so a
6
+ // later notice can't hide an error. +text+: words, or a template
7
+ // (chains.js openChildrenTemplate: a link in it opens that session and hides
8
+ // the toast). The same text again while it shows reuses its item, moved to
9
+ // the end with a fresh timer. When a 4th comes, the oldest non-sticky goes
10
+ // (the oldest of all if every one is sticky).
11
+ const TOAST_MS = 6000;
12
+ const MAX_TOASTS = 3;
13
+
14
+ export function createToasts({ el, onChange = () => {}, doc = globalThis.document }) {
15
+ const entries = [];
16
+
17
+ function sync() {
18
+ el.hidden = entries.length === 0;
19
+ onChange();
20
+ }
21
+
22
+ function remove(entry, notify = true) {
23
+ const index = entries.indexOf(entry);
24
+ if (index < 0) return;
25
+ if (entry.timer !== null) clearTimeout(entry.timer);
26
+ entry.timer = null;
27
+ entries.splice(index, 1);
28
+ entry.el.remove();
29
+ if (notify) sync();
30
+ }
31
+
32
+ function draw(entry, text, action, sticky) {
33
+ render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${action
34
+ ? html`<button type="button" class="toast-action" @click=${() => { remove(entry); action.run(); }}>${action.label}</button>`
35
+ : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>` : nothing}`,
36
+ entry.el);
37
+ }
38
+
39
+ function show(text, action = null, { sticky = false } = {}) {
40
+ const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
41
+ candidate.el.className = "toast-item";
42
+ draw(candidate, text, action, sticky);
43
+ candidate.key = candidate.el.querySelector(".toast-message").textContent;
44
+
45
+ const duplicate = entries.find((entry) => entry.key === candidate.key);
46
+ if (duplicate) {
47
+ if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
+ duplicate.timer = null;
49
+ duplicate.sticky = sticky;
50
+ duplicate.key = candidate.key;
51
+ draw(duplicate, text, action, sticky);
52
+ entries.push(...entries.splice(entries.indexOf(duplicate), 1));
53
+ el.appendChild(duplicate.el);
54
+ if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
+ sync();
56
+ return;
57
+ }
58
+
59
+ if (entries.length === MAX_TOASTS) {
60
+ remove(entries.find((entry) => !entry.sticky) || entries[0], false);
61
+ }
62
+ entries.push(candidate);
63
+ el.appendChild(candidate.el);
64
+ if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
65
+ sync();
66
+ }
67
+
68
+ return { show };
69
+ }
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.77.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -393,6 +393,7 @@ files:
393
393
  - lib/samagotchi/memory_bundle/trash.rb
394
394
  - lib/samagotchi/memory_bundle/uninstaller.rb
395
395
  - lib/samagotchi/memory_paths.rb
396
+ - lib/samagotchi/message_parts.rb
396
397
  - lib/samagotchi/model_catalog.rb
397
398
  - lib/samagotchi/model_list_store.rb
398
399
  - lib/samagotchi/model_match.rb
@@ -412,6 +413,7 @@ files:
412
413
  - lib/samagotchi/paths.rb
413
414
  - lib/samagotchi/peer_sources.rb
414
415
  - lib/samagotchi/pending_input_queue.rb
416
+ - lib/samagotchi/pending_question.rb
415
417
  - lib/samagotchi/plugin/api.rb
416
418
  - lib/samagotchi/plugin/attached_context.rb
417
419
  - lib/samagotchi/plugin/context.rb
@@ -461,6 +463,8 @@ files:
461
463
  - lib/samagotchi/session_inbox.rb
462
464
  - lib/samagotchi/session_manager.rb
463
465
  - lib/samagotchi/session_metrics.rb
466
+ - lib/samagotchi/session_metrics/model_segment.rb
467
+ - lib/samagotchi/session_metrics/totals.rb
464
468
  - lib/samagotchi/session_model_settings.rb
465
469
  - lib/samagotchi/session_observer.rb
466
470
  - lib/samagotchi/session_retention.rb
@@ -483,6 +487,7 @@ files:
483
487
  - lib/samagotchi/terminal_ui/input_support.rb
484
488
  - lib/samagotchi/terminal_ui/line_reader.rb
485
489
  - lib/samagotchi/terminal_ui/live_region.rb
490
+ - lib/samagotchi/terminal_ui/model_segments_text.rb
486
491
  - lib/samagotchi/terminal_ui/one_shot_sink.rb
487
492
  - lib/samagotchi/terminal_ui/plain_surface.rb
488
493
  - lib/samagotchi/terminal_ui/question_prompt.rb
@@ -571,7 +576,6 @@ files:
571
576
  - lib/samagotchi/web/info_probe.rb
572
577
  - lib/samagotchi/web/lan.rb
573
578
  - lib/samagotchi/web/markdown_renderer.rb
574
- - lib/samagotchi/web/message_parts.rb
575
579
  - lib/samagotchi/web/public/activity.js
576
580
  - lib/samagotchi/web/public/annotate_actions.js
577
581
  - lib/samagotchi/web/public/annotate_bar.js
@@ -613,6 +617,7 @@ files:
613
617
  - lib/samagotchi/web/public/live_timing.js
614
618
  - lib/samagotchi/web/public/live_turn.js
615
619
  - lib/samagotchi/web/public/llm_context_chip.js
620
+ - lib/samagotchi/web/public/markdown_place.js
616
621
  - lib/samagotchi/web/public/model_pick.js
617
622
  - lib/samagotchi/web/public/model_picker.js
618
623
  - lib/samagotchi/web/public/notice_links.js
@@ -649,6 +654,7 @@ files:
649
654
  - lib/samagotchi/web/public/thinking_ticker.js
650
655
  - lib/samagotchi/web/public/timing.js
651
656
  - lib/samagotchi/web/public/toast_place.js
657
+ - lib/samagotchi/web/public/toasts.js
652
658
  - lib/samagotchi/web/public/toolkit_chip.js
653
659
  - lib/samagotchi/web/public/turn_events.js
654
660
  - lib/samagotchi/web/public/turn_html.js