@ff-labs/pi-fff 0.9.7-nightly.e0a9e08 → 0.10.0

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.e0a9e08",
4
+ "version": "0.10.0",
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,29 +428,35 @@ 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: {
405
450
  ui: {
406
- addAutocompleteProvider: (
451
+ addAutocompleteProvider?: (
407
452
  factory: (current: AutocompleteProvider) => AutocompleteProvider,
408
453
  ) => void;
409
454
  };
410
455
  }) {
456
+ // pi forks (e.g. omp) may not expose addAutocompleteProvider; skip UI wiring
457
+ // and let tools continue to work instead of failing session_start.
458
+ if (typeof ctx.ui.addAutocompleteProvider !== "function") return;
459
+
411
460
  ctx.ui.addAutocompleteProvider((current) => {
412
461
  const mentionProvider = createFffMentionProvider(getMentionItems);
413
462
 
@@ -430,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) {
430
479
  return current.getSuggestions(lines, cursorLine, cursorCol, options);
431
480
  },
432
481
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
433
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
482
+ return current.applyCompletion(
483
+ lines,
484
+ cursorLine,
485
+ cursorCol,
486
+ item,
487
+ prefix,
488
+ );
434
489
  },
435
490
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
436
491
  return (
437
- current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
492
+ current.shouldTriggerFileCompletion?.(
493
+ lines,
494
+ cursorLine,
495
+ cursorCol,
496
+ ) ?? true
438
497
  );
439
498
  },
440
499
  };
@@ -449,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) {
449
508
  });
450
509
 
451
510
  pi.registerFlag("fff-frecency-db", {
452
- description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
511
+ description:
512
+ "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
453
513
  type: "string",
454
514
  });
455
515
 
456
516
  pi.registerFlag("fff-history-db", {
457
- 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)",
458
519
  type: "string",
459
520
  });
460
521
 
@@ -514,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) {
514
575
  context: any,
515
576
  maxLines = 15,
516
577
  ) => {
517
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
518
- 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() ?? "";
519
582
  if (!output) {
520
583
  text.setText(theme.fg("muted", "No output"));
521
584
  return text;
522
585
  }
523
586
 
524
587
  const lines = output.split("\n");
525
- const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
588
+ const displayLines = lines.slice(
589
+ 0,
590
+ options.expanded ? lines.length : maxLines,
591
+ );
526
592
  let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
527
593
  if (lines.length > displayLines.length) {
528
594
  content += theme.fg(
@@ -543,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) {
543
609
  path: Type.Optional(
544
610
  Type.String({
545
611
  description:
546
- "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.",
547
613
  }),
548
614
  ),
549
615
  exclude: Type.Optional(
@@ -587,18 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) {
587
653
  async execute(_toolCallId, params, signal) {
588
654
  if (signal?.aborted) throw new Error("Operation aborted");
589
655
 
590
- 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);
591
664
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
592
- 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
+
593
669
  // Auto-detect: regex if the pattern has regex metacharacters AND parses
594
670
  // as a valid regex, otherwise plain literal. The fuzzy fallback below
595
671
  // only kicks in for plain mode — regex queries are intentional.
596
672
  const hasRegexSyntax =
597
- params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
673
+ pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
674
+
598
675
  let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
599
676
  if (mode === "regex") {
600
677
  try {
601
- new RegExp(params.pattern);
678
+ new RegExp(pattern);
602
679
  } catch {
603
680
  mode = "plain";
604
681
  }
@@ -607,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) {
607
684
  // Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
608
685
  // to try to read a whole file. That's not what grep is for — return a terse error
609
686
  // steering them to a real pattern, preventing dozens of wasted retries.
610
- const p = params.pattern.trim();
687
+ const p = pattern.trim();
611
688
  const isWildcardOnly =
612
689
  hasRegexSyntax &&
613
690
  /^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
@@ -630,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) {
630
707
  // (case-insensitive when pattern is all lowercase).
631
708
  const smartCase = params.caseSensitive !== true;
632
709
 
633
- const grepResult = f.grep(query, {
710
+ const grepResult = picker.grep(query, {
634
711
  mode,
635
712
  smartCase,
636
713
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -647,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) {
647
724
 
648
725
  // automatic fuzzy fallback allows to broad the queries and find different cases
649
726
  if (result.items.length === 0 && !params.cursor && mode !== "regex") {
650
- const fuzzy = f.grep(params.pattern, {
727
+ const fuzzy = picker.grep(pattern, {
651
728
  mode: "fuzzy",
652
729
  smartCase,
653
730
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
@@ -666,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) {
666
743
  let output = formatGrepOutput(result);
667
744
  const notices: string[] = [];
668
745
  if (result.regexFallbackError) {
669
- notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
746
+ notices.push(
747
+ `Invalid regex: ${result.regexFallbackError}, used literal match`,
748
+ );
670
749
  }
671
750
  if (result.nextCursor) {
672
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
751
+ notices.push(
752
+ `Continue with cursor="${storeCursor(result.nextCursor)}"`,
753
+ );
673
754
  }
674
755
 
675
756
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
@@ -685,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) {
685
766
  },
686
767
 
687
768
  renderCall(args, theme, context) {
688
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
769
+ const text =
770
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
689
771
  const pattern = args?.pattern ?? "";
690
772
  const path = args?.path ?? ".";
691
773
  let content =
@@ -715,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) {
715
797
  path: Type.Optional(
716
798
  Type.String({
717
799
  description:
718
- "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.",
719
801
  }),
720
802
  ),
721
803
  exclude: Type.Optional(
@@ -752,21 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) {
752
834
  async execute(_toolCallId, params, signal) {
753
835
  if (signal?.aborted) throw new Error("Operation aborted");
754
836
 
755
- const f = await ensureFinder(activeCwd);
756
-
757
- // Resume from a prior cursor if supplied — cursor owns query+pageSize so
758
- // the agent can't accidentally mix patterns across pages.
837
+ // if resumed we use the same picker as before
759
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);
760
854
  const effectiveLimit = resumed
761
855
  ? resumed.pageSize
762
856
  : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
857
+
763
858
  const query = resumed
764
859
  ? resumed.query
765
- : 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
+
766
864
  const pattern = resumed ? resumed.pattern : params.pattern;
767
865
  const pageIndex = resumed?.nextPageIndex ?? 0;
866
+ const auxRoot = resumed?.auxRoot ?? aux?.root;
768
867
 
769
- const searchResult = f.fileSearch(query, {
868
+ const searchResult = picker.fileSearch(query, {
770
869
  pageIndex,
771
870
  pageSize: effectiveLimit,
772
871
  });
@@ -781,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) {
781
880
  // shown so far there's another page to fetch.
782
881
  const shownSoFar = pageIndex * effectiveLimit + result.items.length;
783
882
  const hasMore =
784
- result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
883
+ result.items.length >= effectiveLimit &&
884
+ result.totalMatched > shownSoFar;
785
885
 
786
886
  const notices: string[] = [];
787
887
  if (formatted.weak && formatted.shownCount > 0)
@@ -796,6 +896,7 @@ export default function fffExtension(pi: ExtensionAPI) {
796
896
  pattern,
797
897
  pageSize: effectiveLimit,
798
898
  nextPageIndex: pageIndex + 1,
899
+ auxRoot,
799
900
  });
800
901
  notices.push(
801
902
  `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
@@ -815,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) {
815
916
  },
816
917
 
817
918
  renderCall(args, theme, context) {
818
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
919
+ const text =
920
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
819
921
  const pattern = args?.pattern ?? "";
820
922
  const path = args?.path ?? ".";
821
923
  let content =
@@ -848,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) {
848
950
  constraints: Type.Optional(
849
951
  Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
850
952
  ),
851
- context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
953
+ context: Type.Optional(
954
+ Type.Number({ description: "Context lines before+after" }),
955
+ ),
852
956
  limit: Type.Optional(
853
957
  Type.Number({
854
958
  description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
@@ -914,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) {
914
1018
  },
915
1019
 
916
1020
  renderCall(args, theme, context) {
917
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
1021
+ const text =
1022
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
918
1023
  const patterns = args?.patterns ?? [];
919
1024
  const constraints = args?.constraints;
920
1025
  let content =
@@ -936,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) {
936
1041
  // --- commands ---
937
1042
 
938
1043
  pi.registerCommand("fff-mode", {
939
- 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]",
940
1046
  handler: async (args, ctx) => {
941
1047
  const arg = (args || "").trim();
942
1048
 
@@ -950,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) {
950
1056
 
951
1057
  // Validate and set mode
952
1058
  if (!VALID_MODES.includes(arg as FffMode)) {
953
- ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
1059
+ ctx.ui.notify(
1060
+ `Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
1061
+ "warning",
1062
+ );
954
1063
  return;
955
1064
  }
956
1065
 
@@ -971,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) {
971
1080
  pi.registerCommand("fff-health", {
972
1081
  description: "Show FFF file finder health and status",
973
1082
  handler: async (_args, ctx) => {
974
- if (!finder || finder.isDestroyed) {
1083
+ if (!mainFinder || mainFinder.isDestroyed) {
975
1084
  ctx.ui.notify("FFF not initialized", "warning");
976
1085
  return;
977
1086
  }
978
1087
 
979
- const health = finder.healthCheck();
1088
+ const health = mainFinder.healthCheck();
980
1089
  if (!health.ok) {
981
1090
  ctx.ui.notify(`Health check failed: ${health.error}`, "error");
982
1091
  return;
983
1092
  }
984
1093
 
985
- const h = health.value;
986
1094
  const lines = [
987
- `FFF v${h.version}`,
1095
+ `FFF v${health.value.version}`,
988
1096
  `Mode: ${getMode()}`,
989
- `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
990
- `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
991
- `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
992
- `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"}`,
993
1101
  ];
994
1102
 
995
- const progress = finder.getScanProgress();
1103
+ const progress = mainFinder.getScanProgress();
996
1104
  if (progress.ok) {
997
1105
  lines.push(
998
1106
  `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
@@ -1006,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) {
1006
1114
  pi.registerCommand("fff-rescan", {
1007
1115
  description: "Trigger FFF to rescan files",
1008
1116
  handler: async (_args, ctx) => {
1009
- if (!finder || finder.isDestroyed) {
1117
+ if (!mainFinder || mainFinder.isDestroyed) {
1010
1118
  ctx.ui.notify("FFF not initialized", "warning");
1011
1119
  return;
1012
1120
  }
1013
1121
 
1014
- const result = finder.scanFiles();
1122
+ const result = mainFinder.scanFiles();
1015
1123
  if (!result.ok) {
1016
1124
  ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
1017
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
+ }