@evandu/super-pi 0.1.105 → 0.1.106
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.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-DBR_t9lF.js +79 -0
- package/dist/client/assets/index-mo5Dvh_v.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/provider-service.d.ts +14 -0
- package/dist/server/application/provider-service.js +29 -0
- package/dist/server/application/run-service.d.ts +19 -0
- package/dist/server/application/run-service.js +78 -0
- package/dist/server/command-handler.js +6 -0
- package/dist/server/content-search.d.ts +32 -0
- package/dist/server/content-search.js +155 -0
- package/dist/server/file-changes.d.ts +119 -0
- package/dist/server/file-changes.js +278 -0
- package/dist/server/file-diff.d.ts +38 -0
- package/dist/server/file-diff.js +97 -0
- package/dist/server/file-index.d.ts +56 -0
- package/dist/server/file-index.js +150 -0
- package/dist/server/files.d.ts +8 -0
- package/dist/server/files.js +3 -1
- package/dist/server/git.d.ts +17 -0
- package/dist/server/git.js +39 -0
- package/dist/server/http-server.js +121 -1
- package/dist/server/index.js +15 -0
- package/dist/server/pi-session-events.d.ts +9 -0
- package/dist/server/pi-session-events.js +12 -0
- package/dist/server/runtime.d.ts +22 -0
- package/dist/server/runtime.js +16 -1
- package/dist/server/server-context.d.ts +12 -0
- package/dist/server/types.d.ts +71 -0
- package/package.json +11 -1
- package/dist/client/assets/index-UiqTcZca.css +0 -1
- package/dist/client/assets/index-tBfBUY_8.js +0 -74
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FileIndexSnapshot } from "./file-index.js";
|
|
2
|
+
export declare const MAX_CONTENT_RESULTS = 500;
|
|
3
|
+
export declare const MAX_CONTENT_FILE_BYTES: number;
|
|
4
|
+
export declare const MAX_CONTENT_DEPTH = 12;
|
|
5
|
+
export declare const MAX_CONTENT_MS = 10000;
|
|
6
|
+
export type ContentSearchCandidate = {
|
|
7
|
+
path: string;
|
|
8
|
+
kind: "file" | "directory";
|
|
9
|
+
text: boolean | null;
|
|
10
|
+
size: number | null;
|
|
11
|
+
};
|
|
12
|
+
export type ContentSearchHit = {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
line: number;
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
export type ContentSearchResult = {
|
|
19
|
+
hits: ContentSearchHit[];
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
skipped: string[];
|
|
22
|
+
complete: boolean;
|
|
23
|
+
reasons: string[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
|
|
27
|
+
* session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
|
|
28
|
+
* tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
|
|
29
|
+
* Instead give it one verified indexed file at a time; validate again before publishing any hit.
|
|
30
|
+
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
31
|
+
*/
|
|
32
|
+
export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">): Promise<ContentSearchResult>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createGrepTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isTextName, looksBinary, resolveInsideRoot } from "./files.js";
|
|
5
|
+
export const MAX_CONTENT_RESULTS = 500;
|
|
6
|
+
export const MAX_CONTENT_FILE_BYTES = 512 * 1024;
|
|
7
|
+
export const MAX_CONTENT_DEPTH = 12;
|
|
8
|
+
export const MAX_CONTENT_MS = 10000;
|
|
9
|
+
/**
|
|
10
|
+
* SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
|
|
11
|
+
* session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
|
|
12
|
+
* tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
|
|
13
|
+
* Instead give it one verified indexed file at a time; validate again before publishing any hit.
|
|
14
|
+
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
15
|
+
*/
|
|
16
|
+
export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }) {
|
|
17
|
+
await resolveInsideRoot(root, "");
|
|
18
|
+
const hits = [];
|
|
19
|
+
const skipped = new Set(indexStatus.skipped);
|
|
20
|
+
const reasons = new Set(indexStatus.complete ? [] : ["index"]);
|
|
21
|
+
const needle = query.trim();
|
|
22
|
+
if (!needle)
|
|
23
|
+
return { hits, truncated: false, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
24
|
+
const tool = createGrepTool(root);
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), MAX_CONTENT_MS);
|
|
27
|
+
try {
|
|
28
|
+
for (const candidate of candidates) {
|
|
29
|
+
if (candidate.kind !== "file")
|
|
30
|
+
continue;
|
|
31
|
+
if (controller.signal.aborted) {
|
|
32
|
+
reasons.add("time");
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
// Validate even an excluded candidate: metadata and cached paths are not a security boundary.
|
|
36
|
+
let absolute;
|
|
37
|
+
try {
|
|
38
|
+
absolute = await resolveInsideRoot(root, candidate.path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const code = error.code;
|
|
42
|
+
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
43
|
+
throw error;
|
|
44
|
+
skipped.add(candidate.path);
|
|
45
|
+
reasons.add("unreadable");
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
49
|
+
skipped.add(candidate.path);
|
|
50
|
+
reasons.add("depth");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
54
|
+
skipped.add(candidate.path);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let bytes;
|
|
58
|
+
try {
|
|
59
|
+
// Read at most cap+1 bytes even if a file grows after stat; never allocate its untrusted size.
|
|
60
|
+
const handle = await open(absolute, "r");
|
|
61
|
+
try {
|
|
62
|
+
const info = await handle.stat();
|
|
63
|
+
if (!info.isFile()) {
|
|
64
|
+
skipped.add(candidate.path);
|
|
65
|
+
reasons.add("unreadable");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (info.size > MAX_CONTENT_FILE_BYTES) {
|
|
69
|
+
skipped.add(candidate.path);
|
|
70
|
+
reasons.add("size");
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
74
|
+
let length = 0;
|
|
75
|
+
while (length < buffer.length) {
|
|
76
|
+
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
77
|
+
if (!read.bytesRead)
|
|
78
|
+
break;
|
|
79
|
+
length += read.bytesRead;
|
|
80
|
+
}
|
|
81
|
+
if (length > MAX_CONTENT_FILE_BYTES) {
|
|
82
|
+
skipped.add(candidate.path);
|
|
83
|
+
reasons.add("size");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
bytes = buffer.subarray(0, length);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
skipped.add(candidate.path);
|
|
94
|
+
reasons.add("unreadable");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (looksBinary(bytes) || bytes.includes(0)) {
|
|
98
|
+
skipped.add(candidate.path);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
skipped.add(candidate.path);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await tool.execute("content-search", {
|
|
111
|
+
pattern: needle, path: absolute, literal: true, limit: MAX_CONTENT_RESULTS - hits.length,
|
|
112
|
+
}, controller.signal);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (controller.signal.aborted) {
|
|
116
|
+
reasons.add("time");
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
// SDK/ripgrep failures are request failures, never a successful empty search.
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const prefix = basename(absolute) + ":";
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const block of result.content) {
|
|
125
|
+
if (block.type !== "text")
|
|
126
|
+
continue;
|
|
127
|
+
for (const row of block.text.split("\n")) {
|
|
128
|
+
if (!row.startsWith(prefix))
|
|
129
|
+
continue;
|
|
130
|
+
const match = /^(\d+): (.*)$/.exec(row.slice(prefix.length));
|
|
131
|
+
if (!match)
|
|
132
|
+
continue;
|
|
133
|
+
rows.push({ path: candidate.path, name: basename(absolute), line: Number(match[1]), text: match[2] });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (rows.length) {
|
|
137
|
+
await resolveInsideRoot(root, candidate.path);
|
|
138
|
+
hits.push(...rows.slice(0, MAX_CONTENT_RESULTS - hits.length));
|
|
139
|
+
}
|
|
140
|
+
const details = result.details;
|
|
141
|
+
if (details?.truncation?.truncated)
|
|
142
|
+
reasons.add("output");
|
|
143
|
+
if (details?.linesTruncated)
|
|
144
|
+
reasons.add("line");
|
|
145
|
+
if (details?.matchLimitReached || hits.length >= MAX_CONTENT_RESULTS) {
|
|
146
|
+
reasons.add("results");
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
return { hits, truncated: reasons.size > 0, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools whose arguments name a file the agent is about to WRITE.
|
|
3
|
+
*
|
|
4
|
+
* `edit` and `write` are Pi's two file-writing tools (`node_modules/@earendil-works/pi-coding-agent/
|
|
5
|
+
* dist/core/tools/`), and `notebookedit` is the third in the family that edits a notebook. The list is
|
|
6
|
+
* deliberately narrow: a `read` names a file too, and a `read` changes nothing, so treating it as a
|
|
7
|
+
* change would mark a file the agent merely LOOKED at as one it edited - and a marker that lies is worse
|
|
8
|
+
* than no marker, because the user's trust in every other marker goes with it.
|
|
9
|
+
*
|
|
10
|
+
* The names are matched case-insensitively and after trimming, because the name comes off a wire and a
|
|
11
|
+
* tool named "Edit" is the same tool.
|
|
12
|
+
*/
|
|
13
|
+
export declare const FILE_WRITING_TOOLS: Set<string>;
|
|
14
|
+
/**
|
|
15
|
+
* The path an argument names, or `undefined` when the arguments name none.
|
|
16
|
+
*
|
|
17
|
+
* NOT a display string. `describeToolCall` collapses whitespace and caps the length, which is right for
|
|
18
|
+
* a card and wrong for a path that is about to be read off the disk - a path with a run of spaces in it
|
|
19
|
+
* would be reported as a path with one space, and the file would not be found. This returns the value
|
|
20
|
+
* as the tool spelled it, so the caller can resolve it properly.
|
|
21
|
+
*
|
|
22
|
+
* The value is NOT validated here. The caller resolves it against the workspace root and refuses
|
|
23
|
+
* anything that leaves it - the containment rule lives in `files.ts` and has exactly one owner, and this
|
|
24
|
+
* module does not get a second, weaker copy of it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toolTargetPath(name: string, args: unknown): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* One recorded change: a file the agent wrote, and what it looked like before.
|
|
29
|
+
*
|
|
30
|
+
* `before` is the content as it was on disk when the write was announced, or `null` when the file did
|
|
31
|
+
* not exist then - which is how a NEW file is told apart from an EDITED one, and the two are different
|
|
32
|
+
* things to a reader. `null` is also the honest value for a file this server never saw the contents of
|
|
33
|
+
* (too large, binary, unreadable), and `beforeUnknown` says which of those it was.
|
|
34
|
+
*/
|
|
35
|
+
export type RecordedChange = {
|
|
36
|
+
/** Workspace-relative, `/`-separated - the same spelling every other file path uses on the wire. */
|
|
37
|
+
path: string;
|
|
38
|
+
/** The content before the agent's write, or `null` when there was none (a new file, or unreadable). */
|
|
39
|
+
before: string | null;
|
|
40
|
+
/** True when `before` is `null` because the content could not be read, rather than because the file
|
|
41
|
+
* did not exist. The difference matters to the diff view: "the file is new" and "we could not read
|
|
42
|
+
* the old version" are different facts and the reader is told which one happened. */
|
|
43
|
+
beforeUnknown: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* THE ETAG OF THE FILE AS IT WAS WHEN THE BASELINE WAS TAKEN.
|
|
46
|
+
*
|
|
47
|
+
* This is what makes the baseline TRUSTWORTHY rather than merely plausible. See `confirm` below.
|
|
48
|
+
*/
|
|
49
|
+
etag?: string;
|
|
50
|
+
/** When the change was recorded, so the panel can order a turn's changes the way they happened. */
|
|
51
|
+
at: number;
|
|
52
|
+
/** The conversation the change belongs to, so a workspace's changes can be attributed to a session. */
|
|
53
|
+
conversationId?: string;
|
|
54
|
+
};
|
|
55
|
+
export declare class FileChangeTracker {
|
|
56
|
+
#private;
|
|
57
|
+
/**
|
|
58
|
+
* Records that the agent is about to write `path`, capturing the current content as the baseline.
|
|
59
|
+
*
|
|
60
|
+
* Called when the tool call is ANNOUNCED, not when it ends. The announcement is the last moment the
|
|
61
|
+
* old bytes are still on the disk: by the time the tool has finished, the file already holds the new
|
|
62
|
+
* content and the baseline is gone forever. This is the whole reason the tracker reads the disk at
|
|
63
|
+
* all.
|
|
64
|
+
*
|
|
65
|
+
* A file that is already tracked is NOT re-baselined. Re-baselining would set the "before" to the
|
|
66
|
+
* agent's own intermediate state, and the diff would then show only the LAST edit rather than
|
|
67
|
+
* everything the agent did to the file in this turn - which is the thing a reviewer needs to see.
|
|
68
|
+
*
|
|
69
|
+
* THE READ IS SYNCHRONOUS, and that is the whole reason this feature works.
|
|
70
|
+
*
|
|
71
|
+
* Pi announces a tool call and then RUNS it, and it does not wait for this server to acknowledge the
|
|
72
|
+
* announcement. So the tool may well have written the file by the time this server gets around to
|
|
73
|
+
* reading it - and an ASYNCHRONOUS read loses that race almost every time, because the read is queued
|
|
74
|
+
* behind the very write it is trying to get ahead of. The first version of this was asynchronous, and
|
|
75
|
+
* the symptom was a diff that said "+0 / -0": the baseline it captured was the file's NEW content,
|
|
76
|
+
* because by the time the read happened the agent had already written.
|
|
77
|
+
*
|
|
78
|
+
* A synchronous read cannot lose that race in the same way - it happens in the same tick the
|
|
79
|
+
* announcement was parsed, before anything else can run - and the cost is bounded by the size cap
|
|
80
|
+
* below. A blocked event loop for the length of one small file read, once per agent write, is a far
|
|
81
|
+
* better trade than a diff that shows nothing.
|
|
82
|
+
*/
|
|
83
|
+
record(workspaceId: string, path: string, options: {
|
|
84
|
+
conversationId?: string;
|
|
85
|
+
root: string;
|
|
86
|
+
}): void;
|
|
87
|
+
/**
|
|
88
|
+
* CONFIRMS A BASELINE, once the agent's write has finished.
|
|
89
|
+
*
|
|
90
|
+
* The synchronous read makes the baseline correct in practice, but "in practice" is not a guarantee:
|
|
91
|
+
* if the tool somehow ran before the announcement was processed, the baseline would be the file's NEW
|
|
92
|
+
* content and the diff would show nothing at all - a diff that says "no change" about a file the
|
|
93
|
+
* agent just rewrote, which is the worst possible lie this feature could tell.
|
|
94
|
+
*
|
|
95
|
+
* So the baseline is CHECKED rather than trusted. The file's etag after the write must DIFFER from
|
|
96
|
+
* the etag the baseline was taken at - and if it does not, the baseline was taken after the write and
|
|
97
|
+
* is worthless. The change is then marked as having no usable "before", and the diff view says so
|
|
98
|
+
* instead of showing an empty diff.
|
|
99
|
+
*
|
|
100
|
+
* The one case this cannot tell apart is a write that changed nothing (the agent wrote identical
|
|
101
|
+
* bytes). There the baseline and the file agree, and the diff is empty - which is CORRECT, because
|
|
102
|
+
* nothing changed. The reader is told the before-version is unconfirmed, which is true in both cases.
|
|
103
|
+
*/
|
|
104
|
+
confirm(workspaceId: string, path: string, root: string): void;
|
|
105
|
+
/**
|
|
106
|
+
* The changes recorded for a workspace, newest first.
|
|
107
|
+
*
|
|
108
|
+
* `dropped` says how many baselines were evicted, so the panel can say so rather than quietly showing
|
|
109
|
+
* a shorter list than the agent actually produced.
|
|
110
|
+
*/
|
|
111
|
+
list(workspaceId: string): {
|
|
112
|
+
changes: RecordedChange[];
|
|
113
|
+
dropped: number;
|
|
114
|
+
};
|
|
115
|
+
/** The recorded change for one path, or `undefined` when there is none. */
|
|
116
|
+
get(workspaceId: string, path: string): RecordedChange | undefined;
|
|
117
|
+
/** Forgets a workspace's changes. Used when the workspace is deleted. */
|
|
118
|
+
forget(workspaceId: string): void;
|
|
119
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE AGENT CHANGED, AND WHAT IT LOOKED LIKE BEFORE.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. The Files panel could show a file, but it could not show a CHANGE - and a panel
|
|
5
|
+
* that cannot show a change is a file browser, not a workbench. The user asked for the opposite of a
|
|
6
|
+
* browser: "看得到文件才能更好地支持 ai 的改动" - being able to SEE the files is what makes the agent's
|
|
7
|
+
* edits reviewable. Reviewing an edit means seeing two things: WHICH files were touched, and WHAT
|
|
8
|
+
* changed in them. Neither existed.
|
|
9
|
+
*
|
|
10
|
+
* THE SERVICE ALREADY KNEW THE FIRST HALF AND THREW IT AWAY. `tool-calls.ts` reads a tool's target path
|
|
11
|
+
* out of its arguments to draw one line on a card ("edit src/server/files.ts"). That line was the ONLY
|
|
12
|
+
* place the information existed, and it was never sent to the Files panel - the WS protocol had no file
|
|
13
|
+
* event at all, so a file the agent rewrote looked exactly like a file it had never touched.
|
|
14
|
+
*
|
|
15
|
+
* WHY THE PATHS ARE READ FROM THE ARGUMENTS RATHER THAN GUESSED FROM A WATCHER. A watcher sees bytes
|
|
16
|
+
* changing; it does not see WHO changed them, and it cannot distinguish the agent's edit from an
|
|
17
|
+
* unrelated build step. The tool call is the agent saying "I am about to change this path" in its own
|
|
18
|
+
* words - the most reliable source available, and one that costs nothing to read.
|
|
19
|
+
*
|
|
20
|
+
* WHY THE "BEFORE" CONTENT IS CAPTURED HERE RATHER THAN RECOVERED LATER. Once the agent has written the
|
|
21
|
+
* file, the old bytes are gone from the disk. A diff needs a baseline, and the baseline has to be taken
|
|
22
|
+
* BEFORE the write. So this file keeps a copy of the content as it was, taken when the agent's write
|
|
23
|
+
* tool was announced - the last moment the old content is still on disk.
|
|
24
|
+
*
|
|
25
|
+
* WHAT THIS FILE DOES NOT DO. It does not watch the disk. An external editor saving a file is noticed by
|
|
26
|
+
* the CLIENT's existing poll (`?meta=1` on open files), not here - and that asymmetry is deliberate: this
|
|
27
|
+
* file is about the agent's changes, which is the half only the server can see. It does not keep a full
|
|
28
|
+
* copy of every file: only the files the agent actually wrote, and only until the diff is read.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, statSync } from "node:fs";
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
33
|
+
/**
|
|
34
|
+
* The tools whose arguments name a file the agent is about to WRITE.
|
|
35
|
+
*
|
|
36
|
+
* `edit` and `write` are Pi's two file-writing tools (`node_modules/@earendil-works/pi-coding-agent/
|
|
37
|
+
* dist/core/tools/`), and `notebookedit` is the third in the family that edits a notebook. The list is
|
|
38
|
+
* deliberately narrow: a `read` names a file too, and a `read` changes nothing, so treating it as a
|
|
39
|
+
* change would mark a file the agent merely LOOKED at as one it edited - and a marker that lies is worse
|
|
40
|
+
* than no marker, because the user's trust in every other marker goes with it.
|
|
41
|
+
*
|
|
42
|
+
* The names are matched case-insensitively and after trimming, because the name comes off a wire and a
|
|
43
|
+
* tool named "Edit" is the same tool.
|
|
44
|
+
*/
|
|
45
|
+
export const FILE_WRITING_TOOLS = new Set(["edit", "write", "notebookedit", "multiedit"]);
|
|
46
|
+
/**
|
|
47
|
+
* The argument fields that name the file, in the order the tools tend to spell them.
|
|
48
|
+
*
|
|
49
|
+
* The same list `tool-calls.ts` uses to draw its one-line summary, and deliberately the same ORDER: two
|
|
50
|
+
* tables that disagree about which field wins would make the card say "src/a.ts" and the change tracker
|
|
51
|
+
* say "src/b.ts" for the same call, and the user would have no way to tell which one is right.
|
|
52
|
+
*
|
|
53
|
+
* `notebook_path` is here because the notebook tools spell it that way, and it is checked before the
|
|
54
|
+
* generic `path` only because the notebook tools have no plain `path` field to collide with.
|
|
55
|
+
*/
|
|
56
|
+
const PATH_KEYS = ["path", "file_path", "filePath", "target_file", "filename", "file", "notebook_path"];
|
|
57
|
+
/**
|
|
58
|
+
* The path an argument names, or `undefined` when the arguments name none.
|
|
59
|
+
*
|
|
60
|
+
* NOT a display string. `describeToolCall` collapses whitespace and caps the length, which is right for
|
|
61
|
+
* a card and wrong for a path that is about to be read off the disk - a path with a run of spaces in it
|
|
62
|
+
* would be reported as a path with one space, and the file would not be found. This returns the value
|
|
63
|
+
* as the tool spelled it, so the caller can resolve it properly.
|
|
64
|
+
*
|
|
65
|
+
* The value is NOT validated here. The caller resolves it against the workspace root and refuses
|
|
66
|
+
* anything that leaves it - the containment rule lives in `files.ts` and has exactly one owner, and this
|
|
67
|
+
* module does not get a second, weaker copy of it.
|
|
68
|
+
*/
|
|
69
|
+
export function toolTargetPath(name, args) {
|
|
70
|
+
if (typeof name !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
if (!FILE_WRITING_TOOLS.has(name.trim().toLowerCase()))
|
|
73
|
+
return undefined;
|
|
74
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
75
|
+
return undefined;
|
|
76
|
+
const record = args;
|
|
77
|
+
for (const key of PATH_KEYS) {
|
|
78
|
+
const value = record[key];
|
|
79
|
+
if (typeof value !== "string")
|
|
80
|
+
continue;
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (trimmed)
|
|
83
|
+
return trimmed;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The changes recorded for one workspace, and the old contents they need.
|
|
89
|
+
*
|
|
90
|
+
* BOUNDED ON PURPOSE, in two directions. The number of entries is capped because a long session can
|
|
91
|
+
* touch a lot of files, and the copy of every old version is the memory cost of this feature. The size
|
|
92
|
+
* of ONE old version is capped because a single 5MB file would otherwise be the whole budget.
|
|
93
|
+
*
|
|
94
|
+
* When a cap is reached, the OLDEST entry is dropped, and `dropped` counts how many were dropped. That
|
|
95
|
+
* count is reported rather than hidden: a diff view that says "this file changed" and then cannot show
|
|
96
|
+
* the change because the baseline was evicted is a worse experience than one that says "the baseline for
|
|
97
|
+
* this file is no longer available", and the second is what the count makes possible.
|
|
98
|
+
*/
|
|
99
|
+
const MAX_TRACKED_CHANGES = 200;
|
|
100
|
+
const MAX_BASELINE_BYTES = 1024 * 1024;
|
|
101
|
+
/**
|
|
102
|
+
* The identity of a file's current bytes - the same formula `files.ts` uses, so the two agree.
|
|
103
|
+
*
|
|
104
|
+
* Size, nanosecond modification time and inode: the two fields together change on any edit a human
|
|
105
|
+
* would call an edit, and `bigint` is what makes the modification time exact - the millisecond float
|
|
106
|
+
* `stat` reports by default rounds two edits inside the same millisecond into one value, and a check
|
|
107
|
+
* that misses a change is worse than no check.
|
|
108
|
+
*/
|
|
109
|
+
function etagOf(size, mtimeNs, ino) {
|
|
110
|
+
return createHash("sha1").update(`${size}:${mtimeNs}:${ino}`).digest("hex").slice(0, 20);
|
|
111
|
+
}
|
|
112
|
+
export class FileChangeTracker {
|
|
113
|
+
/** Workspace id -> path -> the change recorded for it. A path is recorded once: the LAST change to a
|
|
114
|
+
* file wins, because a file edited three times in a turn is one file with one final state, and the
|
|
115
|
+
* reader wants the diff from before the FIRST change to after the LAST one. */
|
|
116
|
+
#byWorkspace = new Map();
|
|
117
|
+
/** How many changes were dropped because the cap was reached, per workspace. */
|
|
118
|
+
#dropped = new Map();
|
|
119
|
+
/**
|
|
120
|
+
* Records that the agent is about to write `path`, capturing the current content as the baseline.
|
|
121
|
+
*
|
|
122
|
+
* Called when the tool call is ANNOUNCED, not when it ends. The announcement is the last moment the
|
|
123
|
+
* old bytes are still on the disk: by the time the tool has finished, the file already holds the new
|
|
124
|
+
* content and the baseline is gone forever. This is the whole reason the tracker reads the disk at
|
|
125
|
+
* all.
|
|
126
|
+
*
|
|
127
|
+
* A file that is already tracked is NOT re-baselined. Re-baselining would set the "before" to the
|
|
128
|
+
* agent's own intermediate state, and the diff would then show only the LAST edit rather than
|
|
129
|
+
* everything the agent did to the file in this turn - which is the thing a reviewer needs to see.
|
|
130
|
+
*
|
|
131
|
+
* THE READ IS SYNCHRONOUS, and that is the whole reason this feature works.
|
|
132
|
+
*
|
|
133
|
+
* Pi announces a tool call and then RUNS it, and it does not wait for this server to acknowledge the
|
|
134
|
+
* announcement. So the tool may well have written the file by the time this server gets around to
|
|
135
|
+
* reading it - and an ASYNCHRONOUS read loses that race almost every time, because the read is queued
|
|
136
|
+
* behind the very write it is trying to get ahead of. The first version of this was asynchronous, and
|
|
137
|
+
* the symptom was a diff that said "+0 / -0": the baseline it captured was the file's NEW content,
|
|
138
|
+
* because by the time the read happened the agent had already written.
|
|
139
|
+
*
|
|
140
|
+
* A synchronous read cannot lose that race in the same way - it happens in the same tick the
|
|
141
|
+
* announcement was parsed, before anything else can run - and the cost is bounded by the size cap
|
|
142
|
+
* below. A blocked event loop for the length of one small file read, once per agent write, is a far
|
|
143
|
+
* better trade than a diff that shows nothing.
|
|
144
|
+
*/
|
|
145
|
+
record(workspaceId, path, options) {
|
|
146
|
+
const relativePath = this.#relative(options.root, path);
|
|
147
|
+
if (!relativePath)
|
|
148
|
+
return;
|
|
149
|
+
const changes = this.#byWorkspace.get(workspaceId) ?? new Map();
|
|
150
|
+
this.#byWorkspace.set(workspaceId, changes);
|
|
151
|
+
if (changes.has(relativePath))
|
|
152
|
+
return;
|
|
153
|
+
let before = null;
|
|
154
|
+
let beforeUnknown = false;
|
|
155
|
+
let etag;
|
|
156
|
+
try {
|
|
157
|
+
const absolute = resolve(options.root, path);
|
|
158
|
+
// The size is checked BEFORE the read, so an oversized file costs one stat rather than a buffer.
|
|
159
|
+
const info = statSync(absolute, { bigint: true });
|
|
160
|
+
if (info.size > MAX_BASELINE_BYTES) {
|
|
161
|
+
// A baseline larger than the cap is not a baseline - it is a memory cost with no diff to show
|
|
162
|
+
// for it. The change is still recorded (the file WAS changed, and that fact is worth knowing);
|
|
163
|
+
// what is dropped is the ability to show what it was, which `beforeUnknown` says out loud.
|
|
164
|
+
beforeUnknown = true;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
before = readFileSync(absolute, "utf8");
|
|
168
|
+
etag = etagOf(info.size, info.mtimeNs, info.ino);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const code = error?.code;
|
|
173
|
+
// ENOENT is the ordinary case of a NEW file, and it is a fact rather than a failure: the file did
|
|
174
|
+
// not exist before the agent created it, so there is no "before" to show. Anything else (a
|
|
175
|
+
// permission problem, a directory) means we simply do not know, and the two are told apart by
|
|
176
|
+
// `beforeUnknown`.
|
|
177
|
+
beforeUnknown = code !== "ENOENT" && code !== "ENOTDIR";
|
|
178
|
+
}
|
|
179
|
+
changes.set(relativePath, {
|
|
180
|
+
path: relativePath,
|
|
181
|
+
before,
|
|
182
|
+
beforeUnknown,
|
|
183
|
+
...(etag ? { etag } : {}),
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
...(options.conversationId ? { conversationId: options.conversationId } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (changes.size > MAX_TRACKED_CHANGES) {
|
|
188
|
+
// The OLDEST goes, because a reviewer is looking at the most recent work. `Map` preserves
|
|
189
|
+
// insertion order, so the first key is the oldest.
|
|
190
|
+
const oldest = changes.keys().next();
|
|
191
|
+
if (!oldest.done) {
|
|
192
|
+
changes.delete(oldest.value);
|
|
193
|
+
this.#dropped.set(workspaceId, (this.#dropped.get(workspaceId) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* CONFIRMS A BASELINE, once the agent's write has finished.
|
|
199
|
+
*
|
|
200
|
+
* The synchronous read makes the baseline correct in practice, but "in practice" is not a guarantee:
|
|
201
|
+
* if the tool somehow ran before the announcement was processed, the baseline would be the file's NEW
|
|
202
|
+
* content and the diff would show nothing at all - a diff that says "no change" about a file the
|
|
203
|
+
* agent just rewrote, which is the worst possible lie this feature could tell.
|
|
204
|
+
*
|
|
205
|
+
* So the baseline is CHECKED rather than trusted. The file's etag after the write must DIFFER from
|
|
206
|
+
* the etag the baseline was taken at - and if it does not, the baseline was taken after the write and
|
|
207
|
+
* is worthless. The change is then marked as having no usable "before", and the diff view says so
|
|
208
|
+
* instead of showing an empty diff.
|
|
209
|
+
*
|
|
210
|
+
* The one case this cannot tell apart is a write that changed nothing (the agent wrote identical
|
|
211
|
+
* bytes). There the baseline and the file agree, and the diff is empty - which is CORRECT, because
|
|
212
|
+
* nothing changed. The reader is told the before-version is unconfirmed, which is true in both cases.
|
|
213
|
+
*/
|
|
214
|
+
confirm(workspaceId, path, root) {
|
|
215
|
+
const changes = this.#byWorkspace.get(workspaceId);
|
|
216
|
+
if (!changes)
|
|
217
|
+
return;
|
|
218
|
+
// The path is spelled the way the tracker spells it - workspace-relative, `/`-separated - and the
|
|
219
|
+
// caller may hold the tool's own spelling, so both are tried.
|
|
220
|
+
const relativePath = this.#relative(root, path);
|
|
221
|
+
const change = (relativePath ? changes.get(relativePath) : undefined) ?? changes.get(path);
|
|
222
|
+
if (!change || !change.etag)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
const info = statSync(resolve(root, change.path), { bigint: true });
|
|
226
|
+
const current = etagOf(info.size, info.mtimeNs, info.ino);
|
|
227
|
+
if (current === change.etag) {
|
|
228
|
+
// The file is byte-for-byte what the baseline says it was, so the baseline was taken AFTER the
|
|
229
|
+
// write - or nothing changed at all. Either way it cannot be shown as "before".
|
|
230
|
+
change.before = null;
|
|
231
|
+
change.beforeUnknown = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// The file is gone: the agent DELETED it, which is a change of its own. The baseline stands.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The changes recorded for a workspace, newest first.
|
|
240
|
+
*
|
|
241
|
+
* `dropped` says how many baselines were evicted, so the panel can say so rather than quietly showing
|
|
242
|
+
* a shorter list than the agent actually produced.
|
|
243
|
+
*/
|
|
244
|
+
list(workspaceId) {
|
|
245
|
+
const changes = [...(this.#byWorkspace.get(workspaceId)?.values() ?? [])];
|
|
246
|
+
changes.reverse();
|
|
247
|
+
return { changes, dropped: this.#dropped.get(workspaceId) ?? 0 };
|
|
248
|
+
}
|
|
249
|
+
/** The recorded change for one path, or `undefined` when there is none. */
|
|
250
|
+
get(workspaceId, path) {
|
|
251
|
+
return this.#byWorkspace.get(workspaceId)?.get(path);
|
|
252
|
+
}
|
|
253
|
+
/** Forgets a workspace's changes. Used when the workspace is deleted. */
|
|
254
|
+
forget(workspaceId) {
|
|
255
|
+
this.#byWorkspace.delete(workspaceId);
|
|
256
|
+
this.#dropped.delete(workspaceId);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The path as the rest of the app spells it: workspace-relative, `/`-separated, no leading slash.
|
|
260
|
+
*
|
|
261
|
+
* REFUSES ANYTHING OUTSIDE THE ROOT, rather than clamping it. A tool that names `../outside.txt` is
|
|
262
|
+
* either broken or hostile, and a "relative path" that silently becomes `../outside.txt` would be a
|
|
263
|
+
* path the panel would then try to read. The containment decision itself belongs to `files.ts`; this
|
|
264
|
+
* is only about the SPELLING, and it declines to spell a path it cannot vouch for.
|
|
265
|
+
*/
|
|
266
|
+
#relative(root, path) {
|
|
267
|
+
const base = resolve(root);
|
|
268
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(base, path);
|
|
269
|
+
const rel = relative(base, absolute);
|
|
270
|
+
if (rel === "")
|
|
271
|
+
return null;
|
|
272
|
+
if (isAbsolute(rel))
|
|
273
|
+
return null;
|
|
274
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith("../"))
|
|
275
|
+
return null;
|
|
276
|
+
return rel.split(sep).join("/");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FileChangeTracker } from "./file-changes.js";
|
|
2
|
+
/**
|
|
3
|
+
* WHERE THE "BEFORE" CAME FROM. The client renders a different label for each, because they are
|
|
4
|
+
* different claims: "this is what the agent changed" and "this is what the repository had committed"
|
|
5
|
+
* are not the same sentence, and a diff shown without knowing which one it is against is a diff the
|
|
6
|
+
* reader has to guess about.
|
|
7
|
+
*/
|
|
8
|
+
export type DiffBaselineKind = "agent" | "git" | "none";
|
|
9
|
+
export type FileDiff = {
|
|
10
|
+
/** The file, workspace-relative and `/`-separated. */
|
|
11
|
+
path: string;
|
|
12
|
+
/** Which baseline was used. */
|
|
13
|
+
baseline: DiffBaselineKind;
|
|
14
|
+
/** The content before, or `null` when there was none - which is how a NEW file is told from an
|
|
15
|
+
* EDITED one. */
|
|
16
|
+
before: string | null;
|
|
17
|
+
/** True when the "before" could not be READ, as opposed to not existing. A diff against an empty
|
|
18
|
+
* "before" for a file that existed would be a lie, and this is what keeps it from being one. */
|
|
19
|
+
beforeUnknown?: boolean;
|
|
20
|
+
/** The content now, or `null` when the file is gone. */
|
|
21
|
+
after: string | null;
|
|
22
|
+
/** A sentence naming the baseline, for the view to show. */
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The diff of one file, with the best baseline available.
|
|
27
|
+
*
|
|
28
|
+
* `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
|
|
29
|
+
* the review view asks for; naming a specific baseline means the user has been told which comparison
|
|
30
|
+
* they are looking at and it must not silently become a different one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function computeFileDiff(options: {
|
|
33
|
+
root: string;
|
|
34
|
+
path: string;
|
|
35
|
+
preferred?: "auto" | DiffBaselineKind;
|
|
36
|
+
tracker?: FileChangeTracker;
|
|
37
|
+
workspaceId?: string;
|
|
38
|
+
}): Promise<FileDiff>;
|