@nklisch/pi-fff-compat 0.1.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/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @nklisch/pi-fff-compat
2
+
3
+ Exposes the fast [FFF](https://github.com/ff-labs/fff) file index through
4
+ Pi-native `find`/`grep` semantics. Upstream `@ff-labs/pi-fff` is excellent for
5
+ fuzzy/smart discovery, but its `ffind`/`ffgrep` behavior intentionally differs
6
+ from Pi's built-ins; this package provides the conservative compatibility
7
+ surface: **glob-only file lookup and exact regex/literal grep, no fuzzy
8
+ fallback** — backed by the same real-time FFF index.
9
+
10
+ ```sh
11
+ pi install npm:@nklisch/pi-fff-compat
12
+ ```
13
+
14
+ ## Modes
15
+
16
+ | Mode | Tools | How |
17
+ | --- | --- | --- |
18
+ | Default (additive) | `fast_find`, `fast_grep` alongside the built-ins | install and go |
19
+ | Override | registers as `find`/`grep` themselves | `PI_FFF_COMPAT_OVERRIDE=1` |
20
+ | Disabled | nothing | `PI_FFF_COMPAT_DISABLE=1` |
21
+
22
+ ## Commands
23
+
24
+ - `/fff-compat` — index health, indexed file count, watcher/scan status.
25
+ - `/fff-compat-rescan` — trigger a manual rescan.
26
+
27
+ ## Watcher & inotify budget
28
+
29
+ FFF maintains a real-time native watcher with one watch per indexed file.
30
+ Scanning very large trees (e.g. a home directory) can exhaust
31
+ `fs.inotify.max_user_watches` and leave the index silently stale on unwatched
32
+ files. Two independent knobs:
33
+
34
+ - `PI_FFF_COMPAT_HOME_SCAN=1` — opt into home-dir scanning (default off; raise
35
+ `fs.inotify.max_user_watches` when enabling).
36
+ - `PI_FFF_COMPAT_DISABLE_WATCH=1` — scan once, no live watcher (index goes
37
+ stale until rescan).
38
+
39
+ The index itself, frecency, and git-aware filtering come from
40
+ [`@ff-labs/fff-node`](https://www.npmjs.com/package/@ff-labs/fff-node), loaded
41
+ as a normal dependency.
@@ -0,0 +1,76 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import fffCompatSearch from "./fff-compat-search";
3
+
4
+ type ToolDef = { name: string; description?: string };
5
+ type CommandDef = { description?: string };
6
+
7
+ function load() {
8
+ const tools: string[] = [];
9
+ const commands: string[] = [];
10
+ const pi = {
11
+ registerTool: (tool: ToolDef) => {
12
+ tools.push(tool.name);
13
+ },
14
+ registerCommand: (name: string, _options: CommandDef) => {
15
+ commands.push(name);
16
+ },
17
+ on: () => {},
18
+ };
19
+ fffCompatSearch(pi as never);
20
+ return { tools, commands };
21
+ }
22
+
23
+ const ENV_VARS = ["PI_FFF_COMPAT_OVERRIDE", "PI_FFF_COMPAT_DISABLE"] as const;
24
+ const saved = new Map<string, string | undefined>();
25
+
26
+ afterEach(() => {
27
+ for (const name of ENV_VARS) {
28
+ if (saved.has(name)) {
29
+ const value = saved.get(name);
30
+ if (value === undefined) delete process.env[name];
31
+ else process.env[name] = value;
32
+ }
33
+ }
34
+ saved.clear();
35
+ });
36
+
37
+ function setEnv(name: (typeof ENV_VARS)[number], value: string | undefined) {
38
+ if (!saved.has(name)) saved.set(name, process.env[name]);
39
+ if (value === undefined) delete process.env[name];
40
+ else process.env[name] = value;
41
+ }
42
+
43
+ describe("pi-fff-compat registration", () => {
44
+ test("default mode registers additive fast_find/fast_grep tools", () => {
45
+ setEnv("PI_FFF_COMPAT_OVERRIDE", undefined);
46
+ setEnv("PI_FFF_COMPAT_DISABLE", undefined);
47
+ const { tools } = load();
48
+ expect(tools).toContain("fast_find");
49
+ expect(tools).toContain("fast_grep");
50
+ expect(tools).not.toContain("find");
51
+ expect(tools).not.toContain("grep");
52
+ });
53
+
54
+ test("override mode registers as find/grep", () => {
55
+ setEnv("PI_FFF_COMPAT_OVERRIDE", "1");
56
+ const { tools } = load();
57
+ expect(tools).toContain("find");
58
+ expect(tools).toContain("grep");
59
+ expect(tools).not.toContain("fast_find");
60
+ });
61
+
62
+ test("disable env registers nothing", () => {
63
+ setEnv("PI_FFF_COMPAT_DISABLE", "1");
64
+ const { tools, commands } = load();
65
+ expect(tools).toHaveLength(0);
66
+ expect(commands).toHaveLength(0);
67
+ });
68
+
69
+ test("status and rescan commands are always registered", () => {
70
+ setEnv("PI_FFF_COMPAT_OVERRIDE", undefined);
71
+ setEnv("PI_FFF_COMPAT_DISABLE", undefined);
72
+ const { commands } = load();
73
+ expect(commands).toContain("fff-compat");
74
+ expect(commands).toContain("fff-compat-rescan");
75
+ });
76
+ });
@@ -0,0 +1,641 @@
1
+ /**
2
+ * FFF compatibility search for Pi.
3
+ *
4
+ * Upstream @ff-labs/pi-fff is excellent for fuzzy/smart discovery, but its
5
+ * fffind/ffgrep semantics intentionally differ from Pi's built-in find/grep.
6
+ * This extension exposes the same fast FFF index through a conservative surface:
7
+ * glob-only file lookup and exact regex/literal grep with no fuzzy fallback.
8
+ *
9
+ * Default mode registers fast_find/fast_grep alongside the built-ins. Set
10
+ * PI_FFF_COMPAT_OVERRIDE=1 before Pi starts to register these exact
11
+ * compatibility tools as find/grep instead.
12
+ *
13
+ * Watcher & inotify budget:
14
+ * FFF maintains a real-time native inotify watcher with one watch per
15
+ * indexed file. Scanning the home directory (millions of files, mostly
16
+ * caches) blows past fs.inotify.max_user_watches and leaves the index
17
+ * silently stale on files it couldn't watch. Two independent knobs:
18
+ * PI_FFF_COMPAT_HOME_SCAN=1 — opt into home-dir scanning (default off).
19
+ * PI_FFF_COMPAT_DISABLE_WATCH=1 — scan once, no live watcher at all
20
+ * (index goes stale until rescan).
21
+ * Also raise fs.inotify.max_user_watches (see /etc/sysctl.d) when enabling
22
+ * home scanning or working in very large trees.
23
+ */
24
+
25
+ import { statSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { Type } from "@earendil-works/pi-ai";
28
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+
30
+ const EXTENSION_NAME = "pi-fff-compat";
31
+ const PACKAGE_HINT = "bundled dependency of @nklisch/pi-fff-compat";
32
+ const OVERRIDE_ENV = "PI_FFF_COMPAT_OVERRIDE";
33
+ const DISABLE_ENV = "PI_FFF_COMPAT_DISABLE";
34
+ // Home-dir scanning watches every file under ~ (caches, runtimes, browser
35
+ // profiles, etc.), which on this machine is ~1.2M files — far beyond the
36
+ // kernel inotify budget and almost entirely churn-prone noise. Off by
37
+ // default; set PI_FFF_COMPAT_HOME_SCAN=1 to opt in (also raise
38
+ // fs.inotify.max_user_watches).
39
+ const HOME_SCAN_ENV = "PI_FFF_COMPAT_HOME_SCAN";
40
+ // FFF's native watcher already debounces and batches events, but maintaining
41
+ // one inotify watch per indexed file is what exhausts the kernel budget. Set
42
+ // PI_FFF_COMPAT_DISABLE_WATCH=1 for a one-shot scan with no live tracking —
43
+ // the index then goes stale on changes until a manual /fff-compat-rescan.
44
+ const DISABLE_WATCH_ENV = "PI_FFF_COMPAT_DISABLE_WATCH";
45
+ const DEFAULT_FIND_LIMIT = 1000;
46
+ const DEFAULT_GREP_LIMIT = 100;
47
+ const MAX_OUTPUT_BYTES = 50 * 1024;
48
+ const MAX_OUTPUT_LINES = 2000;
49
+ const GREP_MAX_LINE_LENGTH = 500;
50
+ const INITIAL_SCAN_WAIT_MS = 15_000;
51
+
52
+ type Result<T> = { ok: true; value: T } | { ok: false; error: string };
53
+
54
+ type FffFileItem = {
55
+ relativePath: string;
56
+ fileName: string;
57
+ };
58
+
59
+ type FffSearchResult = {
60
+ items: FffFileItem[];
61
+ totalMatched: number;
62
+ totalFiles: number;
63
+ };
64
+
65
+ type FffGrepCursor = unknown;
66
+
67
+ type FffGrepMatch = {
68
+ relativePath: string;
69
+ fileName: string;
70
+ lineNumber: number;
71
+ col: number;
72
+ lineContent: string;
73
+ contextBefore?: string[];
74
+ contextAfter?: string[];
75
+ };
76
+
77
+ type FffGrepResult = {
78
+ items: FffGrepMatch[];
79
+ totalMatched: number;
80
+ totalFiles: number;
81
+ totalFilesSearched: number;
82
+ filteredFileCount: number;
83
+ nextCursor: FffGrepCursor | null;
84
+ regexFallbackError?: string;
85
+ };
86
+
87
+ type FffScanProgress = {
88
+ scannedFilesCount: number;
89
+ isScanning: boolean;
90
+ isWatcherReady: boolean;
91
+ isWarmupComplete: boolean;
92
+ };
93
+
94
+ type FffHealth = {
95
+ version: string;
96
+ git: { repositoryFound: boolean; workdir?: string };
97
+ filePicker: { initialized: boolean; indexedFiles?: number; basePath?: string };
98
+ };
99
+
100
+ type FffFinder = {
101
+ readonly isDestroyed: boolean;
102
+ destroy(): void;
103
+ glob(pattern: string, options?: { pageIndex?: number; pageSize?: number; maxThreads?: number }): Result<FffSearchResult>;
104
+ grep(
105
+ query: string,
106
+ options?: {
107
+ mode?: "plain" | "regex" | "fuzzy";
108
+ smartCase?: boolean;
109
+ pageSize?: number;
110
+ maxMatchesPerFile?: number;
111
+ beforeContext?: number;
112
+ afterContext?: number;
113
+ cursor?: FffGrepCursor | null;
114
+ },
115
+ ): Result<FffGrepResult>;
116
+ waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
117
+ scanFiles(): Result<void>;
118
+ getScanProgress(): Result<FffScanProgress>;
119
+ healthCheck(testPath?: string): Result<FffHealth>;
120
+ };
121
+
122
+ type FffModule = {
123
+ FileFinder: {
124
+ create(options: Record<string, unknown>): Result<FffFinder>;
125
+ };
126
+ };
127
+
128
+ type FindInput = {
129
+ pattern: string;
130
+ path?: string;
131
+ limit?: number;
132
+ };
133
+
134
+ type GrepInput = {
135
+ pattern: string;
136
+ path?: string;
137
+ glob?: string;
138
+ ignoreCase?: boolean;
139
+ literal?: boolean;
140
+ context?: number;
141
+ limit?: number;
142
+ };
143
+
144
+ type ToolNames = {
145
+ find: string;
146
+ grep: string;
147
+ };
148
+
149
+ type Truncation = {
150
+ content: string;
151
+ truncated: boolean;
152
+ omittedLines: number;
153
+ omittedBytes: number;
154
+ };
155
+
156
+ let fffModulePromise: Promise<FffModule> | null = null;
157
+ let finder: FffFinder | null = null;
158
+ let finderCwd: string | null = null;
159
+ let finderPromise: { cwd: string; promise: Promise<FffFinder> } | null = null;
160
+ let activeCwd = process.cwd();
161
+
162
+ function envFlagEnabled(value: string | undefined): boolean {
163
+ const normalized = value?.trim().toLowerCase();
164
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
165
+ }
166
+
167
+ function toPosixPath(value: string): string {
168
+ return value.replace(/\\/g, "/");
169
+ }
170
+
171
+ function stripTrailingSlash(value: string): string {
172
+ return value.replace(/\/+$/, "");
173
+ }
174
+
175
+ function isInsideOrEqual(parent: string, child: string): boolean {
176
+ const relative = path.relative(parent, child);
177
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
178
+ }
179
+
180
+ function resolveWorkspacePath(cwd: string, input: string | undefined, fallback = "."): string {
181
+ const raw = input?.trim() || fallback;
182
+ const absolute = path.resolve(cwd, raw);
183
+ if (!isInsideOrEqual(cwd, absolute)) {
184
+ throw new Error(`Path must stay inside the workspace: ${input}`);
185
+ }
186
+ return absolute;
187
+ }
188
+
189
+ function relativeToWorkspace(cwd: string, absolute: string): string {
190
+ const relative = toPosixPath(path.relative(cwd, absolute));
191
+ return relative === "" ? "." : relative;
192
+ }
193
+
194
+ function loadFffModule(): Promise<FffModule> {
195
+ if (!fffModulePromise) {
196
+ fffModulePromise = (async () => {
197
+ try {
198
+ return (await import("@ff-labs/fff-node")) as FffModule;
199
+ } catch (error) {
200
+ throw new Error(
201
+ `Failed to load @ff-labs/fff-node (${PACKAGE_HINT}): ${error instanceof Error ? error.message : String(error)}`,
202
+ );
203
+ }
204
+ })();
205
+ }
206
+ return fffModulePromise;
207
+ }
208
+
209
+ async function ensureFinder(cwd: string): Promise<FffFinder> {
210
+ if (finder && !finder.isDestroyed && finderCwd === cwd) return finder;
211
+ if (finderPromise && finderPromise.cwd === cwd) return finderPromise.promise;
212
+
213
+ finderPromise = {
214
+ cwd,
215
+ promise: (async () => {
216
+ destroyFinder();
217
+ const { FileFinder } = await loadFffModule();
218
+ const created = FileFinder.create({
219
+ basePath: cwd,
220
+ frecencyDbPath: process.env.FFF_FRECENCY_DB,
221
+ historyDbPath: process.env.FFF_HISTORY_DB,
222
+ aiMode: true,
223
+ enableHomeDirScanning: envFlagEnabled(process.env[HOME_SCAN_ENV]),
224
+ enableFsRootScanning: envFlagEnabled(process.env.FFF_ENABLE_ROOT_SCAN),
225
+ disableWatch: envFlagEnabled(process.env[DISABLE_WATCH_ENV]),
226
+ });
227
+ if (!created.ok) throw new Error(`Failed to create FFF finder: ${created.error}`);
228
+
229
+ finder = created.value;
230
+ finderCwd = cwd;
231
+
232
+ const scan = await finder.waitForScan(INITIAL_SCAN_WAIT_MS);
233
+ if (!scan.ok) throw new Error(`FFF scan failed: ${scan.error}`);
234
+ return finder;
235
+ })().finally(() => {
236
+ finderPromise = null;
237
+ }),
238
+ };
239
+
240
+ return finderPromise.promise;
241
+ }
242
+
243
+ function destroyFinder(): void {
244
+ if (finder && !finder.isDestroyed) finder.destroy();
245
+ finder = null;
246
+ finderCwd = null;
247
+ }
248
+
249
+ function truncateLine(line: string): { text: string; wasTruncated: boolean } {
250
+ const sanitized = line.replace(/\r/g, "").replace(/\n$/, "");
251
+ if (sanitized.length <= GREP_MAX_LINE_LENGTH) return { text: sanitized, wasTruncated: false };
252
+ return { text: `${sanitized.slice(0, GREP_MAX_LINE_LENGTH)}...`, wasTruncated: true };
253
+ }
254
+
255
+ function truncateOutput(text: string): Truncation {
256
+ const lines = text.split("\n");
257
+ const lineLimited = lines.length > MAX_OUTPUT_LINES;
258
+ const byLines = lineLimited ? lines.slice(0, MAX_OUTPUT_LINES).join("\n") : text;
259
+
260
+ const buffer = Buffer.from(byLines, "utf8");
261
+ const byteLimited = buffer.length > MAX_OUTPUT_BYTES;
262
+ const content = byteLimited ? buffer.subarray(0, MAX_OUTPUT_BYTES).toString("utf8").replace(/\uFFFD$/, "") : byLines;
263
+
264
+ return {
265
+ content,
266
+ truncated: lineLimited || byteLimited,
267
+ omittedLines: lineLimited ? lines.length - MAX_OUTPUT_LINES : 0,
268
+ omittedBytes: byteLimited ? buffer.length - MAX_OUTPUT_BYTES : 0,
269
+ };
270
+ }
271
+
272
+ function appendNotices(output: string, notices: string[]): string {
273
+ return notices.length > 0 ? `${output}\n\n[${notices.join(". ")}]` : output;
274
+ }
275
+
276
+ function normalizePositiveLimit(value: number | undefined, fallback: number): number {
277
+ return Number.isFinite(value) && value !== undefined ? Math.max(1, Math.floor(value)) : fallback;
278
+ }
279
+
280
+ function fdStyleGlob(pattern: string): string {
281
+ const trimmed = toPosixPath(pattern.trim());
282
+ if (!trimmed) return "**/*";
283
+ if (trimmed === "**" || trimmed.startsWith("**/") || trimmed.startsWith("/")) return trimmed;
284
+ // Pi's built-in find uses fd --glob. Bare patterns match basenames anywhere
285
+ // under the search root, and path-containing relative patterns are matched
286
+ // against the full path from any depth. `**/` gives FFF glob the same shape.
287
+ return `**/${trimmed}`;
288
+ }
289
+
290
+ function joinGlobWithSearchRoot(searchRootRel: string, pattern: string): string {
291
+ const normalizedPattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
292
+ if (searchRootRel === ".") return normalizedPattern;
293
+ return `${stripTrailingSlash(searchRootRel)}/${normalizedPattern}`;
294
+ }
295
+
296
+ function outputPathRelativeToSearchRoot(cwd: string, searchRootAbs: string, fffRelativePath: string): string {
297
+ const searchRootRel = relativeToWorkspace(cwd, searchRootAbs);
298
+ if (searchRootRel === ".") return fffRelativePath;
299
+
300
+ const prefix = `${stripTrailingSlash(searchRootRel)}/`;
301
+ if (fffRelativePath.startsWith(prefix)) return fffRelativePath.slice(prefix.length);
302
+ if (fffRelativePath === stripTrailingSlash(searchRootRel)) return path.basename(fffRelativePath);
303
+ return toPosixPath(path.relative(searchRootAbs, path.join(cwd, fffRelativePath)));
304
+ }
305
+
306
+ function grepPathConstraint(cwd: string, absolutePath: string): { constraint: string | null; isDirectory: boolean } {
307
+ let stat;
308
+ try {
309
+ stat = statSync(absolutePath);
310
+ } catch {
311
+ throw new Error(`Path not found: ${absolutePath}`);
312
+ }
313
+
314
+ const relative = relativeToWorkspace(cwd, absolutePath);
315
+ const isDirectory = stat.isDirectory();
316
+ if (relative === ".") return { constraint: null, isDirectory };
317
+ return { constraint: isDirectory ? `${stripTrailingSlash(relative)}/` : relative, isDirectory };
318
+ }
319
+
320
+ function buildGrepQuery(cwd: string, input: GrepInput, searchPathAbs: string, searchPattern: string): string {
321
+ const parts: string[] = [];
322
+ const pathConstraint = grepPathConstraint(cwd, searchPathAbs).constraint;
323
+ if (pathConstraint) parts.push(pathConstraint);
324
+ if (input.glob?.trim()) parts.push(input.glob.trim());
325
+ parts.push(searchPattern);
326
+ return parts.join(" ");
327
+ }
328
+
329
+ function escapeRegexLiteral(value: string): string {
330
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
331
+ }
332
+
333
+ function grepModeAndPattern(input: GrepInput): { mode: "plain" | "regex"; pattern: string } {
334
+ if (!input.ignoreCase) {
335
+ return input.literal === true ? { mode: "plain", pattern: input.pattern } : { mode: "regex", pattern: input.pattern };
336
+ }
337
+
338
+ const inner = input.literal === true ? escapeRegexLiteral(input.pattern) : input.pattern;
339
+ return { mode: "regex", pattern: `(?i:${inner})` };
340
+ }
341
+
342
+ function formatGrepPath(cwd: string, searchPathAbs: string, searchPathIsDirectory: boolean, fffRelativePath: string): string {
343
+ if (!searchPathIsDirectory) return path.basename(searchPathAbs);
344
+ return outputPathRelativeToSearchRoot(cwd, searchPathAbs, fffRelativePath);
345
+ }
346
+
347
+ function formatGrepMatches(cwd: string, searchPathAbs: string, searchPathIsDirectory: boolean, matches: FffGrepMatch[], contextLines: number): { output: string; linesTruncated: boolean } {
348
+ let linesTruncated = false;
349
+ const outputLines: string[] = [];
350
+ const sorted = [...matches].sort((a, b) => {
351
+ const byPath = a.relativePath.localeCompare(b.relativePath);
352
+ if (byPath !== 0) return byPath;
353
+ const byLine = a.lineNumber - b.lineNumber;
354
+ if (byLine !== 0) return byLine;
355
+ return a.col - b.col;
356
+ });
357
+
358
+ for (const match of sorted) {
359
+ const displayPath = formatGrepPath(cwd, searchPathAbs, searchPathIsDirectory, match.relativePath);
360
+ if (contextLines > 0) {
361
+ const before = match.contextBefore ?? [];
362
+ before.forEach((line, index) => {
363
+ const lineNumber = match.lineNumber - before.length + index;
364
+ const truncated = truncateLine(line);
365
+ if (truncated.wasTruncated) linesTruncated = true;
366
+ outputLines.push(`${displayPath}-${lineNumber}- ${truncated.text}`);
367
+ });
368
+ }
369
+
370
+ const matchLine = truncateLine(match.lineContent);
371
+ if (matchLine.wasTruncated) linesTruncated = true;
372
+ outputLines.push(`${displayPath}:${match.lineNumber}: ${matchLine.text}`);
373
+
374
+ if (contextLines > 0) {
375
+ const after = match.contextAfter ?? [];
376
+ after.forEach((line, index) => {
377
+ const lineNumber = match.lineNumber + index + 1;
378
+ const truncated = truncateLine(line);
379
+ if (truncated.wasTruncated) linesTruncated = true;
380
+ outputLines.push(`${displayPath}-${lineNumber}- ${truncated.text}`);
381
+ });
382
+ }
383
+ }
384
+
385
+ return { output: outputLines.join("\n"), linesTruncated };
386
+ }
387
+
388
+ function toolNames(): ToolNames {
389
+ return envFlagEnabled(process.env[OVERRIDE_ENV]) ? { find: "find", grep: "grep" } : { find: "fast_find", grep: "fast_grep" };
390
+ }
391
+
392
+ function usageHint(names: ToolNames, overriding: boolean): string {
393
+ const toolLabel = overriding ? "the built-in-looking find/grep tools" : `${names.find}/${names.grep}`;
394
+ return [
395
+ "FFF search tools are available in this session; choose the exact or fuzzy surface intentionally.",
396
+ `Use ${toolLabel} for fast, deterministic search when you want normal Pi find/grep semantics backed by the FFF index.`,
397
+ `Use ${names.find} for glob-only file searches. It returns paths relative to the requested search directory and is the fast replacement for ordinary find-style glob lookup.`,
398
+ `Use ${names.grep} for exact content search. It is regex by default, literal only when literal=true, honors ignoreCase, and intentionally has no fuzzy fallback.`,
399
+ "Use fffind for fuzzy/conceptual file discovery: vague file names, feature names, path fragments, typo-tolerant lookup, and git/frecency-ranked exploration. Do not treat it as an exhaustive deterministic glob result.",
400
+ "Use ffgrep for fuzzy/smart content discovery: broad identifiers, likely related terms, fast exploratory lookup, and frecency-ranked matches. It may fuzzy-fallback when exact matches fail, so prefer the compatibility grep for exact verification.",
401
+ `Prefer workspace-relative paths with ${names.find}/${names.grep}; the compatibility layer rejects paths outside the workspace because the FFF index is workspace-scoped.`,
402
+ ].join("\n");
403
+ }
404
+
405
+ const findSchema = Type.Object({
406
+ pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'" }),
407
+ path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
408
+ limit: Type.Optional(Type.Number({ description: `Maximum number of results (default ${DEFAULT_FIND_LIMIT})` })),
409
+ });
410
+
411
+ const grepSchema = Type.Object({
412
+ pattern: Type.String({ description: "Search pattern" }),
413
+ path: Type.Optional(Type.String({ description: "Directory or file to search in (default: current directory)" })),
414
+ glob: Type.Optional(Type.String({ description: "Include/exclude glob for files, passed with ripgrep-style semantics" })),
415
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search" })),
416
+ literal: Type.Optional(Type.Boolean({ description: "Treat pattern as a literal string instead of regex" })),
417
+ context: Type.Optional(Type.Number({ description: "Context lines before and after each match" })),
418
+ limit: Type.Optional(Type.Number({ description: `Maximum number of matching lines (default ${DEFAULT_GREP_LIMIT})` })),
419
+ });
420
+
421
+ export default function fffCompatSearch(pi: ExtensionAPI) {
422
+ if (envFlagEnabled(process.env[DISABLE_ENV])) return;
423
+
424
+ const names = toolNames();
425
+ const overriding = names.find === "find" || names.grep === "grep";
426
+ const injectedUsageHint = usageHint(names, overriding);
427
+
428
+ pi.on("before_agent_start", async (event) => {
429
+ return {
430
+ systemPrompt: `${event.systemPrompt}\n\n${injectedUsageHint}`,
431
+ };
432
+ });
433
+
434
+ pi.on("session_start", async (_event, ctx) => {
435
+ activeCwd = ctx.cwd;
436
+ if (overriding) {
437
+ ctx.ui.notify(`${EXTENSION_NAME}: overriding built-in find/grep with deterministic FFF-backed tools`, "warning");
438
+ }
439
+ if (envFlagEnabled(process.env[HOME_SCAN_ENV])) {
440
+ ctx.ui.notify(
441
+ `${EXTENSION_NAME}: home-dir scanning is ON (PI_FFF_COMPAT_HOME_SCAN=1). Ensure fs.inotify.max_user_watches is large enough or the index will silently go stale.`,
442
+ "warning",
443
+ );
444
+ }
445
+ if (envFlagEnabled(process.env[DISABLE_WATCH_ENV])) {
446
+ ctx.ui.notify(`${EXTENSION_NAME}: live watcher disabled (PI_FFF_COMPAT_DISABLE_WATCH=1); index goes stale until /fff-compat-rescan`, "info");
447
+ }
448
+
449
+ try {
450
+ await ensureFinder(activeCwd);
451
+ } catch (error) {
452
+ ctx.ui.notify(`${EXTENSION_NAME} init failed: ${error instanceof Error ? error.message : String(error)}`, "error");
453
+ }
454
+ });
455
+
456
+ pi.on("session_shutdown", async () => {
457
+ destroyFinder();
458
+ });
459
+
460
+ pi.registerTool({
461
+ name: names.find,
462
+ label: names.find,
463
+ description: `Search for files by glob pattern using the FFF index while preserving Pi find semantics. Returns paths relative to the search directory. Respects the indexed/gitignored file set. Output is truncated to ${DEFAULT_FIND_LIMIT} results or ${MAX_OUTPUT_BYTES / 1024}KB.`,
464
+ promptSnippet: "Fast deterministic glob file search backed by FFF",
465
+ promptGuidelines: [
466
+ `Use ${names.find} when you need deterministic glob-style file search with FFF speed; use fffind for fuzzy/conceptual file discovery.`,
467
+ ],
468
+ parameters: findSchema,
469
+ async execute(_toolCallId, params: FindInput, signal, _onUpdate, ctx: ExtensionContext) {
470
+ if (signal?.aborted) throw new Error("Operation aborted");
471
+ const cwd = ctx.cwd || activeCwd;
472
+ const f = await ensureFinder(cwd);
473
+ const searchRootAbs = resolveWorkspacePath(cwd, params.path, ".");
474
+ let stat;
475
+ try {
476
+ stat = statSync(searchRootAbs);
477
+ } catch {
478
+ throw new Error(`Path not found: ${searchRootAbs}`);
479
+ }
480
+ if (!stat.isDirectory()) throw new Error(`Path is not a directory: ${searchRootAbs}`);
481
+
482
+ const effectiveLimit = normalizePositiveLimit(params.limit, DEFAULT_FIND_LIMIT);
483
+ const searchRootRel = relativeToWorkspace(cwd, searchRootAbs);
484
+ const fffGlob = joinGlobWithSearchRoot(searchRootRel, fdStyleGlob(params.pattern));
485
+ const result = f.glob(fffGlob, { pageIndex: 0, pageSize: effectiveLimit });
486
+ if (!result.ok) throw new Error(result.error);
487
+ if (signal?.aborted) throw new Error("Operation aborted");
488
+
489
+ if (result.value.items.length === 0) {
490
+ return { content: [{ type: "text", text: "No files found matching pattern" }], details: undefined };
491
+ }
492
+
493
+ const outputPaths = result.value.items
494
+ .map((item) => outputPathRelativeToSearchRoot(cwd, searchRootAbs, item.relativePath))
495
+ .sort((a, b) => a.localeCompare(b));
496
+ const truncated = truncateOutput(outputPaths.join("\n"));
497
+ const notices: string[] = [];
498
+ const details: Record<string, unknown> = {
499
+ backend: "fff.glob",
500
+ totalMatched: result.value.totalMatched,
501
+ totalFiles: result.value.totalFiles,
502
+ glob: fffGlob,
503
+ };
504
+
505
+ if (result.value.totalMatched > result.value.items.length) {
506
+ notices.push(`${effectiveLimit} results limit reached`);
507
+ details.resultLimitReached = effectiveLimit;
508
+ }
509
+ if (truncated.truncated) {
510
+ notices.push(`${MAX_OUTPUT_BYTES / 1024}KB output limit reached`);
511
+ details.truncation = truncated;
512
+ }
513
+
514
+ return {
515
+ content: [{ type: "text", text: appendNotices(truncated.content, notices) }],
516
+ details,
517
+ };
518
+ },
519
+ });
520
+
521
+ pi.registerTool({
522
+ name: names.grep,
523
+ label: names.grep,
524
+ description: `Search file contents using the FFF index while preserving Pi grep semantics: regex by default, literal only when literal=true, ignoreCase honored, no fuzzy fallback. Returns matching lines with file paths and line numbers. Output is truncated to ${DEFAULT_GREP_LIMIT} matches or ${MAX_OUTPUT_BYTES / 1024}KB.`,
525
+ promptSnippet: "Fast deterministic content search backed by FFF",
526
+ promptGuidelines: [
527
+ `Use ${names.grep} when you need exact grep-style content search with FFF speed; use ffgrep for fuzzy/smart discovery.`,
528
+ `Set literal=true on ${names.grep} for fixed-string searches; regex is the default to match Pi's built-in grep semantics.`,
529
+ ],
530
+ parameters: grepSchema,
531
+ async execute(_toolCallId, params: GrepInput, signal, _onUpdate, ctx: ExtensionContext) {
532
+ if (signal?.aborted) throw new Error("Operation aborted");
533
+ const cwd = ctx.cwd || activeCwd;
534
+ const f = await ensureFinder(cwd);
535
+ const searchPathAbs = resolveWorkspacePath(cwd, params.path, ".");
536
+ const { isDirectory } = grepPathConstraint(cwd, searchPathAbs);
537
+ const effectiveLimit = normalizePositiveLimit(params.limit, DEFAULT_GREP_LIMIT);
538
+ const contextLines = Math.max(0, Math.floor(params.context ?? 0));
539
+ const mode = grepModeAndPattern(params);
540
+ const query = buildGrepQuery(cwd, params, searchPathAbs, mode.pattern);
541
+
542
+ const result = f.grep(query, {
543
+ mode: mode.mode,
544
+ smartCase: false,
545
+ pageSize: effectiveLimit,
546
+ maxMatchesPerFile: effectiveLimit,
547
+ beforeContext: contextLines,
548
+ afterContext: contextLines,
549
+ });
550
+ if (!result.ok) throw new Error(result.error);
551
+ if (result.value.regexFallbackError) throw new Error(`Invalid regex: ${result.value.regexFallbackError}`);
552
+ if (signal?.aborted) throw new Error("Operation aborted");
553
+
554
+ if (result.value.items.length === 0) {
555
+ return { content: [{ type: "text", text: "No matches found" }], details: undefined };
556
+ }
557
+
558
+ const formatted = formatGrepMatches(cwd, searchPathAbs, isDirectory, result.value.items, contextLines);
559
+ const truncated = truncateOutput(formatted.output);
560
+ const notices: string[] = [];
561
+ const details: Record<string, unknown> = {
562
+ backend: "fff.grep",
563
+ mode: mode.mode,
564
+ totalMatched: result.value.totalMatched,
565
+ totalFiles: result.value.totalFiles,
566
+ totalFilesSearched: result.value.totalFilesSearched,
567
+ filteredFileCount: result.value.filteredFileCount,
568
+ };
569
+
570
+ if (result.value.nextCursor) {
571
+ notices.push(`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`);
572
+ details.matchLimitReached = effectiveLimit;
573
+ }
574
+ if (formatted.linesTruncated) {
575
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
576
+ details.linesTruncated = true;
577
+ }
578
+ if (truncated.truncated) {
579
+ notices.push(`${MAX_OUTPUT_BYTES / 1024}KB output limit reached`);
580
+ details.truncation = truncated;
581
+ }
582
+
583
+ return {
584
+ content: [{ type: "text", text: appendNotices(truncated.content, notices) }],
585
+ details,
586
+ };
587
+ },
588
+ });
589
+
590
+ pi.registerCommand("fff-compat", {
591
+ description: "Show FFF compatibility search status",
592
+ handler: async (_args, ctx) => {
593
+ try {
594
+ const f = await ensureFinder(ctx.cwd);
595
+ const health = f.healthCheck();
596
+ if (!health.ok) {
597
+ ctx.ui.notify(`FFF compat health failed: ${health.error}`, "error");
598
+ return;
599
+ }
600
+ const progress = f.getScanProgress();
601
+ const homeScan = envFlagEnabled(process.env[HOME_SCAN_ENV]);
602
+ const watchDisabled = envFlagEnabled(process.env[DISABLE_WATCH_ENV]);
603
+ const lines = [
604
+ `Mode: ${overriding ? "override find/grep" : `${names.find}/${names.grep}`}`,
605
+ `FFF v${health.value.version}`,
606
+ `Base: ${health.value.filePicker.basePath ?? ctx.cwd}`,
607
+ `Indexed: ${health.value.filePicker.indexedFiles ?? "unknown"} files`,
608
+ `Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`,
609
+ `Home scan: ${homeScan ? "on (PI_FFF_COMPAT_HOME_SCAN=1)" : "off (workspace only)"}`,
610
+ `Watcher: ${watchDisabled ? "disabled (PI_FFF_COMPAT_DISABLE_WATCH=1)" : "enabled"}`,
611
+ ];
612
+ if (progress.ok) {
613
+ lines.push(
614
+ `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} scanned, warmup ${progress.value.isWarmupComplete ? "done" : "pending"})`,
615
+ `Watcher ready: ${progress.value.isWatcherReady ? "yes" : "no"}`,
616
+ );
617
+ }
618
+ ctx.ui.notify(lines.join("\n"), "info");
619
+ } catch (error) {
620
+ ctx.ui.notify(`FFF compat unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
621
+ }
622
+ },
623
+ });
624
+
625
+ pi.registerCommand("fff-compat-rescan", {
626
+ description: "Trigger a rescan for FFF compatibility search",
627
+ handler: async (_args, ctx) => {
628
+ try {
629
+ const f = await ensureFinder(ctx.cwd);
630
+ const result = f.scanFiles();
631
+ if (!result.ok) {
632
+ ctx.ui.notify(`FFF compat rescan failed: ${result.error}`, "error");
633
+ return;
634
+ }
635
+ ctx.ui.notify("FFF compat rescan triggered", "info");
636
+ } catch (error) {
637
+ ctx.ui.notify(`FFF compat rescan unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
638
+ }
639
+ },
640
+ });
641
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@nklisch/pi-fff-compat",
3
+ "version": "0.1.0",
4
+ "description": "Fast FFF-backed file search through Pi-native find/grep semantics — glob-only file lookup and exact regex/literal grep with no fuzzy fallback.",
5
+ "author": {
6
+ "name": "nklisch"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nklisch/pi-extensions.git",
11
+ "directory": "packages/pi-fff-compat"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/nklisch/pi-extensions/issues"
15
+ },
16
+ "homepage": "https://github.com/nklisch/pi-extensions/tree/main/packages/pi-fff-compat#readme",
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": true
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi-extension",
25
+ "fff",
26
+ "search",
27
+ "find",
28
+ "grep",
29
+ "ripgrep"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions"
34
+ ]
35
+ },
36
+ "private": false,
37
+ "dependencies": {
38
+ "@ff-labs/fff-node": ">=0.9.6 <1"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "0.82.0",
46
+ "@earendil-works/pi-coding-agent": "0.82.0"
47
+ },
48
+ "scripts": {
49
+ "test": "bun test extensions/*.test.ts"
50
+ }
51
+ }