privateer-agent 0.12.25 → 0.12.26
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/package.json +1 -1
- package/src/util/fileMentions.ts +188 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.26",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/util/fileMentions.ts
CHANGED
|
@@ -167,14 +167,159 @@ export interface FileMatch {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// Directory entries we never surface as suggestions (noise / not project files).
|
|
170
|
+
// Pruned from BOTH the single-level listing and the project-wide walk — .git and
|
|
171
|
+
// node_modules are where a repo keeps its hundred thousand entries, and a walk that
|
|
172
|
+
// descended into them would spend its whole budget before reaching real source.
|
|
170
173
|
const IGNORE_DIRS = new Set([".git", "node_modules", ".DS_Store"]);
|
|
171
174
|
|
|
175
|
+
// Bounds for the project-wide walk behind @-search. A source tree with vendor trees
|
|
176
|
+
// pruned (see the .gitignore note below) is a few thousand entries; these caps exist
|
|
177
|
+
// so a pathological tree can never turn one keystroke into a disk storm. Whichever
|
|
178
|
+
// bound trips first ends the walk — the matches gathered up to that point are ranked
|
|
179
|
+
// and returned, so the degrade is "fewer suggestions", never a hang.
|
|
180
|
+
const WALK_MAX_ENTRIES = 20_000;
|
|
181
|
+
const WALK_MAX_MS = 250;
|
|
182
|
+
const WALK_MAX_POOL = 2_000;
|
|
183
|
+
|
|
184
|
+
// ── .gitignore pruning for the walk ──────────────────────────────
|
|
185
|
+
// IGNORE_DIRS alone can't keep the walk inside real source: a repo's heaviest trees
|
|
186
|
+
// are often NOT named node_modules — ios/Pods here, android/build, dist, web-build,
|
|
187
|
+
// .expo elsewhere. If the walk descended into those, a 20k-entry budget could die in
|
|
188
|
+
// one vendor checkout before reaching the first source file (that exact starvation
|
|
189
|
+
// is why `@en.json` found nothing while `@screens/…` worked). Every fuzzy finder
|
|
190
|
+
// answers this the same way: honor .gitignore. So the walk reads one at EVERY level
|
|
191
|
+
// (a nested .gitignore scopes its own subtree — that's what makes ios/.gitignore
|
|
192
|
+
// prune Pods), bounded to the common grammar: comments, blank lines, `dir/`,
|
|
193
|
+
// anchored `/path`, mid-slash paths, and `*`/`?`/`**` globs. Negations (`!…`) are
|
|
194
|
+
// deliberately skipped — full git precedence order is not worth it here, and
|
|
195
|
+
// over-ignoring only hides a suggestion (exact drill-down and resolution at submit
|
|
196
|
+
// still reach anything).
|
|
197
|
+
interface GitPattern { re: RegExp; dirOnly: boolean; anchored: boolean }
|
|
198
|
+
interface GitScope { rel: string; pats: GitPattern[]; parent: GitScope | null }
|
|
199
|
+
|
|
200
|
+
/** Common-grammar .gitignore glob → regex source. Doublestar spans levels at the
|
|
201
|
+
* three git-defined positions (start of pattern, end, embedded between slashes);
|
|
202
|
+
* star and question mark never cross a segment. */
|
|
203
|
+
function globToRegex(pat: string): string {
|
|
204
|
+
let s = pat;
|
|
205
|
+
if (s.startsWith("**/")) s = "\u0000" + s.slice(3);
|
|
206
|
+
s = s.replace(/\/\*\*\//g, "/\u0000");
|
|
207
|
+
if (s.endsWith("/**")) s = s.slice(0, -2) + "\u0001";
|
|
208
|
+
s = s.replace(/\*\*/g, "*"); // any leftover ** behaves like *
|
|
209
|
+
s = s.split("").map((ch) => {
|
|
210
|
+
if (ch === "*") return "[^/]*";
|
|
211
|
+
if (ch === "?") return "[^/]";
|
|
212
|
+
if ("\\.+^${}()|[]".includes(ch)) return "\\" + ch;
|
|
213
|
+
return ch;
|
|
214
|
+
}).join("");
|
|
215
|
+
return s.replace(/\u0000/g, "(?:[^/]*/)*").replace(/\u0001/g, ".*");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function compileGitignore(text: string): GitPattern[] {
|
|
219
|
+
const out: GitPattern[] = [];
|
|
220
|
+
for (const rawLine of text.split("\n")) {
|
|
221
|
+
const line = rawLine.trim();
|
|
222
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
223
|
+
let pat = line;
|
|
224
|
+
const dirOnly = pat.endsWith("/");
|
|
225
|
+
if (dirOnly) pat = pat.slice(0, -1);
|
|
226
|
+
let anchored = pat.startsWith("/");
|
|
227
|
+
if (anchored) pat = pat.slice(1);
|
|
228
|
+
if (!anchored && pat.includes("/")) anchored = true; // a mid-slash pattern is rooted, per git
|
|
229
|
+
if (!pat || pat === "*") continue; // "*" would empty the sweep; treat as noise
|
|
230
|
+
out.push({ re: new RegExp(`^${globToRegex(pat)}$`), dirOnly, anchored });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function readGitignore(dirAbs: string): Promise<GitPattern[] | null> {
|
|
236
|
+
try {
|
|
237
|
+
return compileGitignore(await readFile(join(dirAbs, ".gitignore"), "utf-8"));
|
|
238
|
+
} catch {
|
|
239
|
+
return null; // none here — the common case
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Is `rel` (cwd-relative) ruled out by any scope in the chain? Each scope's
|
|
244
|
+
* patterns are tested against the path relative to ITS dir; a non-anchored
|
|
245
|
+
* pattern matches the entry's own name at any depth. */
|
|
246
|
+
function isIgnoredByGitignore(rel: string, isDir: boolean, scope: GitScope | null): boolean {
|
|
247
|
+
for (let s = scope; s; s = s.parent) {
|
|
248
|
+
const relToScope = s.rel ? rel.slice(s.rel.length + 1) : rel;
|
|
249
|
+
const base = relToScope.slice(relToScope.lastIndexOf("/") + 1);
|
|
250
|
+
for (const p of s.pats) {
|
|
251
|
+
if (p.dirOnly && !isDir) continue;
|
|
252
|
+
if (p.anchored ? p.re.test(relToScope) : p.re.test(base)) return true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Depth-first walk of cwd's subtree, collecting every file and directory as a
|
|
260
|
+
* cwd-relative FileMatch (dirs carry a trailing "/"). IGNORE_DIRS is pruned,
|
|
261
|
+
* .gitignore rules prune vendor/build trees (see the block above), symlinks are
|
|
262
|
+
* listed but NEVER followed (loop + escape), and dotfiles are hidden unless
|
|
263
|
+
* `showHidden`. Bounded by the caps above — see the note on them.
|
|
264
|
+
*/
|
|
265
|
+
async function walkProject(cwd: string, showHidden: boolean): Promise<FileMatch[]> {
|
|
266
|
+
const pool: FileMatch[] = [];
|
|
267
|
+
const deadline = Date.now() + WALK_MAX_MS;
|
|
268
|
+
const rootPats = await readGitignore(cwd);
|
|
269
|
+
const rootScope: GitScope | null = rootPats ? { rel: "", pats: rootPats, parent: null } : null;
|
|
270
|
+
// An explicit stack rather than recursion: a deep tree can't overflow anything,
|
|
271
|
+
// and the budget checks have one natural place (the loop head). Each pending dir
|
|
272
|
+
// carries the gitignore scope chain in force beneath it, as an immutable
|
|
273
|
+
// parent-linked list — no push/pop bookkeeping to drift out of sync.
|
|
274
|
+
const pending: Array<{ abs: string; rel: string; scope: GitScope | null }> = [{ abs: cwd, rel: "", scope: rootScope }];
|
|
275
|
+
let visited = 0;
|
|
276
|
+
while (pending.length > 0) {
|
|
277
|
+
if (visited >= WALK_MAX_ENTRIES || pool.length >= WALK_MAX_POOL || Date.now() > deadline) break;
|
|
278
|
+
const dir = pending.pop()!;
|
|
279
|
+
let entries: import("node:fs").Dirent[];
|
|
280
|
+
try {
|
|
281
|
+
entries = await readdir(dir.abs, { withFileTypes: true });
|
|
282
|
+
} catch {
|
|
283
|
+
continue; // unreadable (permissions / vanished) — skip, like a listing would
|
|
284
|
+
}
|
|
285
|
+
// Sorted so the pool order — and therefore any rank tie — is deterministic.
|
|
286
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
287
|
+
// A .gitignore in THIS dir scopes everything below it.
|
|
288
|
+
const innerPats = await readGitignore(dir.abs);
|
|
289
|
+
const scope: GitScope | null = innerPats ? { rel: dir.rel, pats: innerPats, parent: dir.scope } : dir.scope;
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
if (visited >= WALK_MAX_ENTRIES || pool.length >= WALK_MAX_POOL) break;
|
|
292
|
+
visited++;
|
|
293
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
294
|
+
if (e.name.startsWith(".") && !showHidden) continue;
|
|
295
|
+
const isDir = e.isDirectory();
|
|
296
|
+
const rel = dir.rel ? `${dir.rel}/${e.name}` : e.name;
|
|
297
|
+
if (isIgnoredByGitignore(rel, isDir, scope)) continue;
|
|
298
|
+
pool.push({ path: isDir ? `${rel}/` : rel, isDir });
|
|
299
|
+
// Real subdirectories only — a symlink to a dir is surfaced as a dir pick
|
|
300
|
+
// (drill-down resolves it safely) but never walked.
|
|
301
|
+
if (isDir && !e.isSymbolicLink()) pending.push({ abs: join(dir.abs, e.name), rel, scope });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return pool;
|
|
305
|
+
}
|
|
306
|
+
|
|
172
307
|
/**
|
|
173
308
|
* List up to `limit` files/dirs inside cwd whose path matches `query` — the text the
|
|
174
|
-
* user typed after `@`.
|
|
175
|
-
*
|
|
176
|
-
* query's
|
|
177
|
-
*
|
|
309
|
+
* user typed after `@`. Two tiers:
|
|
310
|
+
*
|
|
311
|
+
* 1. Drill-down (exact): the query's own directory, its children filtered by the
|
|
312
|
+
* basename prefix. `@src/` lists src/; `@src/ut` names src/utils/ first. A
|
|
313
|
+
* trailing "/" means "I'm browsing HERE" and returns only this tier.
|
|
314
|
+
* 2. Project-wide (fuzzy): the rest of the subtree, at any depth, honoring
|
|
315
|
+
* .gitignore so vendor/build trees never crowd out — or starve the budget
|
|
316
|
+
* before — real source. A bare fragment (`@remo`) matches any file/dir NAME
|
|
317
|
+
* containing it — wherever it lives — and a dir-qualified one (`@src/ut`)
|
|
318
|
+
* matches whole paths carrying it. Skipped for a one-character fragment, where
|
|
319
|
+
* a whole-tree sweep is all noise; the drill-down answer is the right one there.
|
|
320
|
+
*
|
|
321
|
+
* Case-insensitive. CWD-constrained: a query that escapes cwd returns nothing, and
|
|
322
|
+
* the walk never leaves the subtree — so no path outside the project can leak.
|
|
178
323
|
*/
|
|
179
324
|
export async function searchFiles(query: string, cwd: string, limit = 50): Promise<FileMatch[]> {
|
|
180
325
|
const q = query ?? "";
|
|
@@ -193,7 +338,7 @@ export async function searchFiles(query: string, cwd: string, limit = 50): Promi
|
|
|
193
338
|
try {
|
|
194
339
|
entries = await readdir(scanAbs, { withFileTypes: true });
|
|
195
340
|
} catch {
|
|
196
|
-
|
|
341
|
+
entries = []; // the dir may not exist — tier 2 below can still search the tree
|
|
197
342
|
}
|
|
198
343
|
const pfx = prefix.toLowerCase();
|
|
199
344
|
const matches: FileMatch[] = [];
|
|
@@ -208,6 +353,44 @@ export async function searchFiles(query: string, cwd: string, limit = 50): Promi
|
|
|
208
353
|
}
|
|
209
354
|
// Directories first, then alphabetical — the natural drill-down order.
|
|
210
355
|
matches.sort((a, b) => (a.isDir === b.isDir ? a.path.localeCompare(b.path) : a.isDir ? -1 : 1));
|
|
356
|
+
|
|
357
|
+
// Tier 2 — the project-wide reach. Off when browsing a dir outright ("src/"), when
|
|
358
|
+
// tier 1 already filled the page, and for a 1-char bare fragment (all noise). A
|
|
359
|
+
// dir-qualified fragment is exempt from the length gate: its match is a whole-path
|
|
360
|
+
// substring, which is already precise at any length ("src/a" hits src/app.ts).
|
|
361
|
+
const dirQualified = dirPart !== ".";
|
|
362
|
+
if (!endsWithSlash && matches.length < limit && (prefix.length === 0 || prefix.length >= 2 || dirQualified)) {
|
|
363
|
+
const pool = await walkProject(cwd, prefix.startsWith("."));
|
|
364
|
+
const ql = q.toLowerCase();
|
|
365
|
+
const seen = new Set(matches.map((m) => m.path));
|
|
366
|
+
const scored: Array<{ m: FileMatch; starts: boolean; depth: number }> = [];
|
|
367
|
+
for (const m of pool) {
|
|
368
|
+
if (seen.has(m.path)) continue;
|
|
369
|
+
seen.add(m.path);
|
|
370
|
+
const bare = m.path.endsWith("/") ? m.path.slice(0, -1) : m.path;
|
|
371
|
+
const name = basename(bare).toLowerCase();
|
|
372
|
+
if (prefix) {
|
|
373
|
+
// A dir-qualified query matches against the whole path ("src/ut" hits
|
|
374
|
+
// src/util/cache.ts, and also another package's src/…); a bare fragment
|
|
375
|
+
// matches the entry's own name, wherever it sits in the tree.
|
|
376
|
+
const hit = dirQualified ? m.path.toLowerCase().includes(ql) : name.includes(pfx);
|
|
377
|
+
if (!hit) continue;
|
|
378
|
+
}
|
|
379
|
+
scored.push({ m, starts: prefix ? name.startsWith(pfx) : false, depth: bare.split("/").length });
|
|
380
|
+
}
|
|
381
|
+
// Relevance: a name that STARTS with the fragment outranks one that merely
|
|
382
|
+
// carries it, then shallower over deeper, then alphabetical. Bare `@` (no
|
|
383
|
+
// fragment) sorts purely shallow-first — a browsable, explorer-style listing.
|
|
384
|
+
scored.sort((a, b) =>
|
|
385
|
+
a.starts !== b.starts ? (a.starts ? -1 : 1)
|
|
386
|
+
: a.depth !== b.depth ? a.depth - b.depth
|
|
387
|
+
: a.m.path.localeCompare(b.m.path),
|
|
388
|
+
);
|
|
389
|
+
for (const s of scored) {
|
|
390
|
+
if (matches.length >= limit) break;
|
|
391
|
+
matches.push(s.m);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
211
394
|
return matches;
|
|
212
395
|
}
|
|
213
396
|
|