samagotchi 0.53.0 → 0.55.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 +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// choice for the next create only (POST /api/sessions' llm_context), never
|
|
13
13
|
// remembered: nothing is sent unless the user sets one.
|
|
14
14
|
|
|
15
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
16
|
+
|
|
15
17
|
export const STRATEGIES = [
|
|
16
18
|
{ value: "none", label: "none" },
|
|
17
19
|
{ value: "stale", label: "stale" },
|
|
@@ -70,11 +72,12 @@ export function chipModel(summary, { kind = SESSION } = {}) {
|
|
|
70
72
|
return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
// The session bar's chip (a lit-html template; info_bar.js), nothing
|
|
76
|
+
// without a summary.
|
|
77
|
+
export function chipTemplate(summary) {
|
|
74
78
|
const chip = chipModel(summary);
|
|
75
|
-
if (!chip) return
|
|
76
|
-
return `<button type="button" class
|
|
77
|
-
`aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
|
|
79
|
+
if (!chip) return nothing;
|
|
80
|
+
return html`<button type="button" class=${`model llmctx-chip${chip.own ? " own" : ""}`} title=${chip.title} aria-haspopup="dialog">${chip.text}</button>`;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// The /llm-context line for the form's values. With +initial+ (the values
|
|
@@ -201,8 +204,8 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
|
201
204
|
return height > room ? { ...place, maxHeight: room } : place;
|
|
202
205
|
}
|
|
203
206
|
|
|
204
|
-
// The chip's popover. el: where the chip is drawn (#infoText,
|
|
205
|
-
//
|
|
207
|
+
// The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
|
|
208
|
+
// clicks are delegated); summary(): the open session's llm_context;
|
|
206
209
|
// session(): the open session's id; running(): a turn runs; run(line):
|
|
207
210
|
// sends the command.
|
|
208
211
|
// The start page's (kind NEW_CHAT): summary() is the picked model's,
|
|
@@ -145,7 +145,9 @@ export function truncate(text, n = 80) {
|
|
|
145
145
|
// asks its tool and command: "execute: rm -rf tmp → Denied").
|
|
146
146
|
export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
147
147
|
if (cancelled || answer) {
|
|
148
|
-
|
|
148
|
+
// A deny's typed reason stays out of the one-liner (the body line has it).
|
|
149
|
+
const result = !cancelled && deniedReason(pq, answer) ? "Denied"
|
|
150
|
+
: resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
|
|
149
151
|
const relay = relayView(pq);
|
|
150
152
|
const asked = summaryAsked(pq);
|
|
151
153
|
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
@@ -165,6 +167,12 @@ function summaryAsked(pq) {
|
|
|
165
167
|
return String(pq.question || pq.header || "").split("\n")[0];
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// The reason typed with an approval's deny ("" when none, or not a deny).
|
|
171
|
+
export function deniedReason(pq, answer) {
|
|
172
|
+
if (approvalAllowed(pq, answer) !== false) return "";
|
|
173
|
+
return answer && answer.freeform ? String(answer.freeform) : "";
|
|
174
|
+
}
|
|
175
|
+
|
|
168
176
|
// Whether the answer allowed the call (the card turns green) or denied it.
|
|
169
177
|
export function approvalAllowed(pq, answer) {
|
|
170
178
|
if (!isApproval(pq)) return null;
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
17
|
import {
|
|
18
|
-
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
18
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, nextCardAction,
|
|
19
19
|
relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
22
|
-
import { clientLabel,
|
|
22
|
+
import { clientLabel, isOwn } from "./turn_events.js";
|
|
23
|
+
import { continueLine } from "./commands.js";
|
|
23
24
|
|
|
24
25
|
// @param sessionId () => the open session's id (answers go there)
|
|
25
26
|
// @param clientId this tab's id in the session's events
|
|
@@ -339,8 +340,10 @@ export function createQuestionCards({
|
|
|
339
340
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
340
341
|
// 4.02: a reopened card's summary already carries the result (the
|
|
341
342
|
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
342
|
-
// stays for a hand-toggled card, whose expanded body the user looks at
|
|
343
|
-
|
|
343
|
+
// stays for a hand-toggled card, whose expanded body the user looks at,
|
|
344
|
+
// and for a deny with a reason: the summary says only "Denied".
|
|
345
|
+
const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
|
|
346
|
+
if (summary && !keepNote) note.classList.add("hidden");
|
|
344
347
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
345
348
|
pending = null;
|
|
346
349
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Refs: quiet links from what a turn touched to the thing itself. A ref is
|
|
2
|
+
// a kind and a target: today a file tool's row title (the server's FileRef,
|
|
3
|
+
// {kind: "file", path, line?, end_line?}) and that row's diff lines (the
|
|
4
|
+
// same file at a line). The DOM carries data-ref-kind and the target's
|
|
5
|
+
// attributes, never what a click does: that is an action, kept per kind in
|
|
6
|
+
// REF_ACTIONS, and a click runs the first one available to this viewer. A
|
|
7
|
+
// ref with none (a phone: no editor) stays plain text; the page marks the
|
|
8
|
+
// kinds that have one on its body (refs-<kind>), which the hover styling
|
|
9
|
+
// keys on.
|
|
10
|
+
|
|
11
|
+
import { escapeHtml } from "./format.js";
|
|
12
|
+
|
|
13
|
+
// The data attributes a ref's element carries.
|
|
14
|
+
export function refAttrs(ref) {
|
|
15
|
+
if (!ref?.kind) return "";
|
|
16
|
+
const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
|
|
17
|
+
return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
|
|
21
|
+
// (1 when none) with: each path segment encoded, the slashes kept. "" with
|
|
22
|
+
// no template or path.
|
|
23
|
+
export function editorUrl(template, path, line) {
|
|
24
|
+
if (!template || !path) return "";
|
|
25
|
+
const encoded = String(path).split("/").map(encodeURIComponent).join("/");
|
|
26
|
+
return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What a ref can do, per kind, in order: the first available one is a
|
|
30
|
+
// click's. +viewer+ is what this page's viewer can do ({editor, editorUrl}:
|
|
31
|
+
// the server's capabilities.editor and editor_url).
|
|
32
|
+
export const REF_ACTIONS = Object.freeze({
|
|
33
|
+
file: Object.freeze([
|
|
34
|
+
Object.freeze({
|
|
35
|
+
id: "editor",
|
|
36
|
+
available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
|
|
37
|
+
url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
|
|
38
|
+
// The hover hint: it opens the file as it is now.
|
|
39
|
+
hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
|
|
40
|
+
}),
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The first action +viewer+ has for a ref of +kind+, else null.
|
|
45
|
+
export function refAction(kind, viewer, actions = REF_ACTIONS) {
|
|
46
|
+
return (actions[kind] || []).find((action) => action.available(viewer)) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The kinds that have an action for +viewer+ (the page's refs-<kind> classes).
|
|
50
|
+
export function availableKinds(viewer, actions = REF_ACTIONS) {
|
|
51
|
+
return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What a click on +el+ points at: {kind, path, line, owner} or null. A row
|
|
55
|
+
// title opens its file at the lines the call named (an edit without any at
|
|
56
|
+
// its first changed line); a diff line (or a hunk header) in a row with a
|
|
57
|
+
// ref opens the row's file at that line. A diff line outside a row (an
|
|
58
|
+
// approval card's dry run) is none.
|
|
59
|
+
export function refTarget(el) {
|
|
60
|
+
const title = el?.closest?.("[data-ref-kind]");
|
|
61
|
+
if (title) {
|
|
62
|
+
const owner = title.closest(".activity-row");
|
|
63
|
+
return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
|
|
64
|
+
}
|
|
65
|
+
const diffLine = el?.closest?.(".diff-line[data-line]");
|
|
66
|
+
const owner = diffLine?.closest(".activity-row");
|
|
67
|
+
const ref = owner?.querySelector("[data-ref-kind]");
|
|
68
|
+
if (!ref) return null;
|
|
69
|
+
return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function targetOf(el) {
|
|
73
|
+
return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// An edit/write without a line range opens at its first changed line (its
|
|
77
|
+
// diff knows it, the call doesn't); null without a diff.
|
|
78
|
+
function firstChange(row) {
|
|
79
|
+
const line = row?.querySelector(".diff-line.diff-add[data-line], .diff-line.diff-del[data-line]");
|
|
80
|
+
return line ? Number(line.dataset.line) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A drag that selected text in the clicked ref's row isn't a click on the
|
|
84
|
+
// ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
|
|
85
|
+
function selectingIn(owner, selection) {
|
|
86
|
+
if (!selection || selection.isCollapsed || !owner) return false;
|
|
87
|
+
return [selection.anchorNode, selection.focusNode].some((node) => node && owner.contains(node));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function defaultOpen(url) {
|
|
91
|
+
// The e2e seam (spec/e2e): a spec defines it before the page loads.
|
|
92
|
+
if (typeof window.__chiRefOpen === "function") window.__chiRefOpen(url);
|
|
93
|
+
else window.location.href = url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One delegated listener on +root+ for every ref on the page. +viewer+()
|
|
97
|
+
// is read per click and hover, so a capabilities change (another session,
|
|
98
|
+
// a reload) is seen; +open+(url) opens an action's URL.
|
|
99
|
+
export function installRefClicks(root, viewer, { open = defaultOpen, selection = () => window.getSelection?.() } = {}) {
|
|
100
|
+
root.addEventListener("click", (e) => {
|
|
101
|
+
if (e.button !== 0 || e.defaultPrevented) return;
|
|
102
|
+
const target = refTarget(e.target);
|
|
103
|
+
if (!target) return;
|
|
104
|
+
const action = refAction(target.kind, viewer());
|
|
105
|
+
if (!action) return;
|
|
106
|
+
if (selectingIn(target.owner, selection())) return;
|
|
107
|
+
const url = action.url(target, viewer());
|
|
108
|
+
if (!url) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
open(url);
|
|
112
|
+
});
|
|
113
|
+
// The hover hint, on the ref's own element, worked out on each hover
|
|
114
|
+
// (the viewer can change): its own title (a row title's hover) first,
|
|
115
|
+
// then the action's hint when there is one.
|
|
116
|
+
root.addEventListener("mouseover", (e) => {
|
|
117
|
+
const target = refTarget(e.target);
|
|
118
|
+
if (!target) return;
|
|
119
|
+
const el = e.target.closest("[data-ref-kind]") || e.target.closest(".diff-line[data-line]");
|
|
120
|
+
if (!el) return;
|
|
121
|
+
if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
|
|
122
|
+
const own = el.dataset.refTitle;
|
|
123
|
+
const action = refAction(target.kind, viewer());
|
|
124
|
+
const title = [own, action ? action.hint(target) : ""].filter(Boolean).join("\n");
|
|
125
|
+
if (title) el.setAttribute("title", title);
|
|
126
|
+
else el.removeAttribute("title");
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The body's classes for +viewer+: refs-<kind> for each kind with an action.
|
|
131
|
+
export function setRefKinds(body, viewer, actions = REF_ACTIONS) {
|
|
132
|
+
const kinds = availableKinds(viewer, actions);
|
|
133
|
+
for (const kind of Object.keys(actions)) body.classList.toggle(`refs-${kind}`, kinds.includes(kind));
|
|
134
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pure helpers for a failed turn's prompt handed back (prompt_restored):
|
|
2
|
+
// what goes back into this tab's composer, and when (a restore that comes
|
|
3
|
+
// before its /turn ack waits for it). Split out of turn_events.js.
|
|
4
|
+
|
|
5
|
+
import { clientLabel, isOwn, withImages } from "./turn_events.js";
|
|
6
|
+
|
|
7
|
+
// What a prompt_restored (the worker rolled a failed turn back and handed its
|
|
8
|
+
// prompt back) does in this tab: `refill` is the text for an empty composer,
|
|
9
|
+
// only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
|
|
10
|
+
// replay after a reload never refills stale text; `own`/`label` mark the
|
|
11
|
+
// failed bubble shown again after the resync.
|
|
12
|
+
export function restoreAction(event, { myId, sentIds, composerEmpty }) {
|
|
13
|
+
const origin = event?.origin || {};
|
|
14
|
+
const own = isOwn(origin.client_id, myId);
|
|
15
|
+
const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
|
|
16
|
+
// The images go back as chips with the text.
|
|
17
|
+
return withImages({
|
|
18
|
+
refill: sent && composerEmpty ? event.prompt : null,
|
|
19
|
+
own,
|
|
20
|
+
label: own ? null : clientLabel(origin.client_id),
|
|
21
|
+
}, sent && composerEmpty ? event.images : null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A turn that fails fast can have its prompt_restored come over the stream
|
|
25
|
+
// before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
|
|
26
|
+
// restore of this tab's own prompt by that id, and the ack takes it back
|
|
27
|
+
// (restoreOnAck), so the prompt goes into the composer the ack would clear.
|
|
28
|
+
export function keepEarlyRestore(event, early, { myId, sentIds }) {
|
|
29
|
+
const origin = event?.origin || {};
|
|
30
|
+
const id = origin.enqueued_id;
|
|
31
|
+
if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
|
|
32
|
+
early.set(id, event);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The ack's enqueued_id (already in sentIds) was restored early: what the
|
|
37
|
+
// composer gets back (as restoreAction for an empty composer), else null.
|
|
38
|
+
export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
|
|
39
|
+
const event = enqueuedId && early.get(enqueuedId);
|
|
40
|
+
if (!event) return null;
|
|
41
|
+
early.delete(enqueuedId);
|
|
42
|
+
return restoreAction(event, { myId, sentIds, composerEmpty: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The /turn this kept entry was for failed: no ack comes, so nothing would
|
|
46
|
+
// take it back. The whole map goes: this tab sends one turn at a time, so
|
|
47
|
+
// every entry waits for an ack that is now never coming (an older one is
|
|
48
|
+
// left over from a send that failed the same way).
|
|
49
|
+
export function dropEarlyRestores(early) {
|
|
50
|
+
const count = early.size;
|
|
51
|
+
early.clear();
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The composer's text with a restored prompt put back into it: the restored
|
|
56
|
+
// text alone when nothing is typed, else the typed text with the restored
|
|
57
|
+
// prompt under it (the user typed between the restore and the ack; their
|
|
58
|
+
// text must not be replaced).
|
|
59
|
+
export function restoreInto(refill, current) {
|
|
60
|
+
const restored = String(refill ?? "");
|
|
61
|
+
const typed = String(current ?? "");
|
|
62
|
+
if (!restored) return typed;
|
|
63
|
+
if (!typed.trim()) return restored;
|
|
64
|
+
return `${typed.replace(/\n+$/, "")}\n${restored}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What the composer holds once a /turn ack lands (4.13): the composer still
|
|
68
|
+
// carries the text that was just sent, so that text goes (it is the history's
|
|
69
|
+
// now) — but text typed since the send stays, and a prompt restored before
|
|
70
|
+
// this ack (restoreOnAck's refill) is put back without replacing it. The
|
|
71
|
+
// sent text is trimmed (turnText), so the composer's is too before they are
|
|
72
|
+
// compared: a trailing newline or space must not keep the message; and an
|
|
73
|
+
// image-only send (placeholders sent) leaves nothing worth keeping.
|
|
74
|
+
// @param sent the text the turn was sent with
|
|
75
|
+
// @param current the composer's text as the ack lands
|
|
76
|
+
// @param refill the prompt an early restore handed back, if any
|
|
77
|
+
export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
|
|
78
|
+
const typed = String(current ?? "").trim();
|
|
79
|
+
const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
|
|
80
|
+
return restoreInto(refill, kept);
|
|
81
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// the previous session set, its "delegated by" parent, its commands, never
|
|
5
5
|
// shows for the next one).
|
|
6
6
|
|
|
7
|
+
import { savedCtxPct } from "./ctx.js";
|
|
8
|
+
import { firstPreview, previewForCard } from "./format.js";
|
|
7
9
|
import { normalizeTiming } from "./timing.js";
|
|
8
10
|
|
|
9
11
|
export function emptySessionState() {
|
|
@@ -46,3 +48,53 @@ export function emptySessionState() {
|
|
|
46
48
|
timing: normalizeTiming(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
51
|
+
|
|
52
|
+
// The fields a session's payload (GET /sessions/:id) sets, read from it
|
|
53
|
+
// alone. ctxPct is left out when the payload has no saved fill, ctxWindow
|
|
54
|
+
// and ctxWindowSource when it has no window: the page keeps what it had
|
|
55
|
+
// (a running turn's replay streams over it).
|
|
56
|
+
export function sessionStateFrom(data) {
|
|
57
|
+
const s = data.session;
|
|
58
|
+
const state = {
|
|
59
|
+
status: s.status,
|
|
60
|
+
model: s.model_name,
|
|
61
|
+
served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
|
|
62
|
+
dir: s.working_directory,
|
|
63
|
+
owner: s.owner || null,
|
|
64
|
+
timing: normalizeTiming(data.timing),
|
|
65
|
+
};
|
|
66
|
+
// The saved fill, so a reloaded or stopped session shows it before its
|
|
67
|
+
// next turn.
|
|
68
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
69
|
+
if (savedPct !== null) state.ctxPct = savedPct;
|
|
70
|
+
if (data.timing?.context?.window_tokens) {
|
|
71
|
+
state.ctxWindow = data.timing.context.window_tokens;
|
|
72
|
+
state.ctxWindowSource = data.timing.context.window_source || null;
|
|
73
|
+
}
|
|
74
|
+
state.firstPreview = sessionFirstPreview(data);
|
|
75
|
+
state.usedMemories = s.used_memory_names || [];
|
|
76
|
+
state.preloadedMemories = s.preloaded_memory_names || [];
|
|
77
|
+
state.mutedMemories = s.muted_memory_names || [];
|
|
78
|
+
state.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
79
|
+
state.llmContext = s.llm_context || null;
|
|
80
|
+
state.thinking = s.thinking || null;
|
|
81
|
+
state.parentId = s.parent_id || null;
|
|
82
|
+
state.continues = s.continues || null;
|
|
83
|
+
state.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The info bar's title: the first user message when the payload has
|
|
88
|
+
// messages, else the card's preview, else the first prompt of its history.
|
|
89
|
+
function sessionFirstPreview(data) {
|
|
90
|
+
const s = data.session;
|
|
91
|
+
let preview = s.first_preview || previewForCard(s);
|
|
92
|
+
if (data.messages && data.messages.length) {
|
|
93
|
+
const first = data.messages.find((m) => m.role === "user");
|
|
94
|
+
if (first && first.content) preview = firstPreview(first.content);
|
|
95
|
+
} else if (!preview && data.history && data.history.length) {
|
|
96
|
+
const txt = firstPreview(data.history[0]);
|
|
97
|
+
if (txt) preview = txt;
|
|
98
|
+
}
|
|
99
|
+
return preview;
|
|
100
|
+
}
|