@evandu/super-pi 0.1.104 → 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/catalog-service.d.ts +9 -0
- package/dist/server/application/catalog-service.js +11 -0
- package/dist/server/application/provider-service.d.ts +77 -0
- package/dist/server/application/provider-service.js +165 -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.d.ts +16 -1
- package/dist/server/command-handler.js +40 -1
- 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 +28 -1
- package/dist/server/pi-session-events.d.ts +9 -0
- package/dist/server/pi-session-events.js +12 -0
- package/dist/server/pi-session-pool.d.ts +19 -1
- package/dist/server/pi-session-pool.js +13 -1
- package/dist/server/pi-session.d.ts +10 -0
- package/dist/server/pi-session.js +11 -0
- package/dist/server/providers.d.ts +160 -0
- package/dist/server/providers.js +396 -0
- package/dist/server/runtime.d.ts +67 -0
- package/dist/server/runtime.js +54 -2
- package/dist/server/server-context.d.ts +15 -0
- package/dist/server/types.d.ts +143 -0
- package/package.json +14 -1
- package/dist/client/assets/index-DDkbPYLz.css +0 -1
- package/dist/client/assets/index-mAu39Bra.js +0 -74
|
@@ -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
|
@@ -16,7 +16,10 @@ import { SubagentRunRecorder } from "./subagent-runs.js";
|
|
|
16
16
|
import { WorkflowEngine } from "./workflow-engine.js";
|
|
17
17
|
import { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
18
18
|
import { CatalogService } from "./application/catalog-service.js";
|
|
19
|
+
import { ProviderService } from "./application/provider-service.js";
|
|
20
|
+
import { ProviderRegistry } from "./providers.js";
|
|
19
21
|
import { RunService } from "./application/run-service.js";
|
|
22
|
+
import { FileChangeTracker } from "./file-changes.js";
|
|
20
23
|
import { ConversationService } from "./application/conversation-service.js";
|
|
21
24
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
22
25
|
import { CommandHandler } from "./command-handler.js";
|
|
@@ -178,8 +181,29 @@ const onDirsChanged = (dirs) => {
|
|
|
178
181
|
workspaceService.setAgentsDir(dirs);
|
|
179
182
|
};
|
|
180
183
|
const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs, store, rootDir, onDirsChanged);
|
|
184
|
+
/**
|
|
185
|
+
* The providers a person added through the settings window.
|
|
186
|
+
*
|
|
187
|
+
* The registry lives in Super Pi's own data directory and holds the API keys; Pi's `models.json` and
|
|
188
|
+
* `auth.json` are written from it, so a provider added here is a provider Pi can use - in a session,
|
|
189
|
+
* in a spawned Pi, and in the terminal.
|
|
190
|
+
*/
|
|
191
|
+
const providerRegistry = new ProviderRegistry(dataDir);
|
|
192
|
+
const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
|
|
181
193
|
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
|
|
182
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);
|
|
183
207
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
184
208
|
conversationService.setMemory(memory);
|
|
185
209
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
|
|
@@ -216,14 +240,17 @@ const serverContext = {
|
|
|
216
240
|
runService,
|
|
217
241
|
extensionUiService,
|
|
218
242
|
catalogService,
|
|
243
|
+
providerService,
|
|
219
244
|
execService,
|
|
220
245
|
toolRegistry,
|
|
221
246
|
toolRunner,
|
|
222
247
|
toolLlm,
|
|
248
|
+
fileChanges,
|
|
249
|
+
fileIndexes,
|
|
223
250
|
terminalEnabled: () => terminalService.enabled,
|
|
224
251
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
225
252
|
};
|
|
226
|
-
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir);
|
|
253
|
+
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService);
|
|
227
254
|
// ------------------------------------------------------------- Lifecycle & shutdown
|
|
228
255
|
let shutdownPromise;
|
|
229
256
|
async function closeHttpServer() {
|
|
@@ -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": {
|
|
@@ -78,6 +78,15 @@ export declare class PiSessionPool {
|
|
|
78
78
|
* knows what one looks like.
|
|
79
79
|
*/
|
|
80
80
|
private readonly mintCapability;
|
|
81
|
+
/**
|
|
82
|
+
* How many times the set of models Pi knows about has changed.
|
|
83
|
+
*
|
|
84
|
+
* Injected rather than imported for the same reason the capability minting is: the pool needs to
|
|
85
|
+
* know WHEN a session is stale, and the caller knows what changed. A provider added through the
|
|
86
|
+
* settings window makes every live session unable to reach the new model, and the next turn is
|
|
87
|
+
* where that gets fixed.
|
|
88
|
+
*/
|
|
89
|
+
private readonly modelEpoch;
|
|
81
90
|
private readonly sessions;
|
|
82
91
|
constructor(
|
|
83
92
|
/** Where Pi keeps its session files. */
|
|
@@ -104,7 +113,16 @@ export declare class PiSessionPool {
|
|
|
104
113
|
* does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
|
|
105
114
|
* knows what one looks like.
|
|
106
115
|
*/
|
|
107
|
-
mintCapability?: (conversationId: string) => string
|
|
116
|
+
mintCapability?: (conversationId: string) => string,
|
|
117
|
+
/**
|
|
118
|
+
* How many times the set of models Pi knows about has changed.
|
|
119
|
+
*
|
|
120
|
+
* Injected rather than imported for the same reason the capability minting is: the pool needs to
|
|
121
|
+
* know WHEN a session is stale, and the caller knows what changed. A provider added through the
|
|
122
|
+
* settings window makes every live session unable to reach the new model, and the next turn is
|
|
123
|
+
* where that gets fixed.
|
|
124
|
+
*/
|
|
125
|
+
modelEpoch?: () => number);
|
|
108
126
|
/**
|
|
109
127
|
* The session for a conversation, built if there is none and REPLACED if the live one is stale.
|
|
110
128
|
*
|
|
@@ -48,6 +48,7 @@ export class PiSessionPool {
|
|
|
48
48
|
onSessionDestroyed;
|
|
49
49
|
factory;
|
|
50
50
|
mintCapability;
|
|
51
|
+
modelEpoch;
|
|
51
52
|
sessions = new Map();
|
|
52
53
|
constructor(
|
|
53
54
|
/** Where Pi keeps its session files. */
|
|
@@ -74,13 +75,23 @@ export class PiSessionPool {
|
|
|
74
75
|
* does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
|
|
75
76
|
* knows what one looks like.
|
|
76
77
|
*/
|
|
77
|
-
mintCapability = () => ""
|
|
78
|
+
mintCapability = () => "",
|
|
79
|
+
/**
|
|
80
|
+
* How many times the set of models Pi knows about has changed.
|
|
81
|
+
*
|
|
82
|
+
* Injected rather than imported for the same reason the capability minting is: the pool needs to
|
|
83
|
+
* know WHEN a session is stale, and the caller knows what changed. A provider added through the
|
|
84
|
+
* settings window makes every live session unable to reach the new model, and the next turn is
|
|
85
|
+
* where that gets fixed.
|
|
86
|
+
*/
|
|
87
|
+
modelEpoch = () => 0) {
|
|
78
88
|
this.sessionDir = sessionDir;
|
|
79
89
|
this.repositoryRoot = repositoryRoot;
|
|
80
90
|
this.agentDir = agentDir;
|
|
81
91
|
this.onSessionDestroyed = onSessionDestroyed;
|
|
82
92
|
this.factory = factory;
|
|
83
93
|
this.mintCapability = mintCapability;
|
|
94
|
+
this.modelEpoch = modelEpoch;
|
|
84
95
|
}
|
|
85
96
|
/**
|
|
86
97
|
* The session for a conversation, built if there is none and REPLACED if the live one is stale.
|
|
@@ -227,6 +238,7 @@ export class PiSessionPool {
|
|
|
227
238
|
tools: request.tools,
|
|
228
239
|
excludeTools: request.excludeTools,
|
|
229
240
|
extensions: superPiExtensionPaths(this.repositoryRoot),
|
|
241
|
+
modelEpoch: this.modelEpoch(),
|
|
230
242
|
};
|
|
231
243
|
}
|
|
232
244
|
/**
|
|
@@ -129,6 +129,16 @@ export interface SessionFingerprint {
|
|
|
129
129
|
excludeTools: string[] | undefined;
|
|
130
130
|
/** Which of Super Pi's own extensions were mounted. */
|
|
131
131
|
extensions: string[];
|
|
132
|
+
/**
|
|
133
|
+
* How many times the set of models Pi knows about has changed.
|
|
134
|
+
*
|
|
135
|
+
* NOT THE MODEL, BUT THE WORLD THE MODEL LIVES IN. A session's `ModelRuntime` is built with it and
|
|
136
|
+
* does not learn about a provider added afterwards, so a session that keeps running on the old
|
|
137
|
+
* runtime cannot switch to a model that only exists in the new one - and Pi's refusal to switch is
|
|
138
|
+
* swallowed into a warning, which would leave the turn running a model the picker does not name.
|
|
139
|
+
* The counter makes such a session stale, and it is rebuilt at its next turn.
|
|
140
|
+
*/
|
|
141
|
+
modelEpoch: number;
|
|
132
142
|
}
|
|
133
143
|
/**
|
|
134
144
|
* Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
|
|
@@ -185,6 +185,8 @@ export function sameFingerprint(a, b) {
|
|
|
185
185
|
return false;
|
|
186
186
|
if (!sameSet(a.extensions, b.extensions))
|
|
187
187
|
return false;
|
|
188
|
+
if (a.modelEpoch !== b.modelEpoch)
|
|
189
|
+
return false;
|
|
188
190
|
return true;
|
|
189
191
|
}
|
|
190
192
|
function sameSet(a, b) {
|
|
@@ -343,8 +345,17 @@ async function resolveSessionManager(sdk, creation) {
|
|
|
343
345
|
* file goes out of its way not to do for a path it can derive. The derivation is Pi's own default:
|
|
344
346
|
* `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`,
|
|
345
347
|
* because on Windows the two disagree often enough to matter.
|
|
348
|
+
*
|
|
349
|
+
* `PI_CODING_AGENT_DIR` WINS, exactly as it wins in Pi (`core/config.ts:getAgentDir`). A person who
|
|
350
|
+
* set it expects Pi to read its whole configuration from there, and this function used to disagree
|
|
351
|
+
* with Pi about it: Pi's model runtime read `models.json` from that directory while the session's
|
|
352
|
+
* resource loader read the skills from `~/.pi/agent`. Two halves of one Pi pointed at two
|
|
353
|
+
* directories, and only one of them was the one the person had configured.
|
|
346
354
|
*/
|
|
347
355
|
function defaultAgentDir() {
|
|
356
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
357
|
+
if (configured)
|
|
358
|
+
return configured;
|
|
348
359
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
349
360
|
return home ? join(home, ".pi", "agent") : join(".pi", "agent");
|
|
350
361
|
}
|