@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.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/{CodeMirrorEditor-CaF1aTpR.js → CodeMirrorEditor-moFTk5SE.js} +2 -2
- package/dist/client/assets/{dist-aC6cFQVF.js → dist-BR4s0dNg.js} +1 -1
- package/dist/client/assets/{dist-Bl_iPEld.js → dist-BUuPRG15.js} +1 -1
- package/dist/client/assets/{dist-CPo4IuhO.js → dist-Bds_PUYK.js} +1 -1
- package/dist/client/assets/{dist-BydrWuS9.js → dist-BsO40qxQ.js} +1 -1
- package/dist/client/assets/{dist-jDX3NY00.js → dist-CCYnr6ul.js} +1 -1
- package/dist/client/assets/{dist-f78PQc3f.js → dist-CL65aMXY.js} +1 -1
- package/dist/client/assets/{dist-BjrDHYro.js → dist-D9m04q-s.js} +1 -1
- package/dist/client/assets/{dist-o-3gMx5l.js → dist-DIfmMr8s.js} +1 -1
- package/dist/client/assets/{dist-CJHSsi_2.js → dist-DPDGd2jl.js} +1 -1
- package/dist/client/assets/{dist-C9gmrpfb.js → dist-DPvy57Ph.js} +1 -1
- package/dist/client/assets/{dist-CKziNS6e.js → dist-DQjJMys5.js} +1 -1
- package/dist/client/assets/{dist-CbkXScEJ.js → dist-Dnr5BxAi.js} +1 -1
- package/dist/client/assets/{dist-C2JgfsQ0.js → dist-DrbUrVWB.js} +1 -1
- package/dist/client/assets/{dist-BG1mkRxC.js → dist-DuWhzonJ.js} +1 -1
- package/dist/client/assets/{dist-DnwnynHW.js → dist-TgNJUr7p.js} +1 -1
- package/dist/client/assets/{dist-Bv-QAejS.js → dist-rhPxFuD1.js} +1 -1
- package/dist/client/assets/{index-CME3BwBz.js → index-BixdV7aQ.js} +23 -23
- package/dist/client/assets/index-CGXMw9LP.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +7 -0
- package/dist/server/application/run-service.js +16 -0
- package/dist/server/file-name-index.d.ts +144 -0
- package/dist/server/file-name-index.js +324 -0
- package/dist/server/http-server.js +112 -3
- package/dist/server/index.js +10 -0
- package/dist/server/server-context.d.ts +9 -0
- package/dist/server/skills.d.ts +23 -0
- package/dist/server/skills.js +171 -0
- package/dist/server/types.d.ts +7 -0
- package/package.json +3 -1
- package/scripts/patch-brace-expansion.mjs +1 -1
- 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,
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
/**
|