samagotchi 0.55.0 → 0.57.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +30 -8
  6. data/docs/configuration.md +84 -8
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/engine.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +63 -0
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  16. data/lib/samagotchi/bootstrap_command.rb +2 -0
  17. data/lib/samagotchi/broadcast_command.rb +2 -0
  18. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  19. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/cache_confirm.rb +51 -0
  27. data/lib/samagotchi/client_id.rb +12 -0
  28. data/lib/samagotchi/config.rb +7 -3
  29. data/lib/samagotchi/context_command.rb +2 -0
  30. data/lib/samagotchi/desktop/macos.rb +3 -0
  31. data/lib/samagotchi/engine.rb +102 -8
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/gem_update.rb +3 -0
  34. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  35. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  36. data/lib/samagotchi/host_config.rb +6 -3
  37. data/lib/samagotchi/host_registry.rb +19 -3
  38. data/lib/samagotchi/installed_versions.rb +3 -0
  39. data/lib/samagotchi/kernel_loop.rb +99 -16
  40. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  41. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  42. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  43. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  44. data/lib/samagotchi/model_settings.rb +5 -2
  45. data/lib/samagotchi/plugin/context.rb +5 -0
  46. data/lib/samagotchi/reasoning_history.rb +132 -0
  47. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  48. data/lib/samagotchi/redacted_inspect.rb +28 -0
  49. data/lib/samagotchi/self_report.rb +21 -0
  50. data/lib/samagotchi/session.rb +23 -2
  51. data/lib/samagotchi/session_commands.rb +55 -15
  52. data/lib/samagotchi/session_manager.rb +2 -1
  53. data/lib/samagotchi/session_setup.rb +7 -5
  54. data/lib/samagotchi/setting_explained.rb +20 -0
  55. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  56. data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
  57. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  58. data/lib/samagotchi/terminal_ui.rb +13 -6
  59. data/lib/samagotchi/thinking.rb +20 -25
  60. data/lib/samagotchi/thinking_command.rb +33 -11
  61. data/lib/samagotchi/tool_runner.rb +14 -3
  62. data/lib/samagotchi/tools/builtins.rb +6 -2
  63. data/lib/samagotchi/tools/execute.rb +3 -2
  64. data/lib/samagotchi/tools/peers.rb +4 -2
  65. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +29 -6
  68. data/lib/samagotchi/web/public/app.js +44 -9
  69. data/lib/samagotchi/web/public/commands.js +36 -3
  70. data/lib/samagotchi/web/public/composer.js +5 -1
  71. data/lib/samagotchi/web/public/index.html +22 -5
  72. data/lib/samagotchi/web/public/info_bar.js +5 -3
  73. data/lib/samagotchi/web/public/level_chip.js +174 -0
  74. data/lib/samagotchi/web/public/model_pick.js +1 -1
  75. data/lib/samagotchi/web/public/question_card.js +5 -3
  76. data/lib/samagotchi/web/public/question_cards.js +21 -1
  77. data/lib/samagotchi/web/public/read_view.js +74 -0
  78. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  79. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  80. data/lib/samagotchi/web/public/refs.js +19 -10
  81. data/lib/samagotchi/web/public/session_state.js +5 -0
  82. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  83. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  84. data/lib/samagotchi/web/public/turn_html.js +4 -2
  85. data/lib/samagotchi/web/public/turn_view.js +5 -3
  86. data/lib/samagotchi/web/token.rb +3 -0
  87. data/lib/samagotchi/worker.rb +17 -5
  88. metadata +13 -1
@@ -0,0 +1,206 @@
1
+ // `path:line` refs in an answer's or a step's text (traceable refs P3).
2
+ // A name becomes a file ref (refs.js: span.ref with data-ref-kind="file")
3
+ // only when it names a file the session's rows touched (their refs): the
4
+ // answer's text is unchanged, it resolves on the page, and nothing the
5
+ // session didn't touch gets linked. The scan is github-pr's LineLinker
6
+ // (bundles/github-pr/plugin.rb) on the DOM: pre, links and fenced blocks
7
+ // are left alone, inline code only when it is the ref alone, a bare URL's
8
+ // text too.
9
+
10
+ import { refAttrs } from "./refs.js";
11
+
12
+ // A path (no spaces), a line, an optional end line and an optional column
13
+ // (kept in the text, not used). LineLinker::REF.
14
+ export const REF = /(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])/;
15
+ export const REF_ALONE = new RegExp(`^${REF.source}$`);
16
+ // A ref, or a bare path (no `:line`): a bare one counts only when it is a
17
+ // touched file exactly (resolveRef's +bare+). Not after a `\` or a `:`
18
+ // either: the tail of `C:\x\foo.rb:3` or `host:lib/foo.rb:3` is another
19
+ // machine's file.
20
+ const REF_OR_PATH = /(?<![\w./@+~\\:-])(?<path>[\w./@+~-]*[\w@+~-])(?::(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])|(?![\w/]|:\d))/g;
21
+ const PATH_ALONE = /^(?<path>[\w./@+~-]*[\w@+~-])$/;
22
+ const URL_SPAN = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
23
+ const MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/g;
24
+ // A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
25
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
26
+ // Elements whose text is never scanned: code blocks, links (a github-pr
27
+ // PR link stays the PR's), a ref already made.
28
+ const SKIP = "pre, a, [data-ref-kind]";
29
+
30
+ // The ref a match names: { path, line, endLine } or null. A path has a `/`
31
+ // or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
32
+ // a ref; an end line not after the line is dropped.
33
+ export function refOf(groups) {
34
+ const path = groups?.path || "";
35
+ if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
36
+ if (groups.from === undefined) return { path, line: null, endLine: null };
37
+ const line = Number(groups.from);
38
+ if (!line) return null;
39
+ const to = groups.to === undefined ? null : Number(groups.to);
40
+ return { path, line, endLine: to && to > line ? to : null };
41
+ }
42
+
43
+ // The absolute touched file +path+ names, or null: the path itself when
44
+ // absolute, else the path under +cwd+ (`.` and `..` folded either way); else (not for a +bare+ path: no
45
+ // `:line`) the ONE touched file ending in `/<path>`. Ambiguous: none.
46
+ export function resolveRef(path, touched, cwd, { bare = false } = {}) {
47
+ const rel = String(path).replace(/^(?:\.\/)+/, "");
48
+ const exact = rel.startsWith("/") ? joinPath("", rel) : cwd ? joinPath(cwd, rel) : null;
49
+ if (exact && touched.has(exact)) return exact;
50
+ if (bare || rel.startsWith("/")) return null;
51
+ const tail = `/${rel.replace(/^(?:\.\.\/)+/, "")}`;
52
+ const found = [...touched].filter((file) => file.endsWith(tail));
53
+ return found.length === 1 ? found[0] : null;
54
+ }
55
+
56
+ // +rel+ under +dir+, `.` and `..` segments folded.
57
+ function joinPath(dir, rel) {
58
+ const out = [];
59
+ for (const part of `${dir}/${rel}`.split("/")) {
60
+ if (part === "" || part === ".") continue;
61
+ if (part === "..") out.pop();
62
+ else out.push(part);
63
+ }
64
+ return `/${out.join("/")}`;
65
+ }
66
+
67
+ // [start, end, ref] for each ref in a plain +text+ that names a touched
68
+ // file: fenced blocks, markdown links and URLs skipped, inline code only
69
+ // as the ref alone (the span with its backticks). +touched+: a Set of
70
+ // absolute paths; +cwd+: the session's working directory.
71
+ export function textRefs(text, touched, cwd) {
72
+ if (!touched?.size || !text) return [];
73
+ const fences = fencedBlocks(text);
74
+ const skipped = [...fences, ...spans(text, URL_SPAN), ...spans(text, MARKDOWN_LINK)];
75
+ const code = inlineCode(text, fences);
76
+ const found = [];
77
+ for (const [start, end, inner] of code) {
78
+ if (insideAny(skipped, start, end)) continue;
79
+ const ref = aloneRef(inner, touched, cwd);
80
+ if (ref) found.push([start, end, ref]);
81
+ }
82
+ for (const match of text.matchAll(REF_OR_PATH)) {
83
+ const start = match.index;
84
+ const end = start + match[0].length;
85
+ if (insideAny(skipped, start, end) || insideAny(code, start, end)) continue;
86
+ const ref = resolved(refOf(match.groups), touched, cwd);
87
+ if (ref) found.push([start, end, ref]);
88
+ }
89
+ return found.sort((a, b) => a[0] - b[0]);
90
+ }
91
+
92
+ // The ref an inline code's text is when it is a ref (or a touched path)
93
+ // alone, else null.
94
+ function aloneRef(text, touched, cwd) {
95
+ const inner = String(text).trim();
96
+ const match = inner.match(REF_ALONE) || inner.match(PATH_ALONE);
97
+ return match ? resolved(refOf(match.groups), touched, cwd) : null;
98
+ }
99
+
100
+ function resolved(ref, touched, cwd) {
101
+ if (!ref) return null;
102
+ const path = resolveRef(ref.path, touched, cwd, { bare: ref.line === null });
103
+ return path ? { kind: "file", path, line: ref.line, end_line: ref.endLine } : null;
104
+ }
105
+
106
+ // The absolute paths of the file refs under +roots+ (the session's rows,
107
+ // approval cards): what a text ref may name.
108
+ export function touchedFiles(roots) {
109
+ const touched = new Set();
110
+ for (const root of roots) {
111
+ for (const el of root.querySelectorAll('[data-ref-kind="file"][data-ref-path]')) {
112
+ touched.add(el.dataset.refPath);
113
+ }
114
+ }
115
+ return touched;
116
+ }
117
+
118
+ // Wraps the refs in +el+'s text in span.ref (refs.js clicks and
119
+ // hover them as it does a row title). Text inside pre, a link or a ref is
120
+ // left alone; an inline code element is wrapped whole when it is a ref
121
+ // alone. Runs again over the same element without wrapping twice.
122
+ export function linkRefs(el, touched, cwd) {
123
+ if (!el || !touched?.size) return;
124
+ const doc = el.ownerDocument;
125
+ for (const code of [...el.querySelectorAll("code")]) {
126
+ if (code.closest(SKIP)) continue;
127
+ const ref = aloneRef(code.textContent, touched, cwd);
128
+ if (!ref) continue;
129
+ const span = refSpan(doc, ref);
130
+ code.replaceWith(span);
131
+ span.append(code);
132
+ }
133
+ const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
134
+ const nodes = [];
135
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
136
+ const skip = node.parentElement?.closest(`${SKIP}, code`);
137
+ if (!skip || !el.contains(skip)) nodes.push(node);
138
+ }
139
+ for (const node of nodes) wrapRefs(node, textRefs(node.data, touched, cwd));
140
+ }
141
+
142
+ function wrapRefs(node, hits) {
143
+ if (!hits.length) return;
144
+ const doc = node.ownerDocument;
145
+ const frag = doc.createDocumentFragment();
146
+ let pos = 0;
147
+ for (const [start, end, ref] of hits) {
148
+ if (start < pos) continue;
149
+ if (start > pos) frag.append(node.data.slice(pos, start));
150
+ const span = refSpan(doc, ref);
151
+ span.textContent = node.data.slice(start, end);
152
+ frag.append(span);
153
+ pos = end;
154
+ }
155
+ if (pos < node.data.length) frag.append(node.data.slice(pos));
156
+ node.replaceWith(frag);
157
+ }
158
+
159
+ function refSpan(doc, ref) {
160
+ const holder = doc.createElement("div");
161
+ holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
162
+ return holder.firstChild;
163
+ }
164
+
165
+ // [start, end] of each fenced block (an unclosed one runs to the end).
166
+ function fencedBlocks(text) {
167
+ const blocks = [];
168
+ let open = null;
169
+ let offset = 0;
170
+ for (const line of text.split(/(?<=\n)/)) {
171
+ const marker = line.match(FENCE_OPEN)?.[1];
172
+ if (!open && marker) {
173
+ open = [offset, marker];
174
+ } else if (open && marker && marker[0] === open[1][0] && marker.length >= open[1].length && line.trim() === marker) {
175
+ blocks.push([open[0], offset + line.length]);
176
+ open = null;
177
+ }
178
+ offset += line.length;
179
+ }
180
+ if (open) blocks.push([open[0], text.length]);
181
+ return blocks;
182
+ }
183
+
184
+ // [start, end, inner] of each inline code span outside +fences+: a run of
185
+ // backticks to the next run of the same length.
186
+ function inlineCode(text, fences) {
187
+ const runs = spans(text, /`+/g).filter(([start, end]) => !insideAny(fences, start, end));
188
+ const found = [];
189
+ let index = 0;
190
+ while (index < runs.length) {
191
+ const [start, end] = runs[index];
192
+ const closing = runs.findIndex(([s, e], k) => k > index && e - s === end - start);
193
+ if (closing < 0) { index += 1; continue; }
194
+ found.push([start, runs[closing][1], text.slice(end, runs[closing][0])]);
195
+ index = closing + 1;
196
+ }
197
+ return found;
198
+ }
199
+
200
+ function spans(text, regex) {
201
+ return [...text.matchAll(regex)].map((m) => [m.index, m.index + m[0].length]);
202
+ }
203
+
204
+ function insideAny(list, start, end) {
205
+ return list.some(([s, e]) => start >= s && end <= e);
206
+ }
@@ -1,7 +1,8 @@
1
1
  // Refs: quiet links from what a turn touched to the thing itself. A ref is
2
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
3
+ // {kind: "file", path, line?, end_line?}), that row's diff lines and a
4
+ // read row's numbered lines (the same file at a line), and an edit/write
5
+ // approval card's paths and its dry run's diff lines. The DOM carries data-ref-kind and the target's
5
6
  // attributes, never what a click does: that is an action, kept per kind in
6
7
  // REF_ACTIONS, and a click runs the first one available to this viewer. A
7
8
  // ref with none (a phone: no editor) stays plain text; the page marks the
@@ -51,19 +52,27 @@ export function availableKinds(viewer, actions = REF_ACTIONS) {
51
52
  return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
52
53
  }
53
54
 
55
+ // What owns a ref and its diff lines: a tool row, or an edit's approval
56
+ // card (its paths and its dry run).
57
+ const OWNERS = ".activity-row, .bubble.approval";
58
+
59
+ // A line of the owner's file: a diff line, or a read row's numbered line.
60
+ const LINES = ".diff-line[data-line], .read-line[data-line]";
61
+
54
62
  // 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.
63
+ // title (or an approval card's path) opens its file at the lines the call
64
+ // named (an edit without any at its first changed line); a diff line (or a
65
+ // hunk header) or a read row's numbered line, in a row or a card with a
66
+ // ref, opens that file at that line.
67
+ // A diff line elsewhere is none.
59
68
  export function refTarget(el) {
60
69
  const title = el?.closest?.("[data-ref-kind]");
61
70
  if (title) {
62
- const owner = title.closest(".activity-row");
71
+ const owner = title.closest(OWNERS);
63
72
  return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
64
73
  }
65
- const diffLine = el?.closest?.(".diff-line[data-line]");
66
- const owner = diffLine?.closest(".activity-row");
74
+ const diffLine = el?.closest?.(LINES);
75
+ const owner = diffLine?.closest(OWNERS);
67
76
  const ref = owner?.querySelector("[data-ref-kind]");
68
77
  if (!ref) return null;
69
78
  return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
@@ -116,7 +125,7 @@ export function installRefClicks(root, viewer, { open = defaultOpen, selection =
116
125
  root.addEventListener("mouseover", (e) => {
117
126
  const target = refTarget(e.target);
118
127
  if (!target) return;
119
- const el = e.target.closest("[data-ref-kind]") || e.target.closest(".diff-line[data-line]");
128
+ const el = e.target.closest("[data-ref-kind]") || e.target.closest(LINES);
120
129
  if (!el) return;
121
130
  if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
122
131
  const own = el.dataset.refTitle;
@@ -45,6 +45,9 @@ export function emptySessionState() {
45
45
  // The LLM context strategy, apply rule and budget the next turn runs
46
46
  // under, each with its source (the info bar's llm ctx chip), or null.
47
47
  llmContext: null,
48
+ // Which of its reasoning the model gets back from the next turn on, with
49
+ // its source (the reasoning history chip), or null (a native host).
50
+ reasoningHistory: null,
48
51
  timing: normalizeTiming(),
49
52
  };
50
53
  }
@@ -78,6 +81,8 @@ export function sessionStateFrom(data) {
78
81
  state.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
79
82
  state.llmContext = s.llm_context || null;
80
83
  state.thinking = s.thinking || null;
84
+ // null on a native host (it ignores the level) and from an older worker.
85
+ state.reasoningHistory = s.reasoning_history || null;
81
86
  state.parentId = s.parent_id || null;
82
87
  state.continues = s.continues || null;
83
88
  state.commands = Array.isArray(data.commands) ? data.commands : [];
@@ -15,7 +15,8 @@
15
15
  // capabilities() the page's capabilities (card.js shownElsewhere)
16
16
  // historyEl the history element (keepFollowing, the failed-turn redraw)
17
17
  // liveTurn, turnDom, shownTexts, questionCards, contextChips,
18
- // llmContextControl, thinkingControl the page's views
18
+ // llmContextControl, thinkingControl,
19
+ // reasoningHistoryControl the page's views
19
20
  // sentIds, earlyRestores restore.js's bookkeeping
20
21
  // stream: {takeGuess(), restart()} the stream was opened from a
21
22
  // guess (takeGuess: whether, and forget it); restart:
@@ -49,7 +50,7 @@ import { keepEarlyRestore, restoreAction } from "./restore.js";
49
50
  export function createStreamHandlers(deps) {
50
51
  const {
51
52
  selected, state, clientId, capabilities, historyEl,
52
- liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl,
53
+ liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
53
54
  sentIds, earlyRestores, stream, composer, getSession,
54
55
  resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
55
56
  appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
@@ -213,6 +214,13 @@ export function createStreamHandlers(deps) {
213
214
  updateInfoBar();
214
215
  thinkingControl.refresh();
215
216
  }
217
+ // /reasoning-history (or /model) changed it; null: the model now runs
218
+ // on a native host, which ignores it (the chip goes).
219
+ if ("reasoning_history" in data) {
220
+ state().reasoningHistory = data.reasoning_history || null;
221
+ updateInfoBar();
222
+ reasoningHistoryControl.refresh();
223
+ }
216
224
  if (view.resync) await resync();
217
225
  // A card's action that isn't anytime (the step-limit question's
218
226
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -2,20 +2,35 @@
2
2
  // low"; "· session" when the session set its own), and a popover with a
3
3
  // level select that runs /thinking in the session's worker (the command's
4
4
  // reply shows in the conversation, like a typed one). The level and where
5
- // it came from are the session's thinking (Thinking::Explained#summary:
5
+ // it came from are the session's thinking (SettingExplained#summary:
6
6
  // {level, source, own}; the worker's snapshot, a command_ran, or the
7
- // server's own reading of the session file).
7
+ // server's own reading of the session file). A config on level_chip.js.
8
8
  //
9
9
  // The start page has one too (kind "new chat"): it shows the picked
10
10
  // model's level (GET /api/models' thinking) and keeps a choice for the
11
11
  // next create only (POST /api/sessions' thinking), never remembered:
12
12
  // nothing is sent unless the user sets one.
13
13
 
14
- import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
15
- import { html, nothing, render } from "./vendor/lit-html.js";
14
+ import { SESSION } from "./llm_context_chip.js";
15
+ import * as level from "./level_chip.js";
16
+ import { html, nothing } from "./vendor/lit-html.js";
16
17
 
17
18
  export const LEVELS = ["off", "low", "medium", "high"];
18
- const DEFAULT = "default";
19
+
20
+ export const CONFIG = Object.freeze({
21
+ name: "Thinking",
22
+ prefix: "think",
23
+ command: "/thinking",
24
+ levels: LEVELS,
25
+ anchor: "[data-think-chip]",
26
+ formClass: "think-form",
27
+ popoverClass: "think-popover",
28
+ nowClass: "think-now",
29
+ when: (fresh) => (fresh ? "For the new chat's first turn on; not remembered for the next one."
30
+ : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again."),
31
+ });
32
+
33
+ export const { nowText } = level;
19
34
 
20
35
  // The new chat's choice (a level, or null) laid over the model's summary:
21
36
  // what the chip shows. The model's own summary without a choice.
@@ -28,13 +43,8 @@ export function withChoice(summary, choice) {
28
43
  // set its own; null without a summary (an older worker). The default with
29
44
  // nothing set anywhere shows too ("think default"): the chip is where the
30
45
  // level is set.
31
- export function chipModel(summary, { kind = SESSION } = {}) {
32
- if (!summary || !summary.level) return null;
33
- const own = !!summary.own;
34
- const what = kind === NEW_CHAT ? "the new chat" : "this session";
35
- const from = own ? what : summary.source || "the model's own";
36
- const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
37
- return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
46
+ export function chipModel(summary, opts) {
47
+ return level.chipModel(CONFIG, summary, opts);
38
48
  }
39
49
 
40
50
  // The session bar's chip (a lit-html template; info_bar.js), nothing
@@ -45,134 +55,19 @@ export function chipTemplate(summary) {
45
55
  return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
46
56
  }
47
57
 
48
- // The /thinking line for a select value ("default" unsets the session's
49
- // own); null when it is what the form opened with.
50
- export function commandLine(level, initial = null) {
51
- if (initial !== null && level === initial) return null;
52
- return `/thinking ${level}`;
53
- }
54
-
55
- // "Now: low (session)", "Now: default (the model's own)".
56
- export function nowText(summary) {
57
- return `Now: ${summary.level} (${summary.source || "the model's own"})`;
58
+ // The /thinking line for a select value, with --yes (level_chip.js
59
+ // commandLine); null when it is what the form opened with.
60
+ export function commandLine(choice, initial = null) {
61
+ return level.commandLine(CONFIG, choice, initial);
58
62
  }
59
63
 
60
- const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
61
-
62
- // The popover's form (a lit-html template). For the new chat (kind
63
- // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
64
- // shows; +now+ is the summary its "Now:" line reads (refresh passes a
65
- // newer one, the rest stays as the form opened).
66
- export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
67
- const fresh = kind === NEW_CHAT;
68
- const chosen = (fresh ? choice : summary.own) || DEFAULT;
69
- const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
70
- const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
71
- : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
72
- return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
73
- ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
74
- ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
75
- ><div class="ctx-pop-meta think-now">${nowText(now)}</div
76
- ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
77
- LEVELS.map((level) => option(level, level, chosen))}</select></label
78
- ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
79
- ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
64
+ // The popover's form (level_chip.js formTemplate).
65
+ export function formTemplate(summary, opts) {
66
+ return level.formTemplate(CONFIG, summary, opts);
80
67
  }
81
68
 
82
- // The chip's popover, as createLlmContextControl's: el is where the chip
83
- // is drawn (clicks are delegated: #infoText, info_bar.js);
84
- // summary(): the open session's thinking; session(): its id; running():
85
- // a turn runs; run(line): sends the command. The start page's (kind
86
- // NEW_CHAT): summary() is the picked model's, choice() the pending level
87
- // and choose(next) takes a Set's (null for "follow the model"). id and
88
- // anchor (the chip's selector) keep it apart from the llm ctx control on
89
- // the same element.
90
- export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
91
- kind = SESSION, choice = () => null, choose = null,
92
- id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
93
- doc = document, win = window }) {
94
- const fresh = kind === NEW_CHAT;
95
- const pop = doc.createElement("div");
96
- pop.id = id;
97
- pop.className = "ctx-popover think-popover";
98
- pop.setAttribute("role", "dialog");
99
- pop.hidden = true;
100
- doc.body.appendChild(pop);
101
- let initial = null;
102
- let openFor = null;
103
- // What the open form was drawn from: the model's or session's summary
104
- // and the form's options. A closed popover is empty, so each open draws
105
- // a fresh form (an unsent pick is gone).
106
- let shown = null;
107
- const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
108
-
109
- function place(anchor) {
110
- const width = Math.min(320, win.innerWidth - 32);
111
- pop.style.width = `${width}px`;
112
- pop.style.maxHeight = "";
113
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
114
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
115
- pop.style.left = `${at.left}px`;
116
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
117
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
118
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
119
- }
120
-
121
- function open(anchor) {
122
- const data = summary();
123
- if (!data) return;
124
- initial = (fresh ? choice() : data.own) || DEFAULT;
125
- openFor = session();
126
- shown = { data, opts: { running: running(), kind, choice: choice() } };
127
- draw(data);
128
- pop.hidden = false;
129
- place(anchor);
130
- }
131
-
132
- function close() {
133
- pop.hidden = true;
134
- initial = null;
135
- openFor = null;
136
- shown = null;
137
- render(nothing, pop);
138
- }
139
-
140
- function isOpen() { return !pop.hidden; }
141
-
142
- // A new summary: an open form keeps the user's pick; only "Now:" follows
143
- // (the same template again: lit writes that line alone).
144
- function refresh() {
145
- if (!isOpen()) return;
146
- const data = summary();
147
- if (!data || session() !== openFor) return close();
148
- draw(data);
149
- }
150
-
151
- el.addEventListener("click", (e) => {
152
- const anchor = e.target.closest(anchorSel);
153
- if (!anchor) return;
154
- e.stopPropagation();
155
- if (isOpen()) close();
156
- else open(anchor);
157
- });
158
- pop.addEventListener("submit", (e) => {
159
- e.preventDefault();
160
- const level = e.target.elements.level.value;
161
- const was = initial;
162
- close();
163
- if (fresh) return choose(level === DEFAULT ? null : level);
164
- const line = commandLine(level, was);
165
- if (line) run(line);
166
- });
167
- pop.addEventListener("click", (e) => {
168
- if (e.target.closest("[data-act]")?.dataset.act === "close") close();
169
- });
170
- doc.addEventListener("click", (e) => {
171
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
172
- });
173
- doc.addEventListener("keydown", (e) => {
174
- if (e.key === "Escape" && isOpen()) close();
175
- });
176
-
177
- return { close, refresh, get open() { return isOpen(); } };
69
+ // The chip's popover (level_chip.js createLevelControl); the start page's
70
+ // passes kind NEW_CHAT, choice and choose.
71
+ export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
72
+ return level.createLevelControl({ cfg: CONFIG, id, ...opts });
178
73
  }
@@ -8,6 +8,7 @@ import { copySourceAttr } from "./copy.js";
8
8
  import { activityDiffHtml } from "./diff_view.js";
9
9
  import { escapeHtml, userBodyHtml } from "./format.js";
10
10
  import { refAttrs } from "./refs.js";
11
+ import { readLinesHtml } from "./read_view.js";
11
12
  import { formatDuration } from "./timing.js";
12
13
  import { emptyAnswerLine } from "./turn_events.js";
13
14
 
@@ -72,7 +73,8 @@ function llmEditHtml(row) {
72
73
  // else the full params line, on hover), the word an exception shows, the
73
74
  // call's duration, the full command (an execute/task_create's view, also
74
75
  // while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
75
- // the rest on hover), its images (+thumbs+(images) draws them) and an
76
+ // the rest on hover; a read of the file as-is also its numbered lines,
77
+ // closed: read_view.js), its images (+thumbs+(images) draws them) and an
76
78
  // edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
77
79
  // part with nothing to say is left out.
78
80
  export function toolRowHtml(row, { thumbs = () => "" } = {}) {
@@ -96,7 +98,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
96
98
  const status = statusParts(row.status, { noMatch: !!row.no_match });
97
99
  return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
98
100
  `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
99
- `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
101
+ `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}${output ? readLinesHtml(row) : ""}` +
100
102
  `${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
101
103
  }
102
104
 
@@ -63,13 +63,15 @@ export function thinkingTemplate({ body, wrapped = true, line = null, cls = "thi
63
63
 
64
64
  // A step's narration: the live box of complete sentences (one per line,
65
65
  // the newest last), the live "…" until the first one, or a closed step's
66
- // full text. None when there is nothing to say.
66
+ // full text. None when there is nothing to say. A closed step's text is
67
+ // set whole (.textContent): its refs (ref_scan.js) split it into spans
68
+ // that a text part's update would leave behind.
67
69
  function textTemplate(text, { live, sentences, placeholder }) {
68
70
  if (live && sentences?.length) {
69
71
  return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
70
72
  }
71
73
  if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
72
- return text ? html`<div class="gen-text">${text}</div>` : nothing;
74
+ return text ? html`<div class="gen-text" .textContent=${text}></div>` : nothing;
73
75
  }
74
76
 
75
77
  // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
@@ -118,7 +120,7 @@ function rawNodes(markup) {
118
120
  // The parts of a drawn tool row a user may have changed (the command's
119
121
  // raw toggle, an opened diff) or that never change once there (the
120
122
  // thumbs): a redraw keeps them as they are.
121
- const KEPT_PARTS = ["activity-command", "activity-diff", "thumbs"];
123
+ const KEPT_PARTS = ["activity-command", "activity-diff", "activity-read", "thumbs"];
122
124
 
123
125
  const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
124
126
 
@@ -5,6 +5,7 @@ require "securerandom"
5
5
  require_relative "../atomic_file"
6
6
 
7
7
  require_relative "../session"
8
+ require_relative "../redacted_inspect"
8
9
 
9
10
  module Samagotchi
10
11
  module Web
@@ -62,6 +63,8 @@ module Samagotchi
62
63
  # its mtime changes (one stat per check), so `chi web --new-token`
63
64
  # takes effect at once, without a restart.
64
65
  class Source
66
+ include RedactedInspect
67
+
65
68
  attr_reader :path
66
69
 
67
70
  def initialize(path = Token.path)
@@ -693,7 +693,8 @@ module Samagotchi
693
693
  resolved = @continue_offer.resolved?(awaiting, result)
694
694
  @engine.synchronize_events do
695
695
  @continue_offer.announce_resolved(result, command) if resolved
696
- announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
696
+ announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
697
+ confirm: result.confirm)
697
698
  shown.each { |event| @engine.announce(event) }
698
699
  end
699
700
  save_or_log(:command) { save_session } unless Array(result.changed).empty?
@@ -706,9 +707,14 @@ module Samagotchi
706
707
  # the cards and notices it showed, held: they follow its command_ran,
707
708
  # as its output
708
709
  def run_command_line(command)
709
- @engine.holding_announcements { command_result(command) { @commands.run(command.line) } }
710
+ @engine.holding_announcements { command_result(command) { @commands.run(command.line, confirm: confirm?(command)) } }
710
711
  end
711
712
 
713
+ # Whether +command+ asks before a costly /thinking or /reasoning-history
714
+ # change (CacheConfirm): only a person at a web tab or a TUI sees the
715
+ # question; chi send, a delegate, a plugin and a card's action apply it.
716
+ def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
717
+
712
718
  # The block's Result for +command+; one that raised, or found no
713
719
  # command for the line, is an error Result saying so.
714
720
  # @return [SessionCommands::Result]
@@ -767,9 +773,9 @@ module Samagotchi
767
773
  # saved.
768
774
  def start_anytime_command(command)
769
775
  @engine.spawn_anytime do
770
- result = command_result(command) { @engine.running_anytime { @commands.run(command.line) } }
776
+ result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
771
777
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
772
- anytime: true)
778
+ anytime: true, confirm: result.confirm)
773
779
  rescue StandardError => e
774
780
  Log.warn(:worker, "anytime_command_failed", line: command.line, error: e.class.name, msg: e.message)
775
781
  end
@@ -777,7 +783,9 @@ module Samagotchi
777
783
 
778
784
  # @param anytime [Boolean] an anytime command's: its line was shown at
779
785
  # its command_queued already
780
- def announce_command(command, status:, output:, changed:, anytime: false)
786
+ # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
787
+ # line that applies it (the TUI's prefill, the web's Apply button)
788
+ def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
781
789
  text = output.to_s
782
790
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
783
791
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -788,6 +796,10 @@ module Samagotchi
788
796
  event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
789
797
  # /thinking or /model: the level the next turn runs at (the web's chip).
790
798
  event[:thinking] = @engine.thinking_explained&.summary if changed.intersect?(%i[thinking model])
799
+ # /reasoning-history or /model: the level the next turn runs under
800
+ # (the web's chip); nil on a native host, which ignores it.
801
+ event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
802
+ event[:confirm] = confirm.to_h if confirm
791
803
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
792
804
  @engine.announce(event)
793
805
  end