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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.25",
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",
@@ -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 `@`. A query with a trailing "/" (or ending at a real dir) lists
175
- * that directory's contents; otherwise it prefix-matches the basename within the
176
- * query's parent dir. Case-insensitive. CWD-constrained: a query that escapes cwd
177
- * returns nothing.
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
- return [];
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