@docyrus/docyrus 0.0.64 → 0.0.66
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/agent-loader.js +165 -20
- package/agent-loader.js.map +2 -2
- package/main.js +188 -101
- package/main.js.map +4 -4
- package/package.json +5 -4
- package/resources/pi-agent/extensions/browser-tools.ts +1 -1
- package/resources/pi-agent/extensions/context.ts +12 -73
- package/resources/pi-agent/extensions/control.ts +1 -1
- package/resources/pi-agent/extensions/loop.ts +4 -1
- package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +1 -1
- package/resources/pi-agent/extensions/pi-bash-live-view/package.json +3 -3
- package/resources/pi-agent/extensions/pi-fff/README.md +152 -0
- package/resources/pi-agent/extensions/pi-fff/VENDORED_FROM.md +7 -0
- package/resources/pi-agent/extensions/pi-fff/package.json +53 -0
- package/resources/pi-agent/extensions/pi-fff/src/index.ts +820 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +1 -1
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +1 -1
- package/resources/pi-agent/extensions/prompt-editor.ts +26 -7
- package/resources/pi-agent/extensions/todos.ts +1 -1
- package/server-loader.js +126 -24
- package/server-loader.js.map +2 -2
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-fff: FFF-powered file search extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Overrides built-in `find` and `grep` tools with FFF and can also replace
|
|
5
|
+
* @-mention autocomplete suggestions in the interactive editor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
CustomEditor,
|
|
11
|
+
truncateHead,
|
|
12
|
+
DEFAULT_MAX_BYTES,
|
|
13
|
+
formatSize,
|
|
14
|
+
} from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
Text,
|
|
17
|
+
type AutocompleteItem,
|
|
18
|
+
type AutocompleteProvider,
|
|
19
|
+
} from "@mariozechner/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import { FileFinder } from "@ff-labs/fff-node";
|
|
22
|
+
import type {
|
|
23
|
+
GrepCursor,
|
|
24
|
+
GrepMode,
|
|
25
|
+
GrepResult,
|
|
26
|
+
SearchResult,
|
|
27
|
+
MixedItem,
|
|
28
|
+
} from "@ff-labs/fff-node";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const DEFAULT_GREP_LIMIT = 100;
|
|
35
|
+
const DEFAULT_FIND_LIMIT = 200;
|
|
36
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
|
+
const MENTION_MAX_RESULTS = 20;
|
|
38
|
+
|
|
39
|
+
type FffMode = "tools-and-ui" | "tools-only" | "override";
|
|
40
|
+
|
|
41
|
+
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
42
|
+
|
|
43
|
+
interface ToolNames {
|
|
44
|
+
grep: string;
|
|
45
|
+
find: string;
|
|
46
|
+
multiGrep: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FFF_TOOL_NAMES: ToolNames = {
|
|
50
|
+
grep: "ffgrep",
|
|
51
|
+
find: "fffind",
|
|
52
|
+
multiGrep: "fff-multi-grep",
|
|
53
|
+
};
|
|
54
|
+
const OVERRIDE_TOOL_NAMES: ToolNames = {
|
|
55
|
+
grep: "grep",
|
|
56
|
+
find: "find",
|
|
57
|
+
multiGrep: "multi_grep",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function resolveToolNames(mode: FffMode): ToolNames {
|
|
61
|
+
return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Cursor store — simple bounded Map for pagination cursors
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const cursorCache = new Map<string, GrepCursor>();
|
|
69
|
+
let cursorCounter = 0;
|
|
70
|
+
|
|
71
|
+
function storeCursor(cursor: GrepCursor): string {
|
|
72
|
+
const id = `fff_c${++cursorCounter}`;
|
|
73
|
+
cursorCache.set(id, cursor);
|
|
74
|
+
if (cursorCache.size > 200) {
|
|
75
|
+
const first = cursorCache.keys().next().value;
|
|
76
|
+
if (first) {cursorCache.delete(first);}
|
|
77
|
+
}
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getCursor(id: string): GrepCursor | undefined {
|
|
82
|
+
return cursorCache.get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Output formatting helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatGrepOutput(result: GrepResult, limit: number): string {
|
|
95
|
+
const items = result.items.slice(0, limit);
|
|
96
|
+
if (items.length === 0) {return "No matches found";}
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
let currentFile = "";
|
|
100
|
+
|
|
101
|
+
for (const match of items) {
|
|
102
|
+
if (match.relativePath !== currentFile) {
|
|
103
|
+
currentFile = match.relativePath;
|
|
104
|
+
if (lines.length > 0) {lines.push("");}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
match.contextBefore?.forEach((line: string, i: number) => {
|
|
108
|
+
lines.push(
|
|
109
|
+
`${match.relativePath}-${match.lineNumber - match.contextBefore!.length + i}- ${truncateLine(line)}`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
lines.push(
|
|
114
|
+
`${match.relativePath}:${match.lineNumber}: ${truncateLine(match.lineContent)}`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
118
|
+
lines.push(
|
|
119
|
+
`${match.relativePath}-${match.lineNumber + 1 + i}- ${truncateLine(line)}`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatFindOutput(result: SearchResult, limit: number): string {
|
|
128
|
+
const items = result.items.slice(0, limit);
|
|
129
|
+
return items.length === 0
|
|
130
|
+
? "No files found matching pattern"
|
|
131
|
+
: items.map((i: { relativePath: string }) => i.relativePath).join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Mention autocomplete helpers
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function extractAtPrefix(textBeforeCursor: string): string | null {
|
|
139
|
+
const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
|
|
140
|
+
return match?.[1] ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildAtCompletionValue(path: string): string {
|
|
144
|
+
return path.includes(" ") ? `@"${path}"` : `@${path}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createFffMentionProvider(
|
|
148
|
+
getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
149
|
+
): AutocompleteProvider {
|
|
150
|
+
return {
|
|
151
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
152
|
+
const currentLine = lines[cursorLine] || "";
|
|
153
|
+
const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
|
|
154
|
+
if (!prefix || options.signal.aborted) {return null;}
|
|
155
|
+
|
|
156
|
+
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
157
|
+
const items = await getItems(query, options.signal);
|
|
158
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
159
|
+
},
|
|
160
|
+
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
161
|
+
const currentLine = _lines[cursorLine] || "";
|
|
162
|
+
const before = currentLine.slice(0, cursorCol - prefix.length);
|
|
163
|
+
const after = currentLine.slice(cursorCol);
|
|
164
|
+
const newLine = before + item.value + after;
|
|
165
|
+
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
166
|
+
return {
|
|
167
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
168
|
+
cursorLine,
|
|
169
|
+
cursorCol: newCursorCol,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Simple editor wrapper that injects FFF @-mention autocomplete alongside base provider
|
|
176
|
+
class FffEditor extends CustomEditor {
|
|
177
|
+
private baseProvider: AutocompleteProvider | undefined;
|
|
178
|
+
private getMentionItems: (
|
|
179
|
+
query: string,
|
|
180
|
+
signal: AbortSignal,
|
|
181
|
+
) => Promise<AutocompleteItem[]>;
|
|
182
|
+
|
|
183
|
+
constructor(
|
|
184
|
+
tui: any,
|
|
185
|
+
theme: any,
|
|
186
|
+
keybindings: any,
|
|
187
|
+
getMentionItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
188
|
+
) {
|
|
189
|
+
super(tui, theme, keybindings);
|
|
190
|
+
this.getMentionItems = getMentionItems;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
override setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
194
|
+
this.baseProvider = provider;
|
|
195
|
+
// Create composite provider that handles @-mentions and falls back to base
|
|
196
|
+
const mentionProvider = createFffMentionProvider(this.getMentionItems);
|
|
197
|
+
const compositeProvider: AutocompleteProvider = {
|
|
198
|
+
getSuggestions: async(lines, cursorLine, cursorCol, options) => {
|
|
199
|
+
// Try @-mention first
|
|
200
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
201
|
+
lines,
|
|
202
|
+
cursorLine,
|
|
203
|
+
cursorCol,
|
|
204
|
+
options,
|
|
205
|
+
);
|
|
206
|
+
if (mentionResult) {return mentionResult;}
|
|
207
|
+
// Fall back to base provider
|
|
208
|
+
return (
|
|
209
|
+
this.baseProvider?.getSuggestions(lines, cursorLine, cursorCol, options) ?? null
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
213
|
+
// Let mention provider handle @ completions, base provider for others
|
|
214
|
+
if (prefix?.startsWith("@")) {
|
|
215
|
+
return mentionProvider.applyCompletion!(
|
|
216
|
+
lines,
|
|
217
|
+
cursorLine,
|
|
218
|
+
cursorCol,
|
|
219
|
+
item,
|
|
220
|
+
prefix,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
this.baseProvider?.applyCompletion?.(
|
|
225
|
+
lines,
|
|
226
|
+
cursorLine,
|
|
227
|
+
cursorCol,
|
|
228
|
+
item,
|
|
229
|
+
prefix,
|
|
230
|
+
) ?? { lines, cursorLine, cursorCol }
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
super.setAutocompleteProvider(compositeProvider);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Extension
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export default function fffExtension(pi: ExtensionAPI) {
|
|
243
|
+
let finder: FileFinder | null = null;
|
|
244
|
+
let finderCwd: string | null = null;
|
|
245
|
+
let activeCwd = process.cwd();
|
|
246
|
+
|
|
247
|
+
// Mode resolution: flag > env > default
|
|
248
|
+
let currentMode: FffMode =
|
|
249
|
+
(pi.getFlag("fff-mode") as FffMode) ??
|
|
250
|
+
(process.env.PI_FFF_MODE as FffMode) ??
|
|
251
|
+
"tools-and-ui";
|
|
252
|
+
|
|
253
|
+
const toolNames = resolveToolNames(currentMode);
|
|
254
|
+
|
|
255
|
+
// DB path resolution: flag > env > undefined (use fff-node defaults)
|
|
256
|
+
const frecencyDbPath =
|
|
257
|
+
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
258
|
+
process.env.FFF_FRECENCY_DB ??
|
|
259
|
+
undefined;
|
|
260
|
+
const historyDbPath =
|
|
261
|
+
(pi.getFlag("fff-history-db") as string | undefined) ??
|
|
262
|
+
process.env.FFF_HISTORY_DB ??
|
|
263
|
+
undefined;
|
|
264
|
+
|
|
265
|
+
function getMode(): FffMode {
|
|
266
|
+
return currentMode;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function setMode(mode: FffMode): void {
|
|
270
|
+
currentMode = mode;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function shouldEnableMentions(): boolean {
|
|
274
|
+
return currentMode !== "tools-only";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
278
|
+
if (finder && !finder.isDestroyed && finderCwd === cwd) {return finder;}
|
|
279
|
+
if (finder && !finder.isDestroyed) {
|
|
280
|
+
finder.destroy();
|
|
281
|
+
finder = null;
|
|
282
|
+
finderCwd = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = FileFinder.create({
|
|
286
|
+
basePath: cwd,
|
|
287
|
+
frecencyDbPath,
|
|
288
|
+
historyDbPath,
|
|
289
|
+
aiMode: true,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!result.ok) {throw new Error(`Failed to create FFF file finder: ${result.error}`);}
|
|
293
|
+
|
|
294
|
+
finder = result.value;
|
|
295
|
+
finderCwd = cwd;
|
|
296
|
+
await finder.waitForScan(15000);
|
|
297
|
+
return finder;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function destroyFinder() {
|
|
301
|
+
if (finder && !finder.isDestroyed) {
|
|
302
|
+
finder.destroy();
|
|
303
|
+
finder = null;
|
|
304
|
+
finderCwd = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function getMentionItems(
|
|
309
|
+
query: string,
|
|
310
|
+
signal: AbortSignal,
|
|
311
|
+
): Promise<AutocompleteItem[]> {
|
|
312
|
+
if (signal.aborted) {return [];}
|
|
313
|
+
const f = await ensureFinder(activeCwd);
|
|
314
|
+
if (signal.aborted) {return [];}
|
|
315
|
+
|
|
316
|
+
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
317
|
+
if (!result.ok) {return [];}
|
|
318
|
+
|
|
319
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
320
|
+
if (mixed.type === "directory") {
|
|
321
|
+
return {
|
|
322
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
323
|
+
label: mixed.item.dirName,
|
|
324
|
+
description: mixed.item.relativePath,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
329
|
+
label: mixed.item.fileName,
|
|
330
|
+
description: mixed.item.relativePath,
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function applyEditorMode(ctx: {
|
|
336
|
+
ui: {
|
|
337
|
+
setEditorComponent: (
|
|
338
|
+
factory: ((tui: any, theme: any, keybindings: any) => any) | undefined,
|
|
339
|
+
) => void;
|
|
340
|
+
};
|
|
341
|
+
}) {
|
|
342
|
+
if (!shouldEnableMentions()) {
|
|
343
|
+
ctx.ui.setEditorComponent(undefined);
|
|
344
|
+
} else {
|
|
345
|
+
ctx.ui.setEditorComponent(
|
|
346
|
+
(tui: any, theme: any, keybindings: any) =>
|
|
347
|
+
new FffEditor(tui, theme, keybindings, getMentionItems),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- Flags / lifecycle ---
|
|
353
|
+
|
|
354
|
+
pi.registerFlag("fff-mode", {
|
|
355
|
+
description: "FFF mode: tools-and-ui | tools-only | override",
|
|
356
|
+
type: "string",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
pi.registerFlag("fff-frecency-db", {
|
|
360
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
361
|
+
type: "string",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
pi.registerFlag("fff-history-db", {
|
|
365
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
366
|
+
type: "string",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
pi.on("session_start", async(_event, ctx) => {
|
|
370
|
+
try {
|
|
371
|
+
activeCwd = ctx.cwd;
|
|
372
|
+
await ensureFinder(activeCwd);
|
|
373
|
+
applyEditorMode(ctx);
|
|
374
|
+
} catch (e: unknown) {
|
|
375
|
+
ctx.ui.notify(
|
|
376
|
+
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
377
|
+
"error",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
pi.on("session_shutdown", async() => {
|
|
383
|
+
destroyFinder();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// --- Shared render helpers ---
|
|
387
|
+
|
|
388
|
+
const renderTextResult = (
|
|
389
|
+
result: { content?: { type: string; text?: string }[] },
|
|
390
|
+
options: { expanded?: boolean },
|
|
391
|
+
theme: any,
|
|
392
|
+
context: any,
|
|
393
|
+
maxLines = 15,
|
|
394
|
+
) => {
|
|
395
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
396
|
+
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
397
|
+
if (!output) {
|
|
398
|
+
text.setText(theme.fg("muted", "No output"));
|
|
399
|
+
return text;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lines = output.split("\n");
|
|
403
|
+
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
|
404
|
+
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
405
|
+
if (lines.length > displayLines.length) {
|
|
406
|
+
content += theme.fg(
|
|
407
|
+
"muted",
|
|
408
|
+
`\n... (${lines.length - displayLines.length} more lines)`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
text.setText(content);
|
|
412
|
+
return text;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// --- grep tool ---
|
|
416
|
+
|
|
417
|
+
const grepSchema = Type.Object({
|
|
418
|
+
pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
|
|
419
|
+
path: Type.Optional(
|
|
420
|
+
Type.String({
|
|
421
|
+
description:
|
|
422
|
+
"Directory or file constraint, e.g. 'src/' or '*.ts' (default: project root)",
|
|
423
|
+
}),
|
|
424
|
+
),
|
|
425
|
+
literal: Type.Optional(
|
|
426
|
+
Type.Boolean({
|
|
427
|
+
description: "Treat pattern as literal string instead of regex (default: true)",
|
|
428
|
+
}),
|
|
429
|
+
),
|
|
430
|
+
context: Type.Optional(
|
|
431
|
+
Type.Number({
|
|
432
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
435
|
+
limit: Type.Optional(
|
|
436
|
+
Type.Number({
|
|
437
|
+
description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
|
|
438
|
+
}),
|
|
439
|
+
),
|
|
440
|
+
cursor: Type.Optional(
|
|
441
|
+
Type.String({ description: "Cursor from previous result for pagination" }),
|
|
442
|
+
),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
pi.registerTool({
|
|
446
|
+
name: toolNames.grep,
|
|
447
|
+
label: toolNames.grep,
|
|
448
|
+
description: `Search file contents for a pattern using FFF (fast, frecency-ranked, git-aware). Returns matching lines with file paths and line numbers. Respects .gitignore. Supports plain text, regex, and fuzzy search modes. Smart case by default. Output truncated to ${DEFAULT_GREP_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB.`,
|
|
449
|
+
promptSnippet:
|
|
450
|
+
"Search file contents for patterns (FFF: frecency-ranked, git-aware, respects .gitignore)",
|
|
451
|
+
promptGuidelines: [
|
|
452
|
+
"Search for bare identifiers (e.g. 'InProgressQuote'), not code syntax or multi-token regex.",
|
|
453
|
+
"Plain text search is faster and more reliable than regex. Prefer it.",
|
|
454
|
+
"After 2 grep calls, read the top result file instead of grepping more.",
|
|
455
|
+
"Use the path parameter for file/directory constraints: '*.ts', 'src/'.",
|
|
456
|
+
],
|
|
457
|
+
parameters: grepSchema,
|
|
458
|
+
|
|
459
|
+
async execute(_toolCallId, params, signal) {
|
|
460
|
+
if (signal?.aborted) {throw new Error("Operation aborted");}
|
|
461
|
+
|
|
462
|
+
const f = await ensureFinder(activeCwd);
|
|
463
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
464
|
+
const query = params.path ? `${params.path} ${params.pattern}` : params.pattern;
|
|
465
|
+
const mode: GrepMode = params.literal === false ? "regex" : "plain";
|
|
466
|
+
|
|
467
|
+
const grepResult = f.grep(query, {
|
|
468
|
+
mode,
|
|
469
|
+
smartCase: true,
|
|
470
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
471
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
472
|
+
beforeContext: params.context ?? 0,
|
|
473
|
+
afterContext: params.context ?? 0,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (!grepResult.ok) {throw new Error(grepResult.error);}
|
|
477
|
+
|
|
478
|
+
const result = grepResult.value;
|
|
479
|
+
let output = formatGrepOutput(result, effectiveLimit);
|
|
480
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
481
|
+
output = truncation.content;
|
|
482
|
+
|
|
483
|
+
const notices: string[] = [];
|
|
484
|
+
if (result.items.length >= effectiveLimit)
|
|
485
|
+
{notices.push(
|
|
486
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
|
|
487
|
+
);}
|
|
488
|
+
if (truncation.truncated)
|
|
489
|
+
{notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);}
|
|
490
|
+
if (result.regexFallbackError)
|
|
491
|
+
{notices.push(`Regex failed: ${result.regexFallbackError}, used literal match`);}
|
|
492
|
+
if (result.nextCursor)
|
|
493
|
+
{notices.push(
|
|
494
|
+
`More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
495
|
+
);}
|
|
496
|
+
|
|
497
|
+
if (notices.length > 0) {output += `\n\n[${notices.join(". ")}]`;}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: output }],
|
|
501
|
+
details: {
|
|
502
|
+
totalMatched: result.totalMatched,
|
|
503
|
+
totalFiles: result.totalFiles,
|
|
504
|
+
truncated: truncation.truncated,
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
renderCall(args, theme, context) {
|
|
510
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
511
|
+
const pattern = args?.pattern ?? "";
|
|
512
|
+
const path = args?.path ?? ".";
|
|
513
|
+
let content =
|
|
514
|
+
theme.fg("toolTitle", theme.bold(toolNames.grep)) +
|
|
515
|
+
" " +
|
|
516
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
517
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
518
|
+
if (args?.limit !== undefined)
|
|
519
|
+
{content += theme.fg("toolOutput", ` limit ${args.limit}`);}
|
|
520
|
+
if (args?.cursor) {content += theme.fg("muted", ` (page)`);}
|
|
521
|
+
text.setText(content);
|
|
522
|
+
return text;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
renderResult(result, options, theme, context) {
|
|
526
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// --- find tool ---
|
|
531
|
+
|
|
532
|
+
const findSchema = Type.Object({
|
|
533
|
+
pattern: Type.String({
|
|
534
|
+
description:
|
|
535
|
+
"Fuzzy search query for file names. Supports path prefixes ('src/') and globs ('*.ts').",
|
|
536
|
+
}),
|
|
537
|
+
path: Type.Optional(
|
|
538
|
+
Type.String({ description: "Directory to search in (default: project root)" }),
|
|
539
|
+
),
|
|
540
|
+
limit: Type.Optional(
|
|
541
|
+
Type.Number({
|
|
542
|
+
description: `Maximum number of results (default: ${DEFAULT_FIND_LIMIT})`,
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
pi.registerTool({
|
|
548
|
+
name: toolNames.find,
|
|
549
|
+
label: toolNames.find,
|
|
550
|
+
description: `Fuzzy file search by name using FFF (fast, frecency-ranked, git-aware). Returns matching file paths relative to project root. Respects .gitignore. Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('*.ts', '**/*.spec.ts'). Output truncated to ${DEFAULT_FIND_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB.`,
|
|
551
|
+
promptSnippet:
|
|
552
|
+
"Find files by name (FFF: fuzzy, frecency-ranked, git-aware, respects .gitignore)",
|
|
553
|
+
promptGuidelines: [
|
|
554
|
+
"Keep queries short -- prefer 1-2 terms max.",
|
|
555
|
+
"Multiple words narrow results (waterfall), they are not OR.",
|
|
556
|
+
"Use this to find files by name. Use grep to search file contents.",
|
|
557
|
+
],
|
|
558
|
+
parameters: findSchema,
|
|
559
|
+
|
|
560
|
+
async execute(_toolCallId, params, signal) {
|
|
561
|
+
if (signal?.aborted) {throw new Error("Operation aborted");}
|
|
562
|
+
|
|
563
|
+
const f = await ensureFinder(activeCwd);
|
|
564
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
565
|
+
const query = params.path ? `${params.path} ${params.pattern}` : params.pattern;
|
|
566
|
+
|
|
567
|
+
const searchResult = f.fileSearch(query, { pageSize: effectiveLimit });
|
|
568
|
+
if (!searchResult.ok) {throw new Error(searchResult.error);}
|
|
569
|
+
|
|
570
|
+
const result = searchResult.value;
|
|
571
|
+
let output = formatFindOutput(result, effectiveLimit);
|
|
572
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
573
|
+
output = truncation.content;
|
|
574
|
+
|
|
575
|
+
const notices: string[] = [];
|
|
576
|
+
if (result.items.length >= effectiveLimit)
|
|
577
|
+
{notices.push(
|
|
578
|
+
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
|
579
|
+
);}
|
|
580
|
+
if (truncation.truncated)
|
|
581
|
+
{notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);}
|
|
582
|
+
if (result.totalMatched > result.items.length)
|
|
583
|
+
{notices.push(
|
|
584
|
+
`${result.totalMatched} total matches (${result.totalFiles} indexed files)`,
|
|
585
|
+
);}
|
|
586
|
+
|
|
587
|
+
if (notices.length > 0) {output += `\n\n[${notices.join(". ")}]`;}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
content: [{ type: "text", text: output }],
|
|
591
|
+
details: {
|
|
592
|
+
totalMatched: result.totalMatched,
|
|
593
|
+
totalFiles: result.totalFiles,
|
|
594
|
+
truncated: truncation.truncated,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
renderCall(args, theme, context) {
|
|
600
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
601
|
+
const pattern = args?.pattern ?? "";
|
|
602
|
+
const path = args?.path ?? ".";
|
|
603
|
+
let content =
|
|
604
|
+
theme.fg("toolTitle", theme.bold(toolNames.find)) +
|
|
605
|
+
" " +
|
|
606
|
+
theme.fg("accent", pattern) +
|
|
607
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
608
|
+
if (args?.limit !== undefined)
|
|
609
|
+
{content += theme.fg("toolOutput", ` (limit ${args.limit})`);}
|
|
610
|
+
text.setText(content);
|
|
611
|
+
return text;
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
renderResult(result, options, theme, context) {
|
|
615
|
+
return renderTextResult(result, options, theme, context, 20);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// --- multi_grep tool ---
|
|
620
|
+
|
|
621
|
+
const multiGrepSchema = Type.Object({
|
|
622
|
+
patterns: Type.Array(Type.String(), {
|
|
623
|
+
description:
|
|
624
|
+
"Patterns to search for (OR logic -- matches lines containing ANY pattern). Include all naming conventions: snake_case, PascalCase, camelCase.",
|
|
625
|
+
}),
|
|
626
|
+
constraints: Type.Optional(
|
|
627
|
+
Type.String({
|
|
628
|
+
description:
|
|
629
|
+
"File constraints, e.g. '*.{ts,tsx} !test/' to filter files. Separate from patterns.",
|
|
630
|
+
}),
|
|
631
|
+
),
|
|
632
|
+
context: Type.Optional(
|
|
633
|
+
Type.Number({
|
|
634
|
+
description: "Number of context lines before and after each match (default: 0)",
|
|
635
|
+
}),
|
|
636
|
+
),
|
|
637
|
+
limit: Type.Optional(
|
|
638
|
+
Type.Number({
|
|
639
|
+
description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
|
|
640
|
+
}),
|
|
641
|
+
),
|
|
642
|
+
cursor: Type.Optional(
|
|
643
|
+
Type.String({ description: "Cursor from previous result for pagination" }),
|
|
644
|
+
),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
pi.registerTool({
|
|
648
|
+
name: toolNames.multiGrep,
|
|
649
|
+
label: toolNames.multiGrep,
|
|
650
|
+
description:
|
|
651
|
+
"Search file contents for lines matching ANY of multiple patterns (OR logic). Uses SIMD-accelerated Aho-Corasick multi-pattern matching. Faster than regex alternation. Patterns are literal text -- never escape special characters. Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
|
|
652
|
+
promptSnippet:
|
|
653
|
+
"Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
|
|
654
|
+
promptGuidelines: [
|
|
655
|
+
`Use ${toolNames.multiGrep} when you need to find multiple identifiers at once (OR logic).`,
|
|
656
|
+
"Include all naming conventions: snake_case, PascalCase, camelCase variants.",
|
|
657
|
+
"Patterns are literal text. Never escape special characters.",
|
|
658
|
+
"Use the constraints parameter for file type/path filtering, not inside patterns.",
|
|
659
|
+
],
|
|
660
|
+
parameters: multiGrepSchema,
|
|
661
|
+
|
|
662
|
+
async execute(_toolCallId, params, signal) {
|
|
663
|
+
if (signal?.aborted) {throw new Error("Operation aborted");}
|
|
664
|
+
if (!params.patterns?.length)
|
|
665
|
+
{throw new Error("patterns array must have at least 1 element");}
|
|
666
|
+
|
|
667
|
+
const f = await ensureFinder(activeCwd);
|
|
668
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
669
|
+
|
|
670
|
+
const grepResult = f.multiGrep({
|
|
671
|
+
patterns: params.patterns,
|
|
672
|
+
constraints: params.constraints,
|
|
673
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
674
|
+
smartCase: true,
|
|
675
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
676
|
+
beforeContext: params.context ?? 0,
|
|
677
|
+
afterContext: params.context ?? 0,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (!grepResult.ok) {throw new Error(grepResult.error);}
|
|
681
|
+
|
|
682
|
+
const result = grepResult.value;
|
|
683
|
+
let output = formatGrepOutput(result, effectiveLimit);
|
|
684
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
685
|
+
output = truncation.content;
|
|
686
|
+
|
|
687
|
+
const notices: string[] = [];
|
|
688
|
+
if (result.items.length >= effectiveLimit)
|
|
689
|
+
{notices.push(
|
|
690
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
|
|
691
|
+
);}
|
|
692
|
+
if (truncation.truncated)
|
|
693
|
+
{notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);}
|
|
694
|
+
if (result.nextCursor)
|
|
695
|
+
{notices.push(
|
|
696
|
+
`More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
697
|
+
);}
|
|
698
|
+
|
|
699
|
+
if (notices.length > 0) {output += `\n\n[${notices.join(". ")}]`;}
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
content: [{ type: "text", text: output }],
|
|
703
|
+
details: {
|
|
704
|
+
totalMatched: result.totalMatched,
|
|
705
|
+
totalFiles: result.totalFiles,
|
|
706
|
+
truncated: truncation.truncated,
|
|
707
|
+
patterns: params.patterns,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
renderCall(args, theme, context) {
|
|
713
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
714
|
+
const patterns = args?.patterns ?? [];
|
|
715
|
+
const constraints = args?.constraints;
|
|
716
|
+
let content =
|
|
717
|
+
theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
|
|
718
|
+
" " +
|
|
719
|
+
theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
|
|
720
|
+
if (constraints) {content += theme.fg("toolOutput", ` (${constraints})`);}
|
|
721
|
+
if (args?.cursor) {content += theme.fg("muted", ` (page)`);}
|
|
722
|
+
text.setText(content);
|
|
723
|
+
return text;
|
|
724
|
+
},
|
|
725
|
+
|
|
726
|
+
renderResult(result, options, theme, context) {
|
|
727
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// --- commands ---
|
|
732
|
+
|
|
733
|
+
pi.registerCommand("fff-mode", {
|
|
734
|
+
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
735
|
+
handler: async(args, ctx) => {
|
|
736
|
+
const arg = (args || "").trim();
|
|
737
|
+
|
|
738
|
+
// No args - show current mode
|
|
739
|
+
if (!arg) {
|
|
740
|
+
const mode = getMode();
|
|
741
|
+
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
742
|
+
const env = process.env.PI_FFF_MODE ?? "unset";
|
|
743
|
+
ctx.ui.notify(`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`, "info");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Validate and set mode
|
|
748
|
+
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
749
|
+
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const newMode = arg as FffMode;
|
|
754
|
+
const oldMode = getMode();
|
|
755
|
+
setMode(newMode);
|
|
756
|
+
|
|
757
|
+
// Apply immediately using the shared function
|
|
758
|
+
applyEditorMode(ctx);
|
|
759
|
+
|
|
760
|
+
const note =
|
|
761
|
+
(oldMode === "override") !== (newMode === "override")
|
|
762
|
+
? " (tool name change requires restart)"
|
|
763
|
+
: "";
|
|
764
|
+
ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'${note}`, "info");
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
pi.registerCommand("fff-health", {
|
|
769
|
+
description: "Show FFF file finder health and status",
|
|
770
|
+
handler: async(_args, ctx) => {
|
|
771
|
+
if (!finder || finder.isDestroyed) {
|
|
772
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const health = finder.healthCheck();
|
|
777
|
+
if (!health.ok) {
|
|
778
|
+
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const h = health.value;
|
|
783
|
+
const lines = [
|
|
784
|
+
`FFF v${h.version}`,
|
|
785
|
+
`Mode: ${getMode()}`,
|
|
786
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
787
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
788
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
789
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
790
|
+
];
|
|
791
|
+
|
|
792
|
+
const progress = finder.getScanProgress();
|
|
793
|
+
if (progress.ok) {
|
|
794
|
+
lines.push(
|
|
795
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
pi.registerCommand("fff-rescan", {
|
|
804
|
+
description: "Trigger FFF to rescan files",
|
|
805
|
+
handler: async(_args, ctx) => {
|
|
806
|
+
if (!finder || finder.isDestroyed) {
|
|
807
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const result = finder.scanFiles();
|
|
812
|
+
if (!result.ok) {
|
|
813
|
+
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
ctx.ui.notify("FFF rescan triggered", "info");
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
}
|