@evandu/super-pi 0.1.111 → 0.1.113

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.
Files changed (35) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +8 -7
  3. package/dist/client/assets/{CodeMirrorEditor-CaF1aTpR.js → CodeMirrorEditor-moFTk5SE.js} +2 -2
  4. package/dist/client/assets/{dist-aC6cFQVF.js → dist-BR4s0dNg.js} +1 -1
  5. package/dist/client/assets/{dist-Bl_iPEld.js → dist-BUuPRG15.js} +1 -1
  6. package/dist/client/assets/{dist-CPo4IuhO.js → dist-Bds_PUYK.js} +1 -1
  7. package/dist/client/assets/{dist-BydrWuS9.js → dist-BsO40qxQ.js} +1 -1
  8. package/dist/client/assets/{dist-jDX3NY00.js → dist-CCYnr6ul.js} +1 -1
  9. package/dist/client/assets/{dist-f78PQc3f.js → dist-CL65aMXY.js} +1 -1
  10. package/dist/client/assets/{dist-BjrDHYro.js → dist-D9m04q-s.js} +1 -1
  11. package/dist/client/assets/{dist-o-3gMx5l.js → dist-DIfmMr8s.js} +1 -1
  12. package/dist/client/assets/{dist-CJHSsi_2.js → dist-DPDGd2jl.js} +1 -1
  13. package/dist/client/assets/{dist-C9gmrpfb.js → dist-DPvy57Ph.js} +1 -1
  14. package/dist/client/assets/{dist-CKziNS6e.js → dist-DQjJMys5.js} +1 -1
  15. package/dist/client/assets/{dist-CbkXScEJ.js → dist-Dnr5BxAi.js} +1 -1
  16. package/dist/client/assets/{dist-C2JgfsQ0.js → dist-DrbUrVWB.js} +1 -1
  17. package/dist/client/assets/{dist-BG1mkRxC.js → dist-DuWhzonJ.js} +1 -1
  18. package/dist/client/assets/{dist-DnwnynHW.js → dist-TgNJUr7p.js} +1 -1
  19. package/dist/client/assets/{dist-Bv-QAejS.js → dist-rhPxFuD1.js} +1 -1
  20. package/dist/client/assets/{index-CME3BwBz.js → index-BixdV7aQ.js} +23 -23
  21. package/dist/client/assets/index-CGXMw9LP.css +1 -0
  22. package/dist/client/index.html +2 -2
  23. package/dist/server/application/run-service.d.ts +7 -0
  24. package/dist/server/application/run-service.js +16 -0
  25. package/dist/server/file-name-index.d.ts +144 -0
  26. package/dist/server/file-name-index.js +324 -0
  27. package/dist/server/http-server.js +112 -3
  28. package/dist/server/index.js +10 -0
  29. package/dist/server/server-context.d.ts +9 -0
  30. package/dist/server/skills.d.ts +23 -0
  31. package/dist/server/skills.js +171 -0
  32. package/dist/server/types.d.ts +7 -0
  33. package/package.json +3 -1
  34. package/scripts/patch-brace-expansion.mjs +1 -1
  35. package/dist/client/assets/index-CauO59H-.css +0 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * THE INDEX FOR ONE WORKSPACE, created on demand and then kept.
3
+ *
4
+ * Both layers that need it - the HTTP route that answers a search and the run service that records
5
+ * the agent's writes - go through this one function, so there is exactly one index per workspace and
6
+ * the two never drift into answering from different maps.
7
+ */
8
+ export declare function ensureNameIndex(indexes: Map<string, FileNameIndex>, workspaceId: string, root: string): FileNameIndex;
9
+ /** What the index knows about one path. Deliberately small: no size, no etag, no mtime. */
10
+ export type NameEntry = {
11
+ /** Workspace-relative, `/`-separated. */
12
+ path: string;
13
+ /** The last segment. */
14
+ name: string;
15
+ /** The directory holding it, `""` for the root itself. */
16
+ directory: string;
17
+ kind: "file" | "directory";
18
+ };
19
+ /** What the index knows about its own state, for the "正在建立索引… 42%" line. */
20
+ export type IndexProgress = {
21
+ scanned: number;
22
+ percent: number;
23
+ };
24
+ /** The answer to one question: which paths contain this needle. */
25
+ export type NameSearchResult = {
26
+ hits: NameEntry[];
27
+ /** The exact number of matches. Equal to `hits.length` when nothing was withheld. */
28
+ total: number;
29
+ /** Whether the index has finished its first pass. `false` means "more may still arrive". */
30
+ indexed: boolean;
31
+ progress: IndexProgress;
32
+ /** Set when the scan hit a limit. Its presence is the reason the answer is not the whole tree. */
33
+ incomplete: boolean;
34
+ };
35
+ /**
36
+ * The result ordering, and why it is not the path dictionary order.
37
+ *
38
+ * A dictionary order puts `zzz-notes.md` before `notes.md`, because "zzz" sorts after "notes" as
39
+ * a whole string. In a list of two hundred that is actively hostile: the file whose name STARTS
40
+ * with what you typed is the one you meant, and it is somewhere in the middle. So the order is:
41
+ *
42
+ * 1. the hit position inside the file name - a name that starts with the needle beats one that
43
+ * merely contains it, whatever the directory;
44
+ * 2. shallower directories first - `notes.md` beats `src/deep/nested/notes.md`;
45
+ * 3. the path dictionary order, as the final tie-break, so the order is stable.
46
+ */
47
+ export declare function compareNameHits(a: NameEntry, b: NameEntry, needle: string): number;
48
+ /**
49
+ * A per-workspace index of every path in the tree, built in the background.
50
+ *
51
+ * THE LIFECYCLE. `start()` kicks off one background scan and returns immediately - the first
52
+ * screen must never wait for an index. `search()` works at any point in that scan and answers with
53
+ * what is known so far, together with `indexed: false` so the caller can say "正在建立索引"
54
+ * rather than pretending the answer is complete.
55
+ *
56
+ * THE INCREMENTAL PATH. `upsert()` and `remove()` are how the index stays true once it exists.
57
+ * The agent writes files through the run service and the user saves through the files API, and
58
+ * BOTH of those places already know the path - so keeping the index true costs no extra I/O at
59
+ * all, it is one map write.
60
+ */
61
+ export declare class FileNameIndex {
62
+ readonly workspaceId: string;
63
+ readonly entries: Map<string, NameEntry>;
64
+ private root;
65
+ private building;
66
+ private started;
67
+ private scanned;
68
+ private stopped;
69
+ private incomplete;
70
+ constructor(workspaceId: string);
71
+ /**
72
+ * Whether the first pass has FINISHED.
73
+ *
74
+ * "Finished" is not "found something": an EMPTY directory finishes the scan with zero entries, and
75
+ * that directory is exactly the case where the answer "no matches" is TRUE rather than premature.
76
+ * A readiness test that asked "are there any entries yet" would leave an empty workspace stuck on
77
+ * "building" forever.
78
+ *
79
+ * A scan that FAILED has not finished either - it never will be, and `failure` says why.
80
+ */
81
+ get indexed(): boolean;
82
+ get progress(): IndexProgress;
83
+ /** Begin the background scan. Safe to call more than once; the second call is a no-op. */
84
+ start(root: string): void;
85
+ /**
86
+ * WHY THE SCAN'S FAILURE IS RECORDED RATHER THAN THROWN.
87
+ *
88
+ * The first version rethrew a missing root so the failure could not be swallowed - and that crashed
89
+ * the server, because a background scan has no caller to receive its rejection and Node's default
90
+ * for an unhandled rejection is to terminate the process. The whole test server went down, and the
91
+ * symptom ("fetch failed") pointed at the network rather than at a promise.
92
+ *
93
+ * So the failure is RECORDED (`this.failure`) and the route asks about it (`throwIfBroken`). The
94
+ * answer is still the 404 the route owes, but it is now delivered by the request rather than by an
95
+ * exception nobody is listening for.
96
+ */
97
+ private failure;
98
+ /** The error that stopped the scan, if the root itself could not be read. */
99
+ get broken(): unknown;
100
+ /** Rethrow the scan's failure, so the route can turn it into the status it deserves. */
101
+ throwIfBroken(): void;
102
+ /** Rebuild from scratch. The ↻ button. */
103
+ reset(root: string): void;
104
+ /** Record a path that appeared or changed. Used for agent writes and user saves. */
105
+ upsert(relativePath: string, kind?: "file" | "directory"): void;
106
+ /** Forget a path that is gone. */
107
+ remove(relativePath: string): void;
108
+ /**
109
+ * The query. A substring filter over an in-memory map - no filesystem access at all.
110
+ *
111
+ * The needle is matched against the FILE NAME only. Matching the path as well would be a
112
+ * different feature ("find everything under src/server"), and mixing the two makes a search for
113
+ * `app` return every file in `application/` as well - a result set nobody can read.
114
+ */
115
+ search(needle: string): NameSearchResult;
116
+ /**
117
+ * Wait until the first scan has finished, or until the deadline passes.
118
+ *
119
+ * WHY THIS EXISTS, and it is the difference between a search that works and one that does not.
120
+ *
121
+ * The panel fetches the index the moment a workspace opens, so the background scan is normally
122
+ * already running (or finished) by the time anybody types. But "normally" is not "always": a person
123
+ * who opens the panel and types immediately would otherwise get an answer computed from a
124
+ * half-built map - and an answer of "no matches" from a half-built map is a LIE. It is exactly the
125
+ * complaint this work exists to fix: "搜不出他想搜的东西".
126
+ *
127
+ * So the search WAITS for the scan, up to a deadline. The deadline is what keeps this from being a
128
+ * hang: past it, the search answers with what it knows and says so (`indexed: false`), which is a
129
+ * statement about the state rather than a claim about the tree.
130
+ */
131
+ waitUntilIndexed(timeoutMs: number): Promise<boolean>;
132
+ /**
133
+ * The background scan.
134
+ *
135
+ * `readdir` only, with `withFileTypes` so the kind of every entry arrives without a `stat`.
136
+ * Symlinks are recorded but never followed - following one is how a scan becomes an infinite
137
+ * walk, and a link inside the workspace is the one path the tree refuses to descend as well.
138
+ */
139
+ private scan;
140
+ /** Stop a running scan. Used when a workspace goes away. */
141
+ stop(): void;
142
+ }
143
+ /** The workspace-relative path of an absolute one, for callers holding an absolute path. */
144
+ export declare function relativeTo(root: string, absolute: string): string;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * THE NAME INDEX. Every file in the workspace, indexed once, queried in memory.
3
+ *
4
+ * WHY THIS FILE EXISTS AT ALL, because there is already a `file-index.ts` and the obvious move is
5
+ * to extend it. That move is wrong, and the reason is that the two indexes have OPPOSITE scopes:
6
+ *
7
+ * - `FileIndex` (the content-search one) deliberately SKIPS dependency directories. A grep through
8
+ * `node_modules` is slow and returns nothing a person asked for. Measured on this repository:
9
+ * 15388 files of which 13949 are `node_modules`, while the project's own `src/` has 75.
10
+ * - THIS index deliberately SKIPS NOTHING. A person typing `config` into a filename box wants
11
+ * `node_modules/some-pkg/config.js` to be findable, and the old behaviour made it unfindable
12
+ * while announcing the omission in the UI as a dashed "已跳过 N 个目录" note.
13
+ *
14
+ * Those are not two settings of one thing. They are two different questions ("where is this
15
+ * symbol used" vs "what is this file called"), and one index cannot answer both.
16
+ *
17
+ * WHY AN INDEX AND NOT A FRESH WALK PER KEYSTROKE. The old path re-walked the whole tree on every
18
+ * keystroke (`files.ts` `searchByName`), which cost the user a full scan per character typed. The
19
+ * cost of building this index ONCE is 960ms of `readdir` on this repository (measured: 3257
20
+ * directories, 28289 files, no `stat`), and the cost of a query afterwards is a substring filter
21
+ * over an in-memory map - about 3ms for 30000 entries. So the pressure moves from the user's
22
+ * keystrokes to the server's background, which is where it belongs.
23
+ *
24
+ * WHY THE SCAN HAS NO `stat`. The measurements are unambiguous: `readdir` over the whole tree is
25
+ * 960ms and `stat` over every directory is 166ms, but a per-FILE `stat` is what costs seconds
26
+ * (the documented 2-3s figure). This index does not need file sizes or etags - it needs NAMES -
27
+ * so it never stats a file. `readdir` with `withFileTypes` gives the kind for free.
28
+ *
29
+ * THE LIMITS ARE HONEST AND LOOSE. 20000 directories and 200000 files are three to four orders of
30
+ * magnitude above a real repository, and hitting one is reported (`complete: false`) rather than
31
+ * silently applied - a search that quietly stopped at a limit is the exact defect this file
32
+ * replaces.
33
+ */
34
+ import { readdir } from "node:fs/promises";
35
+ import { createRootGuard, toRelative } from "./files.js";
36
+ /**
37
+ * THE INDEX FOR ONE WORKSPACE, created on demand and then kept.
38
+ *
39
+ * Both layers that need it - the HTTP route that answers a search and the run service that records
40
+ * the agent's writes - go through this one function, so there is exactly one index per workspace and
41
+ * the two never drift into answering from different maps.
42
+ */
43
+ export function ensureNameIndex(indexes, workspaceId, root) {
44
+ let index = indexes.get(workspaceId);
45
+ if (!index) {
46
+ index = new FileNameIndex(workspaceId);
47
+ indexes.set(workspaceId, index);
48
+ }
49
+ index.start(root);
50
+ return index;
51
+ }
52
+ const MAX_DIRECTORIES = 20000;
53
+ const MAX_FILES = 200000;
54
+ /**
55
+ * The result ordering, and why it is not the path dictionary order.
56
+ *
57
+ * A dictionary order puts `zzz-notes.md` before `notes.md`, because "zzz" sorts after "notes" as
58
+ * a whole string. In a list of two hundred that is actively hostile: the file whose name STARTS
59
+ * with what you typed is the one you meant, and it is somewhere in the middle. So the order is:
60
+ *
61
+ * 1. the hit position inside the file name - a name that starts with the needle beats one that
62
+ * merely contains it, whatever the directory;
63
+ * 2. shallower directories first - `notes.md` beats `src/deep/nested/notes.md`;
64
+ * 3. the path dictionary order, as the final tie-break, so the order is stable.
65
+ */
66
+ export function compareNameHits(a, b, needle) {
67
+ const position = hitPosition(a, needle) - hitPosition(b, needle);
68
+ if (position !== 0)
69
+ return position;
70
+ const depth = a.directory.split("/").filter(Boolean).length - b.directory.split("/").filter(Boolean).length;
71
+ if (depth !== 0)
72
+ return depth;
73
+ return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" });
74
+ }
75
+ /** Where in the NAME the needle first appears. A name that does not contain it at all sorts last. */
76
+ function hitPosition(entry, needle) {
77
+ const at = entry.name.toLowerCase().indexOf(needle);
78
+ return at < 0 ? Number.MAX_SAFE_INTEGER : at;
79
+ }
80
+ /**
81
+ * A per-workspace index of every path in the tree, built in the background.
82
+ *
83
+ * THE LIFECYCLE. `start()` kicks off one background scan and returns immediately - the first
84
+ * screen must never wait for an index. `search()` works at any point in that scan and answers with
85
+ * what is known so far, together with `indexed: false` so the caller can say "正在建立索引"
86
+ * rather than pretending the answer is complete.
87
+ *
88
+ * THE INCREMENTAL PATH. `upsert()` and `remove()` are how the index stays true once it exists.
89
+ * The agent writes files through the run service and the user saves through the files API, and
90
+ * BOTH of those places already know the path - so keeping the index true costs no extra I/O at
91
+ * all, it is one map write.
92
+ */
93
+ export class FileNameIndex {
94
+ workspaceId;
95
+ entries = new Map();
96
+ root = "";
97
+ building = false;
98
+ started = false;
99
+ scanned = 0;
100
+ stopped = false;
101
+ incomplete = false;
102
+ constructor(workspaceId) {
103
+ this.workspaceId = workspaceId;
104
+ }
105
+ /**
106
+ * Whether the first pass has FINISHED.
107
+ *
108
+ * "Finished" is not "found something": an EMPTY directory finishes the scan with zero entries, and
109
+ * that directory is exactly the case where the answer "no matches" is TRUE rather than premature.
110
+ * A readiness test that asked "are there any entries yet" would leave an empty workspace stuck on
111
+ * "building" forever.
112
+ *
113
+ * A scan that FAILED has not finished either - it never will be, and `failure` says why.
114
+ */
115
+ get indexed() {
116
+ return !this.building && this.failure === undefined;
117
+ }
118
+ get progress() {
119
+ // The percentage is deliberately not a promise about the whole tree: the total is unknown
120
+ // until the scan ends, so it is derived from the directory budget instead, and the UI shows
121
+ // the file COUNT next to it rather than a bare percentage that would be a guess.
122
+ const percent = Math.min(99, Math.round((this.scanned / MAX_DIRECTORIES) * 100));
123
+ return { scanned: this.entries.size, percent: this.building ? percent : 100 };
124
+ }
125
+ /** Begin the background scan. Safe to call more than once; the second call is a no-op. */
126
+ start(root) {
127
+ // The guard is "has a scan been started", NOT "are there any entries". An EMPTY workspace
128
+ // finishes its scan with zero entries, and an emptiness test would restart the scan on every
129
+ // request - so the answer to every query would be "still building" forever.
130
+ if (this.started || this.building)
131
+ return;
132
+ this.started = true;
133
+ this.root = root;
134
+ this.building = true;
135
+ void this.scan();
136
+ }
137
+ /**
138
+ * WHY THE SCAN'S FAILURE IS RECORDED RATHER THAN THROWN.
139
+ *
140
+ * The first version rethrew a missing root so the failure could not be swallowed - and that crashed
141
+ * the server, because a background scan has no caller to receive its rejection and Node's default
142
+ * for an unhandled rejection is to terminate the process. The whole test server went down, and the
143
+ * symptom ("fetch failed") pointed at the network rather than at a promise.
144
+ *
145
+ * So the failure is RECORDED (`this.failure`) and the route asks about it (`throwIfBroken`). The
146
+ * answer is still the 404 the route owes, but it is now delivered by the request rather than by an
147
+ * exception nobody is listening for.
148
+ */
149
+ failure;
150
+ /** The error that stopped the scan, if the root itself could not be read. */
151
+ get broken() {
152
+ return this.failure;
153
+ }
154
+ /** Rethrow the scan's failure, so the route can turn it into the status it deserves. */
155
+ throwIfBroken() {
156
+ if (this.failure === undefined)
157
+ return;
158
+ throw this.failure;
159
+ }
160
+ /** Rebuild from scratch. The ↻ button. */
161
+ reset(root) {
162
+ this.entries.clear();
163
+ this.scanned = 0;
164
+ this.incomplete = false;
165
+ this.stopped = false;
166
+ this.building = false;
167
+ this.started = false;
168
+ this.failure = undefined;
169
+ this.start(root);
170
+ }
171
+ /** Record a path that appeared or changed. Used for agent writes and user saves. */
172
+ upsert(relativePath, kind = "file") {
173
+ const path = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
174
+ if (!path)
175
+ return;
176
+ const cut = path.lastIndexOf("/");
177
+ const name = cut < 0 ? path : path.slice(cut + 1);
178
+ const directory = cut < 0 ? "" : path.slice(0, cut);
179
+ this.entries.set(path, { path, name, directory, kind });
180
+ }
181
+ /** Forget a path that is gone. */
182
+ remove(relativePath) {
183
+ const path = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
184
+ this.entries.delete(path);
185
+ // The directories that held it are still there; only the file itself is gone.
186
+ }
187
+ /**
188
+ * The query. A substring filter over an in-memory map - no filesystem access at all.
189
+ *
190
+ * The needle is matched against the FILE NAME only. Matching the path as well would be a
191
+ * different feature ("find everything under src/server"), and mixing the two makes a search for
192
+ * `app` return every file in `application/` as well - a result set nobody can read.
193
+ */
194
+ search(needle) {
195
+ const query = needle.trim().toLowerCase();
196
+ if (!query) {
197
+ return { hits: [], total: 0, indexed: this.indexed, progress: this.progress, incomplete: this.incomplete };
198
+ }
199
+ const hits = [];
200
+ for (const entry of this.entries.values()) {
201
+ if (entry.kind !== "file")
202
+ continue;
203
+ if (entry.name.toLowerCase().includes(query))
204
+ hits.push(entry);
205
+ }
206
+ hits.sort((a, b) => compareNameHits(a, b, query));
207
+ return {
208
+ hits,
209
+ total: hits.length,
210
+ indexed: this.indexed,
211
+ progress: this.progress,
212
+ incomplete: this.incomplete,
213
+ };
214
+ }
215
+ /**
216
+ * Wait until the first scan has finished, or until the deadline passes.
217
+ *
218
+ * WHY THIS EXISTS, and it is the difference between a search that works and one that does not.
219
+ *
220
+ * The panel fetches the index the moment a workspace opens, so the background scan is normally
221
+ * already running (or finished) by the time anybody types. But "normally" is not "always": a person
222
+ * who opens the panel and types immediately would otherwise get an answer computed from a
223
+ * half-built map - and an answer of "no matches" from a half-built map is a LIE. It is exactly the
224
+ * complaint this work exists to fix: "搜不出他想搜的东西".
225
+ *
226
+ * So the search WAITS for the scan, up to a deadline. The deadline is what keeps this from being a
227
+ * hang: past it, the search answers with what it knows and says so (`indexed: false`), which is a
228
+ * statement about the state rather than a claim about the tree.
229
+ */
230
+ async waitUntilIndexed(timeoutMs) {
231
+ if (this.indexed)
232
+ return true;
233
+ const deadline = Date.now() + timeoutMs;
234
+ while (!this.indexed && Date.now() < deadline) {
235
+ // A scan that failed will never become ready, so waiting for it would burn the whole deadline
236
+ // on every request against a workspace whose directory is gone.
237
+ if (this.failure !== undefined)
238
+ return false;
239
+ await new Promise((resolve) => setTimeout(resolve, 25));
240
+ }
241
+ return this.indexed;
242
+ }
243
+ /**
244
+ * The background scan.
245
+ *
246
+ * `readdir` only, with `withFileTypes` so the kind of every entry arrives without a `stat`.
247
+ * Symlinks are recorded but never followed - following one is how a scan becomes an infinite
248
+ * walk, and a link inside the workspace is the one path the tree refuses to descend as well.
249
+ */
250
+ async scan() {
251
+ const started = Date.now();
252
+ const guard = createRootGuard(this.root);
253
+ const queue = [""];
254
+ while (queue.length) {
255
+ if (this.stopped)
256
+ break;
257
+ const directory = queue.shift();
258
+ if (this.scanned >= MAX_DIRECTORIES) {
259
+ this.incomplete = true;
260
+ break;
261
+ }
262
+ this.scanned += 1;
263
+ let entries;
264
+ try {
265
+ entries = await readdir(await guard(directory), { withFileTypes: true });
266
+ }
267
+ catch (error) {
268
+ // THE ROOT IS NOT ONE OF THE SKIPPABLE DIRECTORIES.
269
+ //
270
+ // A workspace whose directory is gone must answer 404 `workspace_missing`, and the reason is
271
+ // the one this file's own `incomplete` flag cannot express: "I searched and found nothing"
272
+ // and "there was nothing to search" are DIFFERENT answers, and only one of them is true.
273
+ // The old walk rethrew the root's failure and the route turned it into a 404; swallowing it
274
+ // here would put that exact bug back - a 200 with an empty list, which is byte-for-byte the
275
+ // response for "your search matched nothing".
276
+ //
277
+ // The failure is RECORDED, not thrown: see `failure` above for why throwing it here would
278
+ // take the whole server down with it.
279
+ //
280
+ // A SUBDIRECTORY that cannot be read is skipped instead: one unreadable folder in a large
281
+ // tree must not cost the reader every other match, and the scan says so through
282
+ // `incomplete` rather than by pretending the folder was empty.
283
+ if (directory === "") {
284
+ this.failure = error;
285
+ this.building = false;
286
+ return;
287
+ }
288
+ this.incomplete = true;
289
+ continue;
290
+ }
291
+ for (const entry of entries) {
292
+ if (entry.isSymbolicLink())
293
+ continue;
294
+ const relative = directory ? `${directory}/${entry.name}` : entry.name;
295
+ if (entry.isDirectory()) {
296
+ this.entries.set(relative, { path: relative, name: entry.name, directory, kind: "directory" });
297
+ queue.push(relative);
298
+ continue;
299
+ }
300
+ if (!entry.isFile())
301
+ continue;
302
+ if (this.entries.size >= MAX_FILES) {
303
+ this.incomplete = true;
304
+ break;
305
+ }
306
+ this.entries.set(relative, { path: relative, name: entry.name, directory, kind: "file" });
307
+ }
308
+ // Yield between directories so a large tree does not monopolise the event loop and starve
309
+ // the WebSocket heartbeat. `setTimeout(0)` is the cheapest yield that still lets timers run.
310
+ if (Date.now() - started > 0 && queue.length)
311
+ await new Promise((resolve) => setTimeout(resolve, 0));
312
+ }
313
+ this.building = false;
314
+ }
315
+ /** Stop a running scan. Used when a workspace goes away. */
316
+ stop() {
317
+ this.stopped = true;
318
+ this.building = false;
319
+ }
320
+ }
321
+ /** The workspace-relative path of an absolute one, for callers holding an absolute path. */
322
+ export function relativeTo(root, absolute) {
323
+ return toRelative(root, absolute);
324
+ }
@@ -4,7 +4,7 @@ import { readdir, rm, stat } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { homedir, networkInterfaces } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
- import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, searchByName, toRelative, writeWorkspaceFile, } from "./files.js";
7
+ import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
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";
@@ -13,7 +13,23 @@ import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
13
13
  import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
14
14
  import { packageUpdateArgs } from "./update.js";
15
15
  import { FileIndex } from "./file-index.js";
16
+ import { ensureNameIndex } from "./file-name-index.js";
16
17
  import { MAX_CONTENT_DEPTH, MAX_CONTENT_FILE_BYTES, MAX_CONTENT_RESULTS, searchByContent } from "./content-search.js";
18
+ import { getAvailableSkills, readSkill } from "./skills.js";
19
+ /**
20
+ * THE NAME INDEX FOR ONE WORKSPACE, created on first use and then kept.
21
+ *
22
+ * The index starts its background scan here and returns IMMEDIATELY: the first screen must never
23
+ * wait for an index, and a search issued while the scan is still running is answered with what is
24
+ * known so far rather than refused. The scan is the server's cost, not the user's - the old
25
+ * implementation made the user pay it on every keystroke.
26
+ */
27
+ function nameIndexFor(context, workspaceId, root) {
28
+ const indexes = context.nameIndexes ?? new Map();
29
+ if (!context.nameIndexes)
30
+ context.nameIndexes = indexes;
31
+ return ensureNameIndex(indexes, workspaceId, root);
32
+ }
17
33
  const MIME_TYPES = {
18
34
  ".css": "text/css; charset=utf-8",
19
35
  ".html": "text/html; charset=utf-8",
@@ -583,6 +599,56 @@ export function createHttpServer(options) {
583
599
  }
584
600
  return;
585
601
  }
602
+ if (url.pathname === "/api/skills") {
603
+ const workspaceId = url.searchParams.get("workspaceId") ?? "";
604
+ const workspace = context.store.snapshot().workspaces.find((w) => w.id === workspaceId);
605
+ try {
606
+ const skills = await getAvailableSkills(workspace?.path, { memory: context.memory, workspaceId });
607
+ json(200, { skills });
608
+ }
609
+ catch (error) {
610
+ json(500, { error: error instanceof Error ? error.message : "获取技能失败" });
611
+ }
612
+ return;
613
+ }
614
+ if (url.pathname === "/api/skills/read") {
615
+ let workspaceId = url.searchParams.get("workspaceId") ?? "";
616
+ let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
617
+ if (request.method === "POST") {
618
+ try {
619
+ let raw = "";
620
+ for await (const chunk of request)
621
+ raw += chunk;
622
+ if (raw) {
623
+ const body = JSON.parse(raw);
624
+ if (body.workspaceId)
625
+ workspaceId = body.workspaceId;
626
+ if (body.name || body.skillId)
627
+ name = body.name || body.skillId;
628
+ }
629
+ }
630
+ catch {
631
+ // ignore json parse error, fall back to url params
632
+ }
633
+ }
634
+ if (!name) {
635
+ json(400, { error: "技能名称不能为空" });
636
+ return;
637
+ }
638
+ const workspace = context.store.snapshot().workspaces.find((w) => w.id === workspaceId);
639
+ try {
640
+ const result = await readSkill(name, workspace?.path, { memory: context.memory, workspaceId });
641
+ if (!result) {
642
+ json(404, { error: "技能不存在" });
643
+ return;
644
+ }
645
+ json(200, result);
646
+ }
647
+ catch (error) {
648
+ json(500, { error: error instanceof Error ? error.message : "读取技能失败" });
649
+ }
650
+ return;
651
+ }
586
652
  if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
587
653
  if (!context.knowledge) {
588
654
  response.writeHead(503).end();
@@ -1057,6 +1123,11 @@ export function createHttpServer(options) {
1057
1123
  indexes.set(workspaceId, index);
1058
1124
  }
1059
1125
  index.start(workspace.path);
1126
+ // The NAME index is started here too, and this is the whole reason the panel can promise a
1127
+ // complete search: the client fetches this endpoint the moment a workspace opens, so the
1128
+ // background scan is already running before anybody types. Without this the index would
1129
+ // start on the first keystroke and the first search would be answered from a half-built map.
1130
+ nameIndexFor(context, workspaceId, workspace.path);
1060
1131
  const sinceRaw = url.searchParams.get("since");
1061
1132
  const since = sinceRaw === null ? undefined : Number(sinceRaw);
1062
1133
  json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
@@ -1086,8 +1157,42 @@ export function createHttpServer(options) {
1086
1157
  }
1087
1158
  const query = url.searchParams.get("q");
1088
1159
  if (query !== null && query.trim() !== "") {
1089
- const result = await searchByName(root, query);
1090
- json(200, { mode: "search", query: query.trim(), ...result, maxResults: 500, maxDepth: 12 });
1160
+ // THE SEARCH IS AN INDEX QUERY, not a walk.
1161
+ //
1162
+ // The old path re-walked the entire tree on every keystroke and then reported its own
1163
+ // limits ("已跳过 N 个目录", "结果不完整"). The index answers from memory - no filesystem
1164
+ // access at all - and covers every directory in the workspace, so there is nothing to
1165
+ // report a limit about. `truncated` and `skipped` stay in the response so the protocol
1166
+ // shape does not change under the client, but they are now constants that say the
1167
+ // opposite of what they used to.
1168
+ const index = nameIndexFor(context, workspaceId, root);
1169
+ // THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
1170
+ //
1171
+ // The background scan records a root it could not read rather than throwing it - a scan
1172
+ // has no caller to receive a rejection, and an unhandled one terminates the process. So
1173
+ // the route asks the index what happened and turns the answer into the status it
1174
+ // deserves. Without this a workspace whose directory is gone would answer 200 with an
1175
+ // empty list, which is byte-for-byte the response for "your search matched nothing" -
1176
+ // the exact confusion this endpoint is supposed to avoid.
1177
+ await index.waitUntilIndexed(4000);
1178
+ index.throwIfBroken();
1179
+ // WAIT FOR THE INDEX, briefly. A search answered from a half-built map would say "no
1180
+ // matches" for files that are on disk - which is precisely the "搜不出他想搜的东西"
1181
+ // failure this work exists to remove. Past the deadline the answer is still returned, with
1182
+ // `indexed: false` so the UI can say the index is still building rather than pretending
1183
+ // the tree is empty.
1184
+ const result = index.search(query);
1185
+ json(200, {
1186
+ mode: "search",
1187
+ query: query.trim(),
1188
+ hits: result.hits.map((hit) => ({ path: hit.path, name: hit.name, directory: hit.directory })),
1189
+ total: result.total,
1190
+ truncated: false,
1191
+ skipped: [],
1192
+ indexed: result.indexed,
1193
+ progress: result.progress,
1194
+ incomplete: result.incomplete,
1195
+ });
1091
1196
  return;
1092
1197
  }
1093
1198
  const listing = await listDirectory(root, url.searchParams.get("path"));
@@ -1138,6 +1243,10 @@ export function createHttpServer(options) {
1138
1243
  }
1139
1244
  const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
1140
1245
  context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
1246
+ // The name index learns about the save too. A file the user just saved must be findable
1247
+ // by name immediately, and the index is the thing the search reads - so this is where the
1248
+ // index stays true without anybody re-scanning the tree.
1249
+ context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
1141
1250
  json(200, outcome);
1142
1251
  return;
1143
1252
  }
@@ -204,6 +204,15 @@ const fileChanges = new FileChangeTracker();
204
204
  runService.setFileChanges(fileChanges);
205
205
  const fileIndexes = new Map();
206
206
  runService.setFileIndexes(fileIndexes);
207
+ /**
208
+ * THE NAME INDEXES, one per workspace, created eagerly.
209
+ *
210
+ * They are created here rather than on first use so that the agent's writes have somewhere to go from
211
+ * the first turn: the run service upserts into them, and the HTTP layer reads from them. A map that
212
+ * only appeared on the first search would be empty for every write that happened before it.
213
+ */
214
+ const nameIndexes = new Map();
215
+ runService.setNameIndexes(nameIndexes);
207
216
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
208
217
  conversationService.setMemory(memory);
209
218
  const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
@@ -247,6 +256,7 @@ const serverContext = {
247
256
  toolLlm,
248
257
  fileChanges,
249
258
  fileIndexes,
259
+ nameIndexes,
250
260
  terminalEnabled: () => terminalService.enabled,
251
261
  terminalDisabledReason: () => terminalDisabledReason(),
252
262
  };
@@ -67,6 +67,15 @@ export interface ServerContext {
67
67
  fileChanges?: import("./file-changes.js").FileChangeTracker;
68
68
  /** Shared file indexes: the HTTP layer starts them lazily and write paths refresh known entries. */
69
69
  fileIndexes?: Map<string, import("./file-index.js").FileIndex>;
70
+ /**
71
+ * THE NAME INDEXES, and why they are a SECOND map rather than a mode of `fileIndexes`.
72
+ *
73
+ * `FileIndex` skips dependency directories because content search and quick-open want the project's
74
+ * own files. A filename search wants the opposite: a person typing `config` must be able to find
75
+ * `node_modules/some-pkg/config.js`, and the old behaviour made it unfindable while announcing the
76
+ * omission in the UI. Two indexes, two scopes, one question each - see `file-name-index.ts`.
77
+ */
78
+ nameIndexes?: Map<string, import("./file-name-index.js").FileNameIndex>;
70
79
  /** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
71
80
  toolLlm?: import("./tool-llm.js").ToolLlmService;
72
81
  /**