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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. 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
- export function chipHtml(summary, escapeHtml) {
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="model llmctx-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
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, redrawn every
205
- // tick: clicks are delegated); summary(): the open session's llm_context;
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
- const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
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, continueLine, isOwn } from "./turn_events.js";
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
- if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
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
+ }