@bacnh85/pi-fff 0.6.0 → 0.7.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 +44 -6
- package/extensions/index.ts +398 -113
- package/package.json +1 -1
- package/extensions/package.json +0 -1
package/README.md
CHANGED
|
@@ -44,25 +44,63 @@ pi install -l npm:@bacnh85/pi-fff
|
|
|
44
44
|
|
|
45
45
|
### `ffgrep`
|
|
46
46
|
|
|
47
|
-
Search file contents. Smart
|
|
47
|
+
Search file contents. Smart-case, auto-detects regex vs literal, git-aware. Results ranked by frecency (most-accessed files first).
|
|
48
48
|
|
|
49
49
|
Parameters:
|
|
50
50
|
- `pattern` — search text or regex
|
|
51
51
|
- `path` — directory/file constraint (e.g. `src/`, `*.ts`)
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
52
|
+
- `exclude` — exclude paths (e.g. `test/,*.min.js`)
|
|
53
|
+
- `caseSensitive` — force case-sensitive (default: smart-case)
|
|
54
54
|
- `context` — context lines around matches
|
|
55
|
-
- `limit` — max matches (default:
|
|
55
|
+
- `limit` — max matches (default: 20)
|
|
56
|
+
- `outputMode` — `"content"` (default), `"files_with_matches"` (one preview per file), or `"count"` (file match counts)
|
|
56
57
|
- `cursor` — pagination cursor from previous result
|
|
57
58
|
|
|
59
|
+
Definition lines (function/class/interface/enum declarations) are auto-expanded with 3 lines of context for scannability.
|
|
60
|
+
|
|
58
61
|
### `fffind`
|
|
59
62
|
|
|
60
|
-
Fuzzy file name search. Frecency-ranked.
|
|
63
|
+
Fuzzy file name search. Frecency-ranked, git-aware, multi-word AND narrowing.
|
|
61
64
|
|
|
62
65
|
Parameters:
|
|
63
66
|
- `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
|
|
64
67
|
- `path` — directory constraint
|
|
65
|
-
- `
|
|
68
|
+
- `exclude` — exclude paths
|
|
69
|
+
- `limit` — max results (default: 30)
|
|
70
|
+
- `cursor` — pagination cursor from previous result
|
|
71
|
+
|
|
72
|
+
When the top result strongly dominates (exact match or score > 2x runner-up), a `→ Read` hint is shown.
|
|
73
|
+
|
|
74
|
+
### `resolve_file`
|
|
75
|
+
|
|
76
|
+
Resolve a vague file reference to an exact path. Auto-resolves when the top FFF candidate strongly dominates; returns ranked candidates when ambiguous.
|
|
77
|
+
|
|
78
|
+
Parameters:
|
|
79
|
+
- `pattern` — fuzzy file query (e.g. `"auth middleware"`, `"Chart component"`)
|
|
80
|
+
- `limit` — max candidates when ambiguous (default: 8)
|
|
81
|
+
|
|
82
|
+
### `fff_multi_grep`
|
|
83
|
+
|
|
84
|
+
Search file contents for any of multiple literal patterns in one pass. Uses FFF multi-grep. Useful for renamed symbols, aliases, or spelling variants.
|
|
85
|
+
|
|
86
|
+
Parameters:
|
|
87
|
+
- `patterns` — array of literal patterns (1-10)
|
|
88
|
+
- `path` — directory/file constraint
|
|
89
|
+
- `exclude` — exclude paths
|
|
90
|
+
- `context` — context lines around matches
|
|
91
|
+
- `limit` — max matches (default: 20)
|
|
92
|
+
- `outputMode` — `"content"`, `"files_with_matches"`, or `"count"` (default: `"content"`)
|
|
93
|
+
- `cursor` — pagination cursor from previous result
|
|
94
|
+
|
|
95
|
+
### `related_files`
|
|
96
|
+
|
|
97
|
+
Find companion files sharing the same base name stem. Discovers test files, type definitions, styles, stories, and other companions.
|
|
98
|
+
|
|
99
|
+
Parameters:
|
|
100
|
+
- `path` — reference file path (fuzzy or exact)
|
|
101
|
+
- `limit` — max related files (default: 8)
|
|
102
|
+
|
|
103
|
+
Example: `related_files({ path: "src/Chart.tsx" })` → `Chart.test.tsx`, `Chart.module.css`, `Chart.types.ts`
|
|
66
104
|
|
|
67
105
|
## Commands
|
|
68
106
|
|
package/extensions/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@earendil-works/pi-tui";
|
|
14
14
|
import type {
|
|
15
15
|
GrepCursor,
|
|
16
|
+
GrepMatch,
|
|
16
17
|
GrepMode,
|
|
17
18
|
GrepResult,
|
|
18
19
|
MixedItem,
|
|
@@ -29,50 +30,33 @@ import { buildQuery } from "./lib/query";
|
|
|
29
30
|
const DEFAULT_GREP_LIMIT = 20;
|
|
30
31
|
const DEFAULT_FIND_LIMIT = 30;
|
|
31
32
|
const GREP_MAX_LINE_LENGTH = 500;
|
|
32
|
-
const MENTION_MAX_RESULTS = 20;
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
37
|
-
|
|
38
|
-
interface ToolNames {
|
|
39
|
-
grep: string;
|
|
40
|
-
find: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const FFF_TOOL_NAMES: ToolNames = {
|
|
44
|
-
grep: "ffgrep",
|
|
45
|
-
find: "fffind",
|
|
46
|
-
};
|
|
47
|
-
const OVERRIDE_TOOL_NAMES: ToolNames = {
|
|
48
|
-
grep: "grep",
|
|
49
|
-
find: "find",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
function resolveToolNames(mode: FffMode): ToolNames {
|
|
53
|
-
return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
|
|
54
|
-
}
|
|
34
|
+
const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const;
|
|
35
|
+
type FffMode = (typeof VALID_MODES)[number];
|
|
55
36
|
|
|
56
37
|
// ---------------------------------------------------------------------------
|
|
57
38
|
// Cursor store — simple bounded Map for pagination cursors
|
|
58
39
|
// ---------------------------------------------------------------------------
|
|
59
40
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
41
|
+
class BoundedMap<V> {
|
|
42
|
+
private map = new Map<string, V>();
|
|
43
|
+
private counter = 0;
|
|
44
|
+
constructor(private maxSize: number) {}
|
|
45
|
+
store(value: V): string {
|
|
46
|
+
const id = `${++this.counter}`;
|
|
47
|
+
this.map.set(id, value);
|
|
48
|
+
if (this.map.size > this.maxSize) {
|
|
49
|
+
const first = this.map.keys().next().value;
|
|
50
|
+
if (first) this.map.delete(first);
|
|
51
|
+
}
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
get(id: string): V | undefined {
|
|
55
|
+
return this.map.get(id);
|
|
69
56
|
}
|
|
70
|
-
return id;
|
|
71
57
|
}
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
return cursorCache.get(id);
|
|
75
|
-
}
|
|
59
|
+
const cursorStore = new BoundedMap<GrepCursor>(200);
|
|
76
60
|
|
|
77
61
|
// Find pagination uses a page-index cursor: native `fileSearch` takes
|
|
78
62
|
// pageIndex/pageSize, so the cursor is just the next page index paired with
|
|
@@ -84,22 +68,7 @@ interface FindCursor {
|
|
|
84
68
|
nextPageIndex: number;
|
|
85
69
|
}
|
|
86
70
|
|
|
87
|
-
const
|
|
88
|
-
let findCursorCounter = 0;
|
|
89
|
-
|
|
90
|
-
function storeFindCursor(cursor: FindCursor): string {
|
|
91
|
-
const id = `${++findCursorCounter}`;
|
|
92
|
-
findCursorCache.set(id, cursor);
|
|
93
|
-
if (findCursorCache.size > 200) {
|
|
94
|
-
const first = findCursorCache.keys().next().value;
|
|
95
|
-
if (first) findCursorCache.delete(first);
|
|
96
|
-
}
|
|
97
|
-
return id;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function getFindCursor(id: string): FindCursor | undefined {
|
|
101
|
-
return findCursorCache.get(id);
|
|
102
|
-
}
|
|
71
|
+
const findCursorStore = new BoundedMap<FindCursor>(200);
|
|
103
72
|
|
|
104
73
|
// ---------------------------------------------------------------------------
|
|
105
74
|
// Output formatting helpers
|
|
@@ -118,7 +87,7 @@ const WARM_FRECENCY = 20;
|
|
|
118
87
|
// git-dirty (most actionable — file is changing right now) beats frecency
|
|
119
88
|
// (historically often-touched). Keeping one function ensures the two tools
|
|
120
89
|
// never drift in how they surface git/frecency signal.
|
|
121
|
-
|
|
90
|
+
function fffFileAnnotation(item: {
|
|
122
91
|
gitStatus?: string;
|
|
123
92
|
totalFrecencyScore?: number;
|
|
124
93
|
accessFrecencyScore?: number;
|
|
@@ -145,13 +114,44 @@ export function fffFileAnnotation(item: {
|
|
|
145
114
|
// provided — inside a file we keep matches in source-line order because the
|
|
146
115
|
// engine emits them that way.
|
|
147
116
|
|
|
148
|
-
function formatGrepOutput(
|
|
117
|
+
function formatGrepOutput(
|
|
118
|
+
result: GrepResult,
|
|
119
|
+
options?: { outputMode?: GrepOutputMode; explicitContext?: number; limit?: number },
|
|
120
|
+
): string {
|
|
149
121
|
if (result.items.length === 0) return "No matches found";
|
|
122
|
+
const outputMode = options?.outputMode ?? "content";
|
|
123
|
+
|
|
124
|
+
// count mode: file: count per file
|
|
125
|
+
if (outputMode === "count") {
|
|
126
|
+
const counts = new Map<string, number>();
|
|
127
|
+
const order: string[] = [];
|
|
128
|
+
for (const item of result.items) {
|
|
129
|
+
if (!counts.has(item.relativePath)) order.push(item.relativePath);
|
|
130
|
+
counts.set(item.relativePath, (counts.get(item.relativePath) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
return order.map((p) => `${p}: ${counts.get(p)}`).join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// files_with_matches mode: one preview per file, with definition auto-expand
|
|
136
|
+
if (outputMode === "files_with_matches") {
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const lines: string[] = [];
|
|
139
|
+
for (const match of result.items) {
|
|
140
|
+
if (!seen.has(match.relativePath)) {
|
|
141
|
+
seen.add(match.relativePath);
|
|
142
|
+
lines.push(`${match.relativePath}${fffFileAnnotation(match)}`);
|
|
143
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
144
|
+
appendDefContext(lines, match, "|");
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
150
149
|
|
|
151
|
-
//
|
|
152
|
-
|
|
150
|
+
// content mode (default) — with definition auto-expand
|
|
151
|
+
const explicitContext = (options?.explicitContext ?? 0) > 0;
|
|
153
152
|
const lines: string[] = [];
|
|
154
153
|
let currentFile = "";
|
|
154
|
+
|
|
155
155
|
for (const match of result.items) {
|
|
156
156
|
if (match.relativePath !== currentFile) {
|
|
157
157
|
if (lines.length > 0) lines.push("");
|
|
@@ -166,10 +166,12 @@ function formatGrepOutput(result: GrepResult): string {
|
|
|
166
166
|
|
|
167
167
|
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
if (explicitContext) {
|
|
170
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
171
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
172
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
173
|
+
});
|
|
174
|
+
} else appendDefContext(lines, match, "-");
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
return lines.join("\n");
|
|
@@ -181,12 +183,46 @@ function formatGrepOutput(result: GrepResult): string {
|
|
|
181
183
|
// When the top score is weak, trim output to a small sample instead of dumping
|
|
182
184
|
// the full limit worth of noise into the agent's context.
|
|
183
185
|
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
186
|
+
const DEFAULT_RESOLVE_LIMIT = 8;
|
|
184
187
|
|
|
185
188
|
function weakScoreThreshold(pattern: string): number {
|
|
186
189
|
const perfect = pattern.length * 12;
|
|
187
190
|
return Math.floor((perfect * 50) / 100);
|
|
188
191
|
}
|
|
189
192
|
|
|
193
|
+
type GrepOutputMode = "content" | "files_with_matches" | "count";
|
|
194
|
+
|
|
195
|
+
function appendDefContext(lines: string[], match: GrepMatch, prefix: string): void {
|
|
196
|
+
if (!match.isDefinition) return;
|
|
197
|
+
const after = match.contextAfter?.slice(0, 3) ?? [];
|
|
198
|
+
for (let i = 0; i < after.length; i++) {
|
|
199
|
+
if (after[i].trim()) lines.push(` ${match.lineNumber + 1 + i}${prefix} ${truncateLine(after[i])}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function scoreDominates(top?: { matchType?: string; exactMatch?: boolean; total?: number } | null, second?: { total?: number } | null): boolean {
|
|
204
|
+
if (!top) return false;
|
|
205
|
+
return top.matchType === "exact" || top.exactMatch === true || !second || (top.total ?? 0) > (second.total ?? 0) * 2;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type GrepResultFormat = { content: { type: "text"; text: string }[]; details: { totalMatched: number; totalFiles: number } };
|
|
209
|
+
|
|
210
|
+
function formatGrepResult(
|
|
211
|
+
result: GrepResult,
|
|
212
|
+
outputMode: GrepOutputMode | undefined,
|
|
213
|
+
explicitContext: number,
|
|
214
|
+
effectiveLimit: number,
|
|
215
|
+
extras?: { regexFallbackError?: string; fuzzyNotice?: string | null },
|
|
216
|
+
): GrepResultFormat {
|
|
217
|
+
let output = formatGrepOutput(result, { outputMode, explicitContext, limit: effectiveLimit });
|
|
218
|
+
const notices: string[] = [];
|
|
219
|
+
if (extras?.regexFallbackError) notices.push(`Invalid regex: ${extras.regexFallbackError}, used literal match`);
|
|
220
|
+
if (result.nextCursor) notices.push(`Continue with cursor="${cursorStore.store(result.nextCursor)}"`);
|
|
221
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
222
|
+
if (extras?.fuzzyNotice) output = `[${extras.fuzzyNotice}]\n${output}`;
|
|
223
|
+
return { content: [{ type: "text", text: output }], details: { totalMatched: result.totalMatched, totalFiles: result.totalFiles } };
|
|
224
|
+
}
|
|
225
|
+
|
|
190
226
|
interface FormattedFind {
|
|
191
227
|
output: string;
|
|
192
228
|
weak: boolean;
|
|
@@ -197,6 +233,7 @@ function formatFindOutput(
|
|
|
197
233
|
result: SearchResult,
|
|
198
234
|
limit: number,
|
|
199
235
|
pattern: string,
|
|
236
|
+
pageIndex = 0,
|
|
200
237
|
): FormattedFind {
|
|
201
238
|
if (result.items.length === 0) {
|
|
202
239
|
return {
|
|
@@ -213,8 +250,18 @@ function formatFindOutput(
|
|
|
213
250
|
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
214
251
|
const shown = result.items.slice(0, effective);
|
|
215
252
|
|
|
253
|
+
const items: string[] = [];
|
|
254
|
+
|
|
255
|
+
// On first page, add a "→ Read" hint when the top candidate strongly dominates
|
|
256
|
+
if (pageIndex === 0 && shown.length > 0 && scoreDominates(result.scores[0], result.scores[1])) {
|
|
257
|
+
const label = result.scores[0]?.matchType === "exact" || result.scores[0]?.exactMatch ? "exact match!" : "best match";
|
|
258
|
+
items.push(`→ Read ${shown[0].relativePath} (${label})`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
items.push(...shown.map((item) => `${item.relativePath}${fffFileAnnotation(item)}`));
|
|
262
|
+
|
|
216
263
|
return {
|
|
217
|
-
output:
|
|
264
|
+
output: items.join("\n"),
|
|
218
265
|
weak,
|
|
219
266
|
shownCount: shown.length,
|
|
220
267
|
};
|
|
@@ -281,7 +328,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
281
328
|
(process.env.PI_FFF_MODE as FffMode) ??
|
|
282
329
|
"tools-and-ui";
|
|
283
330
|
|
|
284
|
-
const
|
|
331
|
+
const grepName = currentMode === "override" ? "grep" : "ffgrep";
|
|
332
|
+
const findName = currentMode === "override" ? "find" : "fffind";
|
|
285
333
|
|
|
286
334
|
// DB path resolution: flag > env > undefined (use fff-node defaults)
|
|
287
335
|
const frecencyDbPath =
|
|
@@ -304,18 +352,6 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
304
352
|
rootScanFlag === "1" ||
|
|
305
353
|
(rootScanFlag == null && (rootScanEnv === "1" || rootScanEnv === "true"));
|
|
306
354
|
|
|
307
|
-
function getMode(): FffMode {
|
|
308
|
-
return currentMode;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function setMode(mode: FffMode): void {
|
|
312
|
-
currentMode = mode;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function shouldEnableMentions(): boolean {
|
|
316
|
-
return currentMode !== "tools-only";
|
|
317
|
-
}
|
|
318
|
-
|
|
319
355
|
function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
320
356
|
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
321
357
|
return Promise.resolve(finder);
|
|
@@ -367,10 +403,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
367
403
|
const f = await ensureFinder(activeCwd);
|
|
368
404
|
if (signal.aborted) return [];
|
|
369
405
|
|
|
370
|
-
const result = f.mixedSearch(query, { pageSize:
|
|
406
|
+
const result = f.mixedSearch(query, { pageSize: 20 });
|
|
371
407
|
if (!result.ok) return [];
|
|
372
408
|
|
|
373
|
-
return result.value.items.slice(0,
|
|
409
|
+
return result.value.items.slice(0, 20).map((mixed: MixedItem) => {
|
|
374
410
|
if (mixed.type === "directory") {
|
|
375
411
|
return {
|
|
376
412
|
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
@@ -398,7 +434,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
398
434
|
|
|
399
435
|
return {
|
|
400
436
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
401
|
-
if (
|
|
437
|
+
if (currentMode !== "tools-only") {
|
|
402
438
|
try {
|
|
403
439
|
const mentionResult = await mentionProvider.getSuggestions(
|
|
404
440
|
lines,
|
|
@@ -551,14 +587,19 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
551
587
|
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
552
588
|
}),
|
|
553
589
|
),
|
|
590
|
+
outputMode: Type.Optional(
|
|
591
|
+
Type.String({
|
|
592
|
+
description: "Output format: 'content' (default), 'files_with_matches' (one preview per file), or 'count' (file match counts)",
|
|
593
|
+
}),
|
|
594
|
+
),
|
|
554
595
|
cursor: Type.Optional(
|
|
555
596
|
Type.String({ description: "Pagination cursor from previous result" }),
|
|
556
597
|
),
|
|
557
598
|
});
|
|
558
599
|
|
|
559
600
|
pi.registerTool({
|
|
560
|
-
name:
|
|
561
|
-
label:
|
|
601
|
+
name: grepName,
|
|
602
|
+
label: grepName,
|
|
562
603
|
description: `Grep file contents. Smart-case, auto-detects regex vs literal, git-aware. Results are ranked by frecency (most-accessed files first); matches within a file stay in source order. Default limit ${DEFAULT_GREP_LIMIT}.`,
|
|
563
604
|
promptSnippet: "Grep contents",
|
|
564
605
|
promptGuidelines: [
|
|
@@ -614,14 +655,16 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
614
655
|
// caseSensitive override flips smartCase off; omitting it keeps smart-case
|
|
615
656
|
// (case-insensitive when pattern is all lowercase).
|
|
616
657
|
const smartCase = params.caseSensitive !== true;
|
|
658
|
+
const explicitContext = params.context ?? 0;
|
|
617
659
|
|
|
660
|
+
// Always request a little context so definition auto-expand can work.
|
|
618
661
|
const grepResult = f.grep(query, {
|
|
619
662
|
mode,
|
|
620
663
|
smartCase,
|
|
621
664
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
622
|
-
cursor: (params.cursor ?
|
|
623
|
-
beforeContext:
|
|
624
|
-
afterContext:
|
|
665
|
+
cursor: (params.cursor ? cursorStore.get(params.cursor) : null) ?? null,
|
|
666
|
+
beforeContext: explicitContext,
|
|
667
|
+
afterContext: Math.max(explicitContext, 3),
|
|
625
668
|
classifyDefinitions: true,
|
|
626
669
|
});
|
|
627
670
|
|
|
@@ -648,25 +691,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
648
691
|
}
|
|
649
692
|
}
|
|
650
693
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (result.regexFallbackError) {
|
|
654
|
-
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
|
655
|
-
}
|
|
656
|
-
if (result.nextCursor) {
|
|
657
|
-
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
661
|
-
if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
|
|
662
|
-
|
|
663
|
-
return {
|
|
664
|
-
content: [{ type: "text", text: output }],
|
|
665
|
-
details: {
|
|
666
|
-
totalMatched: result.totalMatched,
|
|
667
|
-
totalFiles: result.totalFiles,
|
|
668
|
-
},
|
|
669
|
-
};
|
|
694
|
+
const outputMode = params.outputMode as GrepOutputMode | undefined;
|
|
695
|
+
return formatGrepResult(result, outputMode, explicitContext, effectiveLimit, { regexFallbackError: result.regexFallbackError, fuzzyNotice });
|
|
670
696
|
},
|
|
671
697
|
|
|
672
698
|
renderCall(args, theme, context) {
|
|
@@ -674,7 +700,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
674
700
|
const pattern = args?.pattern ?? "";
|
|
675
701
|
const path = args?.path ?? ".";
|
|
676
702
|
let content =
|
|
677
|
-
theme.fg("toolTitle", theme.bold(
|
|
703
|
+
theme.fg("toolTitle", theme.bold(grepName)) +
|
|
678
704
|
" " +
|
|
679
705
|
theme.fg("accent", `/${pattern}/`) +
|
|
680
706
|
theme.fg("toolOutput", ` in ${path}`);
|
|
@@ -720,8 +746,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
720
746
|
});
|
|
721
747
|
|
|
722
748
|
pi.registerTool({
|
|
723
|
-
name:
|
|
724
|
-
label:
|
|
749
|
+
name: findName,
|
|
750
|
+
label: findName,
|
|
725
751
|
description: `Fuzzy path search and glob search. Matches against the whole repo-relative path, not just the filename. Frecency-ranked, git-aware. Multi-word = narrower (AND). Default limit ${DEFAULT_FIND_LIMIT}.`,
|
|
726
752
|
promptSnippet: "Find files by path or glob",
|
|
727
753
|
promptGuidelines: [
|
|
@@ -741,7 +767,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
741
767
|
|
|
742
768
|
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
|
743
769
|
// the agent can't accidentally mix patterns across pages.
|
|
744
|
-
const resumed = params.cursor ?
|
|
770
|
+
const resumed = params.cursor ? findCursorStore.get(params.cursor) : undefined;
|
|
745
771
|
const effectiveLimit = resumed
|
|
746
772
|
? resumed.pageSize
|
|
747
773
|
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
@@ -758,7 +784,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
758
784
|
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
759
785
|
|
|
760
786
|
const result = searchResult.value;
|
|
761
|
-
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
787
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern, pageIndex);
|
|
762
788
|
let output = formatted.output;
|
|
763
789
|
|
|
764
790
|
// Infer hasMore: native fileSearch fills pageSize when more results
|
|
@@ -776,7 +802,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
776
802
|
|
|
777
803
|
if (!formatted.weak && hasMore) {
|
|
778
804
|
const remaining = result.totalMatched - shownSoFar;
|
|
779
|
-
const cursorId =
|
|
805
|
+
const cursorId = findCursorStore.store({
|
|
780
806
|
query,
|
|
781
807
|
pattern,
|
|
782
808
|
pageSize: effectiveLimit,
|
|
@@ -804,7 +830,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
804
830
|
const pattern = args?.pattern ?? "";
|
|
805
831
|
const path = args?.path ?? ".";
|
|
806
832
|
let content =
|
|
807
|
-
theme.fg("toolTitle", theme.bold(
|
|
833
|
+
theme.fg("toolTitle", theme.bold(findName)) +
|
|
808
834
|
" " +
|
|
809
835
|
theme.fg("accent", pattern) +
|
|
810
836
|
theme.fg("toolOutput", ` in ${path}`);
|
|
@@ -820,6 +846,267 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
820
846
|
},
|
|
821
847
|
});
|
|
822
848
|
|
|
849
|
+
// --- resolve_file tool ---
|
|
850
|
+
|
|
851
|
+
const resolveFileSchema = Type.Object({
|
|
852
|
+
pattern: Type.String({
|
|
853
|
+
description:
|
|
854
|
+
"Fuzzy file path query. Turn a vague reference ('auth middleware', 'Chart component') into an exact file path.",
|
|
855
|
+
}),
|
|
856
|
+
limit: Type.Optional(
|
|
857
|
+
Type.Number({
|
|
858
|
+
description: `Max candidates when ambiguous (default ${DEFAULT_RESOLVE_LIMIT})`,
|
|
859
|
+
}),
|
|
860
|
+
),
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
pi.registerTool({
|
|
864
|
+
name: "resolve_file",
|
|
865
|
+
label: "Resolve File",
|
|
866
|
+
description:
|
|
867
|
+
"Resolve a fuzzy file reference to an exact absolute path using FFF. Auto-resolves when the top candidate strongly dominates; returns ranked candidates when ambiguous.",
|
|
868
|
+
promptSnippet: "Resolve a fuzzy file reference",
|
|
869
|
+
promptGuidelines: [
|
|
870
|
+
"Use when you have a vague reference like 'auth middleware' or 'main config' instead of an exact path.",
|
|
871
|
+
"The tool either returns a resolved path (ready for read) or a ranked candidate list.",
|
|
872
|
+
"More specific queries (2-3 words) produce better results.",
|
|
873
|
+
],
|
|
874
|
+
parameters: resolveFileSchema,
|
|
875
|
+
|
|
876
|
+
async execute(_toolCallId, params, signal) {
|
|
877
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
878
|
+
|
|
879
|
+
const f = await ensureFinder(activeCwd);
|
|
880
|
+
const limit = Math.max(1, params.limit ?? DEFAULT_RESOLVE_LIMIT);
|
|
881
|
+
|
|
882
|
+
const result = f.fileSearch(params.pattern, { pageSize: limit });
|
|
883
|
+
if (!result.ok) throw new Error(result.error);
|
|
884
|
+
|
|
885
|
+
if (result.value.items.length === 0) {
|
|
886
|
+
return {
|
|
887
|
+
content: [{ type: "text", text: `No files matched "${params.pattern}".` }],
|
|
888
|
+
details: { resolved: false, totalMatched: 0 },
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const topResult = result.value.items[0];
|
|
893
|
+
const topScore = result.value.scores[0];
|
|
894
|
+
const secondScore = result.value.scores[1];
|
|
895
|
+
|
|
896
|
+
// Auto-resolve when top candidate dominates or is an exact match
|
|
897
|
+
if (scoreDominates(topScore, secondScore)) {
|
|
898
|
+
return {
|
|
899
|
+
content: [
|
|
900
|
+
{
|
|
901
|
+
type: "text",
|
|
902
|
+
text: `→ Read ${topResult.relativePath}${fffFileAnnotation(topResult)}`,
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
details: {
|
|
906
|
+
resolved: true,
|
|
907
|
+
totalMatched: result.value.totalMatched,
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Ambiguous — return ranked candidates
|
|
913
|
+
const candidates = result.value.items
|
|
914
|
+
.slice(0, limit)
|
|
915
|
+
.map(
|
|
916
|
+
(item, i) =>
|
|
917
|
+
`${i + 1}. ${item.relativePath}${fffFileAnnotation(item)}`,
|
|
918
|
+
)
|
|
919
|
+
.join("\n");
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
content: [
|
|
923
|
+
{
|
|
924
|
+
type: "text",
|
|
925
|
+
text: `Ambiguous reference. Top candidates:\n${candidates}`,
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
details: {
|
|
929
|
+
resolved: false,
|
|
930
|
+
totalMatched: result.value.totalMatched,
|
|
931
|
+
},
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// --- fff_multi_grep tool ---
|
|
937
|
+
|
|
938
|
+
const multiGrepSchema = Type.Object({
|
|
939
|
+
patterns: Type.Array(Type.String({ description: "Literal pattern to search for" }), {
|
|
940
|
+
minItems: 1,
|
|
941
|
+
maxItems: 10,
|
|
942
|
+
description: "Search for any of these literal patterns in one pass (useful for renamed symbols, aliases, spelling variants)",
|
|
943
|
+
}),
|
|
944
|
+
path: Type.Optional(
|
|
945
|
+
Type.String({
|
|
946
|
+
description:
|
|
947
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**).",
|
|
948
|
+
}),
|
|
949
|
+
),
|
|
950
|
+
exclude: Type.Optional(
|
|
951
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
952
|
+
description:
|
|
953
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js').",
|
|
954
|
+
}),
|
|
955
|
+
),
|
|
956
|
+
context: Type.Optional(
|
|
957
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
958
|
+
),
|
|
959
|
+
limit: Type.Optional(
|
|
960
|
+
Type.Number({
|
|
961
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
962
|
+
}),
|
|
963
|
+
),
|
|
964
|
+
outputMode: Type.Optional(
|
|
965
|
+
Type.String({
|
|
966
|
+
description: "Output format: 'content' (default), 'files_with_matches', or 'count'",
|
|
967
|
+
}),
|
|
968
|
+
),
|
|
969
|
+
cursor: Type.Optional(
|
|
970
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
971
|
+
),
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
pi.registerTool({
|
|
975
|
+
name: "fff_multi_grep",
|
|
976
|
+
label: "FFF Multi Grep",
|
|
977
|
+
description:
|
|
978
|
+
"Search file contents for any of multiple literal patterns in one pass using FFF multi-grep. Useful for renamed symbols, aliases, or common spelling variants.",
|
|
979
|
+
promptSnippet: "Grep for multiple patterns",
|
|
980
|
+
promptGuidelines: [
|
|
981
|
+
"Provide 2-10 literal patterns. All searches happen in one indexed pass.",
|
|
982
|
+
"Use for symbol renames, API migrations, or finding multiple related terms.",
|
|
983
|
+
"Use path/exclude to scope; use outputMode for concise results.",
|
|
984
|
+
],
|
|
985
|
+
parameters: multiGrepSchema,
|
|
986
|
+
|
|
987
|
+
async execute(_toolCallId, params, signal) {
|
|
988
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
989
|
+
|
|
990
|
+
const f = await ensureFinder(activeCwd);
|
|
991
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
992
|
+
const query = buildQuery(params.path, "", params.exclude, activeCwd);
|
|
993
|
+
const explicitContext = params.context ?? 0;
|
|
994
|
+
|
|
995
|
+
const grepResult = f.multiGrep({
|
|
996
|
+
patterns: params.patterns,
|
|
997
|
+
constraints: query || undefined,
|
|
998
|
+
cursor: (params.cursor ? cursorStore.get(params.cursor) : null) ?? null,
|
|
999
|
+
beforeContext: explicitContext,
|
|
1000
|
+
afterContext: Math.max(explicitContext, 3),
|
|
1001
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
1005
|
+
|
|
1006
|
+
const result = grepResult.value;
|
|
1007
|
+
const outputMode = params.outputMode as GrepOutputMode | undefined;
|
|
1008
|
+
return formatGrepResult(result, outputMode, explicitContext, effectiveLimit);
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// --- related_files tool ---
|
|
1013
|
+
|
|
1014
|
+
const relatedFilesSchema = Type.Object({
|
|
1015
|
+
path: Type.String({
|
|
1016
|
+
description:
|
|
1017
|
+
"File path (relative or fuzzy) to find related companion files for (tests, types, styles, stories, etc.).",
|
|
1018
|
+
}),
|
|
1019
|
+
limit: Type.Optional(
|
|
1020
|
+
Type.Number({
|
|
1021
|
+
description: `Max related files (default ${DEFAULT_RESOLVE_LIMIT})`,
|
|
1022
|
+
}),
|
|
1023
|
+
),
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
pi.registerTool({
|
|
1027
|
+
name: "related_files",
|
|
1028
|
+
label: "Related Files",
|
|
1029
|
+
description:
|
|
1030
|
+
"Find files related to a given file by stem matching — discovers test files, type definitions, styles, and other companion files sharing the same base name.",
|
|
1031
|
+
promptSnippet: "Find companion files",
|
|
1032
|
+
promptGuidelines: [
|
|
1033
|
+
"Pass any file path in the project. The tool strips extensions and test/spec/stories/.d/.module suffixes to find companions.",
|
|
1034
|
+
"Great for finding the test file for a module, or the type definition for an implementation.",
|
|
1035
|
+
],
|
|
1036
|
+
parameters: relatedFilesSchema,
|
|
1037
|
+
|
|
1038
|
+
async execute(_toolCallId, params, signal) {
|
|
1039
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1040
|
+
|
|
1041
|
+
const f = await ensureFinder(activeCwd);
|
|
1042
|
+
const limit = Math.max(1, params.limit ?? DEFAULT_RESOLVE_LIMIT);
|
|
1043
|
+
|
|
1044
|
+
// Resolve the reference file first
|
|
1045
|
+
const refResult = f.fileSearch(params.path, { pageSize: limit * 2 });
|
|
1046
|
+
if (!refResult.ok) throw new Error(refResult.error);
|
|
1047
|
+
if (refResult.value.items.length === 0) {
|
|
1048
|
+
return {
|
|
1049
|
+
content: [{ type: "text", text: `No file matched "${params.path}".` }],
|
|
1050
|
+
details: { reference: "", related: [] },
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const referencePath = refResult.value.items[0].relativePath;
|
|
1055
|
+
|
|
1056
|
+
// Extract stem: strip test/spec/stories/.d/.module suffixes, then extension
|
|
1057
|
+
// Extract stem: strip test/spec/stories/.d/.module suffixes, then extension
|
|
1058
|
+
const stem = (referencePath.split("/").pop() ?? referencePath)
|
|
1059
|
+
.replace(/\.(test|spec|stories)\./g, ".")
|
|
1060
|
+
.replace(/\.d\./g, ".")
|
|
1061
|
+
.replace(/\.module\./g, ".")
|
|
1062
|
+
.replace(/\.[^.]+$/, "");
|
|
1063
|
+
|
|
1064
|
+
// Search for files with the same stem
|
|
1065
|
+
const relatedResult = f.fileSearch(stem, { pageSize: limit * 3 });
|
|
1066
|
+
if (!relatedResult.ok) throw new Error(relatedResult.error);
|
|
1067
|
+
|
|
1068
|
+
// Filter out the reference file and limit
|
|
1069
|
+
const related = relatedResult.value.items
|
|
1070
|
+
.filter((item) => item.relativePath !== referencePath)
|
|
1071
|
+
.filter((item) => {
|
|
1072
|
+
const candidateBase = item.relativePath.split("/").pop() ?? "";
|
|
1073
|
+
const refDir = referencePath.substring(0, referencePath.lastIndexOf("/"));
|
|
1074
|
+
return (
|
|
1075
|
+
candidateBase.includes(stem) ||
|
|
1076
|
+
item.relativePath.includes(`${refDir}/${stem}`)
|
|
1077
|
+
);
|
|
1078
|
+
})
|
|
1079
|
+
.slice(0, limit);
|
|
1080
|
+
|
|
1081
|
+
if (related.length === 0) {
|
|
1082
|
+
return {
|
|
1083
|
+
content: [
|
|
1084
|
+
{
|
|
1085
|
+
type: "text",
|
|
1086
|
+
text: `No related files found for "${referencePath}".`,
|
|
1087
|
+
},
|
|
1088
|
+
],
|
|
1089
|
+
details: { reference: referencePath, related: [] },
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const output = [
|
|
1094
|
+
`Related files for ${referencePath}:`,
|
|
1095
|
+
...related.map(
|
|
1096
|
+
(item, i) => `${i + 1}. ${item.relativePath}${fffFileAnnotation(item)}`,
|
|
1097
|
+
),
|
|
1098
|
+
].join("\n");
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
content: [{ type: "text", text: output }],
|
|
1102
|
+
details: {
|
|
1103
|
+
reference: referencePath,
|
|
1104
|
+
related: related.map((i) => i.relativePath),
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
|
|
823
1110
|
// --- commands ---
|
|
824
1111
|
|
|
825
1112
|
pi.registerCommand("fff-mode", {
|
|
@@ -829,9 +1116,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
829
1116
|
|
|
830
1117
|
// No args - show current mode
|
|
831
1118
|
if (!arg) {
|
|
832
|
-
|
|
833
|
-
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
834
|
-
ctx.ui.notify(`Current mode: '${mode}' (flag: ${flag})`, "info");
|
|
1119
|
+
ctx.ui.notify(`Current mode: '${currentMode}' (flag: ${pi.getFlag("fff-mode") ?? "unset"})`, "info");
|
|
835
1120
|
return;
|
|
836
1121
|
}
|
|
837
1122
|
|
|
@@ -842,8 +1127,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
842
1127
|
}
|
|
843
1128
|
|
|
844
1129
|
const newMode = arg as FffMode;
|
|
845
|
-
const oldMode =
|
|
846
|
-
|
|
1130
|
+
const oldMode = currentMode;
|
|
1131
|
+
currentMode = newMode;
|
|
847
1132
|
|
|
848
1133
|
pi.appendEntry("fff-mode", { mode: newMode });
|
|
849
1134
|
|
|
@@ -872,7 +1157,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
872
1157
|
const h = health.value;
|
|
873
1158
|
const lines = [
|
|
874
1159
|
`FFF v${h.version}`,
|
|
875
|
-
`Mode: ${
|
|
1160
|
+
`Mode: ${currentMode}`,
|
|
876
1161
|
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
877
1162
|
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
878
1163
|
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
package/package.json
CHANGED
package/extensions/package.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"type":"module"}
|