samagotchi 0.68.0 → 0.69.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +15 -8
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +7 -1
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/engine.rb +5 -2
  20. data/lib/samagotchi/guardrails/targets.rb +9 -2
  21. data/lib/samagotchi/host_config.rb +1 -29
  22. data/lib/samagotchi/host_model.rb +0 -7
  23. data/lib/samagotchi/kernel_loop.rb +24 -7
  24. data/lib/samagotchi/llm/errors.rb +47 -1
  25. data/lib/samagotchi/llm/http.rb +20 -2
  26. data/lib/samagotchi/llm_context_stale.rb +6 -0
  27. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  28. data/lib/samagotchi/model_price.rb +2 -4
  29. data/lib/samagotchi/session_metrics.rb +4 -1
  30. data/lib/samagotchi/system_prompt.rb +19 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  32. data/lib/samagotchi/tool_activity.rb +3 -0
  33. data/lib/samagotchi/tool_row_fields.rb +4 -2
  34. data/lib/samagotchi/tool_runner.rb +24 -3
  35. data/lib/samagotchi/toolkit.rb +27 -14
  36. data/lib/samagotchi/toolkit_binding.rb +8 -2
  37. data/lib/samagotchi/toolkit_command.rb +20 -4
  38. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  39. data/lib/samagotchi/tools/registry.rb +95 -8
  40. data/lib/samagotchi/tools/tool_index.rb +223 -72
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -9
  43. data/lib/samagotchi/web/message_parts.rb +5 -2
  44. data/lib/samagotchi/web/public/activity.js +3 -1
  45. data/lib/samagotchi/web/public/app.js +123 -22
  46. data/lib/samagotchi/web/public/chains.js +19 -2
  47. data/lib/samagotchi/web/public/families_view.js +15 -0
  48. data/lib/samagotchi/web/public/index.html +7 -5
  49. data/lib/samagotchi/web/public/model_picker.js +21 -6
  50. data/lib/samagotchi/web/public/session_state.js +5 -0
  51. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  52. data/lib/samagotchi/web/public/timing.js +3 -1
  53. data/lib/samagotchi/web/public/toast_place.js +16 -0
  54. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  55. data/lib/samagotchi/web/public/turn_html.js +4 -2
  56. metadata +2 -1
@@ -44,7 +44,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
44
44
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
45
45
  import { createCardsPlace } from "./cards_place.js";
46
46
  import { createStageView, FOLD_MS } from "./stage_view.js";
47
- import { CONTINUE_OPENER, isOpener, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
47
+ import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
48
48
  import { createTaskStopper } from "./task_stop.js";
49
49
  import { appendQuote } from "./annotations.js";
50
50
  import { parsePresets } from "./annotate_presets.js";
@@ -83,6 +83,7 @@ import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
83
83
  import { liveSlots } from "./stage_model.js";
84
84
  import { currentGen } from "./turn_model.js";
85
85
  import { toolName } from "./activity.js";
86
+ import { toastBottom } from "./toast_place.js";
86
87
 
87
88
  const $ = (s) => document.querySelector(s);
88
89
  const topStripEl = $("#topStrip");
@@ -118,7 +119,7 @@ const llmContextControl = createLlmContextControl({
118
119
  // drops it too, a failed create keeps it).
119
120
  const llmContextNewEl = $("#llmContextNew");
120
121
  let newChatLlmContext = null;
121
- const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
122
+ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
122
123
  const llmContextNewControl = createLlmContextControl({
123
124
  el: llmContextNewEl,
124
125
  summary: newChatLlmSummary,
@@ -154,7 +155,7 @@ const reasoningHistoryControl = createReasoningHistoryControl({
154
155
  // for the next new chat only (sent with its create, then dropped).
155
156
  const thinkingNewEl = $("#thinkingNew");
156
157
  let newChatThinking = null;
157
- const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
158
+ const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
158
159
  const thinkingNewControl = createThinkingControl({
159
160
  el: thinkingNewEl,
160
161
  summary: newChatThinkingSummary,
@@ -587,11 +588,13 @@ function updateComposerMode() {
587
588
  // In a session the box is one line: its minimum follows the class.
588
589
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
589
590
  hadSession = !hadSession;
591
+ // Back on the start page: its remembered model, not the last link's.
592
+ if (!hadSession) restoreStartPick();
590
593
  fitPrompt();
591
594
  placeToast();
592
595
  }
596
+ syncModelPick();
593
597
  if (selected) {
594
- modelPicker.close();
595
598
  llmContextNewControl.close();
596
599
  // The start page's choice was for a new chat: opening a session drops it.
597
600
  if (newChatLlmContext) {
@@ -648,6 +651,8 @@ function syncDeliveryLine() {
648
651
 
649
652
  function setTurnRunning(running) {
650
653
  liveTurn.running = !!running;
654
+ // A fresh link's picker goes now, not at the timing re-read after the turn.
655
+ if (running && selected) current.turnStarted = true;
651
656
  updateComposerMode();
652
657
  updateInfoBar();
653
658
  }
@@ -1096,6 +1101,9 @@ function updateInfoBar() {
1096
1101
  // mid-press) stay.
1097
1102
  renderInfoBar(infoTextEl, bar);
1098
1103
  updateSessionTitle();
1104
+ // A fresh link's picker follows what the bar follows: its model, its
1105
+ // owner, a next link continued from elsewhere.
1106
+ syncModelPick();
1099
1107
  }
1100
1108
 
1101
1109
  // The open session's title, once on screen (session_title.js): its strip
@@ -1289,6 +1297,10 @@ const streamHandlers = createStreamHandlers({
1289
1297
  onInitEvent: (type, data) => onInitEvent(type, data),
1290
1298
  upsertCard: (card) => upsertCard(card),
1291
1299
  refreshCardBodies: () => refreshCardBodies(),
1300
+ syncModelPick: () => {
1301
+ if (pendingPick) pickRanEarly = true;
1302
+ syncModelPick();
1303
+ },
1292
1304
  });
1293
1305
 
1294
1306
  async function refreshTimingFromSession() {
@@ -1332,6 +1344,9 @@ function handleCreate() {
1332
1344
  // /queue wins over both.
1333
1345
  async function handleSendTurn({ delivery: chosen = null } = {}) {
1334
1346
  if (current.owner === "tui") return;
1347
+ // A fresh link's pick first: its /model is queued in the worker once
1348
+ // acked, and the worker runs queued commands before a turn's input.
1349
+ if (pendingPick) await pendingPick;
1335
1350
  let prompt = turnText(promptEl.value, chips);
1336
1351
  if (!prompt) return;
1337
1352
  const armed = deliveryControl.take();
@@ -1516,10 +1531,24 @@ $("#newBtn").addEventListener("click", () => {
1516
1531
  // ── The start page's model picker ──────────────────────────────────────────
1517
1532
  // The hosts' models from GET /api/models, the choice this browser made last
1518
1533
  // preselected while it is still offered (else the server's default). Hidden
1519
- // until the list is in, and in a session (its model is in the info bar). A
1520
- // host that failed is the picker's tooltip; a route that fails leaves the
1521
- // picker hidden and the server's default in charge. The search list itself
1522
- // is model_picker.js'; a pick sends the focus to the message box.
1534
+ // until the list is in, and in a session (its model is in the info bar),
1535
+ // but a fresh chain link's (chains.js freshLink): there it shows the link's
1536
+ // model and a pick runs /model in the link, remembered neither as this
1537
+ // browser's choice nor in Recent. A host that failed is the picker's
1538
+ // tooltip; a route that fails leaves the picker hidden and the server's
1539
+ // default in charge. The search list itself is model_picker.js'; a pick
1540
+ // sends the focus to the message box.
1541
+ const START_PICK_TITLE = "The model the new chat starts on";
1542
+ let startPickTitle = START_PICK_TITLE;
1543
+ // The fresh link the picker shows (null: the start page, or a session it
1544
+ // is hidden in), and a link pick's /model still in flight (a send waits).
1545
+ let pickLinkId = null;
1546
+ let pickSelected = null;
1547
+ let pendingPick = null;
1548
+ let pendingPickId = null;
1549
+ // A command_ran came while the pick was in flight (its sync was skipped):
1550
+ // the ack syncs then.
1551
+ let pickRanEarly = false;
1523
1552
  const modelPicker = createModelPicker({
1524
1553
  button: modelPickEl,
1525
1554
  panel: $("#modelPanel"),
@@ -1527,12 +1556,70 @@ const modelPicker = createModelPicker({
1527
1556
  list: $("#modelList"),
1528
1557
  composer: $("#composer"),
1529
1558
  center: $("#center"),
1530
- onPick: () => {
1531
- renderNewChatLlmContext();
1532
- renderNewChatThinking();
1559
+ remember: () => !selected,
1560
+ onPick: (name) => {
1561
+ if (selected) pickForLink(name);
1562
+ else {
1563
+ renderNewChatLlmContext();
1564
+ renderNewChatThinking();
1565
+ }
1533
1566
  promptEl.focus();
1534
1567
  },
1535
1568
  });
1569
+ // A pick on a fresh link: /model in its worker (its command bubble and the
1570
+ // info bar follow from command_ran); refused, the button goes back to the
1571
+ // link's model, if the page is still on that link.
1572
+ function pickForLink(name) {
1573
+ const id = selected;
1574
+ const run = runSessionCommand(`/model ${name}`).then((ok) => {
1575
+ const ranEarly = pendingPick === run && pickRanEarly;
1576
+ if (pendingPick === run) {
1577
+ pendingPick = null;
1578
+ pendingPickId = null;
1579
+ pickRanEarly = false;
1580
+ }
1581
+ if ((!ok || ranEarly) && selected === id) syncModelPick();
1582
+ return ok;
1583
+ });
1584
+ pendingPick = run;
1585
+ pendingPickId = id;
1586
+ pickRanEarly = false;
1587
+ }
1588
+ // The button's name, tooltip and label for what the page shows: a fresh
1589
+ // link's model, else the start page's (whose choice stays as it is).
1590
+ // Toggles the fresh-link class (the CSS shows the button in a session
1591
+ // only with it); the panel closes when the link changes or stops being fresh.
1592
+ function syncModelPick() {
1593
+ // The list walk only for a link that is fresh otherwise (the info bar
1594
+ // calls this every second while a turn runs).
1595
+ const fresh = !!selected && freshLink(current, { running: liveTurn.running })
1596
+ && !chainNeighbours(allSessions, selected, { continues: current.continues }).next;
1597
+ document.body.classList.toggle("fresh-link", fresh);
1598
+ const linkId = fresh ? selected : null;
1599
+ // Another session (or the start page), or the link stopped being fresh:
1600
+ // a panel left open would come back open where it no longer belongs.
1601
+ if (selected !== pickSelected || linkId !== pickLinkId) modelPicker.close();
1602
+ pickSelected = selected;
1603
+ pickLinkId = linkId;
1604
+ if (fresh) {
1605
+ // A pick in flight keeps its name until its /model is taken (its
1606
+ // command_ran syncs again then).
1607
+ if (!(pendingPick && pendingPickId === selected)) modelPicker.show(current.model);
1608
+ modelPickEl.title = "The model this link starts on";
1609
+ modelPickEl.setAttribute("aria-label", "Model this link starts on");
1610
+ } else {
1611
+ modelPickEl.title = startPickTitle;
1612
+ modelPickEl.setAttribute("aria-label", "Model for the new chat");
1613
+ }
1614
+ }
1615
+ // Leaving a session: the start page's remembered model again (no fetch).
1616
+ function restoreStartPick() {
1617
+ let stored = null;
1618
+ try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1619
+ modelPicker.restore(stored);
1620
+ renderNewChatLlmContext();
1621
+ renderNewChatThinking();
1622
+ }
1536
1623
  async function loadModels() {
1537
1624
  let payload;
1538
1625
  try {
@@ -1543,8 +1630,10 @@ async function loadModels() {
1543
1630
  let stored = null;
1544
1631
  try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1545
1632
  if (!modelPicker.setModels(payload, stored)) return;
1546
- modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
1633
+ startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1547
1634
  modelPickEl.hidden = false;
1635
+ // A fresh link open meanwhile shows its own model again, not the stored one.
1636
+ syncModelPick();
1548
1637
  renderNewChatLlmContext();
1549
1638
  renderNewChatThinking();
1550
1639
  }
@@ -1819,13 +1908,15 @@ async function continueSelected() {
1819
1908
  markArchived([id], true);
1820
1909
  addCreatedSession(created);
1821
1910
  const next = created.id;
1911
+ // A model fallback's warning stays until closed.
1912
+ const sticky = !!created.model_warning;
1822
1913
  if (selected !== id) {
1823
- showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
1914
+ showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
1824
1915
  return;
1825
1916
  }
1826
1917
  await select(next);
1827
1918
  if (selected === next) fillOpener();
1828
- showToast(continuedText(id, created));
1919
+ showToast(continuedText(id, created), null, { sticky });
1829
1920
  } catch (e) {
1830
1921
  const refusal = continueRefusal(e, allSessions);
1831
1922
  if (refusal.kind === "open") {
@@ -1912,22 +2003,32 @@ async function setArchived(id, archive, { undo = true } = {}) {
1912
2003
 
1913
2004
  // The toast sits above the session's composer card (measured: it grows with
1914
2005
  // a wrapping footer or a taller box), so it never covers the card's
1915
- // controls, and above the stage while it shows; with no card in sight (the
1916
- // start page, all sessions) the CSS's 24 px from the bottom.
2006
+ // controls, and above the stage while it shows and a fresh link's open
2007
+ // model panel (toast_place.js); with no card in sight (the start page, all
2008
+ // sessions) the CSS's 24 px from the bottom.
1917
2009
  function placeToast() {
1918
- const gap = 12;
1919
2010
  const dock = document.getElementById("dock");
1920
2011
  const r = dock.getBoundingClientRect();
1921
- const over = document.body.classList.contains("has-session") && r.height > 0;
1922
- const stage = document.getElementById("turnStage");
1923
- const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
1924
- const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
2012
+ const shownTop = (el) => {
2013
+ const box = el && !el.hidden ? el.getBoundingClientRect() : null;
2014
+ return box && box.height > 0 ? box.top : null;
2015
+ };
2016
+ const px = toastBottom({
2017
+ inSession: document.body.classList.contains("has-session") && r.height > 0,
2018
+ viewportHeight: window.innerHeight,
2019
+ dockTop: r.top,
2020
+ tops: [shownTop(document.getElementById("turnStage")), shownTop(document.getElementById("modelPanel"))],
2021
+ toastHeight: document.getElementById("toast").offsetHeight,
2022
+ });
2023
+ const bottom = px === null ? "" : `${px}px`;
1925
2024
  const root = document.documentElement.style;
1926
2025
  if (root.getPropertyValue("--toast-bottom") !== bottom) {
1927
2026
  if (bottom) root.setProperty("--toast-bottom", bottom);
1928
2027
  else root.removeProperty("--toast-bottom");
1929
2028
  }
1930
2029
  }
2030
+ // The model panel opening, closing or changing height (its list filters).
2031
+ new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
1931
2032
 
1932
2033
  // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
1933
2034
  // words, or a template (chains.js openChildrenTemplate: a link in it, a
@@ -1939,8 +2040,8 @@ function showToast(text, action = null, { sticky = false } = {}) {
1939
2040
  render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
1940
2041
  ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
1941
2042
  : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
1942
- placeToast();
1943
2043
  el.hidden = false;
2044
+ placeToast();
1944
2045
  clearTimeout(toastTimer);
1945
2046
  if (!sticky) toastTimer = setTimeout(hide, 6000);
1946
2047
  }
@@ -235,13 +235,30 @@ export function openChildrenTemplate(prevId, children, hrefFor) {
235
235
  }
236
236
 
237
237
  // The toast after a continue: the new link, the previous one archived (when
238
- // the page shows the new one), and how many open delegates moved to it
239
- // (the answer's moved).
238
+ // the page shows the new one), how many open delegates moved to it (the
239
+ // answer's moved), and the model fallback's warning (model_warning: the
240
+ // previous link's host is gone, the link starts on the default).
240
241
  export function continuedText(prevId, created, { opened = true } = {}) {
241
242
  const short = String(prevId).slice(0, 8);
242
243
  const moved = Array.isArray(created?.moved) ? created.moved.length : 0;
243
244
  const parts = [`Continued ${short} in ${String(created?.id || "").slice(0, 8)}`];
244
245
  if (opened) parts.push(`${short} is archived`);
245
246
  if (moved) parts.push(`${moved} open ${moved === 1 ? "delegate" : "delegates"} moved`);
247
+ const warning = typeof created?.model_warning === "string" ? created.model_warning.trim() : "";
248
+ if (warning) parts.push(warning);
246
249
  return parts.join("; ");
247
250
  }
251
+
252
+ // A fresh link: a chain link (its `continues`) with no turn yet, so its
253
+ // composer offers the model picker (a pick runs /model in it). However the
254
+ // link was made (continue →, chi send --continues, a reload). Not fresh once
255
+ // a turn started (+state.turnStarted+, set live before the timing re-read
256
+ // brings its record; a failed or canceled first turn counts too), while one
257
+ // runs, when a terminal chi owns it (sending is refused there) or when it is
258
+ // +continued+ itself (a /model there would wake an archived link).
259
+ export function freshLink(state, { running = false, continued = false } = {}) {
260
+ if (!state?.continues || running || continued || state.turnStarted) return false;
261
+ if (state.owner === "tui") return false;
262
+ const timing = state.timing || {};
263
+ return !(timing.turnRecords || []).length && !timing.activeTurn;
264
+ }
@@ -73,6 +73,20 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
73
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
74
74
  const PHONE_MAX_PX = 600;
75
75
 
76
+ // The status dots' pulse in +root+ on the document timeline's clock (each
77
+ // animation's start time 0), the phase the cards' --pulse-delay gives
78
+ // theirs (lists_view.js cardAnimStyle): a popover keeps its row nodes
79
+ // across draws, reused by position, so a dot that moves to another node
80
+ // or starts pulsing there (a member inserted above, a status flip) would
81
+ // otherwise start its pulse over, out of step with the rest.
82
+ export function syncPulses(root) {
83
+ root.querySelectorAll(".dot").forEach((dot) => {
84
+ for (const a of dot.getAnimations?.() || []) {
85
+ if (a.startTime !== 0) a.startTime = 0;
86
+ }
87
+ });
88
+ }
89
+
76
90
  // The strip's family popover: one #familyPop in body (position: fixed, so
77
91
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
78
92
  // it was opened from and as wide as that card (300 px at least, inside the
@@ -128,6 +142,7 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
128
142
  }
129
143
  // A row inserted before the focused one: the focus stays on its session.
130
144
  keepFocus(el, "data-id", () => render(rows, el));
145
+ syncPulses(el);
131
146
  anchor.setAttribute("aria-expanded", "true");
132
147
  place(anchor);
133
148
  return true;
@@ -578,7 +578,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
578
578
  body.has-session #composer textarea{min-height:0}
579
579
  body.has-session #composeHint{display:none}
580
580
  body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
581
- /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
581
+ /* The model picker (the start page, and a fresh chain link: app.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
582
582
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
583
583
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
584
584
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
@@ -586,7 +586,9 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
586
586
  #composer .actions-col .model-pick.unavailable .model-pick-name{color:var(--text-faint)}
587
587
  .model-pick::after{content:"";position:absolute;right:9px;top:50%;width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none;transition:transform .12s ease}
588
588
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
589
- @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
589
+ /* A fresh link's (body.fresh-link): the session composer is one row and .actions-col is content-sized there, so a px cap keeps the text field's room and Send in view. :not([hidden]) keeps a failed /api/models' hide (this selector outranks the [hidden] one). */
590
+ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){display:inline-block;flex:0 1 auto;min-width:0;max-width:200px}
591
+ @media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
590
592
  /* The new chat's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
591
593
  #composer .actions-col .llmctx-new{order:0;flex:0 1 auto;min-width:0;max-width:min(40%,260px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
592
594
  #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
@@ -594,8 +596,8 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
594
596
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
595
597
  /* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
596
598
  #composer .actions-col .think-new{max-width:min(25%,140px)}
597
- /* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
598
- @media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}#composer .actions-col .think-new{max-width:30%}}
599
+ /* Phones (the key hint is gone there): the chips keep their whole text and the row wraps, Start staying right (its margin-left:auto) on the last line; only a model name wider than the row ellipsizes. */
600
+ @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .llmctx-new,#composer .actions-col .think-new{flex:0 0 auto;max-width:100%}}
599
601
  /* The model search: opaque like #commandList (a list over the conversation
600
602
  must stay readable), above the composer, or below it (.down) when the
601
603
  start page leaves no room above, or over the composer (.over) when
@@ -603,7 +605,7 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
603
605
  #modelPanel{position:absolute;left:10px;width:min(560px,calc(100% - 20px));bottom:calc(100% + 8px);max-height:360px;border-radius:12px;z-index:21;display:flex;flex-direction:column;overflow:hidden;background:var(--bg-panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
604
606
  #modelPanel.down{bottom:auto;top:calc(100% + 8px)}
605
607
  #modelPanel.over{bottom:0}
606
- #modelPanel[hidden],body.has-session #modelPanel{display:none}
608
+ #modelPanel[hidden],body.has-session:not(.fresh-link) #modelPanel{display:none}
607
609
  #modelPanel .model-search{flex:none;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--border)}
608
610
  #modelPanel .model-search svg{flex:none;width:14px;height:14px;color:var(--text-faint)}
609
611
  #modelSearch{flex:1;min-width:0;height:38px;background:transparent;border:0;color:var(--text);font-size:13px}
@@ -46,8 +46,10 @@ function readRecent() {
46
46
  }
47
47
  }
48
48
 
49
- // +onPick+ runs after a pick (the panel is closed by then).
50
- export function createModelPicker({ button, panel, input, list, composer, center, onPick }) {
49
+ // +onPick+ runs after a pick (the panel is closed by then). +remember+:
50
+ // whether a pick is this browser's choice for a new chat (stored, and in
51
+ // Recent); false on a fresh chain link, where a pick is that link's only.
52
+ export function createModelPicker({ button, panel, input, list, composer, center, onPick, remember = () => true }) {
51
53
  let rows = [];
52
54
  let defaultName = "";
53
55
  let chosen = "";
@@ -160,10 +162,12 @@ export function createModelPicker({ button, panel, input, list, composer, center
160
162
  const row = shown[index];
161
163
  if (!row) return;
162
164
  setChosen(row.name);
163
- try {
164
- localStorage.setItem(MODEL_KEY, row.name);
165
- localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
166
- } catch (_) {}
165
+ if (remember()) {
166
+ try {
167
+ localStorage.setItem(MODEL_KEY, row.name);
168
+ localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
169
+ } catch (_) {}
170
+ }
167
171
  close();
168
172
  onPick?.(row.name);
169
173
  }
@@ -227,6 +231,17 @@ export function createModelPicker({ button, panel, input, list, composer, center
227
231
  setChosen(pickModel(rows, payload.default, stored || chosen));
228
232
  return true;
229
233
  },
234
+ // A name on the button, nothing stored (a fresh link's model); one that
235
+ // isn't a row (an alias, a typed host:id) shows as text, no row chosen.
236
+ show(name) {
237
+ setChosen(String(name || ""));
238
+ },
239
+ // Back to the start page's choice: +stored+ while offered, else the
240
+ // default (no fetch; nothing before the list is in).
241
+ restore(stored) {
242
+ if (!rows.length) return;
243
+ setChosen(pickModel(rows, defaultName, stored));
244
+ },
230
245
  chosen: () => chosen,
231
246
  // The picked model's row (its llm_context too); null before the list.
232
247
  chosenRow: () => rows.find((r) => r.name === chosen) || null,
@@ -50,6 +50,11 @@ export function emptySessionState() {
50
50
  reasoningHistory: null,
51
51
  toolkit: null,
52
52
  timing: normalizeTiming(),
53
+ // A turn started on it while the page had it open (setTurnRunning): a
54
+ // fresh link's picker goes at once, before the timing re-read brings the
55
+ // turn's record (chains.js freshLink). Never from a payload, so a resync
56
+ // of the same session keeps it.
57
+ turnStarted: false,
53
58
  // The event id (or, from an older worker, the event_seq) the session was
54
59
  // rendered at (null: no live worker then), where a stream opened later
55
60
  // has to start; set by the page's resync, not by the payload.
@@ -31,7 +31,7 @@
31
31
  // addCommandBubble(view), addNoteBubble(note), findInTurnRoots(selector),
32
32
  // setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
33
33
  // showHookNotice(data), onInitEvent(type, data), upsertCard(card),
34
- // refreshCardBodies() the page's hooks
34
+ // refreshCardBodies(), syncModelPick() the page's hooks
35
35
 
36
36
  import { routeChunk } from "./chunk_router.js";
37
37
  import { extractCtxPct, generationTokens } from "./ctx.js";
@@ -55,7 +55,7 @@ export function createStreamHandlers(deps) {
55
55
  resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
56
56
  appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
57
57
  commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
58
- showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies,
58
+ showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies, syncModelPick,
59
59
  } = deps;
60
60
 
61
61
  return {
@@ -210,6 +210,9 @@ export function createStreamHandlers(deps) {
210
210
  state().served = null;
211
211
  updateInfoBar();
212
212
  }
213
+ // A fresh link's picker shows the model the worker kept, a refused
214
+ // /model too (its pick set the button before the answer).
215
+ if (view.modelName) syncModelPick?.();
213
216
  // /llm-context changed what the next turn runs under.
214
217
  if (data.llm_context) {
215
218
  state().llmContext = data.llm_context;
@@ -258,12 +258,14 @@ function canceledSteps(g, record, tools) {
258
258
  }
259
259
 
260
260
  // A call's row from its tool record alone (no saved parts): the record's
261
- // called_as too, so "(called as bash)" shows after a reload, as live, and
261
+ // called_as and via too, so "(called as bash)" and "(via call_tool)" show
262
+ // after a reload, as live, and
262
263
  // its no_match ("no match" on a grep that found nothing).
263
264
  function recordRows(tools, iteration) {
264
265
  return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
265
266
  key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
266
267
  ...(r.called_as ? { called_as: r.called_as } : {}),
268
+ ...(r.via ? { via: r.via } : {}),
267
269
  ...noMatch(r),
268
270
  }));
269
271
  }
@@ -0,0 +1,16 @@
1
+ // Where the toast sits (app.js placeToast measures, this decides): in a
2
+ // session, +gap+ above the highest of the composer card and what opens
3
+ // over it (the stage while it shows, a fresh link's open model panel), so
4
+ // it covers none of them; null elsewhere (the CSS's 24 px from the bottom).
5
+ // When that leaves the toast no room on screen it stays +gap+ from the top
6
+ // (covering what's under it rather than going off screen).
7
+ // @param tops the tops (px from the viewport's top) of what shows over the
8
+ // card; null for one that doesn't
9
+ // @return {number|null} the toast's bottom offset in px
10
+ export function toastBottom({ inSession, viewportHeight, dockTop, tops = [], toastHeight = 0, gap = 12 }) {
11
+ if (!inSession) return null;
12
+ const top = Math.min(dockTop, ...tops.filter((t) => Number.isFinite(t)));
13
+ const bottom = viewportHeight - top + gap;
14
+ const cap = viewportHeight - toastHeight - gap;
15
+ return Math.round(Math.max(0, Math.min(bottom, cap)));
16
+ }
@@ -1,9 +1,11 @@
1
1
  // The session bar's tools chip: the session's toolkit when it isn't full
2
2
  // ("tools lean"), with the tools it leaves out and how to change them in
3
3
  // its tooltip. The summary is the session's toolkit ({name, label,
4
- // left_out, fallback}: Toolkit::Explained#summary; the worker's snapshot or
5
- // a command_ran; the server's own reading of the session file has no
6
- // left_out). Changing it is /toolkit (no popover yet).
4
+ // left_out, reachable, removed, fallback}: Toolkit::Explained#summary; the
5
+ // worker's snapshot or a command_ran; the server's own reading of the
6
+ // session file has no lists). Left out is reachable (find_tools and
7
+ // call_tool reach them) and removed (only /toolkit add brings one back).
8
+ // Changing it is /toolkit (no popover yet).
7
9
 
8
10
  import { html, nothing } from "./vendor/lit-html.js";
9
11
 
@@ -13,7 +15,12 @@ export function chipModel(summary) {
13
15
  const label = summary.label || summary.name;
14
16
  const parts = [`Toolkit: ${label}, the tools this session declares.`];
15
17
  if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
16
- if (Array.isArray(summary.left_out)) {
18
+ const reachable = Array.isArray(summary.reachable) ? summary.reachable : [];
19
+ const removed = Array.isArray(summary.removed) ? summary.removed : [];
20
+ if (reachable.length) {
21
+ parts.push(`Reachable with find_tools: ${reachable.join(", ")}.`);
22
+ if (removed.length) parts.push(`Removed: ${removed.join(", ")}.`);
23
+ } else if (Array.isArray(summary.left_out)) {
17
24
  parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
18
25
  }
19
26
  parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
@@ -100,8 +100,10 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
100
100
  const params = shown
101
101
  ? html`<span class=${`activity-params${ref?.kind ? " ref" : ""}`} title=${hover || nothing} data-ref-kind=${attr(ref?.kind)} data-ref-path=${attr(ref?.kind && ref.path)} data-ref-line=${attr(ref?.kind && ref.line)} data-ref-end-line=${attr(ref?.kind && ref.end_line)}>${shown}</span>`
102
102
  : nothing;
103
- // The name the model called the tool by when it was an alias's, dim.
104
- const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>` : nothing;
103
+ // The name the model called the tool by when it was an alias's, or the
104
+ // tool it was reached through (call_tool), dim.
105
+ const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>`
106
+ : row.via ? html`<span class="activity-alias">(via ${row.via})</span>` : nothing;
105
107
  const duration = formatDuration(row.duration_ms);
106
108
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
107
109
  const output = full
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.68.0
4
+ version: 0.69.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -640,6 +640,7 @@ files:
640
640
  - lib/samagotchi/web/public/thinking_chip.js
641
641
  - lib/samagotchi/web/public/thinking_ticker.js
642
642
  - lib/samagotchi/web/public/timing.js
643
+ - lib/samagotchi/web/public/toast_place.js
643
644
  - lib/samagotchi/web/public/toolkit_chip.js
644
645
  - lib/samagotchi/web/public/turn_events.js
645
646
  - lib/samagotchi/web/public/turn_html.js