samagotchi 0.42.0 → 0.44.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/configuration.md +26 -0
  4. data/docs/context.md +46 -0
  5. data/docs/internals/context-telemetry.md +4 -1
  6. data/docs/internals/engine.md +1 -0
  7. data/docs/plugins.md +27 -5
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +20 -9
  10. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  11. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  12. data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/context_sources.rb +40 -0
  15. data/lib/samagotchi/context_status.rb +18 -5
  16. data/lib/samagotchi/kernel_loop.rb +24 -9
  17. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  18. data/lib/samagotchi/llm_context_forget.rb +6 -2
  19. data/lib/samagotchi/llm_context_notice.rb +96 -0
  20. data/lib/samagotchi/plugin/attached_context.rb +75 -11
  21. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  22. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  23. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  24. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  25. data/lib/samagotchi/tool_declarations.rb +2 -1
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/app.rb +47 -7
  28. data/lib/samagotchi/web/message_parts.rb +90 -11
  29. data/lib/samagotchi/web/public/app.js +80 -13
  30. data/lib/samagotchi/web/public/ctx.js +27 -2
  31. data/lib/samagotchi/web/public/data.js +6 -3
  32. data/lib/samagotchi/web/public/index.html +12 -0
  33. data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
  34. data/lib/samagotchi/web/public/model_pick.js +3 -1
  35. data/lib/samagotchi/web/public/model_picker.js +2 -0
  36. data/lib/samagotchi/web/public/session_state.js +2 -0
  37. data/lib/samagotchi/web/public/turn_events.js +34 -0
  38. data/lib/samagotchi/web/public/turn_html.js +33 -2
  39. data/lib/samagotchi/web/public/turn_view.js +4 -2
  40. metadata +2 -1
@@ -21,7 +21,10 @@ import {
21
21
  addContext,
22
22
  } from "./data.js";
23
23
  import { createContextChips } from "./context_chips.js";
24
- import { chipHtml as llmContextChipHtml, createLlmContextControl } from "./llm_context_chip.js";
24
+ import {
25
+ chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
26
+ withChoice,
27
+ } from "./llm_context_chip.js";
25
28
  import { renderStable } from "./stable_html.js";
26
29
  import { createShownTexts } from "./shown_texts.js";
27
30
  import { MODEL_KEY } from "./model_pick.js";
@@ -39,7 +42,7 @@ import { parsePresets } from "./annotate_presets.js";
39
42
  import { createAnnotateBar } from "./annotate_bar.js";
40
43
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
41
44
  import { routeChunk } from "./chunk_router.js";
42
- import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
45
+ import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
43
46
  import { createQuestionCards } from "./question_cards.js";
44
47
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
45
48
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
@@ -57,7 +60,7 @@ import {
57
60
  } from "./timing.js";
58
61
  import { createLiveTurn } from "./live_turn.js";
59
62
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
60
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
63
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
61
64
 
62
65
  const $ = (s) => document.querySelector(s);
63
66
  const topStripEl = $("#topStrip");
@@ -86,6 +89,36 @@ const llmContextControl = createLlmContextControl({
86
89
  running: () => liveTurn.running,
87
90
  run: (line) => runSessionCommand(line),
88
91
  });
92
+ // The start page's LLM context chip, beside the model picker: the picked
93
+ // model's values, and a choice for the next new chat only (sent with its
94
+ // create, then dropped; never remembered: a reload or opening a session
95
+ // drops it too, a failed create keeps it).
96
+ const llmContextNewEl = $("#llmContextNew");
97
+ let newChatLlmContext = null;
98
+ const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
99
+ const llmContextNewControl = createLlmContextControl({
100
+ el: llmContextNewEl,
101
+ escapeHtml,
102
+ summary: newChatLlmSummary,
103
+ kind: NEW_CHAT,
104
+ choice: () => newChatLlmContext,
105
+ choose: (next) => {
106
+ newChatLlmContext = next;
107
+ renderNewChatLlmContext();
108
+ promptEl.focus();
109
+ },
110
+ id: "llmContextNewPopover",
111
+ anchor: "#llmContextNew",
112
+ });
113
+ // Hidden without the model's values (the list not in, an older server).
114
+ function renderNewChatLlmContext() {
115
+ const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
116
+ llmContextNewEl.hidden = !chip;
117
+ if (!chip) return llmContextNewControl.close();
118
+ llmContextNewEl.textContent = chip.text;
119
+ llmContextNewEl.title = chip.title;
120
+ llmContextNewEl.classList.toggle("own", chip.own);
121
+ }
89
122
  const markdownWarningEl = $("#markdownWarning");
90
123
  // What the page renders that a notice may stand in for (the server's
91
124
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -908,7 +941,15 @@ function updateComposerMode() {
908
941
  hadSession = !hadSession;
909
942
  fitPrompt();
910
943
  }
911
- if (selected) modelPicker.close();
944
+ if (selected) {
945
+ modelPicker.close();
946
+ llmContextNewControl.close();
947
+ // The start page's choice was for a new chat: opening a session drops it.
948
+ if (newChatLlmContext) {
949
+ newChatLlmContext = null;
950
+ renderNewChatLlmContext();
951
+ }
952
+ }
912
953
  const running = liveTurn.running && !!selected;
913
954
  cancelBtn.style.display = running ? "" : "none";
914
955
  if (running) {
@@ -1121,7 +1162,10 @@ function renderSessionView(data) {
1121
1162
  // next turn (a running turn's replay below streams over it).
1122
1163
  const savedPct = savedCtxPct(data.timing?.context);
1123
1164
  if (savedPct !== null) current.ctxPct = savedPct;
1124
- if (data.timing?.context?.window_tokens) current.ctxWindow = data.timing.context.window_tokens;
1165
+ if (data.timing?.context?.window_tokens) {
1166
+ current.ctxWindow = data.timing.context.window_tokens;
1167
+ current.ctxWindowSource = data.timing.context.window_source || null;
1168
+ }
1125
1169
  current.firstPreview = s.first_preview || previewForCard(s);
1126
1170
  current.usedMemories = s.used_memory_names || [];
1127
1171
  current.preloadedMemories = s.preloaded_memory_names || [];
@@ -1483,11 +1527,17 @@ function showSteers(steers) {
1483
1527
  }
1484
1528
  }
1485
1529
 
1530
+ // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
1531
+ function llmContextNotice(data) {
1532
+ return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
1533
+ }
1534
+
1486
1535
  function showHookNotice(data, { before = null } = {}) {
1487
1536
  removeHintIfEmpty();
1488
1537
  const el = document.createElement("div");
1489
- el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
1538
+ el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
1490
1539
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1540
+ if (data.title) el.title = data.title;
1491
1541
  const follow = isNearBottom();
1492
1542
  if (before) historyEl.insertBefore(el, before);
1493
1543
  else appendToHistory(el);
@@ -1579,18 +1629,20 @@ function placeSnapshotCards(entries) {
1579
1629
  // plain answer, a step the history doesn't have) a bubble before the
1580
1630
  // turn's answer, or before its timing line when it has none.
1581
1631
  function placeTurnNotice(entry, before) {
1632
+ const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
1582
1633
  const line = noticeLine(entry);
1583
1634
  const turnEls = reloadedTurnEls(before);
1584
1635
  const place = turnNoticePlace(entry);
1585
1636
  const block = turnEls.find((el) => el.matches(".turn-work"));
1586
1637
  const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1587
1638
  if (!step) {
1588
- showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
1639
+ showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
1589
1640
  return;
1590
1641
  }
1591
1642
  const el = document.createElement("div");
1592
- el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1643
+ el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
1593
1644
  el.textContent = line;
1645
+ if (extra.title) el.title = extra.title;
1594
1646
  let body = step.querySelector(":scope > .activity-body");
1595
1647
  if (!body) {
1596
1648
  body = document.createElement("div");
@@ -1766,9 +1818,10 @@ function updateInfoBar() {
1766
1818
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1767
1819
  const idShort = selected.slice(0,8);
1768
1820
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1769
- const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1821
+ const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1770
1822
  const tokens = current.timing.tokens;
1771
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex);
1823
+ const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
1824
+ { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct });
1772
1825
  const speedTextNow = lastSpeedText(tokens);
1773
1826
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1774
1827
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1939,7 +1992,10 @@ const streamHandlers = {
1939
1992
  }
1940
1993
  },
1941
1994
  generation_started: (data) => {
1942
- if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
1995
+ if (data?.context_window_tokens) {
1996
+ current.ctxWindow = data.context_window_tokens;
1997
+ current.ctxWindowSource = data.context_window_source || null;
1998
+ }
1943
1999
  liveTurn.timing.setNote("");
1944
2000
  turnDom.generationStarted(data || {});
1945
2001
  },
@@ -2143,6 +2199,9 @@ const streamHandlers = {
2143
2199
  // (a reload puts it back from the snapshot's cards).
2144
2200
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
2145
2201
  steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
2202
+ // A batch of LLM context edits the server applied: a ✂ row of the
2203
+ // current step, its hover listing the outputs.
2204
+ llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2146
2205
  // The provider is asked again after an error, or the turn waits for
2147
2206
  // plugins' setup: a note on the live timing line until it streams.
2148
2207
  generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
@@ -2226,7 +2285,11 @@ async function handleSendTurn() {
2226
2285
  }
2227
2286
  actionBtn.disabled = true;
2228
2287
  try {
2229
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
2288
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2289
+ llm_context: choiceWords(newChatLlmContext) });
2290
+ // The choice was for this chat only.
2291
+ newChatLlmContext = null;
2292
+ renderNewChatLlmContext();
2230
2293
  addCreatedSession(s);
2231
2294
  await select(s.id);
2232
2295
  } catch (e) {
@@ -2439,7 +2502,10 @@ const modelPicker = createModelPicker({
2439
2502
  list: $("#modelList"),
2440
2503
  composer: $("#composer"),
2441
2504
  center: $("#center"),
2442
- onPick: () => promptEl.focus(),
2505
+ onPick: () => {
2506
+ renderNewChatLlmContext();
2507
+ promptEl.focus();
2508
+ },
2443
2509
  });
2444
2510
  async function loadModels() {
2445
2511
  let payload;
@@ -2453,6 +2519,7 @@ async function loadModels() {
2453
2519
  if (!modelPicker.setModels(payload, stored)) return;
2454
2520
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2455
2521
  modelPickEl.hidden = false;
2522
+ renderNewChatLlmContext();
2456
2523
  }
2457
2524
  // The picker's choice for a create request; nothing (the server's default)
2458
2525
  // while the list is not in.
@@ -91,14 +91,39 @@ export function memoryIndexText(memoryIndex) {
91
91
  return `~${countK(total)} tokens in this session's prompt (${scopes.map(([scope, tokens]) => `${scope} ${countK(tokens)}`).join(", ")})`;
92
92
  }
93
93
 
94
+ // The info bar's ctx: "ctx 12%", "ctx ~12%" when the window is chi's
95
+ // default (no server, model list or setting gave one, so the percent is of
96
+ // a guess); "" without a pct.
97
+ export function ctxBarText(pct, windowSource = null) {
98
+ if (num(pct) === null) return "";
99
+ return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
100
+ }
101
+
102
+ // The ctx tooltip's window line ({tokens:, source:, pct:} the session's
103
+ // window, where it came from and the meter's value): "context: ~41.0k of
104
+ // 128.0k tokens (server)", as /stats names the source; "" without a window.
105
+ export function ctxWindowText(window) {
106
+ const tokens = num(window?.tokens);
107
+ if (!tokens || tokens <= 0) return "";
108
+ const pct = num(window.pct);
109
+ const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
110
+ const source = window.source === "default"
111
+ ? "chi's default, a guess: set window_tokens for this model"
112
+ : window.source;
113
+ return `context: ${used}${countK(tokens)} tokens${source ? ` (${source})` : ""}`;
114
+ }
115
+
94
116
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
95
117
  // (in summed over every request, cached with its share, out with the
96
118
  // reasoning part) and its cost, when it has any, and the memory indexes
97
- // its prompt holds (+memoryIndex+, the snapshot's memory_index). A
119
+ // its prompt holds (+memoryIndex+, the snapshot's memory_index); with
120
+ // +window+ (ctxWindowText's) the window line under the title. A
98
121
  // delegated session keeps its own metrics, so these are this session's only.
99
- export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null) {
122
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null) {
100
123
  const memory = memoryIndexText(memoryIndex);
101
124
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
125
+ const windowText = ctxWindowText(window);
126
+ if (windowText) title = `${title}\n${windowText}`;
102
127
  const prompt = num(tokens?.prompt_sum) || 0;
103
128
  const completion = num(tokens?.completion_sum) || 0;
104
129
  if (!prompt && !completion) return `${title}${memoryLine}`;
@@ -30,9 +30,12 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
30
30
  // as a turn (with its images, once they are uploaded). dir: the folder the
31
31
  // chat starts in; model: the model it starts on (as GET /api/models spells
32
32
  // it); either left out means the server's default. preview: that message,
33
- // which names the session until its turn is saved.
34
- export function createIdleSession({ dir, model, preview, ...opts } = {}) {
35
- const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
33
+ // which names the session until its turn is saved. llm_context: the
34
+ // session's own LLM context words ({strategy, apply, budget}), when the
35
+ // start page's chip set some.
36
+ export function createIdleSession({ dir, model, preview, llm_context: llmContext, ...opts } = {}) {
37
+ const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}),
38
+ ...(llmContext ? { llm_context: llmContext } : {}) };
36
39
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
37
40
  }
38
41
 
@@ -172,6 +172,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
172
172
  .activity-output{flex:1 1 100%;min-width:0}
173
173
  /* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
174
174
  .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
175
+ /* A reloaded row's ✂ mark (an LLM context edit on its output), one line above the output. */
176
+ .activity-edit{flex:1 1 100%;min-width:0;margin:2px 0 0 var(--row-gutter);font-size:12px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:help}
175
177
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
176
178
  .activity-diff>summary:hover{color:var(--text-2)}
177
179
  .activity-diff[open]>summary{margin-bottom:4px}
@@ -212,6 +214,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
212
214
  .activity-params{flex:1 1 0}
213
215
  .activity-output{margin-left:0}
214
216
  .activity-diff{margin-left:0}
217
+ .activity-edit{margin-left:0}
215
218
  .activity-row>.activity-command{margin-left:0}
216
219
  }
217
220
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
@@ -374,6 +377,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
374
377
  .gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
375
378
  .gen .activity-body>.hook-notice:first-child{border-top:none}
376
379
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
380
+ .hook-notice.llm-context{border-left-style:dashed;cursor:help}
377
381
  /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
378
382
  .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
379
383
  .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
@@ -491,6 +495,13 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
491
495
  .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}
492
496
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
493
497
  @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
498
+ /* 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). */
499
+ #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}
500
+ #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
501
+ #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
502
+ #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
503
+ /* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
504
+ @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}}
494
505
  /* The model search: opaque like #commandList (a list over the conversation
495
506
  must stay readable), above the composer, or below it (.down) when the
496
507
  start page leaves no room above, or over the composer (.over) when
@@ -1005,6 +1016,7 @@ a{color:var(--accent)}
1005
1016
  </div>
1006
1017
  <div class="actions-col">
1007
1018
  <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1019
+ <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1008
1020
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1009
1021
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
1010
1022
  <div class="split">
@@ -6,6 +6,11 @@
6
6
  // the session's llm_context (LLMContextStrategy::Explained#summary: the
7
7
  // worker's snapshot, a command_ran, or the server's own reading of the
8
8
  // session file).
9
+ //
10
+ // The start page has one too (kind "new chat"): it shows the picked
11
+ // model's values (GET /api/models' llm_context) and its form keeps a
12
+ // choice for the next create only (POST /api/sessions' llm_context), never
13
+ // remembered: nothing is sent unless the user sets one.
9
14
 
10
15
  export const STRATEGIES = [
11
16
  { value: "none", label: "none" },
@@ -13,6 +18,8 @@ export const STRATEGIES = [
13
18
  { value: "stale,forget", label: "stale + forget (experimental)" },
14
19
  ];
15
20
  export const APPLIES = ["payoff", "next_request", "turn_end"];
21
+ export const SESSION = "session";
22
+ export const NEW_CHAT = "new chat";
16
23
 
17
24
  const DEFAULT = "default";
18
25
  const LAYER_ORDER = ["stale", "forget"];
@@ -33,15 +40,34 @@ export function ownValues(summary) {
33
40
  return { strategy, apply: own.apply || DEFAULT, budget };
34
41
  }
35
42
 
36
- // The chip: its text, tooltip and whether the session set its own; null
37
- // without a summary (an older worker).
38
- export function chipModel(summary) {
43
+ // The start page's choice for the new chat ({strategy: [layers], apply,
44
+ // budget_tokens}, the session file's shape; 0 is off) laid over the
45
+ // model's summary: what the chip shows, with the chosen fields' source
46
+ // "session" ("the new chat"). The model's own summary without a choice.
47
+ export function withChoice(summary, choice) {
48
+ if (!summary || !choice) return summary;
49
+ const merged = { ...summary, own: choice };
50
+ // (the budget's source and where are budget_source / budget_where)
51
+ const set = (field, key, value) => Object.assign(merged, { [key]: value, [`${field}_source`]: SESSION,
52
+ [`${field}_where`]: "the new chat" });
53
+ if (Array.isArray(choice.strategy)) set("strategy", "strategy", strategyValue(choice.strategy));
54
+ if (choice.apply) set("apply", "apply", choice.apply);
55
+ if (typeof choice.budget_tokens === "number") {
56
+ set("budget", "budget_tokens", choice.budget_tokens > 0 ? choice.budget_tokens : null);
57
+ }
58
+ return merged;
59
+ }
60
+
61
+ // The chip: its text, tooltip and whether the session (or the new chat)
62
+ // set its own; null without a summary (an older worker).
63
+ export function chipModel(summary, { kind = SESSION } = {}) {
39
64
  if (!summary || !summary.strategy) return null;
40
65
  const own = summary.strategy_source === "session" || !!summary.own;
41
66
  const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
67
+ const what = kind === NEW_CHAT ? "the new chat" : "this session";
42
68
  const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
43
- `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for this session.`;
44
- return { text: `llm ctx ${summary.strategy}${own ? " · session" : ""}`, title, own };
69
+ `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for ${what}.`;
70
+ return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
45
71
  }
46
72
 
47
73
  export function chipHtml(summary, escapeHtml) {
@@ -65,6 +91,54 @@ export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" }
65
91
  return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
66
92
  }
67
93
 
94
+ // A budget as typed ("64000", "64k", "off", "0") in tokens, 0 for off;
95
+ // null when it isn't one or is out of chi's range (LLMContextOverride).
96
+ export function parseBudget(text) {
97
+ const word = String(text).trim().toLowerCase();
98
+ if (word === "off" || word === "0") return 0;
99
+ const match = /^(\d[\d_]*)(k)?$/.exec(word);
100
+ if (!match) return null;
101
+ const tokens = Number(match[1].replace(/_/g, "")) * (match[2] ? 1000 : 1);
102
+ if (tokens === 0) return 0; // "00", "0k": off, as chi reads them
103
+ return tokens >= 4000 && tokens <= 10_000_000 ? tokens : null;
104
+ }
105
+
106
+ // The new chat's choice after a Set: +choice+ with the fields the user
107
+ // changed (vs +initial+, the values the form opened with) applied, a field
108
+ // set back to default dropped; null when nothing is left. Throws on a
109
+ // budget that isn't one.
110
+ export function mergeChoice(choice, { strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
111
+ const next = { ...(choice || {}) };
112
+ const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
113
+ const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
114
+ const key = { strategy: "strategy", apply: "apply", budget: "budget_tokens" };
115
+ for (const field of Object.keys(words)) {
116
+ if (was && words[field] === was[field]) continue;
117
+ const word = words[field];
118
+ if (word === DEFAULT) { delete next[key[field]]; continue; }
119
+ if (field === "strategy") next.strategy = word === "none" ? [] : word.split(/[\s,|]+/).filter(Boolean);
120
+ else if (field === "apply") next.apply = word;
121
+ else {
122
+ const tokens = parseBudget(word);
123
+ if (tokens === null) throw new Error("A budget is a number of tokens from 4000 (4k) to 10000000, or off.");
124
+ next.budget_tokens = tokens;
125
+ }
126
+ }
127
+ return Object.keys(next).length ? next : null;
128
+ }
129
+
130
+ // The choice as POST /api/sessions' llm_context words it ({strategy:
131
+ // "stale,forget"|"none", apply, budget: "64000"|"off"}, the set fields);
132
+ // undefined without one, so the request has none.
133
+ export function choiceWords(choice) {
134
+ if (!choice) return undefined;
135
+ const words = {};
136
+ if (Array.isArray(choice.strategy)) words.strategy = strategyValue(choice.strategy);
137
+ if (choice.apply) words.apply = choice.apply;
138
+ if (typeof choice.budget_tokens === "number") words.budget = choice.budget_tokens > 0 ? String(choice.budget_tokens) : "off";
139
+ return Object.keys(words).length ? words : undefined;
140
+ }
141
+
68
142
  // The strategy list, with the session's own value when it isn't one of them
69
143
  // (forget alone, a newer chi's layer).
70
144
  export function strategyOptions(chosen) {
@@ -84,11 +158,16 @@ export function nowText(summary) {
84
158
  `budget ${budget} (${summary.budget_where})`;
85
159
  }
86
160
 
87
- export function formHtml(summary, escapeHtml, { running = false } = {}) {
88
- const own = ownValues(summary);
161
+ // For the new chat (kind NEW_CHAT), +summary+ is the model's and +choice+
162
+ // the values the form shows.
163
+ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
164
+ const fresh = kind === NEW_CHAT;
165
+ const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
89
166
  const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
167
+ const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
168
+ : "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
90
169
  return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
91
- `<span class="ctx-pop-kind">this session</span>` +
170
+ `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
92
171
  `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
93
172
  `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
94
173
  `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
@@ -99,20 +178,44 @@ export function formHtml(summary, escapeHtml, { running = false } = {}) {
99
178
  APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
100
179
  `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
101
180
  `placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
102
- `<div class="ctx-pop-meta">From the next turn's start. Turning forget on or off re-reads the whole prompt once.` +
103
- `${running ? " A turn is running: set it when it ends." : ""}</div>` +
181
+ `<div class="ctx-pop-error llmctx-error" hidden></div>` +
182
+ `<div class="ctx-pop-meta">${when}${running ? " A turn is running: set it when it ends." : ""}</div>` +
104
183
  `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
105
184
  `<button type="submit" class="llmctx-set"${running ? " disabled" : ""}>Set</button></div></form>`;
106
185
  }
107
186
 
187
+ const EDGE = 16;
188
+ const GAP = 8;
189
+
190
+ // Where the popover goes for the chip's +rect+: above it, or below when
191
+ // the room above is short of its height and below has more; capped to the
192
+ // room on that side (it scrolls), never past the window's edges.
193
+ // @return {{left, top?, bottom?, maxHeight?}}
194
+ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
195
+ const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
196
+ const above = rect.top - GAP - EDGE;
197
+ const below = innerHeight - rect.bottom - GAP - EDGE;
198
+ const down = height > above && below > above;
199
+ const room = Math.max(0, down ? below : above);
200
+ const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
201
+ return height > room ? { ...place, maxHeight: room } : place;
202
+ }
203
+
108
204
  // The chip's popover. el: where the chip is drawn (#infoText, redrawn every
109
205
  // tick: clicks are delegated); summary(): the open session's llm_context;
110
206
  // session(): the open session's id; running(): a turn runs; run(line):
111
207
  // sends the command.
208
+ // The start page's (kind NEW_CHAT): summary() is the picked model's,
209
+ // choice() the pending choice, and choose(next) takes a Set's (null for
210
+ // "follow the model") instead of a command running. id and anchor (the
211
+ // chip's selector) keep the two controls apart.
112
212
  export function createLlmContextControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
213
+ kind = SESSION, choice = () => null, choose = null,
214
+ id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
113
215
  doc = document, win = window }) {
216
+ const fresh = kind === NEW_CHAT;
114
217
  const pop = doc.createElement("div");
115
- pop.id = "llmContextPopover";
218
+ pop.id = id;
116
219
  pop.className = "ctx-popover llmctx-popover";
117
220
  pop.setAttribute("role", "dialog");
118
221
  pop.hidden = true;
@@ -122,19 +225,23 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
122
225
  let openFor = null;
123
226
 
124
227
  function place(anchor) {
125
- const rect = anchor.getBoundingClientRect();
126
228
  const width = Math.min(360, win.innerWidth - 32);
127
229
  pop.style.width = `${width}px`;
128
- pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
129
- pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
230
+ pop.style.maxHeight = "";
231
+ const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
232
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
233
+ pop.style.left = `${at.left}px`;
234
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
235
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
236
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
130
237
  }
131
238
 
132
239
  function open(anchor) {
133
240
  const data = summary();
134
241
  if (!data) return;
135
- initial = ownValues(data);
242
+ initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
136
243
  openFor = session();
137
- pop.innerHTML = formHtml(data, escapeHtml, { running: running() });
244
+ pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
138
245
  pop.hidden = false;
139
246
  place(anchor);
140
247
  }
@@ -161,17 +268,39 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
161
268
  }
162
269
 
163
270
  el.addEventListener("click", (e) => {
164
- const anchor = e.target.closest(".llmctx-chip");
271
+ const anchor = e.target.closest(anchorSel);
165
272
  if (!anchor) return;
166
273
  e.stopPropagation();
167
274
  if (isOpen()) close();
168
275
  else open(anchor);
169
276
  });
277
+ // An edit makes an error the last Set showed stale.
278
+ const clearError = () => {
279
+ const error = pop.querySelector(".llmctx-error");
280
+ if (error && !error.hidden) { error.hidden = true; error.textContent = ""; }
281
+ };
282
+ pop.addEventListener("input", clearError);
283
+ pop.addEventListener("change", clearError);
170
284
  pop.addEventListener("submit", (e) => {
171
285
  e.preventDefault();
172
286
  const form = e.target;
173
- const line = commandLine({ strategy: form.elements.strategy.value, apply: form.elements.apply.value,
174
- budget: form.elements.budget.value }, initial);
287
+ const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
288
+ budget: form.elements.budget.value };
289
+ if (fresh) {
290
+ let next;
291
+ try {
292
+ next = mergeChoice(choice(), values, initial);
293
+ } catch (err) {
294
+ const error = pop.querySelector(".llmctx-error");
295
+ error.textContent = err.message;
296
+ error.hidden = false;
297
+ return;
298
+ }
299
+ close();
300
+ choose(next);
301
+ return;
302
+ }
303
+ const line = commandLine(values, initial);
175
304
  close();
176
305
  if (line) run(line);
177
306
  });
@@ -180,11 +309,12 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
180
309
  if (act === "close") close();
181
310
  else if (act === "reset") {
182
311
  close();
183
- run(commandLine());
312
+ if (fresh) choose(null);
313
+ else run(commandLine());
184
314
  }
185
315
  });
186
316
  doc.addEventListener("click", (e) => {
187
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(".llmctx-chip")) close();
317
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
188
318
  });
189
319
  doc.addEventListener("keydown", (e) => {
190
320
  if (e.key === "Escape" && isOpen()) close();
@@ -19,7 +19,8 @@ export const RECENT_KEY = "chi_model_recent";
19
19
 
20
20
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
21
  // sampling (the configured request parameters as /model words them) when
22
- // the model has some. The
22
+ // the model has some, and llm_context (the LLM context it starts under,
23
+ // LLMContextStrategy::Explained#summary) when the server sent one. The
23
24
  // default host's names are bare (name == id); the server's own default,
24
25
  // unshifted when no host lists it, has no host and goes with them, shown
25
26
  // by its name.
@@ -33,6 +34,7 @@ export function modelRows(payload) {
33
34
  const id = host ? String(m.id || name) : name;
34
35
  const row = { name, host, id, isDefaultHost: !host || name === id };
35
36
  if (m.sampling) row.sampling = String(m.sampling);
37
+ if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
36
38
  return row;
37
39
  });
38
40
  }
@@ -219,6 +219,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
219
219
  return true;
220
220
  },
221
221
  chosen: () => chosen,
222
+ // The picked model's row (its llm_context too); null before the list.
223
+ chosenRow: () => rows.find((r) => r.name === chosen) || null,
222
224
  close,
223
225
  isOpen,
224
226
  };
@@ -22,6 +22,8 @@ export function emptySessionState() {
22
22
  ctxPct: null,
23
23
  // Window the kernel resolved for the running generation (:generation_started).
24
24
  ctxWindow: null,
25
+ // Where it came from ("server", "config", … "default": chi's guess).
26
+ ctxWindowSource: null,
25
27
  status: "",
26
28
  model: "",
27
29
  // What the worker's server said it served for a model name: [served, asked].