@evandu/super-pi 0.1.70 → 0.1.72
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/README.md +12 -0
- package/dist/client/assets/index-Bi2QvT9i.js +63 -0
- package/dist/client/assets/index-CKdlMXJt.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.js +83 -31
- package/dist/server/http-server.js +74 -1
- package/dist/server/index.js +3 -0
- package/dist/server/rpc-channel.d.ts +109 -0
- package/dist/server/rpc-channel.js +117 -0
- package/dist/server/runtime.d.ts +6 -227
- package/dist/server/runtime.js +66 -358
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/session-repository.d.ts +99 -0
- package/dist/server/session-repository.js +245 -0
- package/dist/server/store.d.ts +12 -0
- package/dist/server/store.js +45 -5
- package/dist/server/subagents.js +3 -2
- package/dist/server/tool-registry.d.ts +35 -0
- package/dist/server/tool-registry.js +74 -0
- package/dist/server/types.d.ts +20 -1
- package/dist/server/ws-server.d.ts +5 -0
- package/dist/server/ws-server.js +49 -3
- package/package.json +5 -2
- package/dist/client/assets/index-B0eJbg4U.js +0 -54
- package/dist/client/assets/index-CbHwRb22.css +0 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
|
|
3
|
+
*
|
|
4
|
+
* ============================ WHAT THIS DESTROYS ============================
|
|
5
|
+
* **Every entry written after the boundary is gone from disk, permanently, and there is no way to
|
|
6
|
+
* reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
|
|
7
|
+
* `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
|
|
8
|
+
* accounting, and anything a future Pi version adds. This is the step the whole revert design is built
|
|
9
|
+
* around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
|
|
10
|
+
* and adding one was explicitly left out of scope.
|
|
11
|
+
*
|
|
12
|
+
* It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
|
|
13
|
+
* =============================================================================
|
|
14
|
+
*
|
|
15
|
+
* WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
|
|
16
|
+
* (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
|
|
17
|
+
* `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
|
|
18
|
+
* file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
|
|
19
|
+
* spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
|
|
20
|
+
* next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
|
|
21
|
+
* forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
|
|
22
|
+
* change rather than per-turn bookkeeping that can be skipped exactly once.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
|
|
25
|
+
* `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
|
|
26
|
+
* already appears earlier in the file, no blank lines, and a trailing newline - was measured against
|
|
27
|
+
* real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
|
|
28
|
+
* makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
|
|
29
|
+
* index by line at all; every one of those claims is re-checked here before anything is written, so a
|
|
30
|
+
* Pi that changed the format fails loudly instead of producing a file Pi cannot open.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
|
|
33
|
+
* (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
|
|
34
|
+
* under a different name and renaming it over the original means a reader never observes a half-written
|
|
35
|
+
* file, and a failure before the rename leaves the original untouched.
|
|
36
|
+
*
|
|
37
|
+
* WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
|
|
38
|
+
* named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
|
|
39
|
+
* `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
|
|
40
|
+
* Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
|
|
41
|
+
* operation on a person's conversation history is at least recoverable by hand.
|
|
42
|
+
*
|
|
43
|
+
* EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
|
|
44
|
+
* without touching anybody's real transcript.
|
|
45
|
+
*/
|
|
46
|
+
export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string, beforeBoundary?: boolean): Promise<{
|
|
47
|
+
linesKept: number;
|
|
48
|
+
entriesDropped: number;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
|
|
52
|
+
*
|
|
53
|
+
* ============================ WHAT THIS DOES TO THE ORIGINAL ============================
|
|
54
|
+
* **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
|
|
55
|
+
* whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
|
|
56
|
+
* of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
|
|
57
|
+
* leaves both files whole and keeps no backup because there is nothing to recover.
|
|
58
|
+
* ========================================================================================
|
|
59
|
+
*
|
|
60
|
+
* WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
|
|
61
|
+
* always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
|
|
62
|
+
* Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
|
|
63
|
+
* thing a fork has to produce here, so the file is what gets produced.
|
|
64
|
+
*
|
|
65
|
+
* ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
|
|
66
|
+
* **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
|
|
67
|
+
* RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
|
|
68
|
+
* inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
|
|
69
|
+
* at other entries by id in ways this code has never enumerated - a `compaction` record carries a
|
|
70
|
+
* `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
|
|
71
|
+
* one of those references correctly, with no way to find them all. Copying the bytes cannot break a
|
|
72
|
+
* reference because it changes nothing a reference points at. The cost is that the same entry id now
|
|
73
|
+
* exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
|
|
74
|
+
* and Super Pi only ever opens one file per process.
|
|
75
|
+
*
|
|
76
|
+
* WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
|
|
77
|
+
* what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
|
|
78
|
+
* `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
|
|
79
|
+
* (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
|
|
80
|
+
* records the file this one came from, which is the same one-line lineage pointer Pi itself writes
|
|
81
|
+
* (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
|
|
82
|
+
* verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
|
|
83
|
+
* asked from, and the fork always lives in the same workspace as its source.
|
|
84
|
+
*
|
|
85
|
+
* THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
|
|
86
|
+
* real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
|
|
87
|
+
* every one is re-checked before a byte is written and a failure names the shape actually found. Here the
|
|
88
|
+
* `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
|
|
89
|
+
* file is what is being copied.
|
|
90
|
+
*
|
|
91
|
+
* TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
|
|
92
|
+
* observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
|
|
93
|
+
* session and the history has vanished with no error anywhere" outcome probe B was about.
|
|
94
|
+
*
|
|
95
|
+
* EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
|
|
96
|
+
*/
|
|
97
|
+
export declare function forkSessionFile(sourcePath: string, targetPath: string, expectedSessionId: string, targetSessionId: string): Promise<{
|
|
98
|
+
entriesCopied: number;
|
|
99
|
+
}>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
|
|
4
|
+
*
|
|
5
|
+
* ============================ WHAT THIS DESTROYS ============================
|
|
6
|
+
* **Every entry written after the boundary is gone from disk, permanently, and there is no way to
|
|
7
|
+
* reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
|
|
8
|
+
* `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
|
|
9
|
+
* accounting, and anything a future Pi version adds. This is the step the whole revert design is built
|
|
10
|
+
* around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
|
|
11
|
+
* and adding one was explicitly left out of scope.
|
|
12
|
+
*
|
|
13
|
+
* It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
|
|
14
|
+
* =============================================================================
|
|
15
|
+
*
|
|
16
|
+
* WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
|
|
17
|
+
* (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
|
|
18
|
+
* `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
|
|
19
|
+
* file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
|
|
20
|
+
* spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
|
|
21
|
+
* next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
|
|
22
|
+
* forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
|
|
23
|
+
* change rather than per-turn bookkeeping that can be skipped exactly once.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
|
|
26
|
+
* `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
|
|
27
|
+
* already appears earlier in the file, no blank lines, and a trailing newline - was measured against
|
|
28
|
+
* real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
|
|
29
|
+
* makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
|
|
30
|
+
* index by line at all; every one of those claims is re-checked here before anything is written, so a
|
|
31
|
+
* Pi that changed the format fails loudly instead of producing a file Pi cannot open.
|
|
32
|
+
*
|
|
33
|
+
* WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
|
|
34
|
+
* (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
|
|
35
|
+
* under a different name and renaming it over the original means a reader never observes a half-written
|
|
36
|
+
* file, and a failure before the rename leaves the original untouched.
|
|
37
|
+
*
|
|
38
|
+
* WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
|
|
39
|
+
* named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
|
|
40
|
+
* `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
|
|
41
|
+
* Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
|
|
42
|
+
* operation on a person's conversation history is at least recoverable by hand.
|
|
43
|
+
*
|
|
44
|
+
* EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
|
|
45
|
+
* without touching anybody's real transcript.
|
|
46
|
+
*/
|
|
47
|
+
export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId, beforeBoundary = false) {
|
|
48
|
+
const raw = await readFile(path, "utf8");
|
|
49
|
+
// Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
|
|
50
|
+
// DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
|
|
51
|
+
// line to the rewritten file and undo the property the probe just established.
|
|
52
|
+
const lines = raw.split("\n");
|
|
53
|
+
if (lines.at(-1) === "")
|
|
54
|
+
lines.pop();
|
|
55
|
+
if (!lines.length)
|
|
56
|
+
throw new Error(`Pi 的会话文件是空的,拒绝回退:${path}`);
|
|
57
|
+
const parsed = lines.map((line) => {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(line);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// Every claim probe A made, re-checked here because a Pi upgrade may invalidate any of them. Each
|
|
66
|
+
// failure names the shape that was actually found, because "revert failed" alone sends the reader
|
|
67
|
+
// looking for their own data instead of at the version mismatch that caused it.
|
|
68
|
+
// Checked BEFORE the JSON check, so the refusal names the actual defect: a blank line reads as "this
|
|
69
|
+
// file has a shape my line arithmetic cannot trust", which is what it is, and a bare "not valid JSON"
|
|
70
|
+
// would send the reader looking for a corrupted file instead.
|
|
71
|
+
const blank = lines.findIndex((line) => line.trim() === "");
|
|
72
|
+
if (blank >= 0)
|
|
73
|
+
throw new Error(`Pi 的会话文件第 ${blank} 行是空行,行号与 entry 号不再一一对应,拒绝回退:${path}`);
|
|
74
|
+
const unparseable = parsed.findIndex((entry) => entry === undefined);
|
|
75
|
+
if (unparseable >= 0)
|
|
76
|
+
throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝回退以免损坏它:${path}`);
|
|
77
|
+
const headerLines = parsed.filter((entry) => entry?.type === "session");
|
|
78
|
+
if (headerLines.length !== 1)
|
|
79
|
+
throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝回退:${path}`);
|
|
80
|
+
if (parsed[0]?.type !== "session")
|
|
81
|
+
throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝回退:${path}`);
|
|
82
|
+
if (parsed[0]?.id !== expectedSessionId)
|
|
83
|
+
throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
|
|
84
|
+
if (!raw.endsWith("\n"))
|
|
85
|
+
throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
|
|
86
|
+
let boundary = boundaryEntryId === "" ? 0 : parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
|
|
87
|
+
if (boundary < 0)
|
|
88
|
+
throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
|
|
89
|
+
const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
|
|
90
|
+
if (beforeBoundary)
|
|
91
|
+
boundary -= 1;
|
|
92
|
+
// Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
|
|
93
|
+
// empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
|
|
94
|
+
// must happen here rather than be trusted to a check that cannot fire.
|
|
95
|
+
if (boundary < 0)
|
|
96
|
+
throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
|
|
97
|
+
// The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
|
|
98
|
+
// being KEPT must point at something also being kept. A prefix that violates this is not a session,
|
|
99
|
+
// and writing it would produce a file Pi either rejects or silently misreads.
|
|
100
|
+
const kept = new Set();
|
|
101
|
+
for (let index = 0; index <= boundary; index += 1) {
|
|
102
|
+
const parent = parsed[index]?.parentId;
|
|
103
|
+
if (parent !== undefined && parent !== null && !kept.has(parent))
|
|
104
|
+
throw new Error(`要保留的部分不是自洽的:第 ${index} 行引用了不在保留范围内的 ${JSON.stringify(parent)},拒绝回退以免产出 Pi 打不开的会话。`);
|
|
105
|
+
kept.add(parsed[index]?.id);
|
|
106
|
+
}
|
|
107
|
+
const rewritten = `${lines.slice(0, boundary + 1).join("\n")}\n`;
|
|
108
|
+
const backupPath = `${path}.super-pi-revert-backup`;
|
|
109
|
+
const temporaryPath = `${path}.super-pi-revert-tmp`;
|
|
110
|
+
await writeFile(backupPath, raw, "utf8");
|
|
111
|
+
await writeFile(temporaryPath, rewritten, "utf8");
|
|
112
|
+
await rename(temporaryPath, path);
|
|
113
|
+
// Re-read rather than trust the write. The claim this whole mechanism rests on is "the file's last
|
|
114
|
+
// line is now the boundary entry", and `_buildIndex` derives the leaf from exactly that line - so if
|
|
115
|
+
// the write did not land, nothing downstream would say so until a turn produced wrong context.
|
|
116
|
+
const verified = (await readFile(path, "utf8")).split("\n").filter((line) => line !== "");
|
|
117
|
+
const last = verified[verified.length - 1];
|
|
118
|
+
let lastEntry;
|
|
119
|
+
try {
|
|
120
|
+
lastEntry = JSON.parse(last);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
lastEntry = undefined;
|
|
124
|
+
}
|
|
125
|
+
if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
|
|
126
|
+
throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
|
|
127
|
+
}
|
|
128
|
+
return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
|
|
132
|
+
*
|
|
133
|
+
* ============================ WHAT THIS DOES TO THE ORIGINAL ============================
|
|
134
|
+
* **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
|
|
135
|
+
* whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
|
|
136
|
+
* of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
|
|
137
|
+
* leaves both files whole and keeps no backup because there is nothing to recover.
|
|
138
|
+
* ========================================================================================
|
|
139
|
+
*
|
|
140
|
+
* WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
|
|
141
|
+
* always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
|
|
142
|
+
* Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
|
|
143
|
+
* thing a fork has to produce here, so the file is what gets produced.
|
|
144
|
+
*
|
|
145
|
+
* ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
|
|
146
|
+
* **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
|
|
147
|
+
* RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
|
|
148
|
+
* inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
|
|
149
|
+
* at other entries by id in ways this code has never enumerated - a `compaction` record carries a
|
|
150
|
+
* `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
|
|
151
|
+
* one of those references correctly, with no way to find them all. Copying the bytes cannot break a
|
|
152
|
+
* reference because it changes nothing a reference points at. The cost is that the same entry id now
|
|
153
|
+
* exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
|
|
154
|
+
* and Super Pi only ever opens one file per process.
|
|
155
|
+
*
|
|
156
|
+
* WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
|
|
157
|
+
* what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
|
|
158
|
+
* `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
|
|
159
|
+
* (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
|
|
160
|
+
* records the file this one came from, which is the same one-line lineage pointer Pi itself writes
|
|
161
|
+
* (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
|
|
162
|
+
* verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
|
|
163
|
+
* asked from, and the fork always lives in the same workspace as its source.
|
|
164
|
+
*
|
|
165
|
+
* THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
|
|
166
|
+
* real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
|
|
167
|
+
* every one is re-checked before a byte is written and a failure names the shape actually found. Here the
|
|
168
|
+
* `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
|
|
169
|
+
* file is what is being copied.
|
|
170
|
+
*
|
|
171
|
+
* TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
|
|
172
|
+
* observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
|
|
173
|
+
* session and the history has vanished with no error anywhere" outcome probe B was about.
|
|
174
|
+
*
|
|
175
|
+
* EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
|
|
176
|
+
*/
|
|
177
|
+
export async function forkSessionFile(sourcePath, targetPath, expectedSessionId, targetSessionId) {
|
|
178
|
+
const raw = await readFile(sourcePath, "utf8");
|
|
179
|
+
const lines = raw.split("\n");
|
|
180
|
+
if (lines.at(-1) === "")
|
|
181
|
+
lines.pop();
|
|
182
|
+
if (!lines.length)
|
|
183
|
+
throw new Error(`Pi 的会话文件是空的,拒绝分叉:${sourcePath}`);
|
|
184
|
+
const parsed = lines.map((line) => {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(line);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// The refusal messages name the shape actually found, and say "拒绝分叉" rather than reusing the revert
|
|
193
|
+
// wording: the reader is looking at a fork button, and a message about 回退 would send them to look for a
|
|
194
|
+
// control they did not press.
|
|
195
|
+
const blank = lines.findIndex((line) => line.trim() === "");
|
|
196
|
+
if (blank >= 0)
|
|
197
|
+
throw new Error(`Pi 的会话文件第 ${blank} 行是空行,无法安全复制,拒绝分叉:${sourcePath}`);
|
|
198
|
+
const unparseable = parsed.findIndex((entry) => entry === undefined);
|
|
199
|
+
if (unparseable >= 0)
|
|
200
|
+
throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
|
|
201
|
+
const headerLines = parsed.filter((entry) => entry?.type === "session");
|
|
202
|
+
if (headerLines.length !== 1)
|
|
203
|
+
throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝分叉:${sourcePath}`);
|
|
204
|
+
if (parsed[0]?.type !== "session")
|
|
205
|
+
throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝分叉:${sourcePath}`);
|
|
206
|
+
// The one that matters most. Copying the wrong file would put one person's conversation into another's
|
|
207
|
+
// session, and unlike a wrong truncation it would be written as a brand-new file rather than destroying
|
|
208
|
+
// something - so this check is what stops a fork from being a disclosure.
|
|
209
|
+
if (parsed[0]?.id !== expectedSessionId)
|
|
210
|
+
throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要分叉的会话 ${JSON.stringify(expectedSessionId)}。拒绝复制别人的会话。`);
|
|
211
|
+
if (!raw.endsWith("\n"))
|
|
212
|
+
throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝分叉:${sourcePath}`);
|
|
213
|
+
// Every kept entry must point at another kept entry. The whole file is being copied, so "kept" is all of
|
|
214
|
+
// it - and a file that violates this is not a session Pi can open.
|
|
215
|
+
const known = new Set();
|
|
216
|
+
for (let index = 0; index < parsed.length; index += 1) {
|
|
217
|
+
const parent = parsed[index]?.parentId;
|
|
218
|
+
if (parent !== undefined && parent !== null && !known.has(parent))
|
|
219
|
+
throw new Error(`这个 Pi 会话记录不自洽:第 ${index} 行引用了不存在的 ${JSON.stringify(parent)},拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
|
|
220
|
+
known.add(parsed[index]?.id);
|
|
221
|
+
}
|
|
222
|
+
// The header is REBUILT rather than string-patched, so the two changed fields are structural and
|
|
223
|
+
// everything else - including any field a future Pi adds - is carried across untouched. A regex over the
|
|
224
|
+
// first line would silently drop a field it did not recognise.
|
|
225
|
+
const header = { ...parsed[0], id: targetSessionId, parentSession: sourcePath };
|
|
226
|
+
const rewritten = [JSON.stringify(header), ...lines.slice(1)].join("\n") + "\n";
|
|
227
|
+
const temporaryPath = `${targetPath}.super-pi-fork-tmp`;
|
|
228
|
+
await writeFile(temporaryPath, rewritten, "utf8");
|
|
229
|
+
await rename(temporaryPath, targetPath);
|
|
230
|
+
// Re-read rather than trust the write. The claim this rests on is "a Pi spawned with the new id will
|
|
231
|
+
// open THIS file and find the whole history in it", and nothing downstream would report a failed write -
|
|
232
|
+
// the fork would simply appear to work and the model would start from nothing.
|
|
233
|
+
const verified = (await readFile(targetPath, "utf8")).split("\n").filter((line) => line !== "");
|
|
234
|
+
let verifiedHeader;
|
|
235
|
+
try {
|
|
236
|
+
verifiedHeader = JSON.parse(verified[0]);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
verifiedHeader = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (verified.length !== lines.length || verifiedHeader?.id !== targetSessionId) {
|
|
242
|
+
throw new Error(`写入后的 Pi 会话文件不是预期的样子(写到 ${verified.length} 行,期望 ${lines.length} 行,header id=${JSON.stringify(verifiedHeader?.id ?? null)},期望 ${JSON.stringify(targetSessionId)})。已放弃,没有创建任何会话。`);
|
|
243
|
+
}
|
|
244
|
+
return { entriesCopied: lines.length - 1 };
|
|
245
|
+
}
|
package/dist/server/store.d.ts
CHANGED
|
@@ -223,6 +223,13 @@ export declare class Store {
|
|
|
223
223
|
thinkingLevel?: string;
|
|
224
224
|
}): Promise<Conversation>;
|
|
225
225
|
beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
|
|
226
|
+
/**
|
|
227
|
+
* Applies a streamed delta to the authoritative in-memory transcript without touching disk.
|
|
228
|
+
*
|
|
229
|
+
* The runtime must be able to drain Pi and publish the token before a disk transaction finishes.
|
|
230
|
+
* `checkpointMessage` below makes the same object durable on a bounded cadence and at turn boundaries.
|
|
231
|
+
*/
|
|
232
|
+
appendAssistantDeltaLive(conversationId: string, messageId: string, delta: string): void;
|
|
226
233
|
appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
|
|
227
234
|
/**
|
|
228
235
|
* THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
|
|
@@ -234,7 +241,10 @@ export declare class Store {
|
|
|
234
241
|
* to go back to. Persisting here rather than only in the browser is the point of its living on the
|
|
235
242
|
* message: a reasoning stream that vanishes on reload was never a record of anything.
|
|
236
243
|
*/
|
|
244
|
+
appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number): void;
|
|
237
245
|
appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
|
|
246
|
+
/** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
|
|
247
|
+
checkpointMessage(conversationId: string, messageId: string): Promise<void>;
|
|
238
248
|
/**
|
|
239
249
|
* Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
|
|
240
250
|
* array so the caller can broadcast it as one snapshot.
|
|
@@ -308,6 +318,8 @@ export declare class Store {
|
|
|
308
318
|
* one: it means the caller and the store disagree about the conversation, and guessing is worse.
|
|
309
319
|
*/
|
|
310
320
|
setMessagePiEntryId(conversationId: string, messageId: string, piEntryId: string): Promise<void>;
|
|
321
|
+
/** Cache a locator Pi itself resolved while serving this conversation; never trust it as authorization. */
|
|
322
|
+
setConversationPiSessionFile(conversationId: string, path: string): Promise<void>;
|
|
311
323
|
/**
|
|
312
324
|
* Reverts the conversation to the state right after `messageId`, in `state.json` only.
|
|
313
325
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -879,14 +879,23 @@ export class Store {
|
|
|
879
879
|
return structuredClone(conversation);
|
|
880
880
|
}
|
|
881
881
|
async beginAssistantMessage(conversationId, agentIdentity) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
|
|
882
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Applies a streamed delta to the authoritative in-memory transcript without touching disk.
|
|
884
|
+
*
|
|
885
|
+
* The runtime must be able to drain Pi and publish the token before a disk transaction finishes.
|
|
886
|
+
* `checkpointMessage` below makes the same object durable on a bounded cadence and at turn boundaries.
|
|
887
|
+
*/
|
|
888
|
+
appendAssistantDeltaLive(conversationId, messageId, delta) {
|
|
883
889
|
const conversation = this.requireConversation(conversationId);
|
|
884
890
|
const message = conversation.messages.find((item) => item.id === messageId);
|
|
885
891
|
if (!message)
|
|
886
892
|
throw new Error("Assistant message not found");
|
|
887
893
|
message.content += delta;
|
|
888
894
|
conversation.updatedAt = now();
|
|
889
|
-
|
|
895
|
+
}
|
|
896
|
+
async appendAssistantDelta(conversationId, messageId, delta) {
|
|
897
|
+
this.appendAssistantDeltaLive(conversationId, messageId, delta);
|
|
898
|
+
await this.checkpointMessage(conversationId, messageId);
|
|
890
899
|
}
|
|
891
900
|
/**
|
|
892
901
|
* THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
|
|
@@ -898,13 +907,30 @@ export class Store {
|
|
|
898
907
|
* to go back to. Persisting here rather than only in the browser is the point of its living on the
|
|
899
908
|
* message: a reasoning stream that vanishes on reload was never a record of anything.
|
|
900
909
|
*/
|
|
901
|
-
|
|
910
|
+
appendThinkingDeltaLive(conversationId, messageId, delta, at) {
|
|
902
911
|
const conversation = this.requireConversation(conversationId);
|
|
903
912
|
const message = conversation.messages.find((item) => item.id === messageId);
|
|
904
913
|
if (!message)
|
|
905
914
|
throw new Error("Assistant message not found");
|
|
906
915
|
message.thinking = `${message.thinking ?? ""}${delta}`;
|
|
916
|
+
const blocks = (message.thinkingBlocks ??= []);
|
|
917
|
+
const last = blocks.at(-1);
|
|
918
|
+
if (last && last.at === (at ?? last.at))
|
|
919
|
+
last.text += delta;
|
|
920
|
+
else
|
|
921
|
+
blocks.push({ at: at ?? message.content.length, text: delta });
|
|
907
922
|
conversation.updatedAt = now();
|
|
923
|
+
}
|
|
924
|
+
async appendThinkingDelta(conversationId, messageId, delta) {
|
|
925
|
+
this.appendThinkingDeltaLive(conversationId, messageId, delta);
|
|
926
|
+
await this.checkpointMessage(conversationId, messageId);
|
|
927
|
+
}
|
|
928
|
+
/** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
|
|
929
|
+
async checkpointMessage(conversationId, messageId) {
|
|
930
|
+
const conversation = this.requireConversation(conversationId);
|
|
931
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
932
|
+
if (!message)
|
|
933
|
+
throw new Error("Assistant message not found");
|
|
908
934
|
await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
|
|
909
935
|
}
|
|
910
936
|
/**
|
|
@@ -996,7 +1022,7 @@ export class Store {
|
|
|
996
1022
|
// full output a reader watched grow; the persisted `result` is a bounded 8KB tail of the same
|
|
997
1023
|
// stream. Deleting live would replace the watched text with a possibly-different 8KB slice, and a
|
|
998
1024
|
// finished tool block that vanishes the moment the call ends is a block that taught the reader
|
|
999
|
-
// nothing. The client
|
|
1025
|
+
// nothing. The client prefers `live` when both are present, falling back to `result`.
|
|
1000
1026
|
conversation.updatedAt = now();
|
|
1001
1027
|
await this.persist();
|
|
1002
1028
|
return structuredClone(calls);
|
|
@@ -1036,8 +1062,14 @@ export class Store {
|
|
|
1036
1062
|
merged.set(arg.key, arg);
|
|
1037
1063
|
call.args = [...merged.values()].slice(0, TOOL_ARG_CAP);
|
|
1038
1064
|
}
|
|
1065
|
+
// THE PREVIEW IS "THE TEXT SO FAR", SO IT REPLACES. Every writer sends the whole text: the runtime
|
|
1066
|
+
// accumulates the model's argument fragments before this is called, and Pi sends
|
|
1067
|
+
// `tool_execution_update.partialResult` already accumulated. Appending here would concatenate two
|
|
1068
|
+
// "so far"s - the reader would watch the output print itself twice. The clear at the preparing ->
|
|
1069
|
+
// running handoff (`live: ""`) is the same statement, once: the arguments that were on screen while
|
|
1070
|
+
// the call was being written are gone by the time it runs.
|
|
1039
1071
|
if (patch.live !== undefined)
|
|
1040
|
-
call.live =
|
|
1072
|
+
call.live = patch.live;
|
|
1041
1073
|
// The question rides on the call rather than beside it, so this is the only write it needs.
|
|
1042
1074
|
if (patch.question !== undefined)
|
|
1043
1075
|
call.question = patch.question;
|
|
@@ -1067,6 +1099,14 @@ export class Store {
|
|
|
1067
1099
|
message.piEntryId = piEntryId;
|
|
1068
1100
|
await this.persist();
|
|
1069
1101
|
}
|
|
1102
|
+
/** Cache a locator Pi itself resolved while serving this conversation; never trust it as authorization. */
|
|
1103
|
+
async setConversationPiSessionFile(conversationId, path) {
|
|
1104
|
+
const conversation = this.requireConversation(conversationId);
|
|
1105
|
+
if (conversation.piSessionFile === path)
|
|
1106
|
+
return;
|
|
1107
|
+
conversation.piSessionFile = path;
|
|
1108
|
+
await this.persistMetadata(conversationId);
|
|
1109
|
+
}
|
|
1070
1110
|
/**
|
|
1071
1111
|
* Reverts the conversation to the state right after `messageId`, in `state.json` only.
|
|
1072
1112
|
*
|
package/dist/server/subagents.js
CHANGED
|
@@ -4,7 +4,8 @@ import { mkdir, stat } from "node:fs/promises";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
6
6
|
import { resolvePiCommand, shellQuote } from "./pi-command.js";
|
|
7
|
-
import { buildExtensionUiResponse, describeExtensionUiRequest, killTree,
|
|
7
|
+
import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, } from "./runtime.js";
|
|
8
|
+
import { RpcChannel } from "./rpc-channel.js";
|
|
8
9
|
import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
|
|
9
10
|
/**
|
|
10
11
|
* The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
|
|
@@ -497,7 +498,7 @@ export class SubagentSupervisor {
|
|
|
497
498
|
noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
|
|
498
499
|
}
|
|
499
500
|
// A dead child will never settle, so waiting out another poll would only delay the failure.
|
|
500
|
-
if (!settled &&
|
|
501
|
+
if (!settled && channel.closed)
|
|
501
502
|
throw new Error(`sub-agent ${agentId} (${id}) exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
|
|
502
503
|
}
|
|
503
504
|
for (const [key, count] of repeats)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type MountedTool = {
|
|
2
|
+
id: string;
|
|
3
|
+
slug: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
entrypoint: string;
|
|
8
|
+
source: "user" | "builtin";
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
};
|
|
13
|
+
export declare class ToolRegistry {
|
|
14
|
+
private readonly dataDir;
|
|
15
|
+
private readonly file;
|
|
16
|
+
private items;
|
|
17
|
+
private loaded;
|
|
18
|
+
constructor(dataDir: string);
|
|
19
|
+
private load;
|
|
20
|
+
private save;
|
|
21
|
+
list(): Promise<MountedTool[]>;
|
|
22
|
+
register(input: {
|
|
23
|
+
workspaceId: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
entrypoint: string;
|
|
28
|
+
}, workspaceRoot: string): Promise<MountedTool>;
|
|
29
|
+
remove(id: string): Promise<boolean>;
|
|
30
|
+
setEnabled(id: string, enabled: boolean): Promise<MountedTool | undefined>;
|
|
31
|
+
resolve(id: string, workspaceRoot: string, resource?: string): Promise<{
|
|
32
|
+
tool: MountedTool;
|
|
33
|
+
file: string;
|
|
34
|
+
} | undefined>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile, access, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
const MAX_TOOLS = 128;
|
|
4
|
+
const MAX_SLUG = 64;
|
|
5
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
export class ToolRegistry {
|
|
7
|
+
dataDir;
|
|
8
|
+
file;
|
|
9
|
+
items = [];
|
|
10
|
+
loaded = false;
|
|
11
|
+
constructor(dataDir) {
|
|
12
|
+
this.dataDir = dataDir;
|
|
13
|
+
this.file = join(dataDir, "tools.json");
|
|
14
|
+
}
|
|
15
|
+
async load() {
|
|
16
|
+
if (this.loaded)
|
|
17
|
+
return;
|
|
18
|
+
this.loaded = true;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
21
|
+
if (Array.isArray(parsed))
|
|
22
|
+
this.items = parsed.filter(isTool);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
this.items = [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async save() {
|
|
29
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
30
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
31
|
+
await writeFile(tmp, JSON.stringify(this.items, null, 2), "utf8");
|
|
32
|
+
await rename(tmp, this.file);
|
|
33
|
+
}
|
|
34
|
+
async list() { await this.load(); return this.items.map((x) => ({ ...x })); }
|
|
35
|
+
async register(input, workspaceRoot) {
|
|
36
|
+
await this.load();
|
|
37
|
+
const slug = input.slug.trim().toLowerCase();
|
|
38
|
+
if (!SLUG.test(slug))
|
|
39
|
+
throw new Error("slug 只能包含小写字母、数字和连字符");
|
|
40
|
+
if (!input.name.trim() || input.name.length > 120)
|
|
41
|
+
throw new Error("工具名称无效");
|
|
42
|
+
const entrypoint = safeRelative(input.entrypoint);
|
|
43
|
+
const root = resolve(workspaceRoot);
|
|
44
|
+
const file = resolve(root, entrypoint);
|
|
45
|
+
if (!isInside(root, file))
|
|
46
|
+
throw new Error("entrypoint 必须位于工作区内");
|
|
47
|
+
const info = await stat(file).catch(() => undefined);
|
|
48
|
+
if (!info?.isFile())
|
|
49
|
+
throw new Error("entrypoint 文件不存在");
|
|
50
|
+
if (!entrypoint.endsWith(".html"))
|
|
51
|
+
throw new Error("entrypoint 必须是 HTML 文件");
|
|
52
|
+
if (this.items.some((x) => x.workspaceId === input.workspaceId && x.slug === slug))
|
|
53
|
+
throw new Error("工具 slug 已存在");
|
|
54
|
+
if (this.items.length >= MAX_TOOLS)
|
|
55
|
+
throw new Error("工具数量已达到上限");
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const tool = { id: `tool_${crypto.randomUUID()}`, slug, name: input.name.trim(), description: input.description?.trim().slice(0, 500), workspaceId: input.workspaceId, entrypoint, source: "user", enabled: true, createdAt: now, updatedAt: now };
|
|
58
|
+
this.items.push(tool);
|
|
59
|
+
await this.save();
|
|
60
|
+
return { ...tool };
|
|
61
|
+
}
|
|
62
|
+
async remove(id) { await this.load(); const before = this.items.length; this.items = this.items.filter((x) => x.id !== id); if (this.items.length !== before)
|
|
63
|
+
await this.save(); return before !== this.items.length; }
|
|
64
|
+
async setEnabled(id, enabled) { await this.load(); const item = this.items.find((x) => x.id === id); if (!item)
|
|
65
|
+
return undefined; item.enabled = enabled; item.updatedAt = new Date().toISOString(); await this.save(); return { ...item }; }
|
|
66
|
+
async resolve(id, workspaceRoot, resource = "") { await this.load(); const tool = this.items.find((x) => x.id === id && x.enabled); if (!tool)
|
|
67
|
+
return undefined; const root = resolve(workspaceRoot); const entrypoint = resolve(root, tool.entrypoint); if (!isInside(root, entrypoint))
|
|
68
|
+
return undefined; const file = resource ? resolve(dirname(entrypoint), safeRelative(resource)) : entrypoint; if (!isInside(dirname(entrypoint), file))
|
|
69
|
+
throw new Error("工具资源必须位于工具目录内"); await access(file).catch(() => { throw new Error("工具文件不存在"); }); return { tool: { ...tool }, file }; }
|
|
70
|
+
}
|
|
71
|
+
function safeRelative(value) { const p = value.replace(/\\/g, "/").trim(); if (!p || p.startsWith("/") || p.includes("\0") || p.split("/").includes(".."))
|
|
72
|
+
throw new Error("路径必须是工作区内的相对路径"); return p; }
|
|
73
|
+
function isInside(root, target) { const r = relative(root, target); return r === "" || (!r.startsWith(".." + sep) && r !== ".." && !r.includes(":")); }
|
|
74
|
+
function isTool(value) { const x = value; return !!x && typeof x === "object" && typeof x.id === "string" && typeof x.slug === "string" && typeof x.workspaceId === "string" && typeof x.entrypoint === "string" && (x.source === "user" || x.source === "builtin"); }
|