samagotchi 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +25 -2
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +90 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bridge/card_store.rb +6 -7
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  17. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/engine.rb +91 -16
  20. data/lib/samagotchi/events.rb +8 -1
  21. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  22. data/lib/samagotchi/guardrails/rules.rb +27 -17
  23. data/lib/samagotchi/guardrails/targets.rb +40 -1
  24. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  25. data/lib/samagotchi/llm/errors.rb +33 -1
  26. data/lib/samagotchi/llm_context_command.rb +151 -0
  27. data/lib/samagotchi/llm_context_override.rb +180 -0
  28. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  29. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  31. data/lib/samagotchi/model_match.rb +54 -0
  32. data/lib/samagotchi/model_notes.rb +202 -0
  33. data/lib/samagotchi/parent_report.rb +12 -2
  34. data/lib/samagotchi/plugin/context.rb +3 -2
  35. data/lib/samagotchi/plugin/sessions.rb +3 -1
  36. data/lib/samagotchi/reply_wait.rb +3 -17
  37. data/lib/samagotchi/self_report.rb +15 -0
  38. data/lib/samagotchi/send_command.rb +22 -2
  39. data/lib/samagotchi/session.rb +29 -4
  40. data/lib/samagotchi/session_commands.rb +21 -1
  41. data/lib/samagotchi/session_manager.rb +5 -2
  42. data/lib/samagotchi/session_metrics.rb +26 -3
  43. data/lib/samagotchi/system_prompt.rb +66 -11
  44. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
  46. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  47. data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
  48. data/lib/samagotchi/terminal_ui.rb +16 -6
  49. data/lib/samagotchi/tool_runner.rb +26 -1
  50. data/lib/samagotchi/tools/memory.rb +70 -3
  51. data/lib/samagotchi/turn_flow.rb +36 -8
  52. data/lib/samagotchi/turn_note.rb +40 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +20 -1
  55. data/lib/samagotchi/web/public/app.js +40 -6
  56. data/lib/samagotchi/web/public/ctx.js +28 -5
  57. data/lib/samagotchi/web/public/format.js +5 -2
  58. data/lib/samagotchi/web/public/index.html +12 -0
  59. data/lib/samagotchi/web/public/live_turn.js +1 -0
  60. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  61. data/lib/samagotchi/web/public/session_state.js +3 -0
  62. data/lib/samagotchi/web/public/timing.js +5 -1
  63. data/lib/samagotchi/web/session_summary.rb +2 -0
  64. data/lib/samagotchi/worker.rb +24 -18
  65. metadata +7 -1
@@ -17,6 +17,10 @@ module Samagotchi
17
17
  OPEN = "[SYSTEM: "
18
18
  CLOSE = "]"
19
19
  FAILED = "the previous turn failed before any answer: "
20
+ # A failed turn whose work stayed (#failed_after): "failed after N tool
21
+ # steps: <why>. Its work so far … stays; …".
22
+ FAILED_AFTER = "the previous turn failed after "
23
+ KEPT_WORK = ". Its work so far (tool calls, file changes) stays; "
20
24
  RESTORED = "The message went back to the user, who may send it again."
21
25
  TASKS_LISTED = 5
22
26
  TASK_COMMAND_CHARS = 60
@@ -41,7 +45,12 @@ module Samagotchi
41
45
  # wrote nothing, and wakes pause until they do
42
46
  # @param kept [String, nil] what becomes of the wake's news, after
43
47
  # that (a delegate report's rings stay for the user's next turn)
44
- def failed(summary, restored: false, continued: false, wake: nil, kept: nil)
48
+ # @param steps [Integer, nil] the turn's work stays in the conversation
49
+ # (LLM::FailedTurn.progress): the tool results it got to. Not with
50
+ # +restored+, which took the turn out.
51
+ def failed(summary, restored: false, continued: false, wake: nil, kept: nil, steps: nil)
52
+ return failed_after(summary, steps, continued: continued, wake: wake) if steps && !restored
53
+
45
54
  tail = if restored then RESTORED
46
55
  elsif wake
47
56
  "The wake turn for #{one_line(wake)} was not answered; chi starts no other wake turn until the user writes." +
@@ -52,6 +61,36 @@ module Samagotchi
52
61
  message("#{FAILED}#{one_line(summary)}. #{tail}")
53
62
  end
54
63
 
64
+ # A turn that failed partway, its work kept: "the previous turn failed
65
+ # after 3 tool steps: <why>. Its work so far (tool calls, file changes)
66
+ # stays; …".
67
+ def failed_after(summary, steps, continued: false, wake: nil)
68
+ count = "#{steps} tool step#{"s" if steps != 1}"
69
+ tail = if wake
70
+ "the wake turn for #{one_line(wake)} stopped there; chi starts no other wake turn until the user writes."
71
+ elsif continued then "the continued turn stopped there."
72
+ else
73
+ "the user's last message is not answered yet."
74
+ end
75
+ message("#{FAILED_AFTER}#{count}: #{one_line(summary)}#{KEPT_WORK}#{tail}")
76
+ end
77
+
78
+ # The failure summary of the failed-turn note at the tail of +messages+
79
+ # (behind context notes at most), either kind (rolled back, or its work
80
+ # kept), or nil without one.
81
+ def failure_summary(messages)
82
+ list = Array(messages)
83
+ index = trailing_index(list)
84
+ return nil unless index
85
+
86
+ text = (list[index][:content] || list[index]["content"]).to_s
87
+ if text.start_with?("#{OPEN}#{FAILED}")
88
+ text["#{OPEN}#{FAILED}".length..].sub(/\. [^.]*\.\]\z/, "")
89
+ elsif text.start_with?("#{OPEN}#{FAILED_AFTER}") && text.include?(KEPT_WORK)
90
+ text[0...text.rindex(KEPT_WORK)].sub(/\A#{Regexp.escape(OPEN + FAILED_AFTER)}\d+ tool steps?: /, "")
91
+ end
92
+ end
93
+
55
94
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
56
95
  # @param seconds [Numeric, nil] how long the turn had run
57
96
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.40.0"
4
+ VERSION = "0.41.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -474,6 +474,20 @@ module Samagotchi
474
474
  nil
475
475
  end
476
476
 
477
+ # A session's LLM context strategy, apply rule and budget, each with
478
+ # where it came from (LLMContextStrategy::Explained#summary), as its
479
+ # worker would resolve them; nil when they can't be.
480
+ def llm_context_for(session)
481
+ registry = host_registry
482
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
483
+ target = registry.resolve(name)
484
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
485
+ own = session.llm_context
486
+ LLMContextStrategy.explain(target, names: names, **(own&.resolve_args || {})).summary(own)
487
+ rescue StandardError
488
+ nil
489
+ end
490
+
477
491
  # Built again when config.yml's hosts: changed (compared as read: the
478
492
  # YAML read is mtime-cached), so the model picker lists a host added
479
493
  # while chi web runs. An injected registry (specs) stays.
@@ -613,6 +627,9 @@ module Samagotchi
613
627
  if snapshot
614
628
  session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
615
629
  end
630
+ # The LLM context strategy the next turn runs under (the info bar's
631
+ # chip): the worker's, else worked out here from the session file.
632
+ session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
616
633
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
617
634
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
618
635
  json_response(200, {
@@ -1514,7 +1531,9 @@ module Samagotchi
1514
1531
  # The context last seen and the token sums, for the ctx meter
1515
1532
  # before the next turn streams (live over saved).
1516
1533
  context: source["context"],
1517
- tokens: source["tokens"]
1534
+ tokens: source["tokens"],
1535
+ # The memory indexes the session's prompt holds (the ctx tooltip).
1536
+ memory_index: source["memory_index"]
1518
1537
  }
1519
1538
  end
1520
1539
 
@@ -21,6 +21,7 @@ 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
25
  import { renderStable } from "./stable_html.js";
25
26
  import { createShownTexts } from "./shown_texts.js";
26
27
  import { MODEL_KEY } from "./model_pick.js";
@@ -75,6 +76,16 @@ const contextChips = createContextChips({
75
76
  escapeHtml,
76
77
  toast: (text) => showToast(text),
77
78
  });
79
+ // The open session's LLM context chip in the info bar, and its popover:
80
+ // /llm-context runs in the session's worker.
81
+ const llmContextControl = createLlmContextControl({
82
+ el: infoTextEl,
83
+ escapeHtml,
84
+ summary: () => (selected ? current.llmContext : null),
85
+ session: () => selected,
86
+ running: () => liveTurn.running,
87
+ run: (line) => runSessionCommand(line),
88
+ });
78
89
  const markdownWarningEl = $("#markdownWarning");
79
90
  // What the page renders that a notice may stand in for (the server's
80
91
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -353,7 +364,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
353
364
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
354
365
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
355
366
  const ctx = cardCtxText(s.ctx_pct);
356
- const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
367
+ const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens, undefined, s.memory_index))}">${escapeHtml(ctx)}</span>` : "";
357
368
  const waiting = waitingBadge(s);
358
369
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
359
370
  // A turn a hook stopped wears its own badge, unless the session waits on
@@ -1115,6 +1126,8 @@ function renderSessionView(data) {
1115
1126
  current.usedMemories = s.used_memory_names || [];
1116
1127
  current.preloadedMemories = s.preloaded_memory_names || [];
1117
1128
  current.mutedMemories = s.muted_memory_names || [];
1129
+ current.llmContext = s.llm_context || null;
1130
+ llmContextControl.refresh();
1118
1131
  current.parentId = s.parent_id || null;
1119
1132
  current.commands = Array.isArray(data.commands) ? data.commands : [];
1120
1133
  if (data.messages && data.messages.length) {
@@ -1752,7 +1765,7 @@ function updateInfoBar() {
1752
1765
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1753
1766
  const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1754
1767
  const tokens = current.timing.tokens;
1755
- const ctxTip = tokensTipText(tokens);
1768
+ const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex);
1756
1769
  const speedTextNow = lastSpeedText(tokens);
1757
1770
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1758
1771
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1769,7 +1782,7 @@ function updateInfoBar() {
1769
1782
  const attachCmd = `chi --attach ${selected}`;
1770
1783
  const copied = Date.now() - attachCopiedAt < 2000;
1771
1784
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1772
- 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}${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>`;
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>`;
1773
1786
  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>`;
1774
1787
  // The session clock ticks every second while a turn runs: only its text
1775
1788
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
@@ -2006,10 +2019,13 @@ const streamHandlers = {
2006
2019
  },
2007
2020
  // The turn raised (e.g. the model server stayed unreachable). The worker
2008
2021
  // stays up: it rolls the turn back and hands the prompt back
2009
- // (prompt_restored), and the prompts queued behind it run as usual.
2022
+ // (prompt_restored), and the prompts queued behind it run as usual. A turn
2023
+ // that got to tool steps stays as it is (kept_steps; no prompt_restored).
2010
2024
  turn_failed: (data) => {
2011
- liveTurn.failedText = failedTurnText(data);
2012
- endTurnView("failed", liveTurn.failedText);
2025
+ const text = failedTurnText(data);
2026
+ // Only a rolled-back turn's line waits for its prompt_restored.
2027
+ liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
2028
+ endTurnView("failed", text);
2013
2029
  setLiveStatus("idle");
2014
2030
  },
2015
2031
  // The rolled-back turn left the session: render it from the server again,
@@ -2047,6 +2063,12 @@ const streamHandlers = {
2047
2063
  current.served = null;
2048
2064
  updateInfoBar();
2049
2065
  }
2066
+ // /llm-context changed what the next turn runs under.
2067
+ if (data.llm_context) {
2068
+ current.llmContext = data.llm_context;
2069
+ updateInfoBar();
2070
+ llmContextControl.refresh();
2071
+ }
2050
2072
  if (view.resync) await resync();
2051
2073
  // A card's action that isn't anytime (the step-limit question's
2052
2074
  // /continue) has no bubble: nothing to remove.
@@ -2320,6 +2342,18 @@ async function handleSendCommand(line) {
2320
2342
  }
2321
2343
  }
2322
2344
 
2345
+ // A session command from a control (the llm ctx chip), not the composer:
2346
+ // its command_ran shows its line and reply, as a typed one's would.
2347
+ async function runSessionCommand(line) {
2348
+ if (!selected) return;
2349
+ try {
2350
+ await sendCommand(selected, line, { clientId });
2351
+ followWokenWorker();
2352
+ } catch (e) {
2353
+ addCommandBubble({ label: null, line, text: e.message, failed: true });
2354
+ }
2355
+ }
2356
+
2323
2357
  function addCommandBubble(view) {
2324
2358
  removeHintIfEmpty();
2325
2359
  const div = document.createElement("div");
@@ -71,14 +71,37 @@ function count(n) {
71
71
  return Math.round(n).toLocaleString("en-US");
72
72
  }
73
73
 
74
+ // A token count as the size readouts word it: "450", "2.0k". As
75
+ // MemoryBundle::IndexSize.count_text.
76
+ function countK(n) {
77
+ return n >= 1000 ? `${(Math.round(n / 100) / 10).toFixed(1)}k` : String(Math.round(n));
78
+ }
79
+
80
+ // "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)": the
81
+ // memory indexes the session's prompt holds (the snapshot's memory_index,
82
+ // measured when the prompt was built); "" without one. As
83
+ // Formatting#memory_index_text (spec/shared/labels_matrix.json).
84
+ export function memoryIndexText(memoryIndex) {
85
+ if (!memoryIndex || typeof memoryIndex !== "object") return "";
86
+ const scopes = ["system", "project"]
87
+ .map((scope) => [scope, num(memoryIndex[scope]?.tokens)])
88
+ .filter(([, tokens]) => tokens !== null);
89
+ if (!scopes.length) return "";
90
+ const total = scopes.reduce((sum, [, tokens]) => sum + tokens, 0);
91
+ return `~${countK(total)} tokens in this session's prompt (${scopes.map(([scope, tokens]) => `${scope} ${countK(tokens)}`).join(", ")})`;
92
+ }
93
+
74
94
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
75
95
  // (in summed over every request, cached with its share, out with the
76
- // reasoning part) and its cost, when it has any. A delegated session keeps
77
- // its own metrics, so these are this session's only.
78
- export function tokensTipText(tokens, title = "Context used after the last turn") {
96
+ // reasoning part) and its cost, when it has any, and the memory indexes
97
+ // its prompt holds (+memoryIndex+, the snapshot's memory_index). A
98
+ // 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) {
100
+ const memory = memoryIndexText(memoryIndex);
101
+ const memoryLine = memory ? `\nmemory index: ${memory}` : "";
79
102
  const prompt = num(tokens?.prompt_sum) || 0;
80
103
  const completion = num(tokens?.completion_sum) || 0;
81
- if (!prompt && !completion) return title;
104
+ if (!prompt && !completion) return `${title}${memoryLine}`;
82
105
  const cached = num(tokens.cached_sum) || 0;
83
106
  const reasoning = num(tokens.reasoning_sum) || 0;
84
107
  const parts = [`in ${count(prompt)}`];
@@ -88,7 +111,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
88
111
  const cost = costText(tokens.cost_sum);
89
112
  if (cost) lines.push(`cost: ${cost}`);
90
113
  lines.push("this session only, all requests");
91
- return lines.join("\n");
114
+ return `${lines.join("\n")}${memoryLine}`;
92
115
  }
93
116
 
94
117
  // The info bar's speed: the newest generation's decode speed, "" before one.
@@ -234,11 +234,14 @@ export function recapPlace(userBubbles, turnsSince) {
234
234
  }
235
235
 
236
236
  // The line a failed turn's bubble shows: a provider error's one-line
237
- // summary, else the error's message and class.
237
+ // summary, else the error's message and class. A turn whose work stayed
238
+ // (turn_failed's kept_steps; no prompt_restored follows) says so, as the
239
+ // terminal's rollback hint does.
238
240
  export function failedTurnText(data) {
239
241
  data = data || {};
240
242
  const detail = data.summary || `${data.message || "error"}${data.error_class ? ` (${data.error_class})` : ""}`;
241
- return `\u2715 turn failed: ${detail}`;
243
+ const kept = Number.isInteger(data.kept_steps) ? "; partial progress kept (!rollback restores the pre-turn state)" : "";
244
+ return `\u2715 turn failed: ${detail}${kept}`;
242
245
  }
243
246
 
244
247
  // Whether the server served another model than asked: names equal in any
@@ -684,6 +684,18 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
684
684
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
685
685
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
686
686
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
687
+ /* The LLM context chip (llm_context_chip.js): a model-like chip that opens the strategy popover; accent when the session set its own. */
688
+ #infoBar .meta .llmctx-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
689
+ #infoBar .meta .llmctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
690
+ #infoBar .meta .llmctx-chip.own{color:var(--accent);border-color:var(--accent-border)}
691
+ .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
692
+ .llmctx-form .ctx-pop-head strong{font-family:inherit}
693
+ .llmctx-field{display:flex;align-items:center;gap:8px}
694
+ .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
695
+ .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
696
+ .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
697
+ .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
698
+ .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
687
699
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
688
700
  #contextChips{order:5;flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
689
701
  #contextChips[hidden]{display:none}
@@ -38,6 +38,7 @@ export function createLiveTurn(timingDeps) {
38
38
  this.timing.drop();
39
39
  this.unmatchedMerge = false;
40
40
  this.wakeStart = false;
41
+ this.failedText = null;
41
42
  this.releaseDisplay();
42
43
  },
43
44
  };
@@ -0,0 +1,194 @@
1
+ // The session bar's LLM context chip: the strategy the next turn runs under
2
+ // ("llm ctx stale,forget"; "· session" when the session set its own), and a
3
+ // popover that sets the session's own strategy, apply rule and budget by
4
+ // running /llm-context in its worker (the command's reply shows in the
5
+ // conversation, like a typed one). The values and where each came from are
6
+ // the session's llm_context (LLMContextStrategy::Explained#summary: the
7
+ // worker's snapshot, a command_ran, or the server's own reading of the
8
+ // session file).
9
+
10
+ export const STRATEGIES = [
11
+ { value: "none", label: "none" },
12
+ { value: "stale", label: "stale" },
13
+ { value: "stale,forget", label: "stale + forget (experimental)" },
14
+ ];
15
+ export const APPLIES = ["payoff", "next_request", "turn_end"];
16
+
17
+ const DEFAULT = "default";
18
+ const LAYER_ORDER = ["stale", "forget"];
19
+
20
+ // Layers as one value, in chi's order (stale before forget); unknown ones after.
21
+ export function strategyValue(layers) {
22
+ if (!layers.length) return "none";
23
+ const rank = (name) => { const at = LAYER_ORDER.indexOf(name); return at < 0 ? LAYER_ORDER.length : at; };
24
+ return [...layers].sort((a, b) => rank(a) - rank(b)).join(",");
25
+ }
26
+
27
+ // The session's own values as the form shows them: a strategy value, an
28
+ // apply rule and a budget word ("64000", "off"); "default" / "" for unset.
29
+ export function ownValues(summary) {
30
+ const own = summary?.own || {};
31
+ const strategy = Array.isArray(own.strategy) ? strategyValue(own.strategy) : DEFAULT;
32
+ const budget = typeof own.budget_tokens === "number" ? (own.budget_tokens > 0 ? String(own.budget_tokens) : "off") : "";
33
+ return { strategy, apply: own.apply || DEFAULT, budget };
34
+ }
35
+
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) {
39
+ if (!summary || !summary.strategy) return null;
40
+ const own = summary.strategy_source === "session" || !!summary.own;
41
+ const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
42
+ 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 };
45
+ }
46
+
47
+ export function chipHtml(summary, escapeHtml) {
48
+ const chip = chipModel(summary);
49
+ if (!chip) return "";
50
+ return `<button type="button" class="model llmctx-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
51
+ `aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
52
+ }
53
+
54
+ // The /llm-context line for the form's values. With +initial+ (the values
55
+ // the form opened with) only the fields the user changed go, so a value the
56
+ // form can't show is never unset by a Set; null when nothing changed.
57
+ // Without it every field goes, "default" for one left to the model, and all
58
+ // of them default is a reset.
59
+ export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
60
+ const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
61
+ const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
62
+ const fields = Object.keys(words).filter((field) => !was || words[field] !== was[field]);
63
+ if (!fields.length) return null;
64
+ if (!was && fields.every((field) => words[field] === DEFAULT)) return "/llm-context reset";
65
+ return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
66
+ }
67
+
68
+ // The strategy list, with the session's own value when it isn't one of them
69
+ // (forget alone, a newer chi's layer).
70
+ export function strategyOptions(chosen) {
71
+ if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
72
+ return [...STRATEGIES, { value: chosen, label: chosen }];
73
+ }
74
+
75
+ function option(value, label, chosen, escapeHtml) {
76
+ return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
77
+ }
78
+
79
+ // The popover's form: the values now (each with its source), and the
80
+ // session's own to change.
81
+ export function nowText(summary) {
82
+ const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
83
+ return `Now: ${summary.strategy} (${summary.strategy_where}) · apply ${summary.apply} (${summary.apply_where}) · ` +
84
+ `budget ${budget} (${summary.budget_where})`;
85
+ }
86
+
87
+ export function formHtml(summary, escapeHtml, { running = false } = {}) {
88
+ const own = ownValues(summary);
89
+ const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
90
+ return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
91
+ `<span class="ctx-pop-kind">this session</span>` +
92
+ `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
93
+ `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
94
+ `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
95
+ option(DEFAULT, follow("strategy"), own.strategy, escapeHtml) +
96
+ strategyOptions(own.strategy).map((s) => option(s.value, s.label, own.strategy, escapeHtml)).join("") + `</select></label>` +
97
+ `<label class="llmctx-field"><span>Apply</span><select name="apply">` +
98
+ option(DEFAULT, follow("apply"), own.apply, escapeHtml) +
99
+ APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
100
+ `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
101
+ `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>` +
104
+ `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
105
+ `<button type="submit" class="llmctx-set"${running ? " disabled" : ""}>Set</button></div></form>`;
106
+ }
107
+
108
+ // The chip's popover. el: where the chip is drawn (#infoText, redrawn every
109
+ // tick: clicks are delegated); summary(): the open session's llm_context;
110
+ // session(): the open session's id; running(): a turn runs; run(line):
111
+ // sends the command.
112
+ export function createLlmContextControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
113
+ doc = document, win = window }) {
114
+ const pop = doc.createElement("div");
115
+ pop.id = "llmContextPopover";
116
+ pop.className = "ctx-popover llmctx-popover";
117
+ pop.setAttribute("role", "dialog");
118
+ pop.hidden = true;
119
+ doc.body.appendChild(pop);
120
+ // The values the form opened with, and the session it is for.
121
+ let initial = null;
122
+ let openFor = null;
123
+
124
+ function place(anchor) {
125
+ const rect = anchor.getBoundingClientRect();
126
+ const width = Math.min(360, win.innerWidth - 32);
127
+ 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`;
130
+ }
131
+
132
+ function open(anchor) {
133
+ const data = summary();
134
+ if (!data) return;
135
+ initial = ownValues(data);
136
+ openFor = session();
137
+ pop.innerHTML = formHtml(data, escapeHtml, { running: running() });
138
+ pop.hidden = false;
139
+ place(anchor);
140
+ }
141
+
142
+ function close() {
143
+ pop.hidden = true;
144
+ initial = null;
145
+ openFor = null;
146
+ }
147
+
148
+ function isOpen() { return !pop.hidden; }
149
+
150
+ // A new summary (a command_ran, a re-read): an open form keeps what the
151
+ // user typed; only its "Now:" line and Set's state follow. Another
152
+ // session closes it.
153
+ function refresh() {
154
+ if (!isOpen()) return;
155
+ const data = summary();
156
+ if (!data || session() !== openFor) return close();
157
+ const now = pop.querySelector(".llmctx-now");
158
+ if (now) now.textContent = nowText(data);
159
+ const set = pop.querySelector(".llmctx-set");
160
+ if (set) set.disabled = running();
161
+ }
162
+
163
+ el.addEventListener("click", (e) => {
164
+ const anchor = e.target.closest(".llmctx-chip");
165
+ if (!anchor) return;
166
+ e.stopPropagation();
167
+ if (isOpen()) close();
168
+ else open(anchor);
169
+ });
170
+ pop.addEventListener("submit", (e) => {
171
+ e.preventDefault();
172
+ 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);
175
+ close();
176
+ if (line) run(line);
177
+ });
178
+ pop.addEventListener("click", (e) => {
179
+ const act = e.target.closest("[data-act]")?.dataset.act;
180
+ if (act === "close") close();
181
+ else if (act === "reset") {
182
+ close();
183
+ run(commandLine());
184
+ }
185
+ });
186
+ doc.addEventListener("click", (e) => {
187
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(".llmctx-chip")) close();
188
+ });
189
+ doc.addEventListener("keydown", (e) => {
190
+ if (e.key === "Escape" && isOpen()) close();
191
+ });
192
+
193
+ return { close, refresh, get open() { return isOpen(); } };
194
+ }
@@ -33,6 +33,9 @@ export function emptySessionState() {
33
33
  // worker_version / worker_features): the info bar's stale-worker badge.
34
34
  workerVersion: null,
35
35
  workerFeatures: [],
36
+ // The LLM context strategy, apply rule and budget the next turn runs
37
+ // under, each with its source (the info bar's llm ctx chip), or null.
38
+ llmContext: null,
36
39
  timing: normalizeTiming(),
37
40
  };
38
41
  }
@@ -61,6 +61,9 @@ export function normalizeTiming(data = {}) {
61
61
  activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
62
62
  // The session's token sums and speeds (SessionMetrics' tokens block).
63
63
  tokens: validRecord(data.tokens) ? data.tokens : null,
64
+ // The memory indexes the session's prompt holds (SessionMetrics'
65
+ // memory_index), for the ctx tooltip.
66
+ memoryIndex: validRecord(data.memory_index) ? data.memory_index : null,
64
67
  };
65
68
  }
66
69
 
@@ -96,7 +99,8 @@ export function mergeTiming(timing, data = {}) {
96
99
  if (!replaceById(toolRecords, record)) toolRecords.push(record);
97
100
  });
98
101
  const tokens = fresh.tokens || timing?.tokens || null;
99
- return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
102
+ const memoryIndex = fresh.memoryIndex || timing?.memoryIndex || null;
103
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex }, short: turnRecords.length < count };
100
104
  }
101
105
 
102
106
  function replaceById(records, record) {
@@ -62,6 +62,8 @@ module Samagotchi
62
62
  # one read of analytics.json.
63
63
  ctx_pct: saved&.ctx_pct&.round(1),
64
64
  tokens: saved&.tokens,
65
+ # The memory indexes its prompt held (the card's ctx tooltip).
66
+ memory_index: saved&.memory_index,
65
67
  # Hidden from the strip and the list unless "include archived".
66
68
  archived: ArchiveStore.archived?(session_dir),
67
69
  project_root: session.project_root(cache: root_cache),