samagotchi 0.56.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/README.md +1 -1
  4. data/docs/cli.md +1 -1
  5. data/docs/hooks.md +2 -2
  6. data/docs/memory.md +1 -1
  7. data/docs/plugins.md +63 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  10. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  11. data/lib/samagotchi/bootstrap_command.rb +2 -0
  12. data/lib/samagotchi/broadcast_command.rb +2 -0
  13. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  14. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/context_command.rb +2 -0
  21. data/lib/samagotchi/desktop/macos.rb +3 -0
  22. data/lib/samagotchi/gem_update.rb +3 -0
  23. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  24. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  25. data/lib/samagotchi/host_registry.rb +15 -0
  26. data/lib/samagotchi/installed_versions.rb +3 -0
  27. data/lib/samagotchi/plugin/context.rb +5 -0
  28. data/lib/samagotchi/redacted_inspect.rb +28 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -5
  30. data/lib/samagotchi/tool_runner.rb +14 -3
  31. data/lib/samagotchi/tools/execute.rb +3 -2
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/app.rb +6 -4
  34. data/lib/samagotchi/web/public/app.js +24 -8
  35. data/lib/samagotchi/web/public/index.html +16 -2
  36. data/lib/samagotchi/web/public/question_card.js +5 -3
  37. data/lib/samagotchi/web/public/question_cards.js +21 -1
  38. data/lib/samagotchi/web/public/read_view.js +74 -0
  39. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  40. data/lib/samagotchi/web/public/refs.js +19 -10
  41. data/lib/samagotchi/web/public/turn_html.js +4 -2
  42. data/lib/samagotchi/web/public/turn_view.js +5 -3
  43. data/lib/samagotchi/web/token.rb +3 -0
  44. metadata +6 -1
@@ -66,6 +66,7 @@ import {
66
66
  } from "./timing.js";
67
67
  import { createLiveTurn } from "./live_turn.js";
68
68
  import { installRefClicks, setRefKinds } from "./refs.js";
69
+ import { linkRefs, touchedFiles } from "./ref_scan.js";
69
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
70
71
  import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
71
72
  import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
@@ -837,6 +838,7 @@ function renderHistory(items) {
837
838
  }
838
839
  if (typeof items[0] === "object" && items[0].role) {
839
840
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
841
+ linkTextRefs();
840
842
  } else {
841
843
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
842
844
  }
@@ -901,20 +903,34 @@ async function readTurnEnd() {
901
903
  } catch(_){}
902
904
  }
903
905
 
906
+ // The turn's answer as its rendered markdown, once it is final (the
907
+ // turn-end read, an answer_display re-read); then its refs.
904
908
  function applyFinalMarkdown(messages) {
905
909
  const message = [...messages].reverse().find(
906
910
  (item) => item?.role === "assistant" && typeof item.html === "string",
907
911
  );
908
- if (!message) return;
909
-
910
- const bubble = findInTurnRoots(".bubble.output")
912
+ const bubble = message && findInTurnRoots(".bubble.output")
911
913
  .reverse()
912
914
  .find((item) => showsAnswer(item, message, normalize));
913
- if (!bubble) return;
914
-
915
- bubble.classList.add("markdown");
916
- bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
917
- bubble.innerHTML = message.html;
915
+ if (bubble) {
916
+ bubble.classList.add("markdown");
917
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
918
+ bubble.innerHTML = message.html;
919
+ }
920
+ linkTextRefs();
921
+ }
922
+
923
+ // The `path:line` refs in the answers' and closed steps' texts that name a
924
+ // file the session's rows (and cards) touched, on this page (ref_scan.js).
925
+ // Not mid-stream: at a turn's end and after a reload's render. A ref
926
+ // already made stays as it is.
927
+ function linkTextRefs() {
928
+ const roots = turnRoots();
929
+ const touched = touchedFiles(roots);
930
+ if (!touched.size) return;
931
+ for (const root of roots) {
932
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
933
+ }
918
934
  }
919
935
 
920
936
  function seedSeenFromDom() {
@@ -178,6 +178,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
178
178
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
179
179
  .activity-diff>summary:hover{color:var(--text-2)}
180
180
  .activity-diff[open]>summary{margin-bottom:4px}
181
+ /* A read of the file as-is: its numbered lines, closed under the preview (read_view.js); open, they stand for it. */
182
+ .activity-read{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
183
+ .activity-read>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
184
+ .activity-read>summary:hover{color:var(--text-2)}
185
+ .activity-read[open]>summary{margin-bottom:4px}
186
+ .activity-row:has(>.activity-read[open])>.activity-output{display:none}
187
+ .read-lines{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
188
+ .read-lines code{display:block;min-width:max-content}
189
+ .read-line{display:block;padding:0 10px 0 0;white-space:pre;color:var(--text-2)}
190
+ .read-line::before{content:attr(data-line);display:inline-block;width:5ch;margin-right:10px;padding-right:6px;text-align:right;color:var(--text-faint);user-select:none;font-variant-numeric:tabular-nums}
191
+ .read-meta{color:var(--text-faint)}
181
192
  /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
182
193
  .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
183
194
  .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
@@ -215,6 +226,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
215
226
  .activity-params{flex:1 1 0}
216
227
  .activity-output{margin-left:0}
217
228
  .activity-diff{margin-left:0}
229
+ .activity-read{margin-left:0}
218
230
  .activity-edit{margin-left:0}
219
231
  .activity-row>.activity-command{margin-left:0}
220
232
  }
@@ -371,8 +383,10 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
371
383
  /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
372
384
  .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
373
385
  .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
374
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
375
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
386
+ .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
387
+ .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
388
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
389
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover::before{color:var(--accent)}
376
390
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
377
391
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
378
392
  .diff-hunk{color:var(--text-faint)}
@@ -49,9 +49,10 @@ export function relayView(pq) {
49
49
  };
50
50
  }
51
51
 
52
- // @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
53
- // preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
54
- // is relayView's, for a delegate's approval
52
+ // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?}};
53
+ // paths are what +what+ joins when it shows paths (the card draws a ref
54
+ // for each); preview is an edit/write's dry-run diff (diff_view.js draws
55
+ // it); delegate is relayView's, for a delegate's approval
55
56
  export function approvalView(pq) {
56
57
  if (!isApproval(pq)) return null;
57
58
  const a = pq.approval || {};
@@ -74,6 +75,7 @@ export function approvalView(pq) {
74
75
  // A tool without a command or path (an MCP tool): its compact args.
75
76
  what: command || paths.join("\n") || a.args || "",
76
77
  isCommand: !!command,
78
+ ...(!command && paths.length ? { paths } : {}),
77
79
  where,
78
80
  why: `${reason} (${who})`,
79
81
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
@@ -254,7 +254,8 @@ export function createQuestionCards({
254
254
  const what = doc.createElement("pre");
255
255
  what.className = view.isCommand ? "approval-what command" : "approval-what paths";
256
256
  const code = doc.createElement("code");
257
- code.textContent = view.what;
257
+ if (view.paths) appendPathRefs(code, view.paths);
258
+ else code.textContent = view.what;
258
259
  what.appendChild(code);
259
260
  box.appendChild(what);
260
261
  }
@@ -279,6 +280,25 @@ export function createQuestionCards({
279
280
  return box;
280
281
  }
281
282
 
283
+ // One file ref per path, a line each (refs.js: the card's path opens
284
+ // the current file at the dry run's first change). A directory (a
285
+ // plugin's path ending in /) stays plain text.
286
+ function appendPathRefs(code, paths) {
287
+ paths.forEach((path, i) => {
288
+ if (i) code.appendChild(doc.createTextNode("\n"));
289
+ if (String(path).endsWith("/")) {
290
+ code.appendChild(doc.createTextNode(String(path)));
291
+ return;
292
+ }
293
+ const ref = doc.createElement("span");
294
+ ref.className = "ref";
295
+ ref.dataset.refKind = "file";
296
+ ref.dataset.refPath = String(path);
297
+ ref.textContent = String(path);
298
+ code.appendChild(ref);
299
+ });
300
+ }
301
+
282
302
  // "Waiting for approval in parent ab12: answering here works too", or
283
303
  // an empty (hidden) line while the question isn't relayed.
284
304
  function renderRelayedTo(pq) {
@@ -0,0 +1,74 @@
1
+ // A read row's output as the file's lines, numbered, apart from the DOM.
2
+ // Only when the output is the file text as-is: each numbered line is a
3
+ // ref's line (refs.js opens the row's file there). The row's preview
4
+ // (turn_html.js) strips any "[tag] " and cuts at 300 chars; numbering
5
+ // needs the text exactly as read, so it is drawn on its own, closed.
6
+
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // What follows one of these isn't the file's next line: a ToolRunner cap
10
+ // ("[cut: …]"), a head/tail preview's gap ("[... omitted …]") or the note
11
+ // of a range clamped at the end of the file ("[read: end_line …]"). They
12
+ // are appended at the end (a clamp note, then a cut), so only the last
13
+ // MARKER_LINES lines can be one: such a line earlier is the file's own.
14
+ const MARKER_LINES = 2;
15
+ const MARKER = /^(?:\[cut: |\[\.\.\. omitted |\[read: end_line \d+ exceeds )/;
16
+
17
+ // An image's line instead of its text: attached, or why not (ToolRunner).
18
+ const IMAGE = /\((?:\d+×\d+ )?[A-Z]+\)(?: attached\.| is an image; | is not attached: )/;
19
+
20
+ // The file text of a read row's output, or null when it isn't the file
21
+ // as-is: an error, the head/tail preview of a big file, an image's line.
22
+ function fileText(row) {
23
+ if (row?.tool !== "read" || row.status !== "ok" || row.ref?.kind !== "file" || row.images?.length) return null;
24
+ let text = String(row.output || "");
25
+ // The call the gate replaced says so on a line before the tag.
26
+ if (text.startsWith("ran as: ")) text = text.slice(text.indexOf("\n") + 1);
27
+ if (!text.startsWith("[read]\n")) return null;
28
+ text = text.slice("[read]\n".length);
29
+ if (!text || text.startsWith("Error: ") || text.startsWith("truncated=true\n")) return null;
30
+ if (!text.includes("\n") && IMAGE.test(text)) return null;
31
+ return text;
32
+ }
33
+
34
+ // @return {null | Array<{text, line}>} the output's lines, line null for
35
+ // one that isn't the file's: a marker and all after it, and the "…" of a
36
+ // reload's capped output (output_truncated; a cut line already says so
37
+ // for a live one). The first line is the range's start_line (the ref's
38
+ // line), else 1.
39
+ export function readLines(row) {
40
+ const text = fileText(row);
41
+ if (text === null) return null;
42
+ const lines = text.split("\n");
43
+ // The file's last newline ends a line, it doesn't start one.
44
+ if (lines.at(-1) === "") lines.pop();
45
+ let next = Number(row.ref.line) || 1;
46
+ let numbering = true;
47
+ const out = [];
48
+ lines.forEach((line, i) => {
49
+ if (numbering && i >= lines.length - MARKER_LINES && MARKER.test(line)) {
50
+ numbering = false;
51
+ // The newline before a marker is the marker's: the blank it leaves
52
+ // isn't a line of the file.
53
+ if (i > 0 && lines[i - 1] === "" && out.at(-1)?.line !== null) out.pop();
54
+ }
55
+ out.push({ text: line, line: numbering ? next++ : null });
56
+ });
57
+ if (row.output_truncated && numbering) out.push({ text: "…", line: null });
58
+ return out;
59
+ }
60
+
61
+ // A read row's numbered lines behind a closed toggle ("lines 40–60"); ""
62
+ // when its output isn't the file as-is.
63
+ export function readLinesHtml(row) {
64
+ const lines = readLines(row);
65
+ const numbered = lines?.filter((l) => l.line !== null) || [];
66
+ if (!numbered.length) return "";
67
+ const first = numbered[0].line;
68
+ const last = numbered.at(-1).line;
69
+ const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
+ const spans = lines.map(({ text, line }) => line === null
71
+ ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
72
+ : `<span class="read-line" data-line="${line}">${escapeHtml(text) || " "}</span>`).join("");
73
+ return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
+ }
@@ -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;
@@ -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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.56.0
4
+ version: 0.57.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -203,6 +203,8 @@ files:
203
203
  - lib/samagotchi/bundles/mcp/plugin.rb
204
204
  - lib/samagotchi/bundles/skills/manifest.yml
205
205
  - lib/samagotchi/bundles/skills/plugin.rb
206
+ - lib/samagotchi/bundles/slow-commands/manifest.yml
207
+ - lib/samagotchi/bundles/slow-commands/plugin.rb
206
208
  - lib/samagotchi/bundles/source-links/hooks/source_links.rb
207
209
  - lib/samagotchi/bundles/source-links/manifest.yml
208
210
  - lib/samagotchi/bundles/source-links/source_links.md
@@ -400,6 +402,7 @@ files:
400
402
  - lib/samagotchi/reasoning_history_command.rb
401
403
  - lib/samagotchi/recap_setup.rb
402
404
  - lib/samagotchi/recap_store.rb
405
+ - lib/samagotchi/redacted_inspect.rb
403
406
  - lib/samagotchi/relay.rb
404
407
  - lib/samagotchi/relay_desk.rb
405
408
  - lib/samagotchi/relay_verifier.rb
@@ -557,7 +560,9 @@ files:
557
560
  - lib/samagotchi/web/public/prompt_history.js
558
561
  - lib/samagotchi/web/public/question_card.js
559
562
  - lib/samagotchi/web/public/question_cards.js
563
+ - lib/samagotchi/web/public/read_view.js
560
564
  - lib/samagotchi/web/public/reasoning_history_chip.js
565
+ - lib/samagotchi/web/public/ref_scan.js
561
566
  - lib/samagotchi/web/public/refs.js
562
567
  - lib/samagotchi/web/public/restore.js
563
568
  - lib/samagotchi/web/public/route.js