@ff-labs/pi-fff 0.9.7-nightly.98d4d4e → 0.9.7-nightly.9a637cc

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@ff-labs/pi-fff",
3
3
  "public": true,
4
- "version": "0.9.7-nightly.98d4d4e",
4
+ "version": "0.9.7-nightly.9a637cc",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -45,8 +45,14 @@
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "*",
47
47
  "@earendil-works/pi-tui": "*",
48
+ "@ff-labs/fff-bun": "*",
48
49
  "@sinclair/typebox": "*"
49
50
  },
51
+ "peerDependenciesMeta": {
52
+ "@ff-labs/fff-bun": {
53
+ "optional": true
54
+ }
55
+ },
50
56
  "devDependencies": {
51
57
  "@types/node": "^22.0.0",
52
58
  "typescript": "^5.0.0"
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { FileFinderApi } from "@ff-labs/fff-node";
5
+ import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
6
+
7
+ export const MAX_AUX = 3;
8
+ export const IDLE_TTL_MS = 5 * 60 * 1000;
9
+
10
+ interface AuxPicker {
11
+ root: string;
12
+ finder: FileFinderApi;
13
+ lastUsed: number;
14
+ }
15
+
16
+ export interface AuxOpts {
17
+ frecencyDbPath?: string;
18
+ historyDbPath?: string;
19
+ enableFsRootScanning: boolean;
20
+ }
21
+
22
+ export class AuxFinderPool {
23
+ private entries: AuxPicker[] = [];
24
+ constructor(private opts: AuxOpts) {}
25
+
26
+ destroy(): void {
27
+ for (const e of this.entries) {
28
+ e.finder.destroy();
29
+ }
30
+
31
+ this.entries = [];
32
+ }
33
+
34
+ private sweepIdle(now = Date.now()): void {
35
+ const kept: AuxPicker[] = [];
36
+ for (const e of this.entries) {
37
+ if (now - e.lastUsed > IDLE_TTL_MS) {
38
+ if (!e.finder.isDestroyed) e.finder.destroy();
39
+ } else {
40
+ kept.push(e);
41
+ }
42
+ }
43
+ this.entries = kept;
44
+ }
45
+
46
+ async acquire(
47
+ maybeRoot: string,
48
+ opts?: { exact?: boolean },
49
+ ): Promise<{ finder: FileFinderApi; root: string }> {
50
+ this.sweepIdle();
51
+ let covering: AuxPicker | null = null;
52
+ for (const e of this.entries) {
53
+ if (e.finder.isDestroyed) continue;
54
+ if (opts?.exact ? e.root !== maybeRoot : !rootCovers(e.root, maybeRoot)) continue;
55
+ if (!covering || e.root.length > covering.root.length) covering = e;
56
+ }
57
+
58
+ if (covering) {
59
+ covering.lastUsed = Date.now();
60
+ return { finder: covering.finder, root: covering.root };
61
+ }
62
+
63
+ if (this.entries.length >= MAX_AUX) {
64
+ let oldest = this.entries[0];
65
+ for (const e of this.entries)
66
+ if (e.lastUsed < oldest.lastUsed) oldest = e;
67
+ if (!oldest.finder.isDestroyed) oldest.finder.destroy();
68
+ this.entries = this.entries.filter((e) => e !== oldest);
69
+ }
70
+
71
+ const { FileFinder } = await loadSdk();
72
+ const result = FileFinder.create({
73
+ basePath: maybeRoot,
74
+ frecencyDbPath: this.opts.frecencyDbPath,
75
+ historyDbPath: this.opts.historyDbPath,
76
+ aiMode: true,
77
+ enableHomeDirScanning: true,
78
+ enableFsRootScanning: this.opts.enableFsRootScanning,
79
+ });
80
+ if (!result.ok)
81
+ throw new Error(
82
+ `Failed to create aux file finder for ${maybeRoot}: ${result.error}`,
83
+ );
84
+
85
+ await result.value.waitForScan(SCAN_TIMEOUT_MS);
86
+ this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() });
87
+ return { finder: result.value, root: maybeRoot };
88
+ }
89
+
90
+ size(): number {
91
+ this.sweepIdle();
92
+ return this.entries.length;
93
+ }
94
+ }
95
+
96
+ // Split an absolute path into an existing directory (the aux root) and a
97
+ // remainder usable as a fuzzy path constraint relative to that root. Glob and
98
+ // nonexistent segments both go into the suffix: we walk up to the nearest
99
+ // existing ancestor so partially-wrong paths still resolve to a search root.
100
+ export function resolveAuxRoot(
101
+ absPath: string,
102
+ ): { root: string; suffix: string } | null {
103
+ const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
104
+ if (!path.isAbsolute(trimmed)) return null;
105
+ if (trimmed === path.sep) return { root: path.sep, suffix: "" };
106
+
107
+ const parts = trimmed.split(path.sep);
108
+ const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p));
109
+ const boundary = firstGlob === -1 ? parts.length : firstGlob;
110
+
111
+ // Deepest existing non-glob prefix wins; everything below it is suffix.
112
+ for (let i = boundary; i > 0; i--) {
113
+ const candidate = parts.slice(0, i).join(path.sep) || path.sep;
114
+ let stat: fs.Stats;
115
+ try {
116
+ stat = fs.statSync(candidate);
117
+ } catch {
118
+ continue;
119
+ }
120
+ if (stat.isFile()) {
121
+ return {
122
+ root: parts.slice(0, i - 1).join(path.sep) || path.sep,
123
+ suffix: parts.slice(i - 1).join("/"),
124
+ };
125
+ }
126
+ return { root: candidate, suffix: parts.slice(i).join("/") };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // Decide whether a `path` parameter should route to the workspace finder or
132
+ // to an aux finder. Accepts absolute paths, `~`-prefixed paths, and relative
133
+ // paths escaping the workspace (`../other-project`); everything is resolved
134
+ // against cwd first. Returns null to signal "no rerouting" (workspace finder).
135
+ export function routePathConstraint(
136
+ pathConstraint: string | undefined,
137
+ cwd: string,
138
+ ): { root: string; suffix: string } | null {
139
+ if (!pathConstraint) return null;
140
+ let candidate = pathConstraint.trim();
141
+ if (!candidate) return null;
142
+ if (candidate === "~" || candidate.startsWith("~/"))
143
+ candidate = path.join(os.homedir(), candidate.slice(1));
144
+ if (!path.isAbsolute(candidate)) {
145
+ // Plain workspace-relative constraints stay on the workspace finder.
146
+ if (candidate !== ".." && !candidate.startsWith("../")) return null;
147
+ candidate = path.resolve(cwd, candidate);
148
+ }
149
+ const rel = path.relative(cwd, candidate);
150
+ if (rel !== ".." && !rel.startsWith(`..${path.sep}`)) return null;
151
+ return resolveAuxRoot(candidate);
152
+ }
153
+
154
+
155
+ export function rootCovers(root: string, target: string): boolean {
156
+ if (root === target) return true;
157
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
158
+ return target.startsWith(prefix);
159
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * @-mention autocomplete suggestions to the interactive editor.
6
6
  */
7
7
 
8
+ import nodePath from "node:path";
8
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
10
  import {
10
11
  type AutocompleteItem,
@@ -12,15 +13,19 @@ import {
12
13
  Text,
13
14
  } from "@earendil-works/pi-tui";
14
15
  import type {
16
+ FileFinderApi,
15
17
  GrepCursor,
16
18
  GrepMode,
17
19
  GrepResult,
18
20
  MixedItem,
19
21
  SearchResult,
20
22
  } from "@ff-labs/fff-node";
21
- import { FileFinder } from "@ff-labs/fff-node";
22
23
  import { Type } from "@sinclair/typebox";
24
+ import { AuxFinderPool, routePathConstraint } from "./aux-finders";
23
25
  import { buildQuery } from "./query";
26
+ import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
27
+
28
+ export { SCAN_TIMEOUT_MS } from "./sdk";
24
29
 
25
30
  // ---------------------------------------------------------------------------
26
31
  // Constants
@@ -85,6 +90,7 @@ interface FindCursor {
85
90
  pattern: string;
86
91
  pageSize: number;
87
92
  nextPageIndex: number;
93
+ auxRoot?: string;
88
94
  }
89
95
 
90
96
  const findCursorCache = new Map<string, FindCursor>();
@@ -255,7 +261,9 @@ function createFffMentionProvider(
255
261
 
256
262
  const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
257
263
  const items = await getItems(query, options.signal);
258
- return options.signal.aborted || items.length === 0 ? null : { items, prefix };
264
+ return options.signal.aborted || items.length === 0
265
+ ? null
266
+ : { items, prefix };
259
267
  },
260
268
  applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
261
269
  const currentLine = _lines[cursorLine] || "";
@@ -264,7 +272,11 @@ function createFffMentionProvider(
264
272
  const newLine = before + item.value + after;
265
273
  const newCursorCol = cursorCol - prefix.length + item.value.length;
266
274
  return {
267
- lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
275
+ lines: [
276
+ ..._lines.slice(0, cursorLine),
277
+ newLine,
278
+ ..._lines.slice(cursorLine + 1),
279
+ ],
268
280
  cursorLine,
269
281
  cursorCol: newCursorCol,
270
282
  };
@@ -277,13 +289,13 @@ function createFffMentionProvider(
277
289
  // ---------------------------------------------------------------------------
278
290
 
279
291
  export default function fffExtension(pi: ExtensionAPI) {
280
- let finder: FileFinder | null = null;
292
+ let mainFinder: FileFinderApi | null = null;
281
293
  let finderCwd: string | null = null;
282
294
  // Concurrent ensureFinder() callers share the same in-flight promise so
283
295
  // FileFinder.create() (which takes native DB locks) runs at most once per
284
296
  // base path at a time — otherwise parallel tool calls would race and
285
297
  // deadlock at the native layer (issue #403).
286
- let finderPromise: Promise<FileFinder> | null = null;
298
+ let finderPromise: Promise<FileFinderApi> | null = null;
287
299
  let activeCwd = process.cwd();
288
300
 
289
301
  // Mode resolution: flag > env > default
@@ -331,18 +343,27 @@ export default function fffExtension(pi: ExtensionAPI) {
331
343
  return currentMode !== "tools-only";
332
344
  }
333
345
 
334
- function ensureFinder(cwd: string): Promise<FileFinder> {
335
- if (finder && !finder.isDestroyed && finderCwd === cwd)
336
- return Promise.resolve(finder);
346
+ let auxPool = new AuxFinderPool({
347
+ frecencyDbPath,
348
+ historyDbPath,
349
+ enableFsRootScanning,
350
+ });
351
+
352
+ // in case cwd changes we need to figure this out
353
+ function ensureFinder(cwd: string): Promise<FileFinderApi> {
354
+ if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd)
355
+ return Promise.resolve(mainFinder);
356
+
337
357
  if (finderPromise) return finderPromise;
338
358
 
339
359
  finderPromise = (async () => {
340
- if (finder && !finder.isDestroyed) {
341
- finder.destroy();
342
- finder = null;
360
+ if (mainFinder && !mainFinder.isDestroyed) {
361
+ mainFinder.destroy();
362
+ mainFinder = null;
343
363
  finderCwd = null;
344
364
  }
345
365
 
366
+ const { FileFinder } = await loadSdk();
346
367
  const result = FileFinder.create({
347
368
  basePath: cwd,
348
369
  frecencyDbPath,
@@ -355,10 +376,10 @@ export default function fffExtension(pi: ExtensionAPI) {
355
376
  if (!result.ok)
356
377
  throw new Error(`Failed to create FFF file finder: ${result.error}`);
357
378
 
358
- finder = result.value;
379
+ mainFinder = result.value;
359
380
  finderCwd = cwd;
360
- await finder.waitForScan(15000);
361
- return finder;
381
+ await mainFinder.waitForScan(SCAN_TIMEOUT_MS);
382
+ return mainFinder;
362
383
  })().finally(() => {
363
384
  finderPromise = null;
364
385
  });
@@ -367,11 +388,33 @@ export default function fffExtension(pi: ExtensionAPI) {
367
388
  }
368
389
 
369
390
  function destroyFinder() {
370
- if (finder && !finder.isDestroyed) {
371
- finder.destroy();
372
- finder = null;
391
+ if (mainFinder && !mainFinder.isDestroyed) {
392
+ mainFinder.destroy();
393
+ mainFinder = null;
373
394
  finderCwd = null;
374
395
  }
396
+
397
+ if (auxPool) {
398
+ auxPool.destroy();
399
+ }
400
+ }
401
+
402
+ async function resolveFinderForPath(
403
+ pathParam: string | undefined,
404
+ pattern: string,
405
+ exclude: string | string[] | undefined,
406
+ ): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
407
+ const route = routePathConstraint(pathParam, activeCwd);
408
+ if (!route) return null;
409
+ const aux = await auxPool.acquire(route.root);
410
+ // A broader covering picker may have been reused; rebase the suffix so the
411
+ // constraint stays relative to the picker's actual root.
412
+ const rebase = nodePath
413
+ .relative(aux.root, route.root)
414
+ .replaceAll(nodePath.sep, "/");
415
+ const suffix = [rebase, route.suffix].filter(Boolean).join("/");
416
+ const query = buildQuery(suffix || undefined, pattern, exclude, aux.root);
417
+ return { finder: aux.finder, query, root: aux.root };
375
418
  }
376
419
 
377
420
  async function getMentionItems(
@@ -385,20 +428,22 @@ export default function fffExtension(pi: ExtensionAPI) {
385
428
  const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
386
429
  if (!result.ok) return [];
387
430
 
388
- return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
389
- if (mixed.type === "directory") {
431
+ return result.value.items
432
+ .slice(0, MENTION_MAX_RESULTS)
433
+ .map((mixed: MixedItem) => {
434
+ if (mixed.type === "directory") {
435
+ return {
436
+ value: buildAtCompletionValue(mixed.item.relativePath),
437
+ label: mixed.item.dirName,
438
+ description: mixed.item.relativePath,
439
+ };
440
+ }
390
441
  return {
391
442
  value: buildAtCompletionValue(mixed.item.relativePath),
392
- label: mixed.item.dirName,
443
+ label: mixed.item.fileName,
393
444
  description: mixed.item.relativePath,
394
445
  };
395
- }
396
- return {
397
- value: buildAtCompletionValue(mixed.item.relativePath),
398
- label: mixed.item.fileName,
399
- description: mixed.item.relativePath,
400
- };
401
- });
446
+ });
402
447
  }
403
448
 
404
449
  function registerAutocompleteProvider(ctx: {
@@ -434,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) {
434
479
  return current.getSuggestions(lines, cursorLine, cursorCol, options);
435
480
  },
436
481
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
437
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
482
+ return current.applyCompletion(
483
+ lines,
484
+ cursorLine,
485
+ cursorCol,
486
+ item,
487
+ prefix,
488
+ );
438
489
  },
439
490
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
440
491
  return (
441
- current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
492
+ current.shouldTriggerFileCompletion?.(
493
+ lines,
494
+ cursorLine,
495
+ cursorCol,
496
+ ) ?? true
442
497
  );
443
498
  },
444
499
  };
@@ -453,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) {
453
508
  });
454
509
 
455
510
  pi.registerFlag("fff-frecency-db", {
456
- description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
511
+ description:
512
+ "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
457
513
  type: "string",
458
514
  });
459
515
 
460
516
  pi.registerFlag("fff-history-db", {
461
- description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
517
+ description:
518
+ "Path to the query history database (overrides FFF_HISTORY_DB env)",
462
519
  type: "string",
463
520
  });
464
521
 
@@ -518,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) {
518
575
  context: any,
519
576
  maxLines = 15,
520
577
  ) => {
521
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
522
- const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
578
+ const text =
579
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
580
+ const output =
581
+ result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
523
582
  if (!output) {
524
583
  text.setText(theme.fg("muted", "No output"));
525
584
  return text;
526
585
  }
527
586
 
528
587
  const lines = output.split("\n");
529
- const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
588
+ const displayLines = lines.slice(
589
+ 0,
590
+ options.expanded ? lines.length : maxLines,
591
+ );
530
592
  let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
531
593
  if (lines.length > displayLines.length) {
532
594
  content += theme.fg(
@@ -547,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) {
547
609
  path: Type.Optional(
548
610
  Type.String({
549
611
  description:
550
- "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
612
+ "Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
551
613
  }),
552
614
  ),
553
615
  exclude: Type.Optional(
@@ -591,18 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) {
591
653
  async execute(_toolCallId, params, signal) {
592
654
  if (signal?.aborted) throw new Error("Operation aborted");
593
655
 
594
- const f = await ensureFinder(activeCwd);
656
+ const pattern = params.pattern;
657
+ const aux = await resolveFinderForPath(
658
+ params.path,
659
+ pattern,
660
+ params.exclude,
661
+ );
662
+
663
+ const picker = aux ? aux.finder : await ensureFinder(activeCwd);
595
664
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
596
- const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
665
+ const query = aux
666
+ ? aux.query
667
+ : buildQuery(params.path, pattern, params.exclude, activeCwd);
668
+
597
669
  // Auto-detect: regex if the pattern has regex metacharacters AND parses
598
670
  // as a valid regex, otherwise plain literal. The fuzzy fallback below
599
671
  // only kicks in for plain mode — regex queries are intentional.
600
672
  const hasRegexSyntax =
601
- params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
673
+ pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
674
+
602
675
  let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
603
676
  if (mode === "regex") {
604
677
  try {
605
- new RegExp(params.pattern);
678
+ new RegExp(pattern);
606
679
  } catch {
607
680
  mode = "plain";
608
681
  }
@@ -611,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) {
611
684
  // Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
612
685
  // to try to read a whole file. That's not what grep is for — return a terse error
613
686
  // steering them to a real pattern, preventing dozens of wasted retries.
614
- const p = params.pattern.trim();
687
+ const p = pattern.trim();
615
688
  const isWildcardOnly =
616
689
  hasRegexSyntax &&
617
690
  /^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
@@ -634,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) {
634
707
  // (case-insensitive when pattern is all lowercase).
635
708
  const smartCase = params.caseSensitive !== true;
636
709
 
637
- const grepResult = f.grep(query, {
710
+ const grepResult = picker.grep(query, {
638
711
  mode,
639
712
  smartCase,
640
713
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -651,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) {
651
724
 
652
725
  // automatic fuzzy fallback allows to broad the queries and find different cases
653
726
  if (result.items.length === 0 && !params.cursor && mode !== "regex") {
654
- const fuzzy = f.grep(params.pattern, {
727
+ const fuzzy = picker.grep(pattern, {
655
728
  mode: "fuzzy",
656
729
  smartCase,
657
730
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -670,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) {
670
743
  let output = formatGrepOutput(result);
671
744
  const notices: string[] = [];
672
745
  if (result.regexFallbackError) {
673
- notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
746
+ notices.push(
747
+ `Invalid regex: ${result.regexFallbackError}, used literal match`,
748
+ );
674
749
  }
675
750
  if (result.nextCursor) {
676
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
751
+ notices.push(
752
+ `Continue with cursor="${storeCursor(result.nextCursor)}"`,
753
+ );
677
754
  }
678
755
 
679
756
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
@@ -689,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) {
689
766
  },
690
767
 
691
768
  renderCall(args, theme, context) {
692
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
769
+ const text =
770
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
693
771
  const pattern = args?.pattern ?? "";
694
772
  const path = args?.path ?? ".";
695
773
  let content =
@@ -719,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) {
719
797
  path: Type.Optional(
720
798
  Type.String({
721
799
  description:
722
- "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
800
+ "Path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
723
801
  }),
724
802
  ),
725
803
  exclude: Type.Optional(
@@ -756,21 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) {
756
834
  async execute(_toolCallId, params, signal) {
757
835
  if (signal?.aborted) throw new Error("Operation aborted");
758
836
 
759
- const f = await ensureFinder(activeCwd);
760
-
761
- // Resume from a prior cursor if supplied — cursor owns query+pageSize so
762
- // the agent can't accidentally mix patterns across pages.
837
+ // if resumed we use the same picker as before
763
838
  const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
839
+ const aux = resumed
840
+ ? resumed.auxRoot
841
+ ? {
842
+ finder: (await auxPool.acquire(resumed.auxRoot, { exact: true }))
843
+ .finder,
844
+ root: resumed.auxRoot,
845
+ }
846
+ : null
847
+ : await resolveFinderForPath(
848
+ params.path,
849
+ params.pattern,
850
+ params.exclude,
851
+ );
852
+
853
+ const picker = aux ? aux.finder : await ensureFinder(activeCwd);
764
854
  const effectiveLimit = resumed
765
855
  ? resumed.pageSize
766
856
  : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
857
+
767
858
  const query = resumed
768
859
  ? resumed.query
769
- : buildQuery(params.path, params.pattern, params.exclude, activeCwd);
860
+ : aux && "query" in aux
861
+ ? (aux as { query: string }).query
862
+ : buildQuery(params.path, params.pattern, params.exclude, activeCwd);
863
+
770
864
  const pattern = resumed ? resumed.pattern : params.pattern;
771
865
  const pageIndex = resumed?.nextPageIndex ?? 0;
866
+ const auxRoot = resumed?.auxRoot ?? aux?.root;
772
867
 
773
- const searchResult = f.fileSearch(query, {
868
+ const searchResult = picker.fileSearch(query, {
774
869
  pageIndex,
775
870
  pageSize: effectiveLimit,
776
871
  });
@@ -785,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) {
785
880
  // shown so far there's another page to fetch.
786
881
  const shownSoFar = pageIndex * effectiveLimit + result.items.length;
787
882
  const hasMore =
788
- result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
883
+ result.items.length >= effectiveLimit &&
884
+ result.totalMatched > shownSoFar;
789
885
 
790
886
  const notices: string[] = [];
791
887
  if (formatted.weak && formatted.shownCount > 0)
@@ -800,6 +896,7 @@ export default function fffExtension(pi: ExtensionAPI) {
800
896
  pattern,
801
897
  pageSize: effectiveLimit,
802
898
  nextPageIndex: pageIndex + 1,
899
+ auxRoot,
803
900
  });
804
901
  notices.push(
805
902
  `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
@@ -819,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) {
819
916
  },
820
917
 
821
918
  renderCall(args, theme, context) {
822
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
919
+ const text =
920
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
823
921
  const pattern = args?.pattern ?? "";
824
922
  const path = args?.path ?? ".";
825
923
  let content =
@@ -852,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) {
852
950
  constraints: Type.Optional(
853
951
  Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
854
952
  ),
855
- context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
953
+ context: Type.Optional(
954
+ Type.Number({ description: "Context lines before+after" }),
955
+ ),
856
956
  limit: Type.Optional(
857
957
  Type.Number({
858
958
  description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
@@ -918,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) {
918
1018
  },
919
1019
 
920
1020
  renderCall(args, theme, context) {
921
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
1021
+ const text =
1022
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
922
1023
  const patterns = args?.patterns ?? [];
923
1024
  const constraints = args?.constraints;
924
1025
  let content =
@@ -940,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) {
940
1041
  // --- commands ---
941
1042
 
942
1043
  pi.registerCommand("fff-mode", {
943
- description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
1044
+ description:
1045
+ "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
944
1046
  handler: async (args, ctx) => {
945
1047
  const arg = (args || "").trim();
946
1048
 
@@ -954,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) {
954
1056
 
955
1057
  // Validate and set mode
956
1058
  if (!VALID_MODES.includes(arg as FffMode)) {
957
- ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
1059
+ ctx.ui.notify(
1060
+ `Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
1061
+ "warning",
1062
+ );
958
1063
  return;
959
1064
  }
960
1065
 
@@ -975,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) {
975
1080
  pi.registerCommand("fff-health", {
976
1081
  description: "Show FFF file finder health and status",
977
1082
  handler: async (_args, ctx) => {
978
- if (!finder || finder.isDestroyed) {
1083
+ if (!mainFinder || mainFinder.isDestroyed) {
979
1084
  ctx.ui.notify("FFF not initialized", "warning");
980
1085
  return;
981
1086
  }
982
1087
 
983
- const health = finder.healthCheck();
1088
+ const health = mainFinder.healthCheck();
984
1089
  if (!health.ok) {
985
1090
  ctx.ui.notify(`Health check failed: ${health.error}`, "error");
986
1091
  return;
987
1092
  }
988
1093
 
989
- const h = health.value;
990
1094
  const lines = [
991
- `FFF v${h.version}`,
1095
+ `FFF v${health.value.version}`,
992
1096
  `Mode: ${getMode()}`,
993
- `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
994
- `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
995
- `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
996
- `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
1097
+ `Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`,
1098
+ `Picker: ${health.value.filePicker.initialized ? `${health.value.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
1099
+ `Frecency: ${health.value.frecency.initialized ? "active" : "disabled"}`,
1100
+ `Query tracker: ${health.value.queryTracker.initialized ? "active" : "disabled"}`,
997
1101
  ];
998
1102
 
999
- const progress = finder.getScanProgress();
1103
+ const progress = mainFinder.getScanProgress();
1000
1104
  if (progress.ok) {
1001
1105
  lines.push(
1002
1106
  `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
@@ -1010,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) {
1010
1114
  pi.registerCommand("fff-rescan", {
1011
1115
  description: "Trigger FFF to rescan files",
1012
1116
  handler: async (_args, ctx) => {
1013
- if (!finder || finder.isDestroyed) {
1117
+ if (!mainFinder || mainFinder.isDestroyed) {
1014
1118
  ctx.ui.notify("FFF not initialized", "warning");
1015
1119
  return;
1016
1120
  }
1017
1121
 
1018
- const result = finder.scanFiles();
1122
+ const result = mainFinder.scanFiles();
1019
1123
  if (!result.ok) {
1020
1124
  ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
1021
1125
  return;
package/src/query.ts CHANGED
@@ -10,7 +10,11 @@ export function normalizePathConstraint(
10
10
  if (path.isAbsolute(trimmed)) {
11
11
  const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
12
12
  if (relative === "") return null;
13
- if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
13
+ if (
14
+ relative.startsWith("../") ||
15
+ relative === ".." ||
16
+ path.isAbsolute(relative)
17
+ ) {
14
18
  throw new Error(
15
19
  `Path constraint must be relative to the workspace: ${pathConstraint}`,
16
20
  );
@@ -22,6 +26,9 @@ export function normalizePathConstraint(
22
26
  // Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
23
27
  if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
24
28
 
29
+ // wif we left with the ** it means anything so treat it as a cwd path
30
+ if (trimmed === "**" || trimmed === "**/" || trimmed === "**/*") return null;
31
+
25
32
  // FFF's glob matcher can treat a hidden directory root glob such as
26
33
  // `.agents/**` as empty, while the tool contract says this means "inside
27
34
  // this directory". Collapse simple trailing recursive directory globs to the
package/src/sdk.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node";
2
+
3
+ export const SCAN_TIMEOUT_MS = 15_000;
4
+
5
+ /** pi can be run either under node or sdk, we resolve correct SDK version at runtime */
6
+ export type FileFinderStatic = {
7
+ create(options: InitOptions): Result<FileFinderApi>;
8
+ };
9
+
10
+ let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
11
+
12
+ function detectRuntime(): "bun" | "node" {
13
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
14
+ if (
15
+ typeof process !== "undefined" &&
16
+ (process as { versions?: { bun?: string } }).versions?.bun
17
+ )
18
+ return "bun";
19
+ return "node";
20
+ }
21
+
22
+ export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
23
+ if (sdkPromise) return sdkPromise;
24
+
25
+ // default to node as it seems like default option
26
+ const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
27
+ sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
28
+ return sdkPromise;
29
+ }