samagotchi 0.74.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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 +21 -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 +48 -1
  13. data/docs/plugins.md +2 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +7 -5
  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 +140 -9
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
  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.rb +1 -1
  39. data/lib/samagotchi/session_chain.rb +2 -8
  40. data/lib/samagotchi/session_commands.rb +2 -0
  41. data/lib/samagotchi/session_manager.rb +1 -2
  42. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  43. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  44. data/lib/samagotchi/session_metrics.rb +118 -83
  45. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  46. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  47. data/lib/samagotchi/toolkit.rb +9 -8
  48. data/lib/samagotchi/toolkit_command.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +1 -5
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +3 -0
  52. data/lib/samagotchi/web/public/app.js +4 -3
  53. data/lib/samagotchi/web/public/chains.js +42 -21
  54. data/lib/samagotchi/web/public/composer.js +9 -5
  55. data/lib/samagotchi/web/public/ctx.js +73 -11
  56. data/lib/samagotchi/web/public/index.html +32 -35
  57. data/lib/samagotchi/web/public/info_bar.js +4 -4
  58. data/lib/samagotchi/web/public/info_bar_model.js +18 -8
  59. data/lib/samagotchi/web/public/lists_view.js +2 -2
  60. data/lib/samagotchi/web/public/question_cards.js +1 -1
  61. data/lib/samagotchi/web/public/session_state.js +7 -0
  62. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  63. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  64. data/lib/samagotchi/web/public/timing.js +11 -2
  65. metadata +4 -1
@@ -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,8 +1026,10 @@ 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}
1031
+ /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1032
+ .chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
1036
1033
  /* The session page's links to the previous and the next link of its chain. */
1037
1034
  #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
1038
1035
  #infoBar .meta a.chain-link:hover{background:var(--accent-bg)}
@@ -21,12 +21,12 @@ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
21
21
  export const PREV = "←";
22
22
  export const NEXT = "→";
23
23
 
24
- // A chain's ← / → link (chains.js chainNeighbours): its date (else its short
25
- // id), its preview and id in the tooltip. +link+: {id, date, preview,
26
- // archived}.
24
+ // A chain's ← / → link (chains.js chainNeighbours): its date or time (else
25
+ // its short id), its id, full start and preview in the tooltip. +link+:
26
+ // {id, date, stamp, preview, archived} (info_bar_model.js chainLinkView).
27
27
  export function chainLinkTemplate(link, arrow, what) {
28
28
  const short = String(link.id).slice(0, 8);
29
- const title = `${what}: ${short}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
29
+ const title = `${what}: ${short}${link.stamp ? ` · ${link.stamp}` : ""}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
30
30
  const text = arrow === PREV ? `${arrow} ${link.date || short}` : `${link.date || short} ${arrow}`;
31
31
  return html`<a class="model chain-link" href=${sessionHash(link.id)} title=${title}>${text}</a>`;
32
32
  }
@@ -4,7 +4,7 @@
4
4
  // its list and its in-flight actions. Pure: app.js's updateInfoBar applies
5
5
  // it, every second while a turn runs (the session clock).
6
6
  import { canStopSession, childrenSummary, delegatedBy, modelLabel, promptNotesChip } from "./format.js";
7
- import { chainDate, chainNeighbours } from "./chains.js";
7
+ import { chainClock, chainDate, chainNeighbours } from "./chains.js";
8
8
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
9
9
  import { formatDuration, sessionDurationNow } from "./timing.js";
10
10
  import { workerBadge } from "./update_notice.js";
@@ -21,12 +21,18 @@ export function workerBadgeFor(id, state, versions) {
21
21
  }
22
22
 
23
23
  // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
24
- // shows it (info_bar.js chainLinkTemplate), or null. +cardTitle+: the
25
- // lists' title for a session (a chain's opener reads its first link's).
26
- export function chainLinkView(link, cardTitle) {
24
+ // shows it (info_bar.js chainLinkTemplate), or null. Its date: its local
25
+ // HH:MM when it started on the open link's day (+selfIso+: the open
26
+ // session's created_at; the day is implied), else its YYYY-MM-DD; its
27
+ // stamp (the tooltip's) always both. +cardTitle+: the lists' title for a
28
+ // session (a chain's opener reads its first link's).
29
+ export function chainLinkView(link, cardTitle, selfIso = null) {
27
30
  if (!link) return null;
28
- return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? cardTitle(link.session) : "",
29
- archived: !!link.session?.archived };
31
+ const iso = link.session?.created_at;
32
+ const day = chainDate(iso);
33
+ const clock = chainClock(iso);
34
+ return { id: link.id, date: day && day === chainDate(selfIso) ? clock : day, stamp: day ? `${day} ${clock}` : "",
35
+ preview: link.session ? cardTitle(link.session) : "", archived: !!link.session?.archived };
30
36
  }
31
37
 
32
38
  // The bar's buttons: continue (not on a link continued already, which has
@@ -61,11 +67,15 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
61
67
  const tokens = state.timing.tokens;
62
68
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
63
69
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
64
- budget: state.llmContext?.budget_tokens });
70
+ budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
71
+ state.timing.modelSegments);
65
72
  const speedTextNow = lastSpeedText(tokens);
66
73
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
67
74
  const sessionDuration = sessionDurationNow(state.timing, running);
68
75
  // A delegated session names its parent, as a link to it.
76
+ // The open link's start: the payload's, else its list entry's (a fresh
77
+ // link may not be listed yet: then neither, and its links show dates).
78
+ const selfIso = state.createdAt || sessions.find((x) => x.id === id)?.created_at || null;
69
79
  const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
70
80
  return {
71
81
  id,
@@ -74,7 +84,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
74
84
  model: modelLabel(state.model, ...(state.served || [])),
75
85
  // A link of a session chain: ← the previous link, → the next one (each
76
86
  // only while the list holds it).
77
- chain: { prev: chainLinkView(chain.prev, cardTitle), next: chainLinkView(chain.next, cardTitle) },
87
+ chain: { prev: chainLinkView(chain.prev, cardTitle, selfIso), next: chainLinkView(chain.next, cardTitle, selfIso) },
78
88
  parent: parentChip ? { id: state.parentId, title: parentChip.title } : null,
79
89
  // A parent names its delegates; the link opens all sessions.
80
90
  kids: childrenSummary({ id }, sessions),
@@ -129,7 +129,7 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
129
129
  // member does; with rows (all sessions) an open family lists its members
130
130
  // inline, the strip's opens in a popover; matchIds: a search's matching
131
131
  // members. A family in a session chain (chains.js foldChains) carries its
132
- // `day N` chip, which opens the other links in a popover. entering: a card
132
+ // `#N` chip, which opens the other links in a popover. entering: a card
133
133
  // new to its list (enteringCards: it fades in); anim: its inline animation
134
134
  // phases (cardAnimStyle).
135
135
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
@@ -342,7 +342,7 @@ export function createListsView(deps) {
342
342
  doc.addEventListener("scroll", () => familyPop.reposition(), true);
343
343
 
344
344
  // A chain's popover (chains.js): the other links of the chain a card's
345
- // `day N` chip stands for, under the chip, in the strip or all sessions
345
+ // `#N` chip stands for, under the chip, in the strip or all sessions
346
346
  // (the list it was opened from: chainFrom). A row opens that link.
347
347
  const chainFamilies = new Map(); // head id → family, as the lists last drew it
348
348
  let chainFrom = null; // the list element the open popover's chip is in
@@ -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
  };
@@ -23,11 +23,17 @@ export function emptySessionState() {
23
23
  parentId: null,
24
24
  // The previous link of its chain (its `continues`), or null.
25
25
  continues: null,
26
+ // Its start (the summary's created_at; the info bar's chain links are
27
+ // times on its day), or null.
28
+ createdAt: null,
26
29
  ctxPct: null,
27
30
  // Window the kernel resolved for the running generation (:generation_started).
28
31
  ctxWindow: null,
29
32
  // Where it came from ("server", "config", … "default": chi's guess).
30
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,
31
37
  status: "",
32
38
  model: "",
33
39
  // What the worker's server said it served for a model name: [served, asked].
@@ -107,6 +113,7 @@ export function sessionStateFrom(data) {
107
113
  state.toolkit = s.toolkit || null;
108
114
  state.parentId = s.parent_id || null;
109
115
  state.continues = s.continues || null;
116
+ state.createdAt = s.created_at || null;
110
117
  state.commands = Array.isArray(data.commands) ? data.commands : [];
111
118
  return state;
112
119
  }
@@ -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.74.0
4
+ version: 0.76.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -380,6 +380,7 @@ files:
380
380
  - lib/samagotchi/memory_bundle/installer.rb
381
381
  - lib/samagotchi/memory_bundle/listing.rb
382
382
  - lib/samagotchi/memory_bundle/manifest.rb
383
+ - lib/samagotchi/memory_bundle/memory_files.rb
383
384
  - lib/samagotchi/memory_bundle/merger.rb
384
385
  - lib/samagotchi/memory_bundle/placeholder.rb
385
386
  - lib/samagotchi/memory_bundle/plugin_ref.rb
@@ -460,6 +461,8 @@ files:
460
461
  - lib/samagotchi/session_inbox.rb
461
462
  - lib/samagotchi/session_manager.rb
462
463
  - lib/samagotchi/session_metrics.rb
464
+ - lib/samagotchi/session_metrics/model_segment.rb
465
+ - lib/samagotchi/session_metrics/totals.rb
463
466
  - lib/samagotchi/session_model_settings.rb
464
467
  - lib/samagotchi/session_observer.rb
465
468
  - lib/samagotchi/session_retention.rb