@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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DIFF BETWEEN WHAT A FILE WAS AND WHAT IT IS NOW.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE SERVER SENDS TWO TEXTS RATHER THAN A DIFF. The difference between two texts is computed by
|
|
5
|
+
* an algorithm, and there are good ones (jsdiff, on the client). Sending a diff instead would mean the
|
|
6
|
+
* server owns a second implementation of that algorithm, and the two would disagree at the edges - a
|
|
7
|
+
* hunk boundary, an empty file, a file with no trailing newline. Sending the two TEXTS means there is
|
|
8
|
+
* exactly ONE implementation of "what is the difference between these two strings" in the whole
|
|
9
|
+
* product, and the server's job is reduced to the one thing only it can do: produce the "before".
|
|
10
|
+
*
|
|
11
|
+
* WHERE THE "BEFORE" COMES FROM, in the order the user cares about.
|
|
12
|
+
*
|
|
13
|
+
* 1. THE AGENT'S OWN BASELINE. The server captured the file's content at the moment the agent's write
|
|
14
|
+
* tool was announced (`file-changes.ts`), so this is the exact content the agent started from. It is
|
|
15
|
+
* the right answer for "what did the agent just do", and it works in a directory that is not a git
|
|
16
|
+
* repository - which is most of them, for a user who has not initialised a repo.
|
|
17
|
+
* 2. GIT'S COMMITTED VERSION. For a file in a git repository, `HEAD` is a baseline that exists even
|
|
18
|
+
* when the server was not running when the change was made, and it is the baseline a reviewer
|
|
19
|
+
* already has in mind.
|
|
20
|
+
* 3. NOTHING. When neither exists - a brand new file in a directory that is not a repository, with no
|
|
21
|
+
* agent change to reconstruct - the answer is that there is no baseline, and the view says so
|
|
22
|
+
* rather than showing a diff against nothing.
|
|
23
|
+
*
|
|
24
|
+
* The BUFFER is the fourth baseline and it does not come from the server: the "before" of a buffer is
|
|
25
|
+
* the file on disk and the "after" is what the user has typed, and the client already holds both. So
|
|
26
|
+
* the client computes that one itself and never asks.
|
|
27
|
+
*/
|
|
28
|
+
import { readFile } from "node:fs/promises";
|
|
29
|
+
import { gitHeadContent, isGitRepository } from "./git.js";
|
|
30
|
+
import { FilesError, resolveInsideRoot } from "./files.js";
|
|
31
|
+
/**
|
|
32
|
+
* The diff of one file, with the best baseline available.
|
|
33
|
+
*
|
|
34
|
+
* `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
|
|
35
|
+
* the review view asks for; naming a specific baseline means the user has been told which comparison
|
|
36
|
+
* they are looking at and it must not silently become a different one.
|
|
37
|
+
*/
|
|
38
|
+
export async function computeFileDiff(options) {
|
|
39
|
+
const { root, path, preferred = "auto", tracker, workspaceId } = options;
|
|
40
|
+
const after = await readAfter(root, path);
|
|
41
|
+
// The agent's baseline. It is the RIGHT one whenever it exists, because it is the content the agent
|
|
42
|
+
// actually started from - which is not necessarily HEAD, and not necessarily what the file looked
|
|
43
|
+
// like when the server started.
|
|
44
|
+
const agentChange = tracker && workspaceId ? tracker.get(workspaceId, path) : undefined;
|
|
45
|
+
if ((preferred === "auto" || preferred === "agent") && agentChange) {
|
|
46
|
+
return {
|
|
47
|
+
path,
|
|
48
|
+
baseline: "agent",
|
|
49
|
+
before: agentChange.before,
|
|
50
|
+
...(agentChange.beforeUnknown ? { beforeUnknown: true } : {}),
|
|
51
|
+
after,
|
|
52
|
+
label: agentChange.before === null && !agentChange.beforeUnknown
|
|
53
|
+
? "这个文件是 agent 新建的"
|
|
54
|
+
: "agent 改动前",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (preferred === "auto" || preferred === "git") {
|
|
58
|
+
if (await isGitRepository(root)) {
|
|
59
|
+
const head = await gitHeadContent(root, path);
|
|
60
|
+
if (head !== null) {
|
|
61
|
+
return { path, baseline: "git", before: head, after, label: "Git 已提交的版本" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (preferred === "agent" && !agentChange) {
|
|
66
|
+
// The caller ASKED for the agent's baseline and there is none. Saying so is the honest answer; a
|
|
67
|
+
// silent fall-back to git would show the user a comparison they did not ask for.
|
|
68
|
+
return { path, baseline: "none", before: null, after, label: "没有 agent 改动前的版本可比较" };
|
|
69
|
+
}
|
|
70
|
+
return { path, baseline: "none", before: null, after, label: "没有可比较的基线" };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The file's content now, or `null` when it is gone.
|
|
74
|
+
*
|
|
75
|
+
* A MISSING FILE IS NOT AN ERROR. The agent deleting a file is a change the user needs to see, and a
|
|
76
|
+
* diff view that refused to open because the file no longer exists would refuse to show exactly the
|
|
77
|
+
* change that matters most.
|
|
78
|
+
*
|
|
79
|
+
* THE PATH GOES THROUGH `resolveInsideRoot`, like every other path this server reads. The path arrives
|
|
80
|
+
* from a browser, so it is untrusted, and the question "does this resolve to somewhere inside the
|
|
81
|
+
* workspace" has exactly one owner in this codebase (`files.ts`). A second, weaker copy of the check
|
|
82
|
+
* here would be a check that is correct once.
|
|
83
|
+
*/
|
|
84
|
+
async function readAfter(root, path) {
|
|
85
|
+
try {
|
|
86
|
+
const target = await resolveInsideRoot(root, path);
|
|
87
|
+
return await readFile(target, "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// A file that is GONE is a fact the diff reports; a path that ESCAPES the workspace is a refusal
|
|
91
|
+
// the route turns into a 403. They are told apart here so the route can do the right thing with
|
|
92
|
+
// each, rather than both becoming "no content".
|
|
93
|
+
if (error instanceof FilesError && (error.status === 403 || error.code === "outside_workspace"))
|
|
94
|
+
throw error;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type FileMeta } from "./files.js";
|
|
2
|
+
export type IndexedFile = {
|
|
3
|
+
path: string;
|
|
4
|
+
kind: "file" | "directory";
|
|
5
|
+
size: number | null;
|
|
6
|
+
mtimeMs: number | null;
|
|
7
|
+
etag: string | null;
|
|
8
|
+
text: boolean | null;
|
|
9
|
+
};
|
|
10
|
+
export type FileIndexEvent = {
|
|
11
|
+
type: "files.index";
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
version: number;
|
|
14
|
+
changed: IndexedFile[];
|
|
15
|
+
removed: string[];
|
|
16
|
+
complete: boolean;
|
|
17
|
+
skipped: string[];
|
|
18
|
+
};
|
|
19
|
+
export type FileIndexSnapshot = {
|
|
20
|
+
version: number;
|
|
21
|
+
builtAt: number;
|
|
22
|
+
complete: boolean;
|
|
23
|
+
changed: IndexedFile[];
|
|
24
|
+
removed: string[];
|
|
25
|
+
skipped: string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Per-workspace index. The background pass uses readdir only because measurements on this repository
|
|
29
|
+
* show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
|
|
30
|
+
* deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
|
|
31
|
+
* where the existing read/write metadata already paid for it. The client polls only those open files.
|
|
32
|
+
*/
|
|
33
|
+
export declare class FileIndex {
|
|
34
|
+
readonly workspaceId: string;
|
|
35
|
+
private readonly broadcast;
|
|
36
|
+
readonly entries: Map<string, IndexedFile>;
|
|
37
|
+
private readonly history;
|
|
38
|
+
private root;
|
|
39
|
+
private queue;
|
|
40
|
+
private building;
|
|
41
|
+
private skipped;
|
|
42
|
+
version: number;
|
|
43
|
+
builtAt: number;
|
|
44
|
+
complete: boolean;
|
|
45
|
+
constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void);
|
|
46
|
+
start(root: string): void;
|
|
47
|
+
stop(): void;
|
|
48
|
+
snapshot(since?: number): FileIndexSnapshot;
|
|
49
|
+
refresh(path: string): Promise<void>;
|
|
50
|
+
/** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
|
|
51
|
+
* caller's file API operation has already passed the sole containment guard and read this metadata. */
|
|
52
|
+
recordFile(path: string, meta: FileMeta, mtimeMs?: number): void;
|
|
53
|
+
remove(path: string): void;
|
|
54
|
+
private scan;
|
|
55
|
+
private publish;
|
|
56
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative } from "./files.js";
|
|
3
|
+
const MAX_DIRECTORIES = 4000;
|
|
4
|
+
const MAX_FILES = 30000;
|
|
5
|
+
const MAX_MS = 4000;
|
|
6
|
+
/**
|
|
7
|
+
* Per-workspace index. The background pass uses readdir only because measurements on this repository
|
|
8
|
+
* show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
|
|
9
|
+
* deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
|
|
10
|
+
* where the existing read/write metadata already paid for it. The client polls only those open files.
|
|
11
|
+
*/
|
|
12
|
+
export class FileIndex {
|
|
13
|
+
workspaceId;
|
|
14
|
+
broadcast;
|
|
15
|
+
entries = new Map();
|
|
16
|
+
history = [];
|
|
17
|
+
root = "";
|
|
18
|
+
queue = [];
|
|
19
|
+
building = false;
|
|
20
|
+
skipped = [];
|
|
21
|
+
version = 0;
|
|
22
|
+
builtAt = 0;
|
|
23
|
+
complete = false;
|
|
24
|
+
constructor(workspaceId, broadcast) {
|
|
25
|
+
this.workspaceId = workspaceId;
|
|
26
|
+
this.broadcast = broadcast;
|
|
27
|
+
}
|
|
28
|
+
start(root) {
|
|
29
|
+
if (this.building || this.builtAt)
|
|
30
|
+
return;
|
|
31
|
+
this.root = root;
|
|
32
|
+
this.building = true;
|
|
33
|
+
this.queue = [""];
|
|
34
|
+
void this.scan();
|
|
35
|
+
}
|
|
36
|
+
stop() { }
|
|
37
|
+
snapshot(since) {
|
|
38
|
+
const valid = Number.isFinite(since) && since !== undefined;
|
|
39
|
+
const deltas = valid ? this.history.filter((item) => item.version > since) : [];
|
|
40
|
+
return { version: this.version, builtAt: this.builtAt, complete: this.complete, skipped: this.skipped,
|
|
41
|
+
changed: valid ? deltas.flatMap((item) => item.changed) : [...this.entries.values()],
|
|
42
|
+
removed: valid ? deltas.flatMap((item) => item.removed) : [] };
|
|
43
|
+
}
|
|
44
|
+
async refresh(path) {
|
|
45
|
+
if (!this.root)
|
|
46
|
+
return;
|
|
47
|
+
const guard = createRootGuard(this.root);
|
|
48
|
+
try {
|
|
49
|
+
const absolute = await guard(path);
|
|
50
|
+
const info = await stat(absolute);
|
|
51
|
+
const rel = toRelative(this.root, absolute);
|
|
52
|
+
if (info.isDirectory())
|
|
53
|
+
this.publish([{ path: rel, kind: "directory", size: null, mtimeMs: null, etag: null, text: null }], []);
|
|
54
|
+
else {
|
|
55
|
+
const meta = await fileMeta(absolute);
|
|
56
|
+
this.recordFile(rel, meta, info.mtimeMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const code = error.code;
|
|
61
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
62
|
+
this.remove(path);
|
|
63
|
+
else
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
|
|
68
|
+
* caller's file API operation has already passed the sole containment guard and read this metadata. */
|
|
69
|
+
recordFile(path, meta, mtimeMs = Date.parse(meta.modifiedAt)) {
|
|
70
|
+
const previous = this.entries.get(path);
|
|
71
|
+
if (previous?.kind === "file" && previous.etag === meta.etag)
|
|
72
|
+
return;
|
|
73
|
+
this.publish([{ path, kind: "file", size: meta.size, mtimeMs, etag: meta.etag, text: isTextName(path) }], []);
|
|
74
|
+
}
|
|
75
|
+
remove(path) { if (this.entries.delete(path))
|
|
76
|
+
this.publish([], [path]); }
|
|
77
|
+
async scan() {
|
|
78
|
+
const started = Date.now();
|
|
79
|
+
let dirs = 0;
|
|
80
|
+
let files = 0;
|
|
81
|
+
let incomplete = false;
|
|
82
|
+
const changed = [];
|
|
83
|
+
const guard = createRootGuard(this.root);
|
|
84
|
+
scan: while (this.queue.length) {
|
|
85
|
+
if (dirs >= MAX_DIRECTORIES || files >= MAX_FILES || Date.now() - started >= MAX_MS) {
|
|
86
|
+
incomplete = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const directory = this.queue.shift();
|
|
90
|
+
let listed;
|
|
91
|
+
try {
|
|
92
|
+
listed = await readdir(await guard(directory), { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
this.skipped.push(directory || "/");
|
|
96
|
+
incomplete = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
dirs += 1;
|
|
100
|
+
for (const entry of listed) {
|
|
101
|
+
const path = directory ? directory + "/" + entry.name : entry.name;
|
|
102
|
+
if (entry.isSymbolicLink()) {
|
|
103
|
+
this.skipped.push(path);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
if (SEARCH_SKIP_DIRS.has(entry.name)) {
|
|
108
|
+
this.skipped.push(path);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
this.queue.push(path);
|
|
112
|
+
changed.push({ path, kind: "directory", size: null, mtimeMs: null, etag: null, text: null });
|
|
113
|
+
}
|
|
114
|
+
else if (entry.isFile()) {
|
|
115
|
+
if (files >= MAX_FILES || Date.now() - started >= MAX_MS) {
|
|
116
|
+
incomplete = true;
|
|
117
|
+
break scan;
|
|
118
|
+
}
|
|
119
|
+
files += 1;
|
|
120
|
+
changed.push({ path, kind: "file", size: null, mtimeMs: null, etag: null, text: isTextName(path) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
124
|
+
}
|
|
125
|
+
this.building = false;
|
|
126
|
+
this.complete = !incomplete;
|
|
127
|
+
this.builtAt = Date.now();
|
|
128
|
+
// Quick Open does not need per-directory progress. One final batch bounds WS traffic and keeps the
|
|
129
|
+
// existing on-demand tree interactive throughout the scan (including dependency directories).
|
|
130
|
+
this.publish(changed, [], true);
|
|
131
|
+
}
|
|
132
|
+
publish(changed, removed, force = false) {
|
|
133
|
+
changed = changed.map((entry) => {
|
|
134
|
+
// A targeted stat may race the cheap directory walk. Do not let the later readdir-only result erase
|
|
135
|
+
// metadata that was already paid for and verified.
|
|
136
|
+
const previous = this.entries.get(entry.path);
|
|
137
|
+
const merged = previous?.kind === entry.kind && entry.size === null ? { ...entry, size: previous.size, mtimeMs: previous.mtimeMs, etag: previous.etag } : entry;
|
|
138
|
+
this.entries.set(entry.path, merged);
|
|
139
|
+
return merged;
|
|
140
|
+
});
|
|
141
|
+
if (!changed.length && !removed.length && !force)
|
|
142
|
+
return;
|
|
143
|
+
this.version += 1;
|
|
144
|
+
const event = { type: "files.index", workspaceId: this.workspaceId, version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
|
|
145
|
+
this.history.push({ version: this.version, changed, removed });
|
|
146
|
+
if (this.history.length > 128)
|
|
147
|
+
this.history.shift();
|
|
148
|
+
this.broadcast(event);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/server/files.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ export declare const MAX_SEARCH_RESULTS = 500;
|
|
|
15
15
|
/** How deep the recursive search walks. Twelve levels is past any real source tree; the cap is what
|
|
16
16
|
* turns a pathologically deep dependency tree from a hang into a result. */
|
|
17
17
|
export declare const MAX_SEARCH_DEPTH = 12;
|
|
18
|
+
/** Directories the recursive search skips. The TREE does not skip them - a directory the user cannot
|
|
19
|
+
* browse is a directory they cannot inspect a file in, and the binary handling below is only reachable
|
|
20
|
+
* if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
|
|
21
|
+
* from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
|
|
22
|
+
* reported in the response rather than being invisible. */
|
|
23
|
+
/** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
|
|
24
|
+
* does not use it: users may still browse dependencies on demand. */
|
|
25
|
+
export declare const SEARCH_SKIP_DIRS: Set<string>;
|
|
18
26
|
/**
|
|
19
27
|
* Extensions this panel OPENS without needing a sniff. It is a hint used only to label a file in the tree
|
|
20
28
|
* before it is read, and never as a permission: a `.png` is refused by name even if a sniffing pass would
|
package/dist/server/files.js
CHANGED
|
@@ -52,7 +52,9 @@ export const MAX_SEARCH_DEPTH = 12;
|
|
|
52
52
|
* if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
|
|
53
53
|
* from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
|
|
54
54
|
* reported in the response rather than being invisible. */
|
|
55
|
-
|
|
55
|
+
/** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
|
|
56
|
+
* does not use it: users may still browse dependencies on demand. */
|
|
57
|
+
export const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
|
|
56
58
|
/**
|
|
57
59
|
* Extensions that are binary by definition, checked BEFORE any bytes are read.
|
|
58
60
|
*
|
package/dist/server/git.d.ts
CHANGED
|
@@ -35,3 +35,20 @@ export declare function gitDiff(root: string, path: string, staged: boolean): Pr
|
|
|
35
35
|
diff: string;
|
|
36
36
|
kind: "text";
|
|
37
37
|
}>;
|
|
38
|
+
/**
|
|
39
|
+
* The content of a file AS IT WAS COMMITTED, or `null` when there is no such version.
|
|
40
|
+
*
|
|
41
|
+
* WHY THIS EXISTS ALONGSIDE `gitDiff`. `gitDiff` answers "what changed relative to HEAD" as a PATCH -
|
|
42
|
+
* which is a good thing to read and a bad thing to diff against: a patch is a set of hunks, and
|
|
43
|
+
* reconstructing the "before" text from it is a lossy operation that a review view should not depend on.
|
|
44
|
+
* The review view needs the two TEXTS, so that the difference between them is computed by one
|
|
45
|
+
* well-tested algorithm rather than by two different ones.
|
|
46
|
+
*
|
|
47
|
+
* `null` covers three different situations, and the caller must not confuse them: the repository has no
|
|
48
|
+
* commits yet (`unborn`), the file was never committed (it is new or untracked), or the file is not in
|
|
49
|
+
* this repository at all. The first two are the same answer for a review - "there is no committed
|
|
50
|
+
* version to compare against" - and the third is a refusal, which the caller reports.
|
|
51
|
+
*/
|
|
52
|
+
export declare function gitHeadContent(root: string, path: string): Promise<string | null>;
|
|
53
|
+
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
54
|
+
export declare function isGitRepository(root: string): Promise<boolean>;
|
package/dist/server/git.js
CHANGED
|
@@ -28,3 +28,42 @@ export async function gitLog(root, limit) { const hasHead = await git(root, ["re
|
|
|
28
28
|
export async function gitDiff(root, path, staged) { const safe = resolve(root, path); if (!safe.startsWith(resolve(root) + sep) || !existsSync(safe))
|
|
29
29
|
throw Object.assign(new Error("文件路径无效"), { code: "path" }); const check = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false); if (!check)
|
|
30
30
|
return { path, staged, diff: null, kind: "untracked" }; const out = await git(root, ["diff", ...(staged ? ["--cached"] : []), "--", path]); return { path, staged, diff: out.stdout, kind: "text" }; }
|
|
31
|
+
/**
|
|
32
|
+
* The content of a file AS IT WAS COMMITTED, or `null` when there is no such version.
|
|
33
|
+
*
|
|
34
|
+
* WHY THIS EXISTS ALONGSIDE `gitDiff`. `gitDiff` answers "what changed relative to HEAD" as a PATCH -
|
|
35
|
+
* which is a good thing to read and a bad thing to diff against: a patch is a set of hunks, and
|
|
36
|
+
* reconstructing the "before" text from it is a lossy operation that a review view should not depend on.
|
|
37
|
+
* The review view needs the two TEXTS, so that the difference between them is computed by one
|
|
38
|
+
* well-tested algorithm rather than by two different ones.
|
|
39
|
+
*
|
|
40
|
+
* `null` covers three different situations, and the caller must not confuse them: the repository has no
|
|
41
|
+
* commits yet (`unborn`), the file was never committed (it is new or untracked), or the file is not in
|
|
42
|
+
* this repository at all. The first two are the same answer for a review - "there is no committed
|
|
43
|
+
* version to compare against" - and the third is a refusal, which the caller reports.
|
|
44
|
+
*/
|
|
45
|
+
export async function gitHeadContent(root, path) {
|
|
46
|
+
const safe = resolve(root, path);
|
|
47
|
+
if (!safe.startsWith(resolve(root) + sep))
|
|
48
|
+
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
49
|
+
const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false);
|
|
50
|
+
if (!hasHead)
|
|
51
|
+
return null;
|
|
52
|
+
const tracked = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false);
|
|
53
|
+
if (!tracked)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const out = await git(root, ["show", `HEAD:${path}`]);
|
|
57
|
+
return out.stdout;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The file is tracked but `HEAD:path` does not resolve - a path with characters git quotes, or a
|
|
61
|
+
// repository in a state this server does not model. Treated as "no committed version", which is the
|
|
62
|
+
// honest answer rather than a crash.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
67
|
+
export async function isGitRepository(root) {
|
|
68
|
+
return git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
|
|
69
|
+
}
|
|
@@ -8,9 +8,12 @@ import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES
|
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
9
|
import { checkDiskVersion } from "./version.js";
|
|
10
10
|
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
11
|
+
import { computeFileDiff } from "./file-diff.js";
|
|
11
12
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
12
13
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
13
14
|
import { packageUpdateArgs } from "./update.js";
|
|
15
|
+
import { FileIndex } from "./file-index.js";
|
|
16
|
+
import { MAX_CONTENT_DEPTH, MAX_CONTENT_FILE_BYTES, MAX_CONTENT_RESULTS, searchByContent } from "./content-search.js";
|
|
14
17
|
const MIME_TYPES = {
|
|
15
18
|
".css": "text/css; charset=utf-8",
|
|
16
19
|
".html": "text/html; charset=utf-8",
|
|
@@ -952,6 +955,118 @@ export function createHttpServer(options) {
|
|
|
952
955
|
}
|
|
953
956
|
return;
|
|
954
957
|
}
|
|
958
|
+
if (url.pathname === "/api/files/diff") {
|
|
959
|
+
const json = (status, body) => {
|
|
960
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
961
|
+
};
|
|
962
|
+
try {
|
|
963
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
964
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
965
|
+
if (!workspaceId || !workspace) {
|
|
966
|
+
json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const path = url.searchParams.get("path")?.trim() ?? "";
|
|
970
|
+
if (!path) {
|
|
971
|
+
json(400, { error: "需要 path 参数", code: "bad_request" });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const requested = url.searchParams.get("base")?.trim();
|
|
975
|
+
const preferred = requested === "git" || requested === "agent" ? requested : "auto";
|
|
976
|
+
// The baseline is chosen by the server, but the client can name one - and when it names one, that
|
|
977
|
+
// is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
|
|
978
|
+
const diff = await computeFileDiff({
|
|
979
|
+
root: workspace.path,
|
|
980
|
+
path,
|
|
981
|
+
preferred,
|
|
982
|
+
tracker: context.fileChanges,
|
|
983
|
+
workspaceId,
|
|
984
|
+
});
|
|
985
|
+
json(200, diff);
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
const described = describeFilesError(error);
|
|
989
|
+
json(described.status, described.body);
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (url.pathname === "/api/files/search") {
|
|
994
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
995
|
+
try {
|
|
996
|
+
if (request.method !== "GET") {
|
|
997
|
+
json(405, { error: "Content search requires GET", code: "method" });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1001
|
+
const query = url.searchParams.get("q")?.trim() ?? "";
|
|
1002
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1003
|
+
if (!workspaceId || !workspace) {
|
|
1004
|
+
json(404, { error: "workspace not found", code: "no_workspace" });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
await resolveInsideRoot(workspace.path, "");
|
|
1008
|
+
if (query.length > 256 || /[\r\n]/.test(query)) {
|
|
1009
|
+
json(400, { error: "Search query must be one line, at most 256 characters", code: "bad_query" });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!query) {
|
|
1013
|
+
json(200, { mode: "content", query, hits: [], truncated: false, skipped: [], complete: true, reasons: [], maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1017
|
+
if (!context.fileIndexes)
|
|
1018
|
+
context.fileIndexes = indexes;
|
|
1019
|
+
let index = indexes.get(workspaceId);
|
|
1020
|
+
if (!index) {
|
|
1021
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1022
|
+
indexes.set(workspaceId, index);
|
|
1023
|
+
}
|
|
1024
|
+
index.start(workspace.path);
|
|
1025
|
+
const deadline = Date.now() + 4500;
|
|
1026
|
+
while (!index.builtAt && Date.now() < deadline)
|
|
1027
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1028
|
+
const snapshot = index.snapshot();
|
|
1029
|
+
const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
|
|
1030
|
+
json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
const described = describeFilesError(error);
|
|
1034
|
+
json(described.status, described.body);
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (url.pathname === "/api/files/index") {
|
|
1039
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
1040
|
+
try {
|
|
1041
|
+
if (request.method !== "GET") {
|
|
1042
|
+
json(405, { error: "File index requires GET", code: "method" });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1046
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1047
|
+
if (!workspaceId || !workspace) {
|
|
1048
|
+
json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1052
|
+
if (!context.fileIndexes)
|
|
1053
|
+
context.fileIndexes = indexes;
|
|
1054
|
+
let index = indexes.get(workspaceId);
|
|
1055
|
+
if (!index) {
|
|
1056
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1057
|
+
indexes.set(workspaceId, index);
|
|
1058
|
+
}
|
|
1059
|
+
index.start(workspace.path);
|
|
1060
|
+
const sinceRaw = url.searchParams.get("since");
|
|
1061
|
+
const since = sinceRaw === null ? undefined : Number(sinceRaw);
|
|
1062
|
+
json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
|
|
1063
|
+
}
|
|
1064
|
+
catch (error) {
|
|
1065
|
+
const described = describeFilesError(error);
|
|
1066
|
+
json(described.status, described.body);
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
955
1070
|
if (url.pathname === "/api/files" || url.pathname === "/api/file") {
|
|
956
1071
|
const json = (status, body) => {
|
|
957
1072
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
@@ -983,10 +1098,14 @@ export function createHttpServer(options) {
|
|
|
983
1098
|
const rel = url.searchParams.get("path") ?? "";
|
|
984
1099
|
if (url.searchParams.get("meta") === "1") {
|
|
985
1100
|
const resolved = await resolveInsideRoot(root, rel);
|
|
986
|
-
|
|
1101
|
+
const meta = await fileMeta(resolved);
|
|
1102
|
+
const path = toRelative(root, resolved);
|
|
1103
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
|
|
1104
|
+
json(200, { path, ...meta });
|
|
987
1105
|
return;
|
|
988
1106
|
}
|
|
989
1107
|
const file = await readWorkspaceFile(root, rel);
|
|
1108
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
|
|
990
1109
|
json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
|
|
991
1110
|
return;
|
|
992
1111
|
}
|
|
@@ -1018,6 +1137,7 @@ export function createHttpServer(options) {
|
|
|
1018
1137
|
return;
|
|
1019
1138
|
}
|
|
1020
1139
|
const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
|
|
1140
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
|
|
1021
1141
|
json(200, outcome);
|
|
1022
1142
|
return;
|
|
1023
1143
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { CatalogService } from "./application/catalog-service.js";
|
|
|
19
19
|
import { ProviderService } from "./application/provider-service.js";
|
|
20
20
|
import { ProviderRegistry } from "./providers.js";
|
|
21
21
|
import { RunService } from "./application/run-service.js";
|
|
22
|
+
import { FileChangeTracker } from "./file-changes.js";
|
|
22
23
|
import { ConversationService } from "./application/conversation-service.js";
|
|
23
24
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
24
25
|
import { CommandHandler } from "./command-handler.js";
|
|
@@ -191,6 +192,18 @@ const providerRegistry = new ProviderRegistry(dataDir);
|
|
|
191
192
|
const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
|
|
192
193
|
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
|
|
193
194
|
runService.setMemory(memory);
|
|
195
|
+
/**
|
|
196
|
+
* WHAT THE AGENT CHANGED, and what each file looked like before.
|
|
197
|
+
*
|
|
198
|
+
* The tracker is created here rather than inside the service because it outlives one run: the changes a
|
|
199
|
+
* session produced are the session's record, and a tracker that died with the run would take the answer
|
|
200
|
+
* to "what did you just change" with it. It is handed to the run service as a setter, the same shape
|
|
201
|
+
* `memory` uses.
|
|
202
|
+
*/
|
|
203
|
+
const fileChanges = new FileChangeTracker();
|
|
204
|
+
runService.setFileChanges(fileChanges);
|
|
205
|
+
const fileIndexes = new Map();
|
|
206
|
+
runService.setFileIndexes(fileIndexes);
|
|
194
207
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
195
208
|
conversationService.setMemory(memory);
|
|
196
209
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
|
|
@@ -232,6 +245,8 @@ const serverContext = {
|
|
|
232
245
|
toolRegistry,
|
|
233
246
|
toolRunner,
|
|
234
247
|
toolLlm,
|
|
248
|
+
fileChanges,
|
|
249
|
+
fileIndexes,
|
|
235
250
|
terminalEnabled: () => terminalService.enabled,
|
|
236
251
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
237
252
|
};
|
|
@@ -60,6 +60,15 @@ export type TurnItem =
|
|
|
60
60
|
/** The callbacks a turn supplies. The names match the RPC runtime's, so the two paths behave alike. */
|
|
61
61
|
export interface TurnEventHandlers {
|
|
62
62
|
onToolCall?(call: ToolCallNotice): void;
|
|
63
|
+
/**
|
|
64
|
+
* Called when a tool that WRITES A FILE is announced, with the path it is about to write.
|
|
65
|
+
*
|
|
66
|
+
* The same hook the RPC runtime has, and for the same reason: the announcement is the last moment
|
|
67
|
+
* the old content is still on the disk, so it is the only moment a diff baseline can be taken. The two
|
|
68
|
+
* paths (SDK session and spawned RPC) must agree on WHEN this fires, or the same edit would be
|
|
69
|
+
* baselined on one path and not on the other.
|
|
70
|
+
*/
|
|
71
|
+
onFileWrite?(path: string, toolName: string, toolCallId?: string): void;
|
|
63
72
|
onToolUpdate?(call: ToolCallNotice): void;
|
|
64
73
|
onToolPreparing?(call: ToolCallPreparing): void;
|
|
65
74
|
onToolEnd?(call: ToolCallNotice): void;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* from, not what they mean.
|
|
18
18
|
*/
|
|
19
19
|
import { describeToolCall, describeToolCallArgs, extractPartialResultText } from "./tool-calls.js";
|
|
20
|
+
import { toolTargetPath } from "./file-changes.js";
|
|
20
21
|
/** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
|
|
21
22
|
const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
|
|
22
23
|
/**
|
|
@@ -258,6 +259,17 @@ export function handleSessionEvent(event, queue, handlers, report, failure) {
|
|
|
258
259
|
catch (error) {
|
|
259
260
|
console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
260
261
|
}
|
|
262
|
+
// The file-writing call, announced before it runs - the only moment the old content is still on
|
|
263
|
+
// the disk. Fired and forgotten: the listener does its own I/O, off this handler's path.
|
|
264
|
+
const writePath = toolTargetPath(name, record.args);
|
|
265
|
+
if (writePath) {
|
|
266
|
+
try {
|
|
267
|
+
handlers.onFileWrite?.(writePath, name, id);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
console.warn(`PiRpcRuntime: the file-write handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
261
273
|
return true;
|
|
262
274
|
}
|
|
263
275
|
case "tool_execution_update": {
|