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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +25 -2
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +90 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -7
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +91 -16
- data/lib/samagotchi/events.rb +8 -1
- data/lib/samagotchi/guardrails/model_size.rb +3 -4
- data/lib/samagotchi/guardrails/rules.rb +27 -17
- data/lib/samagotchi/guardrails/targets.rb +40 -1
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/llm/errors.rb +33 -1
- data/lib/samagotchi/llm_context_command.rb +151 -0
- data/lib/samagotchi/llm_context_override.rb +180 -0
- data/lib/samagotchi/llm_context_strategy.rb +63 -13
- data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
- data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
- data/lib/samagotchi/model_match.rb +54 -0
- data/lib/samagotchi/model_notes.rb +202 -0
- data/lib/samagotchi/parent_report.rb +12 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +15 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +29 -4
- data/lib/samagotchi/session_commands.rb +21 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +26 -3
- data/lib/samagotchi/system_prompt.rb +66 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
- data/lib/samagotchi/terminal_ui.rb +16 -6
- data/lib/samagotchi/tool_runner.rb +26 -1
- data/lib/samagotchi/tools/memory.rb +70 -3
- data/lib/samagotchi/turn_flow.rb +36 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -1
- data/lib/samagotchi/web/public/app.js +40 -6
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +5 -2
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/live_turn.js +1 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- data/lib/samagotchi/worker.rb +24 -18
- metadata +7 -1
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -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
|
-
|
|
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]`
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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 "${escapeHtml(attachCmd)}" 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
|
-
|
|
2012
|
-
|
|
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
|
|
77
|
-
// its
|
|
78
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -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
|
-
|
|
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),
|