From 5d69126e51a2fdc7f480e30b5fb51fcf7fd04e46 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:56:56 -0700 Subject: [PATCH 1/2] fix(pi-fff): route out-of-workspace path constraints to a rotating aux finder pool Hotfix prototype for #463. When the agent passes an absolute `path` outside the workspace cwd to ffgrep/fffind, spin up (or reuse) a FileFinder rooted at that path instead of throwing "Path constraint must be relative to the workspace". Pool keeps at most 3 aux finders, LRU-evicted, dropped after 5 minutes of inactivity. Find pagination cursors carry the aux root so resumes hit the same finder. --- packages/pi-fff/src/aux-finders.ts | 126 +++++++++++++++++++++++ packages/pi-fff/src/index.ts | 58 ++++++++++- packages/pi-fff/test/aux-finders.test.ts | 36 +++++++ 3 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 packages/pi-fff/src/aux-finders.ts create mode 100644 packages/pi-fff/test/aux-finders.test.ts diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts new file mode 100644 index 00000000..576d934f --- /dev/null +++ b/packages/pi-fff/src/aux-finders.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import path from "node:path"; +import { FileFinder } from "@ff-labs/fff-node"; + +// Hotfix prototype for issue #463: agent occasionally needs to grep/find +// outside the workspace cwd. Maintain a tiny rotating pool of additional +// FileFinder instances rooted at out-of-workspace paths. LRU-evicted at +// MAX_AUX, dropped after IDLE_TTL_MS of no use. + +export const MAX_AUX = 3; +export const IDLE_TTL_MS = 5 * 60 * 1000; + +interface AuxEntry { + root: string; + finder: FileFinder; + lastUsed: number; +} + +export interface AuxOpts { + frecencyDbPath?: string; + historyDbPath?: string; + enableFsRootScanning: boolean; +} + +export class AuxFinderPool { + private entries: AuxEntry[] = []; + constructor(private opts: AuxOpts) {} + + private sweepIdle(now = Date.now()): void { + const kept: AuxEntry[] = []; + for (const e of this.entries) { + if (now - e.lastUsed > IDLE_TTL_MS) { + if (!e.finder.isDestroyed) e.finder.destroy(); + } else { + kept.push(e); + } + } + this.entries = kept; + } + + async acquire(root: string): Promise { + this.sweepIdle(); + const existing = this.entries.find((e) => e.root === root); + if (existing && !existing.finder.isDestroyed) { + existing.lastUsed = Date.now(); + return existing.finder; + } + if (this.entries.length >= MAX_AUX) { + let oldest = this.entries[0]; + for (const e of this.entries) if (e.lastUsed < oldest.lastUsed) oldest = e; + if (!oldest.finder.isDestroyed) oldest.finder.destroy(); + this.entries = this.entries.filter((e) => e !== oldest); + } + const result = FileFinder.create({ + basePath: root, + frecencyDbPath: this.opts.frecencyDbPath, + historyDbPath: this.opts.historyDbPath, + aiMode: true, + enableHomeDirScanning: true, + enableFsRootScanning: this.opts.enableFsRootScanning, + }); + if (!result.ok) + throw new Error(`Failed to create aux file finder for ${root}: ${result.error}`); + await result.value.waitForScan(15000); + this.entries.push({ root, finder: result.value, lastUsed: Date.now() }); + return result.value; + } + + destroyAll(): void { + for (const e of this.entries) if (!e.finder.isDestroyed) e.finder.destroy(); + this.entries = []; + } + + // Exposed for tests/diagnostics. + size(): number { + this.sweepIdle(); + return this.entries.length; + } +} + +// Split an absolute path into the longest non-glob directory prefix (the aux +// root) and a remainder usable as a path constraint relative to that root. +// Returns null if the prefix doesn't exist on disk. +export function resolveAuxRoot( + absPath: string, +): { root: string; suffix: string } | null { + const normalized = path.normalize(absPath.trim()); + if (!path.isAbsolute(normalized)) return null; + const parts = normalized.split(path.sep); + const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p)); + let rootPath: string; + let suffix: string; + if (firstGlob === -1) { + rootPath = normalized; + suffix = ""; + } else { + rootPath = parts.slice(0, firstGlob).join(path.sep) || path.sep; + suffix = parts.slice(firstGlob).join("/"); + } + const stripped = rootPath.replace(/\/+$/, "") || "/"; + let stat: fs.Stats; + try { + stat = fs.statSync(stripped); + } catch { + return null; + } + if (stat.isFile()) { + return { root: path.dirname(stripped), suffix: path.basename(stripped) }; + } + return { root: stripped, suffix }; +} + +// Decide whether a `path` parameter should route to the workspace finder or +// to an aux finder. An absolute path is "outside workspace" when the relative +// from cwd starts with `..`. Returns null to signal "no rerouting". +export function routePathConstraint( + pathConstraint: string | undefined, + cwd: string, +): { root: string; suffix: string } | null { + if (!pathConstraint) return null; + const trimmed = pathConstraint.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) return null; + const rel = path.relative(cwd, trimmed); + if (!rel.startsWith("..") && rel !== "..") return null; + return resolveAuxRoot(trimmed); +} diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 6d8ea80c..f36f6ae1 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -22,6 +22,7 @@ import type { SearchResult, } from "@ff-labs/fff-node"; import { Type } from "@sinclair/typebox"; +import { AuxFinderPool, routePathConstraint } from "./aux-finders"; import { buildQuery } from "./query"; // Isomorphic SDK loading. pi hosts run under either bun (omp / oh-my-pi) or @@ -118,6 +119,7 @@ interface FindCursor { pattern: string; pageSize: number; nextPageIndex: number; + auxRoot?: string; } const findCursorCache = new Map(); @@ -318,6 +320,7 @@ export default function fffExtension(pi: ExtensionAPI) { // deadlock at the native layer (issue #403). let finderPromise: Promise | null = null; let activeCwd = process.cwd(); + let auxPool: AuxFinderPool | null = null; // Mode resolution: flag > env > default let currentMode: FffMode = @@ -406,6 +409,36 @@ export default function fffExtension(pi: ExtensionAPI) { finder = null; finderCwd = null; } + if (auxPool) { + auxPool.destroyAll(); + auxPool = null; + } + } + + function getAuxPool(): AuxFinderPool { + if (!auxPool) { + auxPool = new AuxFinderPool({ + frecencyDbPath, + historyDbPath, + enableFsRootScanning, + }); + } + return auxPool; + } + + // Out-of-workspace path constraint -> route to a pooled aux FileFinder + // rooted at the requested directory. Returns null when the workspace + // finder should handle the call normally. + async function resolveFinderForPath( + pathParam: string | undefined, + pattern: string, + exclude: string | string[] | undefined, + ): Promise<{ finder: FileFinder; query: string; root: string } | null> { + const route = routePathConstraint(pathParam, activeCwd); + if (!route) return null; + const aux = await getAuxPool().acquire(route.root); + const query = buildQuery(route.suffix || undefined, pattern, exclude, route.root); + return { finder: aux, query, root: route.root }; } async function getMentionItems( @@ -625,9 +658,16 @@ export default function fffExtension(pi: ExtensionAPI) { async execute(_toolCallId, params, signal) { if (signal?.aborted) throw new Error("Operation aborted"); - const f = await ensureFinder(activeCwd); + const aux = await resolveFinderForPath( + params.path, + params.pattern, + params.exclude, + ); + const f = aux ? aux.finder : await ensureFinder(activeCwd); const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); - const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd); + const query = aux + ? aux.query + : buildQuery(params.path, params.pattern, params.exclude, activeCwd); // Auto-detect: regex if the pattern has regex metacharacters AND parses // as a valid regex, otherwise plain literal. The fuzzy fallback below // only kicks in for plain mode — regex queries are intentional. @@ -790,19 +830,26 @@ export default function fffExtension(pi: ExtensionAPI) { async execute(_toolCallId, params, signal) { if (signal?.aborted) throw new Error("Operation aborted"); - const f = await ensureFinder(activeCwd); - // Resume from a prior cursor if supplied — cursor owns query+pageSize so // the agent can't accidentally mix patterns across pages. const resumed = params.cursor ? getFindCursor(params.cursor) : undefined; + const aux = resumed + ? resumed.auxRoot + ? { finder: await getAuxPool().acquire(resumed.auxRoot), root: resumed.auxRoot } + : null + : await resolveFinderForPath(params.path, params.pattern, params.exclude); + const f = aux ? aux.finder : await ensureFinder(activeCwd); const effectiveLimit = resumed ? resumed.pageSize : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT); const query = resumed ? resumed.query - : buildQuery(params.path, params.pattern, params.exclude, activeCwd); + : aux && "query" in aux + ? (aux as { query: string }).query + : buildQuery(params.path, params.pattern, params.exclude, activeCwd); const pattern = resumed ? resumed.pattern : params.pattern; const pageIndex = resumed?.nextPageIndex ?? 0; + const auxRoot = resumed?.auxRoot ?? aux?.root; const searchResult = f.fileSearch(query, { pageIndex, @@ -834,6 +881,7 @@ export default function fffExtension(pi: ExtensionAPI) { pattern, pageSize: effectiveLimit, nextPageIndex: pageIndex + 1, + auxRoot, }); notices.push( `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`, diff --git a/packages/pi-fff/test/aux-finders.test.ts b/packages/pi-fff/test/aux-finders.test.ts new file mode 100644 index 00000000..d64c32fe --- /dev/null +++ b/packages/pi-fff/test/aux-finders.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAuxRoot, routePathConstraint } from "../src/aux-finders"; + +describe("routePathConstraint", () => { + const cwd = "/tmp/workspace"; + + test("returns null for relative paths", () => { + expect(routePathConstraint("src/", cwd)).toBeNull(); + expect(routePathConstraint("**/*.ts", cwd)).toBeNull(); + expect(routePathConstraint(undefined, cwd)).toBeNull(); + }); + + test("returns null for absolute paths inside the workspace", () => { + expect(routePathConstraint("/tmp/workspace/src", cwd)).toBeNull(); + }); + + test("routes absolute paths outside the workspace", () => { + const route = routePathConstraint("/tmp", cwd); + expect(route).toEqual({ root: "/tmp", suffix: "" }); + }); + + test("splits glob suffix from existing dir prefix", () => { + const route = routePathConstraint("/tmp/**/*.ts", cwd); + expect(route).toEqual({ root: "/tmp", suffix: "**/*.ts" }); + }); + + test("returns null when prefix does not exist on disk", () => { + expect(routePathConstraint("/tmp/__nonexistent_dir_xyz__", cwd)).toBeNull(); + }); +}); + +describe("resolveAuxRoot", () => { + test("returns null for relative input", () => { + expect(resolveAuxRoot("src/")).toBeNull(); + }); +}); From 34758e1ac71b0a901d7f6df560bc846ca2f3880e Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Wed, 15 Jul 2026 16:11:42 -0700 Subject: [PATCH 2/2] fix: Redesign the aux finder --- package-lock.json | 210 ------------------ packages/pi-fff/src/aux-finders.ts | 149 ++++++++----- packages/pi-fff/src/index.ts | 262 ++++++++++++----------- packages/pi-fff/src/query.ts | 9 +- packages/pi-fff/src/sdk.ts | 29 +++ packages/pi-fff/test/aux-finders.test.ts | 135 +++++++++++- packages/pi-fff/test/aux-pool.test.ts | 89 ++++++++ packages/pi-fff/test/query.test.ts | 8 + 8 files changed, 497 insertions(+), 394 deletions(-) create mode 100644 packages/pi-fff/src/sdk.ts create mode 100644 packages/pi-fff/test/aux-pool.test.ts diff --git a/package-lock.json b/package-lock.json index 4d41e2c0..9b97c38f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1303,174 +1303,6 @@ "zod-to-json-schema": "^3.25.0" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz", - "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz", - "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz", - "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz", - "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz", - "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz", - "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz", - "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz", - "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz", - "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@oven/bun-windows-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz", - "integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz", - "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz", - "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2641,41 +2473,6 @@ "license": "BSD-3-Clause", "peer": true }, - "node_modules/bun": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz", - "integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==", - "cpu": [ - "arm64", - "x64" - ], - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "peer": true, - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.10", - "@oven/bun-darwin-x64": "1.3.10", - "@oven/bun-darwin-x64-baseline": "1.3.10", - "@oven/bun-linux-aarch64": "1.3.10", - "@oven/bun-linux-aarch64-musl": "1.3.10", - "@oven/bun-linux-x64": "1.3.10", - "@oven/bun-linux-x64-baseline": "1.3.10", - "@oven/bun-linux-x64-musl": "1.3.10", - "@oven/bun-linux-x64-musl-baseline": "1.3.10", - "@oven/bun-windows-aarch64": "1.3.10", - "@oven/bun-windows-x64": "1.3.10", - "@oven/bun-windows-x64-baseline": "1.3.10" - } - }, "node_modules/bun-types": { "version": "1.3.10", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz", @@ -4307,10 +4104,6 @@ "linux", "win32" ], - "bin": { - "fff-demo": "examples/search.ts", - "fff-grep": "examples/grep.ts" - }, "devDependencies": { "@types/bun": "^1.3.8", "typescript": "^5.0.0" @@ -4327,9 +4120,6 @@ "@ff-labs/fff-bin-linux-x64-musl": "0.0.0", "@ff-labs/fff-bin-win32-arm64": "0.0.0", "@ff-labs/fff-bin-win32-x64": "0.0.0" - }, - "peerDependencies": { - "bun": ">=1.0.0" } }, "packages/fff-bun/node_modules/@ff-labs/fff-bin-darwin-arm64": { diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts index 576d934f..8bec5b75 100644 --- a/packages/pi-fff/src/aux-finders.ts +++ b/packages/pi-fff/src/aux-finders.ts @@ -1,18 +1,15 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { FileFinder } from "@ff-labs/fff-node"; - -// Hotfix prototype for issue #463: agent occasionally needs to grep/find -// outside the workspace cwd. Maintain a tiny rotating pool of additional -// FileFinder instances rooted at out-of-workspace paths. LRU-evicted at -// MAX_AUX, dropped after IDLE_TTL_MS of no use. +import type { FileFinderApi } from "@ff-labs/fff-node"; +import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; export const MAX_AUX = 3; export const IDLE_TTL_MS = 5 * 60 * 1000; -interface AuxEntry { +interface AuxPicker { root: string; - finder: FileFinder; + finder: FileFinderApi; lastUsed: number; } @@ -23,11 +20,19 @@ export interface AuxOpts { } export class AuxFinderPool { - private entries: AuxEntry[] = []; + private entries: AuxPicker[] = []; constructor(private opts: AuxOpts) {} + destroy(): void { + for (const e of this.entries) { + e.finder.destroy(); + } + + this.entries = []; + } + private sweepIdle(now = Date.now()): void { - const kept: AuxEntry[] = []; + const kept: AuxPicker[] = []; for (const e of this.entries) { if (now - e.lastUsed > IDLE_TTL_MS) { if (!e.finder.isDestroyed) e.finder.destroy(); @@ -38,21 +43,34 @@ export class AuxFinderPool { this.entries = kept; } - async acquire(root: string): Promise { + async acquire( + maybeRoot: string, + opts?: { exact?: boolean }, + ): Promise<{ finder: FileFinderApi; root: string }> { this.sweepIdle(); - const existing = this.entries.find((e) => e.root === root); - if (existing && !existing.finder.isDestroyed) { - existing.lastUsed = Date.now(); - return existing.finder; + let covering: AuxPicker | null = null; + for (const e of this.entries) { + if (e.finder.isDestroyed) continue; + if (opts?.exact ? e.root !== maybeRoot : !rootCovers(e.root, maybeRoot)) continue; + if (!covering || e.root.length > covering.root.length) covering = e; + } + + if (covering) { + covering.lastUsed = Date.now(); + return { finder: covering.finder, root: covering.root }; } + if (this.entries.length >= MAX_AUX) { let oldest = this.entries[0]; - for (const e of this.entries) if (e.lastUsed < oldest.lastUsed) oldest = e; + for (const e of this.entries) + if (e.lastUsed < oldest.lastUsed) oldest = e; if (!oldest.finder.isDestroyed) oldest.finder.destroy(); this.entries = this.entries.filter((e) => e !== oldest); } + + const { FileFinder } = await loadSdk(); const result = FileFinder.create({ - basePath: root, + basePath: maybeRoot, frecencyDbPath: this.opts.frecencyDbPath, historyDbPath: this.opts.historyDbPath, aiMode: true, @@ -60,67 +78,82 @@ export class AuxFinderPool { enableFsRootScanning: this.opts.enableFsRootScanning, }); if (!result.ok) - throw new Error(`Failed to create aux file finder for ${root}: ${result.error}`); - await result.value.waitForScan(15000); - this.entries.push({ root, finder: result.value, lastUsed: Date.now() }); - return result.value; - } + throw new Error( + `Failed to create aux file finder for ${maybeRoot}: ${result.error}`, + ); - destroyAll(): void { - for (const e of this.entries) if (!e.finder.isDestroyed) e.finder.destroy(); - this.entries = []; + await result.value.waitForScan(SCAN_TIMEOUT_MS); + this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() }); + return { finder: result.value, root: maybeRoot }; } - // Exposed for tests/diagnostics. size(): number { this.sweepIdle(); return this.entries.length; } } -// Split an absolute path into the longest non-glob directory prefix (the aux -// root) and a remainder usable as a path constraint relative to that root. -// Returns null if the prefix doesn't exist on disk. +// Split an absolute path into an existing directory (the aux root) and a +// remainder usable as a fuzzy path constraint relative to that root. Glob and +// nonexistent segments both go into the suffix: we walk up to the nearest +// existing ancestor so partially-wrong paths still resolve to a search root. export function resolveAuxRoot( absPath: string, ): { root: string; suffix: string } | null { - const normalized = path.normalize(absPath.trim()); - if (!path.isAbsolute(normalized)) return null; - const parts = normalized.split(path.sep); + const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/"; + if (!path.isAbsolute(trimmed)) return null; + if (trimmed === path.sep) return { root: path.sep, suffix: "" }; + + const parts = trimmed.split(path.sep); const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p)); - let rootPath: string; - let suffix: string; - if (firstGlob === -1) { - rootPath = normalized; - suffix = ""; - } else { - rootPath = parts.slice(0, firstGlob).join(path.sep) || path.sep; - suffix = parts.slice(firstGlob).join("/"); - } - const stripped = rootPath.replace(/\/+$/, "") || "/"; - let stat: fs.Stats; - try { - stat = fs.statSync(stripped); - } catch { - return null; - } - if (stat.isFile()) { - return { root: path.dirname(stripped), suffix: path.basename(stripped) }; + const boundary = firstGlob === -1 ? parts.length : firstGlob; + + // Deepest existing non-glob prefix wins; everything below it is suffix. + for (let i = boundary; i > 0; i--) { + const candidate = parts.slice(0, i).join(path.sep) || path.sep; + let stat: fs.Stats; + try { + stat = fs.statSync(candidate); + } catch { + continue; + } + if (stat.isFile()) { + return { + root: parts.slice(0, i - 1).join(path.sep) || path.sep, + suffix: parts.slice(i - 1).join("/"), + }; + } + return { root: candidate, suffix: parts.slice(i).join("/") }; } - return { root: stripped, suffix }; + return null; } // Decide whether a `path` parameter should route to the workspace finder or -// to an aux finder. An absolute path is "outside workspace" when the relative -// from cwd starts with `..`. Returns null to signal "no rerouting". +// to an aux finder. Accepts absolute paths, `~`-prefixed paths, and relative +// paths escaping the workspace (`../other-project`); everything is resolved +// against cwd first. Returns null to signal "no rerouting" (workspace finder). export function routePathConstraint( pathConstraint: string | undefined, cwd: string, ): { root: string; suffix: string } | null { if (!pathConstraint) return null; - const trimmed = pathConstraint.trim(); - if (!trimmed || !path.isAbsolute(trimmed)) return null; - const rel = path.relative(cwd, trimmed); - if (!rel.startsWith("..") && rel !== "..") return null; - return resolveAuxRoot(trimmed); + let candidate = pathConstraint.trim(); + if (!candidate) return null; + if (candidate === "~" || candidate.startsWith("~/")) + candidate = path.join(os.homedir(), candidate.slice(1)); + if (!path.isAbsolute(candidate)) { + // Plain workspace-relative constraints stay on the workspace finder. + if (candidate !== ".." && !candidate.startsWith("../")) return null; + candidate = path.resolve(cwd, candidate); + } + const rel = path.relative(cwd, candidate); + if (rel !== ".." && !rel.startsWith(`..${path.sep}`)) return null; + return resolveAuxRoot(candidate); +} + + +export function rootCovers(root: string, target: string): boolean { + if (root === target) return true; + const prefix = root.endsWith(path.sep) ? root : root + path.sep; + return target.startsWith(prefix); } diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index f36f6ae1..649aaca2 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -5,6 +5,7 @@ * @-mention autocomplete suggestions to the interactive editor. */ +import nodePath from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem, @@ -16,45 +17,15 @@ import type { GrepCursor, GrepMode, GrepResult, - InitOptions, MixedItem, - Result, SearchResult, } from "@ff-labs/fff-node"; import { Type } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; import { buildQuery } from "./query"; +import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; -// Isomorphic SDK loading. pi hosts run under either bun (omp / oh-my-pi) or -// node; the two SDKs implement the same FileFinderApi. Static imports of -// @ff-labs/fff-node pull ffi-rs + optional native binaries into the module -// graph, which trips oh-my-pi's static extension validator (issue #668). We -// defeat static resolution with a variable-name dynamic import. -type FileFinderStatic = { - create(options: InitOptions): Result; -}; - -let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null; - -function detectRuntime(): "bun" | "node" { - if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun"; - if ( - typeof process !== "undefined" && - (process as { versions?: { bun?: string } }).versions?.bun - ) - return "bun"; - return "node"; -} - -function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> { - if (sdkPromise) return sdkPromise; - // Fail loud on wrong-runtime SDK rather than falling back — a fallback would - // re-introduce the ffi-rs cost on bun (the whole point of the bun SDK is to - // avoid it) and mask packaging bugs where the correct SDK wasn't installed. - const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node"; - sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>; - return sdkPromise; -} +export { SCAN_TIMEOUT_MS } from "./sdk"; // --------------------------------------------------------------------------- // Constants @@ -290,7 +261,9 @@ function createFffMentionProvider( const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1); const items = await getItems(query, options.signal); - return options.signal.aborted || items.length === 0 ? null : { items, prefix }; + return options.signal.aborted || items.length === 0 + ? null + : { items, prefix }; }, applyCompletion(_lines, cursorLine, cursorCol, item, prefix) { const currentLine = _lines[cursorLine] || ""; @@ -299,7 +272,11 @@ function createFffMentionProvider( const newLine = before + item.value + after; const newCursorCol = cursorCol - prefix.length + item.value.length; return { - lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)], + lines: [ + ..._lines.slice(0, cursorLine), + newLine, + ..._lines.slice(cursorLine + 1), + ], cursorLine, cursorCol: newCursorCol, }; @@ -312,7 +289,7 @@ function createFffMentionProvider( // --------------------------------------------------------------------------- export default function fffExtension(pi: ExtensionAPI) { - let finder: FileFinderApi | null = null; + let mainFinder: FileFinderApi | null = null; let finderCwd: string | null = null; // Concurrent ensureFinder() callers share the same in-flight promise so // FileFinder.create() (which takes native DB locks) runs at most once per @@ -320,7 +297,6 @@ export default function fffExtension(pi: ExtensionAPI) { // deadlock at the native layer (issue #403). let finderPromise: Promise | null = null; let activeCwd = process.cwd(); - let auxPool: AuxFinderPool | null = null; // Mode resolution: flag > env > default let currentMode: FffMode = @@ -367,15 +343,23 @@ export default function fffExtension(pi: ExtensionAPI) { return currentMode !== "tools-only"; } + let auxPool = new AuxFinderPool({ + frecencyDbPath, + historyDbPath, + enableFsRootScanning, + }); + + // in case cwd changes we need to figure this out function ensureFinder(cwd: string): Promise { - if (finder && !finder.isDestroyed && finderCwd === cwd) - return Promise.resolve(finder); + if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd) + return Promise.resolve(mainFinder); + if (finderPromise) return finderPromise; finderPromise = (async () => { - if (finder && !finder.isDestroyed) { - finder.destroy(); - finder = null; + if (mainFinder && !mainFinder.isDestroyed) { + mainFinder.destroy(); + mainFinder = null; finderCwd = null; } @@ -392,10 +376,10 @@ export default function fffExtension(pi: ExtensionAPI) { if (!result.ok) throw new Error(`Failed to create FFF file finder: ${result.error}`); - finder = result.value; + mainFinder = result.value; finderCwd = cwd; - await finder.waitForScan(15000); - return finder; + await mainFinder.waitForScan(SCAN_TIMEOUT_MS); + return mainFinder; })().finally(() => { finderPromise = null; }); @@ -404,41 +388,33 @@ export default function fffExtension(pi: ExtensionAPI) { } function destroyFinder() { - if (finder && !finder.isDestroyed) { - finder.destroy(); - finder = null; + if (mainFinder && !mainFinder.isDestroyed) { + mainFinder.destroy(); + mainFinder = null; finderCwd = null; } - if (auxPool) { - auxPool.destroyAll(); - auxPool = null; - } - } - function getAuxPool(): AuxFinderPool { - if (!auxPool) { - auxPool = new AuxFinderPool({ - frecencyDbPath, - historyDbPath, - enableFsRootScanning, - }); + if (auxPool) { + auxPool.destroy(); } - return auxPool; } - // Out-of-workspace path constraint -> route to a pooled aux FileFinder - // rooted at the requested directory. Returns null when the workspace - // finder should handle the call normally. async function resolveFinderForPath( pathParam: string | undefined, pattern: string, exclude: string | string[] | undefined, - ): Promise<{ finder: FileFinder; query: string; root: string } | null> { + ): Promise<{ finder: FileFinderApi; query: string; root: string } | null> { const route = routePathConstraint(pathParam, activeCwd); if (!route) return null; - const aux = await getAuxPool().acquire(route.root); - const query = buildQuery(route.suffix || undefined, pattern, exclude, route.root); - return { finder: aux, query, root: route.root }; + const aux = await auxPool.acquire(route.root); + // A broader covering picker may have been reused; rebase the suffix so the + // constraint stays relative to the picker's actual root. + const rebase = nodePath + .relative(aux.root, route.root) + .replaceAll(nodePath.sep, "/"); + const suffix = [rebase, route.suffix].filter(Boolean).join("/"); + const query = buildQuery(suffix || undefined, pattern, exclude, aux.root); + return { finder: aux.finder, query, root: aux.root }; } async function getMentionItems( @@ -452,20 +428,22 @@ export default function fffExtension(pi: ExtensionAPI) { const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS }); if (!result.ok) return []; - return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => { - if (mixed.type === "directory") { + return result.value.items + .slice(0, MENTION_MAX_RESULTS) + .map((mixed: MixedItem) => { + if (mixed.type === "directory") { + return { + value: buildAtCompletionValue(mixed.item.relativePath), + label: mixed.item.dirName, + description: mixed.item.relativePath, + }; + } return { value: buildAtCompletionValue(mixed.item.relativePath), - label: mixed.item.dirName, + label: mixed.item.fileName, description: mixed.item.relativePath, }; - } - return { - value: buildAtCompletionValue(mixed.item.relativePath), - label: mixed.item.fileName, - description: mixed.item.relativePath, - }; - }); + }); } function registerAutocompleteProvider(ctx: { @@ -501,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) { return current.getSuggestions(lines, cursorLine, cursorCol, options); }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { - return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + return current.applyCompletion( + lines, + cursorLine, + cursorCol, + item, + prefix, + ); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { return ( - current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true + current.shouldTriggerFileCompletion?.( + lines, + cursorLine, + cursorCol, + ) ?? true ); }, }; @@ -520,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) { }); pi.registerFlag("fff-frecency-db", { - description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)", + description: + "Path to the frecency database (overrides FFF_FRECENCY_DB env)", type: "string", }); pi.registerFlag("fff-history-db", { - description: "Path to the query history database (overrides FFF_HISTORY_DB env)", + description: + "Path to the query history database (overrides FFF_HISTORY_DB env)", type: "string", }); @@ -585,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) { context: any, maxLines = 15, ) => { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? ""; + const text = + (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const output = + result.content?.find((c) => c.type === "text")?.text?.trim() ?? ""; if (!output) { text.setText(theme.fg("muted", "No output")); return text; } const lines = output.split("\n"); - const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines); + const displayLines = lines.slice( + 0, + options.expanded ? lines.length : maxLines, + ); let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (lines.length > displayLines.length) { content += theme.fg( @@ -614,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) { path: Type.Optional( Type.String({ description: - "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.", + "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.", }), ), exclude: Type.Optional( @@ -658,25 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) { async execute(_toolCallId, params, signal) { if (signal?.aborted) throw new Error("Operation aborted"); + const pattern = params.pattern; const aux = await resolveFinderForPath( params.path, - params.pattern, + pattern, params.exclude, ); - const f = aux ? aux.finder : await ensureFinder(activeCwd); + + const picker = aux ? aux.finder : await ensureFinder(activeCwd); const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); const query = aux ? aux.query - : buildQuery(params.path, params.pattern, params.exclude, activeCwd); + : buildQuery(params.path, pattern, params.exclude, activeCwd); + // Auto-detect: regex if the pattern has regex metacharacters AND parses // as a valid regex, otherwise plain literal. The fuzzy fallback below // only kicks in for plain mode — regex queries are intentional. const hasRegexSyntax = - params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + let mode: GrepMode = hasRegexSyntax ? "regex" : "plain"; if (mode === "regex") { try { - new RegExp(params.pattern); + new RegExp(pattern); } catch { mode = "plain"; } @@ -685,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) { // Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex // to try to read a whole file. That's not what grep is for — return a terse error // steering them to a real pattern, preventing dozens of wasted retries. - const p = params.pattern.trim(); + const p = pattern.trim(); const isWildcardOnly = hasRegexSyntax && /^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test( @@ -708,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) { // (case-insensitive when pattern is all lowercase). const smartCase = params.caseSensitive !== true; - const grepResult = f.grep(query, { + const grepResult = picker.grep(query, { mode, smartCase, maxMatchesPerFile: Math.min(effectiveLimit, 50), @@ -725,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) { // automatic fuzzy fallback allows to broad the queries and find different cases if (result.items.length === 0 && !params.cursor && mode !== "regex") { - const fuzzy = f.grep(params.pattern, { + const fuzzy = picker.grep(pattern, { mode: "fuzzy", smartCase, maxMatchesPerFile: Math.min(effectiveLimit, 50), @@ -744,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) { let output = formatGrepOutput(result); const notices: string[] = []; if (result.regexFallbackError) { - notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`); + notices.push( + `Invalid regex: ${result.regexFallbackError}, used literal match`, + ); } if (result.nextCursor) { - notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`); + notices.push( + `Continue with cursor="${storeCursor(result.nextCursor)}"`, + ); } if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; @@ -763,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const text = + (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const pattern = args?.pattern ?? ""; const path = args?.path ?? "."; let content = @@ -793,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) { path: Type.Optional( Type.String({ description: - "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.", + "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.", }), ), exclude: Type.Optional( @@ -830,28 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) { async execute(_toolCallId, params, signal) { if (signal?.aborted) throw new Error("Operation aborted"); - // Resume from a prior cursor if supplied — cursor owns query+pageSize so - // the agent can't accidentally mix patterns across pages. + // if resumed we use the same picker as before const resumed = params.cursor ? getFindCursor(params.cursor) : undefined; const aux = resumed ? resumed.auxRoot - ? { finder: await getAuxPool().acquire(resumed.auxRoot), root: resumed.auxRoot } + ? { + finder: (await auxPool.acquire(resumed.auxRoot, { exact: true })) + .finder, + root: resumed.auxRoot, + } : null - : await resolveFinderForPath(params.path, params.pattern, params.exclude); - const f = aux ? aux.finder : await ensureFinder(activeCwd); + : await resolveFinderForPath( + params.path, + params.pattern, + params.exclude, + ); + + const picker = aux ? aux.finder : await ensureFinder(activeCwd); const effectiveLimit = resumed ? resumed.pageSize : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT); + const query = resumed ? resumed.query : aux && "query" in aux ? (aux as { query: string }).query : buildQuery(params.path, params.pattern, params.exclude, activeCwd); + const pattern = resumed ? resumed.pattern : params.pattern; const pageIndex = resumed?.nextPageIndex ?? 0; const auxRoot = resumed?.auxRoot ?? aux?.root; - const searchResult = f.fileSearch(query, { + const searchResult = picker.fileSearch(query, { pageIndex, pageSize: effectiveLimit, }); @@ -866,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) { // shown so far there's another page to fetch. const shownSoFar = pageIndex * effectiveLimit + result.items.length; const hasMore = - result.items.length >= effectiveLimit && result.totalMatched > shownSoFar; + result.items.length >= effectiveLimit && + result.totalMatched > shownSoFar; const notices: string[] = []; if (formatted.weak && formatted.shownCount > 0) @@ -901,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const text = + (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const pattern = args?.pattern ?? ""; const path = args?.path ?? "."; let content = @@ -934,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) { constraints: Type.Optional( Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }), ), - context: Type.Optional(Type.Number({ description: "Context lines before+after" })), + context: Type.Optional( + Type.Number({ description: "Context lines before+after" }), + ), limit: Type.Optional( Type.Number({ description: `Max matches (default ${DEFAULT_GREP_LIMIT})`, @@ -1000,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const text = + (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const patterns = args?.patterns ?? []; const constraints = args?.constraints; let content = @@ -1022,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) { // --- commands --- pi.registerCommand("fff-mode", { - description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]", + description: + "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]", handler: async (args, ctx) => { const arg = (args || "").trim(); @@ -1036,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) { // Validate and set mode if (!VALID_MODES.includes(arg as FffMode)) { - ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning"); + ctx.ui.notify( + `Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, + "warning", + ); return; } @@ -1057,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) { pi.registerCommand("fff-health", { description: "Show FFF file finder health and status", handler: async (_args, ctx) => { - if (!finder || finder.isDestroyed) { + if (!mainFinder || mainFinder.isDestroyed) { ctx.ui.notify("FFF not initialized", "warning"); return; } - const health = finder.healthCheck(); + const health = mainFinder.healthCheck(); if (!health.ok) { ctx.ui.notify(`Health check failed: ${health.error}`, "error"); return; } - const h = health.value; const lines = [ - `FFF v${h.version}`, + `FFF v${health.value.version}`, `Mode: ${getMode()}`, - `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`, - `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`, - `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`, - `Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`, + `Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`, + `Picker: ${health.value.filePicker.initialized ? `${health.value.filePicker.indexedFiles ?? 0} files` : "not initialized"}`, + `Frecency: ${health.value.frecency.initialized ? "active" : "disabled"}`, + `Query tracker: ${health.value.queryTracker.initialized ? "active" : "disabled"}`, ]; - const progress = finder.getScanProgress(); + const progress = mainFinder.getScanProgress(); if (progress.ok) { lines.push( `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`, @@ -1092,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) { pi.registerCommand("fff-rescan", { description: "Trigger FFF to rescan files", handler: async (_args, ctx) => { - if (!finder || finder.isDestroyed) { + if (!mainFinder || mainFinder.isDestroyed) { ctx.ui.notify("FFF not initialized", "warning"); return; } - const result = finder.scanFiles(); + const result = mainFinder.scanFiles(); if (!result.ok) { ctx.ui.notify(`Rescan failed: ${result.error}`, "error"); return; diff --git a/packages/pi-fff/src/query.ts b/packages/pi-fff/src/query.ts index 0f1dc963..38ccb6dc 100644 --- a/packages/pi-fff/src/query.ts +++ b/packages/pi-fff/src/query.ts @@ -10,7 +10,11 @@ export function normalizePathConstraint( if (path.isAbsolute(trimmed)) { const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/"); if (relative === "") return null; - if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) { + if ( + relative.startsWith("../") || + relative === ".." || + path.isAbsolute(relative) + ) { throw new Error( `Path constraint must be relative to the workspace: ${pathConstraint}`, ); @@ -22,6 +26,9 @@ export function normalizePathConstraint( // Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically. if (trimmed.startsWith("./")) trimmed = trimmed.slice(2); + // wif we left with the ** it means anything so treat it as a cwd path + if (trimmed === "**" || trimmed === "**/" || trimmed === "**/*") return null; + // FFF's glob matcher can treat a hidden directory root glob such as // `.agents/**` as empty, while the tool contract says this means "inside // this directory". Collapse simple trailing recursive directory globs to the diff --git a/packages/pi-fff/src/sdk.ts b/packages/pi-fff/src/sdk.ts new file mode 100644 index 00000000..8299ea01 --- /dev/null +++ b/packages/pi-fff/src/sdk.ts @@ -0,0 +1,29 @@ +import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node"; + +export const SCAN_TIMEOUT_MS = 15_000; + +/** pi can be run either under node or sdk, we resolve correct SDK version at runtime */ +export type FileFinderStatic = { + create(options: InitOptions): Result; +}; + +let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null; + +function detectRuntime(): "bun" | "node" { + if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun"; + if ( + typeof process !== "undefined" && + (process as { versions?: { bun?: string } }).versions?.bun + ) + return "bun"; + return "node"; +} + +export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> { + if (sdkPromise) return sdkPromise; + + // default to node as it seems like default option + const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node"; + sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>; + return sdkPromise; +} diff --git a/packages/pi-fff/test/aux-finders.test.ts b/packages/pi-fff/test/aux-finders.test.ts index d64c32fe..0b829bf9 100644 --- a/packages/pi-fff/test/aux-finders.test.ts +++ b/packages/pi-fff/test/aux-finders.test.ts @@ -1,13 +1,22 @@ -import { describe, expect, test } from "bun:test"; -import { resolveAuxRoot, routePathConstraint } from "../src/aux-finders"; +import { afterAll, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + resolveAuxRoot, + rootCovers, + routePathConstraint, +} from "../src/aux-finders"; describe("routePathConstraint", () => { const cwd = "/tmp/workspace"; - test("returns null for relative paths", () => { + test("returns null for workspace-relative paths", () => { expect(routePathConstraint("src/", cwd)).toBeNull(); expect(routePathConstraint("**/*.ts", cwd)).toBeNull(); expect(routePathConstraint(undefined, cwd)).toBeNull(); + // Dot-prefixed names are not parent escapes. + expect(routePathConstraint("..foo/bar", cwd)).toBeNull(); }); test("returns null for absolute paths inside the workspace", () => { @@ -24,13 +33,129 @@ describe("routePathConstraint", () => { expect(route).toEqual({ root: "/tmp", suffix: "**/*.ts" }); }); - test("returns null when prefix does not exist on disk", () => { - expect(routePathConstraint("/tmp/__nonexistent_dir_xyz__", cwd)).toBeNull(); + test("walks up to existing ancestor when tail does not exist", () => { + const route = routePathConstraint("/tmp/__nonexistent_dir_xyz__", cwd); + expect(route).toEqual({ root: "/tmp", suffix: "__nonexistent_dir_xyz__" }); + }); + + test("expands ~ to the home directory", () => { + expect(routePathConstraint("~", cwd)).toEqual({ + root: os.homedir(), + suffix: "", + }); + expect(routePathConstraint("~/__fff_nope__", cwd)).toEqual({ + root: os.homedir(), + suffix: "__fff_nope__", + }); + }); + + describe("relative paths escaping the workspace", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "fff-route-")); + const workspace = path.join(base, "workspace"); + const sibling = path.join(base, "fff-demo"); + fs.mkdirSync(workspace); + fs.mkdirSync(sibling); + + afterAll(() => { + fs.rmSync(base, { recursive: true, force: true }); + }); + + test("routes ../sibling to the sibling directory", () => { + expect(routePathConstraint("../fff-demo/", workspace)).toEqual({ + root: sibling, + suffix: "", + }); + }); + + test("routes .. to the parent directory", () => { + expect(routePathConstraint("..", workspace)).toEqual({ + root: base, + suffix: "", + }); + }); + + test("keeps globs in the suffix", () => { + expect(routePathConstraint("../fff-demo/**/*.ts", workspace)).toEqual({ + root: sibling, + suffix: "**/*.ts", + }); + }); + + test("walks up when the sibling does not exist", () => { + expect(routePathConstraint("../missing-xyz/src", workspace)).toEqual({ + root: base, + suffix: "missing-xyz/src", + }); + }); + + test("returns null when .. resolves back inside the workspace", () => { + expect( + routePathConstraint("../workspace/src", workspace), + ).toBeNull(); + }); + }); +}); + +describe("rootCovers", () => { + test("covers itself and descendants only", () => { + expect(rootCovers("/a/b", "/a/b")).toBe(true); + expect(rootCovers("/a/b", "/a/b/c")).toBe(true); + expect(rootCovers("/", "/a")).toBe(true); + expect(rootCovers("/a/b", "/a")).toBe(false); + expect(rootCovers("/a/b", "/a/bc")).toBe(false); }); }); describe("resolveAuxRoot", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fff-aux-")); + const tmpFile = path.join(tmpDir, "real-file.ts"); + fs.writeFileSync(tmpFile, ""); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + test("returns null for relative input", () => { expect(resolveAuxRoot("src/")).toBeNull(); }); + + test("existing directory becomes the root with empty suffix", () => { + expect(resolveAuxRoot(tmpDir)).toEqual({ root: tmpDir, suffix: "" }); + }); + + test("existing file roots at its parent with basename suffix", () => { + expect(resolveAuxRoot(tmpFile)).toEqual({ + root: tmpDir, + suffix: "real-file.ts", + }); + }); + + test("nonexistent tail becomes a fuzzy suffix", () => { + expect(resolveAuxRoot(path.join(tmpDir, "nope", "foo.ts"))).toEqual({ + root: tmpDir, + suffix: "nope/foo.ts", + }); + }); + + test("glob after nonexistent segment stays in the suffix", () => { + expect(resolveAuxRoot(path.join(tmpDir, "nope", "**", "*.ts"))).toEqual({ + root: tmpDir, + suffix: "nope/**/*.ts", + }); + }); + + test("fully bogus path walks up to filesystem root", () => { + expect(resolveAuxRoot("/__fff_nope__/x/y.ts")).toEqual({ + root: "/", + suffix: "__fff_nope__/x/y.ts", + }); + }); + + test("bare filesystem root resolves to itself", () => { + expect(resolveAuxRoot("/")).toEqual({ root: "/", suffix: "" }); + }); + + test("trailing slashes are ignored", () => { + expect(resolveAuxRoot(`${tmpDir}/`)).toEqual({ root: tmpDir, suffix: "" }); + }); }); diff --git a/packages/pi-fff/test/aux-pool.test.ts b/packages/pi-fff/test/aux-pool.test.ts new file mode 100644 index 00000000..6ea03a53 --- /dev/null +++ b/packages/pi-fff/test/aux-pool.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, mock, test } from "bun:test"; + +interface MockFinder { + isDestroyed: boolean; + basePath: string; + waitForScan: (ms: number) => Promise; + destroy: () => void; +} + +const created: MockFinder[] = []; + +function createMockFinder(basePath: string): MockFinder { + const finder: MockFinder = { + isDestroyed: false, + basePath, + waitForScan: async () => {}, + destroy: () => { + finder.isDestroyed = true; + }, + }; + created.push(finder); + return finder; +} + +const finderModule = { + FileFinder: { + create: (options: { basePath: string }) => ({ + ok: true, + value: createMockFinder(options.basePath), + }), + }, +}; + +mock.module("@ff-labs/fff-node", () => finderModule); +mock.module("@ff-labs/fff-bun", () => finderModule); + +const { AuxFinderPool } = await import("../src/aux-finders"); + +function makePool() { + created.length = 0; + return new AuxFinderPool({ enableFsRootScanning: false }); +} + +describe("AuxFinderPool covering reuse", () => { + test("reuses a picker rooted at an ancestor of the requested path", async () => { + const pool = makePool(); + const a = await pool.acquire("/a/b/c"); + expect(a.root).toBe("/a/b/c"); + + const b = await pool.acquire("/a/b/c/d"); + expect(b.finder).toBe(a.finder); + expect(b.root).toBe("/a/b/c"); + expect(created.length).toBe(1); + }); + + test("does not reuse a picker rooted deeper than the requested path", async () => { + const pool = makePool(); + await pool.acquire("/a/b/c"); + const broad = await pool.acquire("/a/b"); + expect(broad.root).toBe("/a/b"); + expect(created.length).toBe(2); + }); + + test("prefers the deepest covering picker", async () => { + const pool = makePool(); + const narrow = await pool.acquire("/a/b/c"); + await pool.acquire("/a/b"); + + const again = await pool.acquire("/a/b/c/src"); + expect(again.finder).toBe(narrow.finder); + expect(again.root).toBe("/a/b/c"); + }); + + test("exact mode skips ancestor reuse", async () => { + const pool = makePool(); + await pool.acquire("/a/b"); + const exact = await pool.acquire("/a/b/c", { exact: true }); + expect(exact.root).toBe("/a/b/c"); + expect(created.length).toBe(2); + }); + + test("does not treat sibling prefixes as covering", async () => { + const pool = makePool(); + await pool.acquire("/a/bc"); + const other = await pool.acquire("/a/b"); + expect(other.root).toBe("/a/b"); + expect(created.length).toBe(2); + }); +}); diff --git a/packages/pi-fff/test/query.test.ts b/packages/pi-fff/test/query.test.ts index ba9f979f..50e03741 100644 --- a/packages/pi-fff/test/query.test.ts +++ b/packages/pi-fff/test/query.test.ts @@ -68,4 +68,12 @@ describe("path constraint normalization", () => { "src/**/*.ts", ); }); + + test("bare recursive globs constrain nothing", () => { + expect(normalizePathConstraint("**", cwd)).toBeNull(); + expect(normalizePathConstraint("**/", cwd)).toBeNull(); + expect(normalizePathConstraint("**/*", cwd)).toBeNull(); + expect(normalizePathConstraint("./**", cwd)).toBeNull(); + expect(buildQuery("**", "needle", undefined, cwd)).toBe("needle"); + }); });