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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/hooks.md +2 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -5
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/app.js +24 -8
- data/lib/samagotchi/web/public/index.html +16 -2
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- 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
|
-
|
|
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 (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
//
|
|
54
|
-
// is
|
|
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
|
-
|
|
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?})
|
|
4
|
-
// same file at a line)
|
|
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
|
|
56
|
-
// its first changed line); a diff line (or a
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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(
|
|
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?.(
|
|
66
|
-
const owner = diffLine?.closest(
|
|
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(
|
|
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
|
|
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"
|
|
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
|
|
data/lib/samagotchi/web/token.rb
CHANGED
|
@@ -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.
|
|
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
|