@nmzpy/pi-ember-stack 0.1.6 → 0.2.2
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 +83 -83
- package/package.json +62 -48
- package/plugins/devin-auth/extensions/index.ts +68 -7
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
- package/plugins/devin-auth/src/models.ts +42 -196
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
- package/plugins/devin-auth/src/oauth/types.ts +71 -71
- package/plugins/devin-auth/src/stream.ts +1 -1
- package/plugins/index.ts +29 -6
- package/plugins/pi-compact-tools/index.ts +35 -192
- package/plugins/pi-compact-tools/renderer.ts +589 -0
- package/plugins/pi-custom-agents/index.ts +727 -373
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
- package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
- package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
- package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +975 -0
- package/plugins/pi-ember-fff/query.ts +247 -0
- package/plugins/pi-ember-fff/test/query.test.ts +222 -0
- package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
- package/plugins/pi-ember-tps/index.ts +144 -0
- package/plugins/pi-ember-ui/ember.json +99 -0
- package/plugins/pi-ember-ui/index.ts +805 -0
- package/plugins/pi-ember-ui/mode-colors.ts +196 -0
- package/tsconfig.json +2 -1
- package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-ember-fff: Ember-owned FFF-powered file search extension for pi.
|
|
3
|
+
*
|
|
4
|
+
* Forked from @ff-labs/pi-fff 0.9.6 (MIT, Copyright (c) Dmitry Kovalenko).
|
|
5
|
+
* Always registers canonical `grep` and `find` tool names (override mode),
|
|
6
|
+
* and delegates rendering to the shared Ember compact renderer from
|
|
7
|
+
* @nmzpy/pi-ember-stack so the TUI stays consistent across all tools.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
type AutocompleteItem,
|
|
13
|
+
type AutocompleteProvider,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import type {
|
|
16
|
+
GrepCursor,
|
|
17
|
+
GrepMode,
|
|
18
|
+
GrepResult,
|
|
19
|
+
MixedItem,
|
|
20
|
+
SearchResult,
|
|
21
|
+
} from "@ff-labs/fff-node";
|
|
22
|
+
import { FileFinder } from "@ff-labs/fff-node";
|
|
23
|
+
import { Type } from "@sinclair/typebox";
|
|
24
|
+
import {
|
|
25
|
+
buildExternalAllowlist,
|
|
26
|
+
buildQuery,
|
|
27
|
+
resolveExternalTarget,
|
|
28
|
+
type ExternalAllowlist,
|
|
29
|
+
type ExternalTarget,
|
|
30
|
+
} from "./query.ts";
|
|
31
|
+
import { getSharedRenderer, bashGrepInfo } from "../pi-compact-tools/index.ts";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const DEFAULT_GREP_LIMIT = 20;
|
|
38
|
+
const DEFAULT_FIND_LIMIT = 30;
|
|
39
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
40
|
+
const MENTION_MAX_RESULTS = 20;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Cursor store — simple bounded Map for pagination cursors
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
interface CachedGrepCursor {
|
|
47
|
+
cursor: GrepCursor;
|
|
48
|
+
externalDir?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cursorCache = new Map<string, CachedGrepCursor>();
|
|
52
|
+
let cursorCounter = 0;
|
|
53
|
+
|
|
54
|
+
function storeCursor(cursor: GrepCursor, externalDir?: string): string {
|
|
55
|
+
const id = `fff_c${++cursorCounter}`;
|
|
56
|
+
cursorCache.set(id, { cursor, externalDir });
|
|
57
|
+
if (cursorCache.size > 200) {
|
|
58
|
+
const first = cursorCache.keys().next().value;
|
|
59
|
+
if (first) cursorCache.delete(first);
|
|
60
|
+
}
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getCursor(id: string): CachedGrepCursor | undefined {
|
|
65
|
+
return cursorCache.get(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface FindCursor {
|
|
69
|
+
query: string;
|
|
70
|
+
pattern: string;
|
|
71
|
+
pageSize: number;
|
|
72
|
+
nextPageIndex: number;
|
|
73
|
+
externalDir?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const findCursorCache = new Map<string, FindCursor>();
|
|
77
|
+
let findCursorCounter = 0;
|
|
78
|
+
|
|
79
|
+
function storeFindCursor(cursor: FindCursor): string {
|
|
80
|
+
const id = `${++findCursorCounter}`;
|
|
81
|
+
findCursorCache.set(id, cursor);
|
|
82
|
+
if (findCursorCache.size > 200) {
|
|
83
|
+
const first = findCursorCache.keys().next().value;
|
|
84
|
+
if (first) findCursorCache.delete(first);
|
|
85
|
+
}
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getFindCursor(id: string): FindCursor | undefined {
|
|
90
|
+
return findCursorCache.get(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Output formatting helpers
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const HOT_FRECENCY = 25;
|
|
103
|
+
const WARM_FRECENCY = 20;
|
|
104
|
+
|
|
105
|
+
export function fffFileAnnotation(item: {
|
|
106
|
+
gitStatus?: string;
|
|
107
|
+
totalFrecencyScore?: number;
|
|
108
|
+
accessFrecencyScore?: number;
|
|
109
|
+
}): string {
|
|
110
|
+
const git = item.gitStatus;
|
|
111
|
+
if (git && git !== "clean" && git !== "unknown" && git !== "") {
|
|
112
|
+
return ` [${git} in git]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
|
|
116
|
+
if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
|
|
117
|
+
if (frecency >= WARM_FRECENCY) return " [often touched file]";
|
|
118
|
+
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatGrepOutput(result: GrepResult): string {
|
|
123
|
+
if (result.items.length === 0) return "No matches found";
|
|
124
|
+
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
let currentFile = "";
|
|
127
|
+
|
|
128
|
+
for (const match of result.items) {
|
|
129
|
+
if (match.relativePath !== currentFile) {
|
|
130
|
+
if (lines.length > 0) lines.push("");
|
|
131
|
+
currentFile = match.relativePath;
|
|
132
|
+
lines.push(`${currentFile}${fffFileAnnotation(match)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
match.contextBefore?.forEach((line: string, i: number) => {
|
|
136
|
+
const lineNum = match.lineNumber - match.contextBefore!.length + i;
|
|
137
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
141
|
+
|
|
142
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
143
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
144
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
152
|
+
|
|
153
|
+
function weakScoreThreshold(pattern: string): number {
|
|
154
|
+
const perfect = pattern.length * 12;
|
|
155
|
+
return Math.floor((perfect * 50) / 100);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface FormattedFind {
|
|
159
|
+
output: string;
|
|
160
|
+
weak: boolean;
|
|
161
|
+
shownCount: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatFindOutput(
|
|
165
|
+
result: SearchResult,
|
|
166
|
+
limit: number,
|
|
167
|
+
pattern: string,
|
|
168
|
+
): FormattedFind {
|
|
169
|
+
if (result.items.length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
output: "No files found matching pattern",
|
|
172
|
+
weak: false,
|
|
173
|
+
shownCount: 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const reordered = result.items.map((item) => ({ item }));
|
|
178
|
+
|
|
179
|
+
const topScore = result.scores[0]?.total ?? 0;
|
|
180
|
+
const weak = topScore < weakScoreThreshold(pattern);
|
|
181
|
+
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
182
|
+
const shown = reordered.slice(0, effective);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
output: shown
|
|
186
|
+
.map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
|
|
187
|
+
.join("\n"),
|
|
188
|
+
weak,
|
|
189
|
+
shownCount: shown.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Mention autocomplete helpers
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function extractAtPrefix(textBeforeCursor: string): string | null {
|
|
198
|
+
const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
|
|
199
|
+
return match?.[1] ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildAtCompletionValue(path: string): string {
|
|
203
|
+
return path.includes(" ") ? `@"${path}"` : `@${path}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createFffMentionProvider(
|
|
207
|
+
getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
208
|
+
): AutocompleteProvider {
|
|
209
|
+
return {
|
|
210
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
211
|
+
const currentLine = lines[cursorLine] || "";
|
|
212
|
+
const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
|
|
213
|
+
if (!prefix || options.signal.aborted) return null;
|
|
214
|
+
|
|
215
|
+
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
216
|
+
const items = await getItems(query, options.signal);
|
|
217
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
218
|
+
},
|
|
219
|
+
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
220
|
+
const currentLine = _lines[cursorLine] || "";
|
|
221
|
+
const before = currentLine.slice(0, cursorCol - prefix.length);
|
|
222
|
+
const after = currentLine.slice(cursorCol);
|
|
223
|
+
const newLine = before + item.value + after;
|
|
224
|
+
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
225
|
+
return {
|
|
226
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
227
|
+
cursorLine,
|
|
228
|
+
cursorCol: newCursorCol,
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Extension
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export default function emberFffExtension(pi: ExtensionAPI) {
|
|
239
|
+
const renderer = getSharedRenderer();
|
|
240
|
+
|
|
241
|
+
let finder: FileFinder | null = null;
|
|
242
|
+
let finderCwd: string | null = null;
|
|
243
|
+
let finderPromise: Promise<FileFinder> | null = null;
|
|
244
|
+
let activeCwd = process.cwd();
|
|
245
|
+
|
|
246
|
+
let externalFinder: FileFinder | null = null;
|
|
247
|
+
let externalFinderDir: string | null = null;
|
|
248
|
+
let externalFinderPromise: Promise<FileFinder> | null = null;
|
|
249
|
+
let externalAllowlist: ExternalAllowlist = buildExternalAllowlist();
|
|
250
|
+
|
|
251
|
+
const frecencyDbPath =
|
|
252
|
+
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
253
|
+
process.env.FFF_FRECENCY_DB ??
|
|
254
|
+
undefined;
|
|
255
|
+
const historyDbPath =
|
|
256
|
+
(pi.getFlag("fff-history-db") as string | undefined) ??
|
|
257
|
+
process.env.FFF_HISTORY_DB ??
|
|
258
|
+
undefined;
|
|
259
|
+
|
|
260
|
+
function resolveBoolOpt(flagName: string, envName: string): boolean {
|
|
261
|
+
const flag = pi.getFlag(flagName);
|
|
262
|
+
if (typeof flag === "boolean") return flag;
|
|
263
|
+
if (typeof flag === "string") return flag === "true" || flag === "1";
|
|
264
|
+
const env = process.env[envName];
|
|
265
|
+
return env === "1" || env === "true";
|
|
266
|
+
}
|
|
267
|
+
const enableFsRootScanning = resolveBoolOpt(
|
|
268
|
+
"fff-enable-root-scan",
|
|
269
|
+
"FFF_ENABLE_ROOT_SCAN",
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const enableExternalAllow = (() => {
|
|
273
|
+
const flag = pi.getFlag("fff-external-allow");
|
|
274
|
+
if (typeof flag === "boolean") return flag;
|
|
275
|
+
if (typeof flag === "string") return flag === "true" || flag === "1";
|
|
276
|
+
const env = process.env.FFF_EXTERNAL_ALLOW;
|
|
277
|
+
if (env !== undefined) return env === "1" || env === "true";
|
|
278
|
+
return true; // default ON
|
|
279
|
+
})();
|
|
280
|
+
|
|
281
|
+
function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
282
|
+
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
283
|
+
return Promise.resolve(finder);
|
|
284
|
+
if (finderPromise) return finderPromise;
|
|
285
|
+
|
|
286
|
+
finderPromise = (async () => {
|
|
287
|
+
if (finder && !finder.isDestroyed) {
|
|
288
|
+
finder.destroy();
|
|
289
|
+
finder = null;
|
|
290
|
+
finderCwd = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const result = FileFinder.create({
|
|
294
|
+
basePath: cwd,
|
|
295
|
+
frecencyDbPath,
|
|
296
|
+
historyDbPath,
|
|
297
|
+
aiMode: true,
|
|
298
|
+
enableHomeDirScanning: true,
|
|
299
|
+
enableFsRootScanning,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!result.ok)
|
|
303
|
+
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
304
|
+
|
|
305
|
+
finder = result.value;
|
|
306
|
+
finderCwd = cwd;
|
|
307
|
+
await finder.waitForScan(15000);
|
|
308
|
+
return finder;
|
|
309
|
+
})().finally(() => {
|
|
310
|
+
finderPromise = null;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return finderPromise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function destroyFinder() {
|
|
317
|
+
if (finder && !finder.isDestroyed) {
|
|
318
|
+
finder.destroy();
|
|
319
|
+
finder = null;
|
|
320
|
+
finderCwd = null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ensureExternalFinder(dir: string): Promise<FileFinder> {
|
|
325
|
+
if (externalFinder && !externalFinder.isDestroyed && externalFinderDir === dir)
|
|
326
|
+
return Promise.resolve(externalFinder);
|
|
327
|
+
if (externalFinderPromise) return externalFinderPromise;
|
|
328
|
+
|
|
329
|
+
externalFinderPromise = (async () => {
|
|
330
|
+
if (externalFinder && !externalFinder.isDestroyed) {
|
|
331
|
+
externalFinder.destroy();
|
|
332
|
+
externalFinder = null;
|
|
333
|
+
externalFinderDir = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = FileFinder.create({
|
|
337
|
+
basePath: dir,
|
|
338
|
+
aiMode: true,
|
|
339
|
+
enableHomeDirScanning: false,
|
|
340
|
+
enableFsRootScanning: false,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!result.ok)
|
|
344
|
+
throw new Error(`Failed to create external FFF file finder: ${result.error}`);
|
|
345
|
+
|
|
346
|
+
externalFinder = result.value;
|
|
347
|
+
externalFinderDir = dir;
|
|
348
|
+
await externalFinder.waitForScan(15000);
|
|
349
|
+
return externalFinder;
|
|
350
|
+
})().finally(() => {
|
|
351
|
+
externalFinderPromise = null;
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return externalFinderPromise;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function destroyExternalFinder() {
|
|
358
|
+
if (externalFinder && !externalFinder.isDestroyed) {
|
|
359
|
+
externalFinder.destroy();
|
|
360
|
+
externalFinder = null;
|
|
361
|
+
externalFinderDir = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* If params.path targets an allowlisted external directory, return the
|
|
367
|
+
* external finder and the query scoped to that dir. Otherwise return
|
|
368
|
+
* the workspace finder and workspace query.
|
|
369
|
+
*/
|
|
370
|
+
async function resolveFinderAndQuery(
|
|
371
|
+
pathParam: string | undefined,
|
|
372
|
+
pattern: string,
|
|
373
|
+
exclude: string | string[] | undefined,
|
|
374
|
+
): Promise<{ finder: FileFinder; query: string }> {
|
|
375
|
+
if (enableExternalAllow && externalAllowlist.entries.length > 0) {
|
|
376
|
+
const target = resolveExternalTarget(pathParam, externalAllowlist);
|
|
377
|
+
if (target) {
|
|
378
|
+
const f = await ensureExternalFinder(target.entry.dir);
|
|
379
|
+
const query = buildQuery(
|
|
380
|
+
target.relativePath || undefined,
|
|
381
|
+
pattern,
|
|
382
|
+
exclude,
|
|
383
|
+
target.entry.dir,
|
|
384
|
+
externalAllowlist,
|
|
385
|
+
);
|
|
386
|
+
return { finder: f, query };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const f = await ensureFinder(activeCwd);
|
|
390
|
+
const query = buildQuery(pathParam, pattern, exclude, activeCwd, externalAllowlist);
|
|
391
|
+
return { finder: f, query };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function getMentionItems(
|
|
395
|
+
query: string,
|
|
396
|
+
signal: AbortSignal,
|
|
397
|
+
): Promise<AutocompleteItem[]> {
|
|
398
|
+
if (signal.aborted) return [];
|
|
399
|
+
const f = await ensureFinder(activeCwd);
|
|
400
|
+
if (signal.aborted) return [];
|
|
401
|
+
|
|
402
|
+
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
403
|
+
if (!result.ok) return [];
|
|
404
|
+
|
|
405
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
406
|
+
if (mixed.type === "directory") {
|
|
407
|
+
return {
|
|
408
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
409
|
+
label: mixed.item.dirName,
|
|
410
|
+
description: mixed.item.relativePath,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
415
|
+
label: mixed.item.fileName,
|
|
416
|
+
description: mixed.item.relativePath,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function registerAutocompleteProvider(ctx: {
|
|
422
|
+
ui: {
|
|
423
|
+
addAutocompleteProvider?: (
|
|
424
|
+
factory: (current: AutocompleteProvider) => AutocompleteProvider,
|
|
425
|
+
) => void;
|
|
426
|
+
};
|
|
427
|
+
}) {
|
|
428
|
+
if (typeof ctx.ui.addAutocompleteProvider !== "function") return;
|
|
429
|
+
|
|
430
|
+
ctx.ui.addAutocompleteProvider((current) => {
|
|
431
|
+
const mentionProvider = createFffMentionProvider(getMentionItems);
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
435
|
+
try {
|
|
436
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
437
|
+
lines,
|
|
438
|
+
cursorLine,
|
|
439
|
+
cursorCol,
|
|
440
|
+
options,
|
|
441
|
+
);
|
|
442
|
+
if (mentionResult) return mentionResult;
|
|
443
|
+
} catch {
|
|
444
|
+
// Delegate when FFF lookup is unavailable.
|
|
445
|
+
}
|
|
446
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
447
|
+
},
|
|
448
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
449
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
450
|
+
},
|
|
451
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
452
|
+
return (
|
|
453
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
|
|
454
|
+
);
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- Flags / lifecycle ---
|
|
461
|
+
|
|
462
|
+
pi.registerFlag("fff-frecency-db", {
|
|
463
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
464
|
+
type: "string",
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
pi.registerFlag("fff-history-db", {
|
|
468
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
469
|
+
type: "string",
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
pi.registerFlag("fff-enable-root-scan", {
|
|
473
|
+
description:
|
|
474
|
+
"Allow indexing when launched from the filesystem root (also: FFF_ENABLE_ROOT_SCAN env)",
|
|
475
|
+
type: "boolean",
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
pi.registerFlag("fff-external-allow", {
|
|
479
|
+
description:
|
|
480
|
+
"Allow grep/find to search the auto-detected @earendil-works/pi-coding-agent package directory via the ./pi-coding-agent alias (also: FFF_EXTERNAL_ALLOW env; default: true)",
|
|
481
|
+
type: "boolean",
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
485
|
+
try {
|
|
486
|
+
activeCwd = ctx.cwd;
|
|
487
|
+
registerAutocompleteProvider(ctx);
|
|
488
|
+
await ensureFinder(activeCwd);
|
|
489
|
+
} catch (e: unknown) {
|
|
490
|
+
ctx.ui.notify(
|
|
491
|
+
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
492
|
+
"error",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
pi.on("session_shutdown", async () => {
|
|
498
|
+
destroyFinder();
|
|
499
|
+
destroyExternalFinder();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// --- bash grep → ripgrep rewrite ---
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Rewrite a bash grep command to an equivalent rg (ripgrep) command.
|
|
506
|
+
* Returns the rewritten command, or undefined if the command is not a
|
|
507
|
+
* simple grep invocation that can be safely converted.
|
|
508
|
+
*/
|
|
509
|
+
function rewriteGrepToRg(command: string): string | undefined {
|
|
510
|
+
const cdMatch = /^(\s*cd\s+([^\s&]+)\s*&&\s*)(.*)$/.exec(command);
|
|
511
|
+
const prefix = cdMatch?.[1] ?? "";
|
|
512
|
+
const body = cdMatch?.[3] ?? command;
|
|
513
|
+
if (!/^\s*grep\b/.test(body)) return undefined;
|
|
514
|
+
const beforePipe = body.split(/\s+\|/)[0].trim();
|
|
515
|
+
const afterGrep = beforePipe.replace(/^\s*grep\s+/, "");
|
|
516
|
+
// Strip stderr redirects (2>/dev/null, 2>&1, etc.) so they don't
|
|
517
|
+
// become false path arguments.
|
|
518
|
+
const cleaned = afterGrep.replace(/\s+2>(?:&\d+|\/dev\/null|\S+)/g, "").trim();
|
|
519
|
+
const tokens = cleaned.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
520
|
+
if (!tokens) return undefined;
|
|
521
|
+
|
|
522
|
+
const rgArgs: string[] = ["rg"];
|
|
523
|
+
let pattern: string | undefined;
|
|
524
|
+
let paths: string[] = [];
|
|
525
|
+
let includeGlobs: string[] = [];
|
|
526
|
+
let excludeGlobs: string[] = [];
|
|
527
|
+
let caseInsensitive = false;
|
|
528
|
+
let fixedStrings = false;
|
|
529
|
+
let wordRegex = false;
|
|
530
|
+
let countOnly = false;
|
|
531
|
+
let filesOnly = false;
|
|
532
|
+
let invertMatch = false;
|
|
533
|
+
let contextAfter = 0;
|
|
534
|
+
let contextBefore = 0;
|
|
535
|
+
let contextBoth = 0;
|
|
536
|
+
let lineNumber = false;
|
|
537
|
+
let noFilename = false;
|
|
538
|
+
|
|
539
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
540
|
+
const tok = tokens[i];
|
|
541
|
+
if (tok === "-i" || tok === "--ignore-case") { caseInsensitive = true; continue; }
|
|
542
|
+
if (tok === "-F" || tok === "--fixed-strings") { fixedStrings = true; continue; }
|
|
543
|
+
if (tok === "-w" || tok === "--word-regexp") { wordRegex = true; continue; }
|
|
544
|
+
if (tok === "-c" || tok === "--count") { countOnly = true; continue; }
|
|
545
|
+
if (tok === "-l" || tok === "--files-with-matches") { filesOnly = true; continue; }
|
|
546
|
+
if (tok === "-v" || tok === "--invert-match") { invertMatch = true; continue; }
|
|
547
|
+
if (tok === "-n" || tok === "--line-number") { lineNumber = true; continue; }
|
|
548
|
+
if (tok === "-h" || tok === "--no-filename") { noFilename = true; continue; }
|
|
549
|
+
if (tok === "-E" || tok === "--extended-regexp") { continue; }
|
|
550
|
+
if (tok === "-r" || tok === "-R" || tok === "--recursive") { continue; }
|
|
551
|
+
if (tok === "-s" || tok === "--no-messages") { continue; }
|
|
552
|
+
if (tok === "-A") { contextAfter = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
553
|
+
if (tok === "-B") { contextBefore = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
554
|
+
if (tok === "-C") { contextBoth = parseInt(tokens[++i] ?? "0", 10) || 0; continue; }
|
|
555
|
+
if (tok.startsWith("-A")) { contextAfter = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
556
|
+
if (tok.startsWith("-B")) { contextBefore = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
557
|
+
if (tok.startsWith("-C")) { contextBoth = parseInt(tok.slice(2), 10) || 0; continue; }
|
|
558
|
+
if (tok === "--include") { includeGlobs.push(tokens[++i] ?? ""); continue; }
|
|
559
|
+
if (tok.startsWith("--include=")) { includeGlobs.push(tok.slice(10)); continue; }
|
|
560
|
+
if (tok === "--exclude") { excludeGlobs.push(tokens[++i] ?? ""); continue; }
|
|
561
|
+
if (tok.startsWith("--exclude=")) { excludeGlobs.push(tok.slice(10)); continue; }
|
|
562
|
+
if (tok === "--exclude-dir") { excludeGlobs.push(`${tokens[++i] ?? ""}/`); continue; }
|
|
563
|
+
if (tok.startsWith("--exclude-dir=")) { excludeGlobs.push(`${tok.slice(13)}/`); continue; }
|
|
564
|
+
// Handle combined short flags like -rn, -in, -rnI, etc.
|
|
565
|
+
if (/^-[a-zA-Z]{2,}$/.test(tok)) {
|
|
566
|
+
let bail = false;
|
|
567
|
+
for (const ch of tok.slice(1)) {
|
|
568
|
+
switch (ch) {
|
|
569
|
+
case "i": caseInsensitive = true; break;
|
|
570
|
+
case "F": fixedStrings = true; break;
|
|
571
|
+
case "w": wordRegex = true; break;
|
|
572
|
+
case "c": countOnly = true; break;
|
|
573
|
+
case "l": filesOnly = true; break;
|
|
574
|
+
case "v": invertMatch = true; break;
|
|
575
|
+
case "n": lineNumber = true; break;
|
|
576
|
+
case "h": noFilename = true; break;
|
|
577
|
+
case "E": case "r": case "R": case "s": break;
|
|
578
|
+
default: bail = true; break;
|
|
579
|
+
}
|
|
580
|
+
if (bail) break;
|
|
581
|
+
}
|
|
582
|
+
if (bail) return undefined;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (tok.startsWith("-")) {
|
|
586
|
+
// Unknown flag — bail to be safe.
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
if (pattern === undefined) {
|
|
590
|
+
pattern = tok.replace(/^["']|["']$/g, "");
|
|
591
|
+
} else {
|
|
592
|
+
paths.push(tok.replace(/^["']|["']$/g, ""));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (pattern === undefined) return undefined;
|
|
596
|
+
|
|
597
|
+
if (caseInsensitive) rgArgs.push("-i");
|
|
598
|
+
if (fixedStrings) rgArgs.push("-F");
|
|
599
|
+
if (wordRegex) rgArgs.push("-w");
|
|
600
|
+
if (countOnly) rgArgs.push("-c");
|
|
601
|
+
if (filesOnly) rgArgs.push("-l");
|
|
602
|
+
if (invertMatch) rgArgs.push("-v");
|
|
603
|
+
if (lineNumber || noFilename) rgArgs.push("-n");
|
|
604
|
+
if (noFilename) rgArgs.push("--no-filename");
|
|
605
|
+
if (contextAfter > 0) rgArgs.push("-A", String(contextAfter));
|
|
606
|
+
if (contextBefore > 0) rgArgs.push("-B", String(contextBefore));
|
|
607
|
+
if (contextBoth > 0) rgArgs.push("-C", String(contextBoth));
|
|
608
|
+
for (const g of includeGlobs) rgArgs.push("-g", g);
|
|
609
|
+
for (const g of excludeGlobs) rgArgs.push("-g", `!${g}`);
|
|
610
|
+
// rg is recursive by default; add -- to separate pattern from paths.
|
|
611
|
+
rgArgs.push("--", pattern);
|
|
612
|
+
for (const p of paths) rgArgs.push(p);
|
|
613
|
+
|
|
614
|
+
const rgCmd = rgArgs.map((a) => {
|
|
615
|
+
return /[\s'"!]/.test(a) ? `'${a.replace(/'/g, "'\\''")}'` : a;
|
|
616
|
+
}).join(" ");
|
|
617
|
+
return prefix + rgCmd;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
pi.on("tool_call", (event: any) => {
|
|
621
|
+
if (event.toolName !== "bash") return;
|
|
622
|
+
const command = event.input?.command;
|
|
623
|
+
if (typeof command !== "string") return;
|
|
624
|
+
if (!bashGrepInfo(command)) return;
|
|
625
|
+
const rewritten = rewriteGrepToRg(command);
|
|
626
|
+
if (rewritten) event.input.command = rewritten;
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// --- grep tool ---
|
|
630
|
+
|
|
631
|
+
const grepSchema = Type.Object({
|
|
632
|
+
pattern: Type.String({
|
|
633
|
+
description: "Search pattern (literal text or regex)",
|
|
634
|
+
}),
|
|
635
|
+
path: Type.Optional(
|
|
636
|
+
Type.String({
|
|
637
|
+
description:
|
|
638
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
|
|
639
|
+
}),
|
|
640
|
+
),
|
|
641
|
+
exclude: Type.Optional(
|
|
642
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
643
|
+
description:
|
|
644
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
645
|
+
}),
|
|
646
|
+
),
|
|
647
|
+
caseSensitive: Type.Optional(
|
|
648
|
+
Type.Boolean({
|
|
649
|
+
description:
|
|
650
|
+
"Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
|
|
651
|
+
}),
|
|
652
|
+
),
|
|
653
|
+
context: Type.Optional(
|
|
654
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
655
|
+
),
|
|
656
|
+
limit: Type.Optional(
|
|
657
|
+
Type.Number({
|
|
658
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
659
|
+
}),
|
|
660
|
+
),
|
|
661
|
+
cursor: Type.Optional(
|
|
662
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
663
|
+
),
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
pi.registerTool({
|
|
667
|
+
name: "grep",
|
|
668
|
+
label: "grep",
|
|
669
|
+
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}.`,
|
|
670
|
+
promptSnippet: "Grep contents",
|
|
671
|
+
promptGuidelines: [
|
|
672
|
+
"Prefer bare identifiers as patterns. Literal queries are most efficient.",
|
|
673
|
+
"Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
|
|
674
|
+
"caseSensitive: true when you need exact case (smart-case otherwise).",
|
|
675
|
+
"After 1-2 greps, read the top match instead of more greps.",
|
|
676
|
+
],
|
|
677
|
+
parameters: grepSchema,
|
|
678
|
+
renderShell: "self",
|
|
679
|
+
|
|
680
|
+
async execute(_toolCallId, params, signal) {
|
|
681
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
682
|
+
|
|
683
|
+
const cachedGrepCursor = params.cursor ? getCursor(params.cursor) : undefined;
|
|
684
|
+
let f: FileFinder;
|
|
685
|
+
let query: string;
|
|
686
|
+
if (cachedGrepCursor) {
|
|
687
|
+
f = cachedGrepCursor.externalDir
|
|
688
|
+
? await ensureExternalFinder(cachedGrepCursor.externalDir)
|
|
689
|
+
: await ensureFinder(activeCwd);
|
|
690
|
+
query = buildQuery(params.path, params.pattern, params.exclude, activeCwd, externalAllowlist);
|
|
691
|
+
} else {
|
|
692
|
+
const resolved = await resolveFinderAndQuery(
|
|
693
|
+
params.path,
|
|
694
|
+
params.pattern,
|
|
695
|
+
params.exclude,
|
|
696
|
+
);
|
|
697
|
+
f = resolved.finder;
|
|
698
|
+
query = resolved.query;
|
|
699
|
+
}
|
|
700
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
701
|
+
const hasRegexSyntax =
|
|
702
|
+
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
703
|
+
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
704
|
+
if (mode === "regex") {
|
|
705
|
+
try {
|
|
706
|
+
new RegExp(params.pattern);
|
|
707
|
+
} catch {
|
|
708
|
+
mode = "plain";
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const p = params.pattern.trim();
|
|
713
|
+
const isWildcardOnly =
|
|
714
|
+
hasRegexSyntax &&
|
|
715
|
+
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
716
|
+
p,
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
if (isWildcardOnly) {
|
|
720
|
+
return {
|
|
721
|
+
content: [
|
|
722
|
+
{
|
|
723
|
+
type: "text",
|
|
724
|
+
text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
details: { totalMatched: 0, totalFiles: 0 },
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const smartCase = params.caseSensitive !== true;
|
|
732
|
+
|
|
733
|
+
const grepResult = f.grep(query, {
|
|
734
|
+
mode,
|
|
735
|
+
smartCase,
|
|
736
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
737
|
+
cursor: cachedGrepCursor?.cursor ?? null,
|
|
738
|
+
beforeContext: params.context ?? 0,
|
|
739
|
+
afterContext: params.context ?? 0,
|
|
740
|
+
classifyDefinitions: true,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
744
|
+
|
|
745
|
+
let result = grepResult.value;
|
|
746
|
+
let fuzzyNotice: string | null = null;
|
|
747
|
+
|
|
748
|
+
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
|
749
|
+
const fuzzy = f.grep(params.pattern, {
|
|
750
|
+
mode: "fuzzy",
|
|
751
|
+
smartCase,
|
|
752
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
753
|
+
cursor: null,
|
|
754
|
+
beforeContext: 0,
|
|
755
|
+
afterContext: 0,
|
|
756
|
+
classifyDefinitions: true,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
760
|
+
fuzzyNotice = `0 exact matches. Maybe you meant this?`;
|
|
761
|
+
result = fuzzy.value;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
let output = formatGrepOutput(result);
|
|
766
|
+
const notices: string[] = [];
|
|
767
|
+
if (result.regexFallbackError) {
|
|
768
|
+
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
|
769
|
+
}
|
|
770
|
+
if (result.nextCursor) {
|
|
771
|
+
notices.push(`Continue with cursor="${storeCursor(result.nextCursor, f === externalFinder ? externalFinderDir ?? undefined : undefined)}"`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
775
|
+
if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
content: [{ type: "text", text: output }],
|
|
779
|
+
details: {
|
|
780
|
+
totalMatched: result.totalMatched,
|
|
781
|
+
totalFiles: result.totalFiles,
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
renderCall(args: any, theme: any, context: any) {
|
|
787
|
+
return renderer.renderCall("grep", args, theme, context);
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
renderResult(result: any, options: any, theme: any, context: any) {
|
|
791
|
+
return renderer.renderResult("grep", context.args, result, options, theme, context);
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// --- find tool ---
|
|
796
|
+
|
|
797
|
+
const findSchema = Type.Object({
|
|
798
|
+
pattern: Type.String({
|
|
799
|
+
description:
|
|
800
|
+
"Fuzzy filename search and glob search. Frecency-ranked, git-aware. Multi-word = narrower (AND) not bound to order, use for multi word related concept search. Prefer this over ls/find/bash as the first exploration step whenever the user names a concept, feature, or symbol — it surfaces the relevant files in one call. Only use ls/read on a directory when you specifically need the alphabetical layout of an unknown repo, or when a concept search returned nothing.",
|
|
801
|
+
}),
|
|
802
|
+
path: Type.Optional(
|
|
803
|
+
Type.String({
|
|
804
|
+
description:
|
|
805
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path. Use ./pi-coding-agent to search the installed @earendil-works/pi-coding-agent package docs and examples.",
|
|
806
|
+
}),
|
|
807
|
+
),
|
|
808
|
+
exclude: Type.Optional(
|
|
809
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
810
|
+
description:
|
|
811
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
812
|
+
}),
|
|
813
|
+
),
|
|
814
|
+
limit: Type.Optional(
|
|
815
|
+
Type.Number({
|
|
816
|
+
description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
|
|
817
|
+
}),
|
|
818
|
+
),
|
|
819
|
+
cursor: Type.Optional(
|
|
820
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
821
|
+
),
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
pi.registerTool({
|
|
825
|
+
name: "find",
|
|
826
|
+
label: "find",
|
|
827
|
+
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}.`,
|
|
828
|
+
promptSnippet: "Find files by path or glob",
|
|
829
|
+
promptGuidelines: [
|
|
830
|
+
"Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
|
|
831
|
+
"Keep queries to 1-2 terms; extra words narrow.",
|
|
832
|
+
"Use for paths, not content. Use grep for content.",
|
|
833
|
+
"For exact path matches use a glob in `path` — e.g. path: '**/profile.h' for exact filename, or path: 'src/**/profile.h' scoped to a subtree. Bare patterns are fuzzy.",
|
|
834
|
+
"To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
|
|
835
|
+
"Use exclude: 'test/,*.min.js' to cut noise in large repos.",
|
|
836
|
+
],
|
|
837
|
+
parameters: findSchema,
|
|
838
|
+
renderShell: "self",
|
|
839
|
+
|
|
840
|
+
async execute(_toolCallId, params, signal) {
|
|
841
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
842
|
+
|
|
843
|
+
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
844
|
+
const effectiveLimit = resumed
|
|
845
|
+
? resumed.pageSize
|
|
846
|
+
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
847
|
+
|
|
848
|
+
let f: FileFinder;
|
|
849
|
+
let query: string;
|
|
850
|
+
if (resumed) {
|
|
851
|
+
f = resumed.externalDir
|
|
852
|
+
? await ensureExternalFinder(resumed.externalDir)
|
|
853
|
+
: await ensureFinder(activeCwd);
|
|
854
|
+
query = resumed.query;
|
|
855
|
+
} else {
|
|
856
|
+
const resolved = await resolveFinderAndQuery(
|
|
857
|
+
params.path,
|
|
858
|
+
params.pattern,
|
|
859
|
+
params.exclude,
|
|
860
|
+
);
|
|
861
|
+
f = resolved.finder;
|
|
862
|
+
query = resolved.query;
|
|
863
|
+
}
|
|
864
|
+
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
865
|
+
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
866
|
+
|
|
867
|
+
const searchResult = f.fileSearch(query, {
|
|
868
|
+
pageIndex,
|
|
869
|
+
pageSize: effectiveLimit,
|
|
870
|
+
});
|
|
871
|
+
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
872
|
+
|
|
873
|
+
const result = searchResult.value;
|
|
874
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
875
|
+
let output = formatted.output;
|
|
876
|
+
|
|
877
|
+
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
878
|
+
const hasMore =
|
|
879
|
+
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
|
|
880
|
+
|
|
881
|
+
const notices: string[] = [];
|
|
882
|
+
if (formatted.weak && formatted.shownCount > 0)
|
|
883
|
+
notices.push(
|
|
884
|
+
`Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
if (!formatted.weak && hasMore) {
|
|
888
|
+
const remaining = result.totalMatched - shownSoFar;
|
|
889
|
+
const cursorId = storeFindCursor({
|
|
890
|
+
query,
|
|
891
|
+
pattern,
|
|
892
|
+
pageSize: effectiveLimit,
|
|
893
|
+
nextPageIndex: pageIndex + 1,
|
|
894
|
+
externalDir: f === externalFinder ? externalFinderDir ?? undefined : undefined,
|
|
895
|
+
});
|
|
896
|
+
notices.push(
|
|
897
|
+
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
902
|
+
return {
|
|
903
|
+
content: [{ type: "text", text: output }],
|
|
904
|
+
details: {
|
|
905
|
+
totalMatched: result.totalMatched,
|
|
906
|
+
totalFiles: result.totalFiles,
|
|
907
|
+
pageIndex,
|
|
908
|
+
hasMore,
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
renderCall(args: any, theme: any, context: any) {
|
|
914
|
+
return renderer.renderCall("find", args, theme, context);
|
|
915
|
+
},
|
|
916
|
+
|
|
917
|
+
renderResult(result: any, options: any, theme: any, context: any) {
|
|
918
|
+
return renderer.renderResult("find", context.args, result, options, theme, context);
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// --- commands ---
|
|
923
|
+
|
|
924
|
+
pi.registerCommand("fff-health", {
|
|
925
|
+
description: "Show FFF file finder health and status",
|
|
926
|
+
handler: async (_args, ctx) => {
|
|
927
|
+
if (!finder || finder.isDestroyed) {
|
|
928
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const health = finder.healthCheck();
|
|
933
|
+
if (!health.ok) {
|
|
934
|
+
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const h = health.value;
|
|
939
|
+
const lines = [
|
|
940
|
+
`FFF v${h.version}`,
|
|
941
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
942
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
943
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
944
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
945
|
+
];
|
|
946
|
+
|
|
947
|
+
const progress = finder.getScanProgress();
|
|
948
|
+
if (progress.ok) {
|
|
949
|
+
lines.push(
|
|
950
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
pi.registerCommand("fff-rescan", {
|
|
959
|
+
description: "Trigger FFF to rescan files",
|
|
960
|
+
handler: async (_args, ctx) => {
|
|
961
|
+
if (!finder || finder.isDestroyed) {
|
|
962
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const result = finder.scanFiles();
|
|
967
|
+
if (!result.ok) {
|
|
968
|
+
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
ctx.ui.notify("FFF rescan triggered", "info");
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
}
|