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