samagotchi 0.41.0 → 0.43.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/cli.md +8 -6
  4. data/docs/configuration.md +8 -0
  5. data/docs/context.md +46 -0
  6. data/docs/memory.md +18 -0
  7. data/docs/plugins.md +27 -5
  8. data/docs/sessions.md +5 -5
  9. data/docs/testing.md +16 -0
  10. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  11. data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +8 -4
  14. data/lib/samagotchi/context_command.rb +5 -2
  15. data/lib/samagotchi/context_sources.rb +40 -0
  16. data/lib/samagotchi/engine.rb +31 -1
  17. data/lib/samagotchi/model_notes.rb +7 -4
  18. data/lib/samagotchi/plugin/attached_context.rb +75 -11
  19. data/lib/samagotchi/prompt_note.rb +53 -0
  20. data/lib/samagotchi/self_report.rb +32 -0
  21. data/lib/samagotchi/session.rb +16 -1
  22. data/lib/samagotchi/session_commands.rb +24 -5
  23. data/lib/samagotchi/session_metrics.rb +21 -4
  24. data/lib/samagotchi/system_prompt.rb +14 -1
  25. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -0
  26. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  27. data/lib/samagotchi/tool_declarations.rb +2 -1
  28. data/lib/samagotchi/turn_flow.rb +8 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +55 -8
  31. data/lib/samagotchi/web/public/app.js +73 -11
  32. data/lib/samagotchi/web/public/ctx.js +27 -2
  33. data/lib/samagotchi/web/public/data.js +6 -3
  34. data/lib/samagotchi/web/public/format.js +24 -0
  35. data/lib/samagotchi/web/public/index.html +8 -0
  36. data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
  37. data/lib/samagotchi/web/public/model_pick.js +3 -1
  38. data/lib/samagotchi/web/public/model_picker.js +2 -0
  39. data/lib/samagotchi/web/public/session_state.js +5 -0
  40. data/lib/samagotchi/web/public/timing.js +3 -1
  41. data/lib/samagotchi/web/public/turn_view.js +15 -2
  42. data/lib/samagotchi/web/session_summary.rb +2 -0
  43. data/lib/samagotchi/worker.rb +16 -4
  44. data/lib/samagotchi/worker_wakes.rb +2 -1
  45. metadata +2 -1
@@ -21,14 +21,17 @@ 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";
28
31
  import { emptySessionState } from "./session_state.js";
29
32
  import { createModelPicker } from "./model_picker.js";
30
33
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
31
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary } from "./format.js";
34
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
32
35
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
33
36
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
34
37
  import { createStageView } from "./stage_view.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";
@@ -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,11 +1162,15 @@ 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 || [];
1128
1172
  current.mutedMemories = s.muted_memory_names || [];
1173
+ current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1129
1174
  current.llmContext = s.llm_context || null;
1130
1175
  llmContextControl.refresh();
1131
1176
  current.parentId = s.parent_id || null;
@@ -1232,6 +1277,8 @@ async function readTurnEnd() {
1232
1277
  if (id !== selected) return;
1233
1278
  if (d.session) {
1234
1279
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
1280
+ // The notes chip: the first turn's build (or a /model) records them.
1281
+ if (Array.isArray(d.session.prompt_notes)) current.promptNotes = d.session.prompt_notes;
1235
1282
  if (d.session.status) current.status = d.session.status;
1236
1283
  if (!(await mergeTurnTiming(id, d.timing))) return;
1237
1284
  liveTurn.timing.finishLine(line);
@@ -1763,9 +1810,10 @@ function updateInfoBar() {
1763
1810
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1764
1811
  const idShort = selected.slice(0,8);
1765
1812
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1766
- const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1813
+ const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1767
1814
  const tokens = current.timing.tokens;
1768
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex);
1815
+ const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
1816
+ { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct });
1769
1817
  const speedTextNow = lastSpeedText(tokens);
1770
1818
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1771
1819
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1778,11 +1826,14 @@ function updateInfoBar() {
1778
1826
  const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
1779
1827
  // A parent names its delegates; the link opens all sessions.
1780
1828
  const kids = childrenSummary({ id: selected }, allSessions);
1829
+ // The model notes the session's prompt carried.
1830
+ const notes = promptNotesChip(current.promptNotes);
1831
+ const notesHtml = notes ? `<span class="model notes-chip" title="${escapeHtml(notes.title)}">${escapeHtml(notes.text)}</span>` : "";
1781
1832
  const kidsHtml = kids ? `<a class="model children-chip${kids.waitingText ? " waiting" : ""}" href="${escapeHtml(ALL_SESSIONS_HASH)}" title="${escapeHtml(`${kids.title} (all sessions)`)}">${escapeHtml(kids.text)}${kids.waitingText ? ` · <span class="kids-waiting">${escapeHtml(kids.waitingText)}</span>` : ""}</a>` : "";
1782
1833
  const attachCmd = `chi --attach ${selected}`;
1783
1834
  const copied = Date.now() - attachCopiedAt < 2000;
1784
1835
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1785
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1836
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1786
1837
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1787
1838
  // The session clock ticks every second while a turn runs: only its text
1788
1839
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
@@ -1933,7 +1984,10 @@ const streamHandlers = {
1933
1984
  }
1934
1985
  },
1935
1986
  generation_started: (data) => {
1936
- if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
1987
+ if (data?.context_window_tokens) {
1988
+ current.ctxWindow = data.context_window_tokens;
1989
+ current.ctxWindowSource = data.context_window_source || null;
1990
+ }
1937
1991
  liveTurn.timing.setNote("");
1938
1992
  turnDom.generationStarted(data || {});
1939
1993
  },
@@ -2220,7 +2274,11 @@ async function handleSendTurn() {
2220
2274
  }
2221
2275
  actionBtn.disabled = true;
2222
2276
  try {
2223
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
2277
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2278
+ llm_context: choiceWords(newChatLlmContext) });
2279
+ // The choice was for this chat only.
2280
+ newChatLlmContext = null;
2281
+ renderNewChatLlmContext();
2224
2282
  addCreatedSession(s);
2225
2283
  await select(s.id);
2226
2284
  } catch (e) {
@@ -2433,7 +2491,10 @@ const modelPicker = createModelPicker({
2433
2491
  list: $("#modelList"),
2434
2492
  composer: $("#composer"),
2435
2493
  center: $("#center"),
2436
- onPick: () => promptEl.focus(),
2494
+ onPick: () => {
2495
+ renderNewChatLlmContext();
2496
+ promptEl.focus();
2497
+ },
2437
2498
  });
2438
2499
  async function loadModels() {
2439
2500
  let payload;
@@ -2447,6 +2508,7 @@ async function loadModels() {
2447
2508
  if (!modelPicker.setModels(payload, stored)) return;
2448
2509
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2449
2510
  modelPickEl.hidden = false;
2511
+ renderNewChatLlmContext();
2450
2512
  }
2451
2513
  // The picker's choice for a create request; nothing (the server's default)
2452
2514
  // 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
 
@@ -301,3 +301,27 @@ export function memChipTitle(names) {
301
301
  const list = names || [];
302
302
  return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
303
303
  }
304
+
305
+ // "model_notes_deepseek (system, 612 chars), model_notes_x (project, 80
306
+ // chars)": the model notes the session's prompt carried (its prompt_notes);
307
+ // "" without any. As PromptNote.text (spec/shared/labels_matrix.json).
308
+ export function promptNotesText(notes) {
309
+ return (Array.isArray(notes) ? notes : [])
310
+ .filter((note) => note && typeof note.name === "string" && note.name.trim())
311
+ .map((note) => {
312
+ const details = [note.scope, Number.isFinite(note.chars) ? `${Math.trunc(note.chars)} chars` : null].filter(Boolean);
313
+ return details.length ? `${note.name.trim()} (${details.join(", ")})` : note.name.trim();
314
+ })
315
+ .join(", ");
316
+ }
317
+
318
+ // The info bar's model notes chip, for a session whose prompt carried
319
+ // some: "notes: deepseek" (the names without model_notes_), the full line
320
+ // its tooltip; null without notes.
321
+ export function promptNotesChip(notes) {
322
+ const text = promptNotesText(notes);
323
+ if (!text) return null;
324
+ const names = notes.filter((note) => note && typeof note.name === "string" && note.name.trim())
325
+ .map((note) => note.name.trim().replace(/^model_notes_/, ""));
326
+ return { text: `notes: ${names.join(", ")}`, title: `model notes in this session's prompt: ${text}` };
327
+ }
@@ -491,6 +491,13 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
491
491
  .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
492
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
493
493
  @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
494
+ /* 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). */
495
+ #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}
496
+ #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
497
+ #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
498
+ #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
499
+ /* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
500
+ @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
501
  /* The model search: opaque like #commandList (a list over the conversation
495
502
  must stay readable), above the composer, or below it (.down) when the
496
503
  start page leaves no room above, or over the composer (.over) when
@@ -1005,6 +1012,7 @@ a{color:var(--accent)}
1005
1012
  </div>
1006
1013
  <div class="actions-col">
1007
1014
  <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>
1015
+ <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1008
1016
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1009
1017
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
1010
1018
  <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
  };
@@ -14,11 +14,16 @@ export function emptySessionState() {
14
14
  // in the info bar's tooltip).
15
15
  preloadedMemories: [],
16
16
  mutedMemories: [],
17
+ // The model notes the session's prompt carried (its prompt_notes: name,
18
+ // scope, chars, digest; the info bar's notes chip).
19
+ promptNotes: [],
17
20
  // A delegated session's parent (the info bar's "delegated by" chip).
18
21
  parentId: null,
19
22
  ctxPct: null,
20
23
  // Window the kernel resolved for the running generation (:generation_started).
21
24
  ctxWindow: null,
25
+ // Where it came from ("server", "config", … "default": chi's guess).
26
+ ctxWindowSource: null,
22
27
  status: "",
23
28
  model: "",
24
29
  // What the worker's server said it served for a model name: [served, asked].
@@ -233,8 +233,10 @@ function turnGroup(g, items, timing) {
233
233
 
234
234
  // A canceled turn that ran more than one step or any call has no answer:
235
235
  // its partial text stays in the block, as it does live (turn_view.js
236
- // turnEnded promotes only a completed turn or a lone step with no tools).
236
+ // turnEnded promotes only a completed turn or a lone step with no tools). A
237
+ // failed turn never answered: its texts are steps.
237
238
  function canceledSteps(g, record, tools) {
239
+ if (record?.status === "failed") return true;
238
240
  return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
239
241
  }
240
242