@ff-labs/pi-fff 0.6.5-nightly.e00b41d → 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.
Files changed (3) hide show
  1. package/package.json +2 -1
  2. package/src/index.ts +519 -286
  3. package/src/query.ts +87 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ff-labs/pi-fff",
3
3
  "public": true,
4
- "version": "0.6.5-nightly.e00b41d",
4
+ "version": "0.7.0",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
+ "test": "bun test test/",
39
40
  "typecheck": "tsc --noEmit"
40
41
  },
41
42
  "dependencies": {
package/src/index.ts CHANGED
@@ -6,12 +6,7 @@
6
6
  */
7
7
 
8
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";
9
+ import { CustomEditor } from "@mariozechner/pi-coding-agent";
15
10
  import {
16
11
  Text,
17
12
  type AutocompleteItem,
@@ -26,13 +21,14 @@ import type {
26
21
  SearchResult,
27
22
  MixedItem,
28
23
  } from "@ff-labs/fff-node";
24
+ import { buildQuery } from "./query";
29
25
 
30
26
  // ---------------------------------------------------------------------------
31
27
  // Constants
32
28
  // ---------------------------------------------------------------------------
33
29
 
34
- const DEFAULT_GREP_LIMIT = 100;
35
- const DEFAULT_FIND_LIMIT = 200;
30
+ const DEFAULT_GREP_LIMIT = 20;
31
+ const DEFAULT_FIND_LIMIT = 30;
36
32
  const GREP_MAX_LINE_LENGTH = 500;
37
33
  const MENTION_MAX_RESULTS = 20;
38
34
 
@@ -82,6 +78,33 @@ function getCursor(id: string): GrepCursor | undefined {
82
78
  return cursorCache.get(id);
83
79
  }
84
80
 
81
+ // Find pagination uses a page-index cursor: native `fileSearch` takes
82
+ // pageIndex/pageSize, so the cursor is just the next page index paired with
83
+ // the query+limit that produced it. Stored tokens are opaque IDs to the agent.
84
+ interface FindCursor {
85
+ query: string;
86
+ pattern: string;
87
+ pageSize: number;
88
+ nextPageIndex: number;
89
+ }
90
+
91
+ const findCursorCache = new Map<string, FindCursor>();
92
+ let findCursorCounter = 0;
93
+
94
+ function storeFindCursor(cursor: FindCursor): string {
95
+ const id = `${++findCursorCounter}`;
96
+ findCursorCache.set(id, cursor);
97
+ if (findCursorCache.size > 200) {
98
+ const first = findCursorCache.keys().next().value;
99
+ if (first) findCursorCache.delete(first);
100
+ }
101
+ return id;
102
+ }
103
+
104
+ function getFindCursor(id: string): FindCursor | undefined {
105
+ return findCursorCache.get(id);
106
+ }
107
+
85
108
  // ---------------------------------------------------------------------------
86
109
  // Output formatting helpers
87
110
  // ---------------------------------------------------------------------------
@@ -91,44 +114,122 @@ function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
91
114
  return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
92
115
  }
93
116
 
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";
117
+ const HOT_FRECENCY = 25;
118
+ const WARM_FRECENCY = 20;
119
+
120
+ // Shared annotation helper for both find-output paths and grep-output file
121
+ // headers. Returns at most ONE tag so output stays scannable. Priority:
122
+ // git-dirty (most actionable — file is changing right now) beats frecency
123
+ // (historically often-touched). Keeping one function ensures the two tools
124
+ // never drift in how they surface git/frecency signal.
125
+ export function fffFileAnnotation(item: {
126
+ gitStatus?: string;
127
+ totalFrecencyScore?: number;
128
+ accessFrecencyScore?: number;
129
+ }): string {
130
+ const git = item.gitStatus;
131
+ if (git && git !== "clean" && git !== "unknown" && git !== "") {
132
+ return ` [${git} in git]`;
133
+ }
134
+
135
+ const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
136
+ if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
137
+ if (frecency >= WARM_FRECENCY) return " [often touched file]";
97
138
 
139
+ return "";
140
+ }
141
+
142
+ // fff-core native definition classifier (byte-level scanner in Rust) is enabled
143
+ // via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
144
+ // downstream consumers; pi-fff does NOT use it to re-sort.
145
+ //
146
+ // Ordering policy: NO CUSTOM SORTING. The engine already returns items in
147
+ // frecency order (most-accessed files first). pi-fff only groups consecutive
148
+ // matches into per-file blocks and preserves whatever order the engine
149
+ // provided — inside a file we keep matches in source-line order because the
150
+ // engine emits them that way.
151
+
152
+ function formatGrepOutput(result: GrepResult): string {
153
+ if (result.items.length === 0) return "No matches found";
154
+
155
+ // Build file-grouped output in the order files first appear in the result.
156
+ // This preserves native frecency ordering across files without re-sorting.
98
157
  const lines: string[] = [];
99
158
  let currentFile = "";
159
+ let shown = 0;
100
160
 
101
- for (const match of items) {
161
+ for (const match of result.items) {
102
162
  if (match.relativePath !== currentFile) {
103
- currentFile = match.relativePath;
104
163
  if (lines.length > 0) lines.push("");
164
+ currentFile = match.relativePath;
165
+ lines.push(`${currentFile}${fffFileAnnotation(match)}`);
105
166
  }
106
167
 
107
168
  match.contextBefore?.forEach((line: string, i: number) => {
108
- lines.push(
109
- `${match.relativePath}-${match.lineNumber - match.contextBefore!.length + i}- ${truncateLine(line)}`,
110
- );
169
+ const lineNum = match.lineNumber - match.contextBefore!.length + i;
170
+ lines.push(` ${lineNum}- ${truncateLine(line)}`);
111
171
  });
112
172
 
113
- lines.push(
114
- `${match.relativePath}:${match.lineNumber}: ${truncateLine(match.lineContent)}`,
115
- );
173
+ lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
174
+ shown++;
116
175
 
117
176
  match.contextAfter?.forEach((line: string, i: number) => {
118
- lines.push(
119
- `${match.relativePath}-${match.lineNumber + 1 + i}- ${truncateLine(line)}`,
120
- );
177
+ const lineNum = match.lineNumber + 1 + i;
178
+ lines.push(` ${lineNum}- ${truncateLine(line)}`);
121
179
  });
122
180
  }
123
181
 
124
182
  return lines.join("\n");
125
183
  }
126
184
 
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");
185
+ // Weak-match threshold is derived from the query length, matching the
186
+ // scoring formula in crates/fff-core/src/score.rs: a perfect match scores
187
+ // `len * 16`, so we treat anything below 50% of that as scattered fuzzy noise.
188
+ // When the top score is weak, trim output to a small sample instead of dumping
189
+ // the full limit worth of noise into the agent's context.
190
+ const FIND_WEAK_SAMPLE_SIZE = 5;
191
+
192
+ function weakScoreThreshold(pattern: string): number {
193
+ const perfect = pattern.length * 12;
194
+ return Math.floor((perfect * 50) / 100);
195
+ }
196
+
197
+ interface FormattedFind {
198
+ output: string;
199
+ weak: boolean;
200
+ shownCount: number;
201
+ }
202
+
203
+ function formatFindOutput(
204
+ result: SearchResult,
205
+ limit: number,
206
+ pattern: string,
207
+ ): FormattedFind {
208
+ if (result.items.length === 0) {
209
+ return {
210
+ output: "No files found matching pattern",
211
+ weak: false,
212
+ shownCount: 0,
213
+ };
214
+ }
215
+
216
+ // NO CUSTOM SORTING — trust native frecency order from the engine.
217
+ const reordered = result.items.map((item) => ({ item }));
218
+
219
+ // Peek at the top native score to decide whether results are scattered
220
+ // fuzzy noise (query length-scaled threshold from score.rs).
221
+ const topScore = result.scores[0]?.total ?? 0;
222
+ const weak = topScore < weakScoreThreshold(pattern);
223
+ const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
224
+ const shown = reordered.slice(0, effective);
225
+
226
+ return {
227
+ output: shown
228
+ .map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
229
+ .join("\n"),
230
+ weak,
231
+ shownCount: shown.length,
232
+ };
132
233
  }
133
234
 
134
235
  // ---------------------------------------------------------------------------
@@ -155,7 +256,9 @@ function createFffMentionProvider(
155
256
 
156
257
  const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
157
258
  const items = await getItems(query, options.signal);
158
- return options.signal.aborted || items.length === 0 ? null : { items, prefix };
259
+ return options.signal.aborted || items.length === 0
260
+ ? null
261
+ : { items, prefix };
159
262
  },
160
263
  applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
161
264
  const currentLine = _lines[cursorLine] || "";
@@ -164,7 +267,11 @@ function createFffMentionProvider(
164
267
  const newLine = before + item.value + after;
165
268
  const newCursorCol = cursorCol - prefix.length + item.value.length;
166
269
  return {
167
- lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
270
+ lines: [
271
+ ..._lines.slice(0, cursorLine),
272
+ newLine,
273
+ ..._lines.slice(cursorLine + 1),
274
+ ],
168
275
  cursorLine,
169
276
  cursorCol: newCursorCol,
170
277
  };
@@ -172,68 +279,11 @@ function createFffMentionProvider(
172
279
  };
173
280
  }
174
281
 
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
- }
282
+ // FffEditor is defined inside fffExtension() so it can capture `getMentionItems`
283
+ // via closure rather than via a 4th constructor parameter. This makes the class
284
+ // safe to subclass via `new SubClass(tui, theme, keybindings)` -- the pattern
285
+ // pi-vim and pi-image-attachments use to compose editors. See:
286
+ // https://github.com/badlogic/pi-mono/issues/3935
237
287
 
238
288
  // ---------------------------------------------------------------------------
239
289
  // Extension
@@ -242,6 +292,11 @@ class FffEditor extends CustomEditor {
242
292
  export default function fffExtension(pi: ExtensionAPI) {
243
293
  let finder: FileFinder | null = null;
244
294
  let finderCwd: string | null = null;
295
+ // Concurrent ensureFinder() callers share the same in-flight promise so
296
+ // FileFinder.create() (which takes native DB locks) runs at most once per
297
+ // base path at a time — otherwise parallel tool calls would race and
298
+ // deadlock at the native layer (issue #403).
299
+ let finderPromise: Promise<FileFinder> | null = null;
245
300
  let activeCwd = process.cwd();
246
301
 
247
302
  // Mode resolution: flag > env > default
@@ -274,27 +329,37 @@ export default function fffExtension(pi: ExtensionAPI) {
274
329
  return currentMode !== "tools-only";
275
330
  }
276
331
 
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
- }
332
+ function ensureFinder(cwd: string): Promise<FileFinder> {
333
+ if (finder && !finder.isDestroyed && finderCwd === cwd)
334
+ return Promise.resolve(finder);
335
+ if (finderPromise) return finderPromise;
284
336
 
285
- const result = FileFinder.create({
286
- basePath: cwd,
287
- frecencyDbPath,
288
- historyDbPath,
289
- aiMode: true,
290
- });
337
+ finderPromise = (async () => {
338
+ if (finder && !finder.isDestroyed) {
339
+ finder.destroy();
340
+ finder = null;
341
+ finderCwd = null;
342
+ }
291
343
 
292
- if (!result.ok) throw new Error(`Failed to create FFF file finder: ${result.error}`);
344
+ const result = FileFinder.create({
345
+ basePath: cwd,
346
+ frecencyDbPath,
347
+ historyDbPath,
348
+ aiMode: true,
349
+ });
350
+
351
+ if (!result.ok)
352
+ throw new Error(`Failed to create FFF file finder: ${result.error}`);
353
+
354
+ finder = result.value;
355
+ finderCwd = cwd;
356
+ await finder.waitForScan(15000);
357
+ return finder;
358
+ })().finally(() => {
359
+ finderPromise = null;
360
+ });
293
361
 
294
- finder = result.value;
295
- finderCwd = cwd;
296
- await finder.waitForScan(15000);
297
- return finder;
362
+ return finderPromise;
298
363
  }
299
364
 
300
365
  function destroyFinder() {
@@ -316,20 +381,80 @@ export default function fffExtension(pi: ExtensionAPI) {
316
381
  const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
317
382
  if (!result.ok) return [];
318
383
 
319
- return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
320
- if (mixed.type === "directory") {
384
+ return result.value.items
385
+ .slice(0, MENTION_MAX_RESULTS)
386
+ .map((mixed: MixedItem) => {
387
+ if (mixed.type === "directory") {
388
+ return {
389
+ value: buildAtCompletionValue(mixed.item.relativePath),
390
+ label: mixed.item.dirName,
391
+ description: mixed.item.relativePath,
392
+ };
393
+ }
321
394
  return {
322
395
  value: buildAtCompletionValue(mixed.item.relativePath),
323
- label: mixed.item.dirName,
396
+ label: mixed.item.fileName,
324
397
  description: mixed.item.relativePath,
325
398
  };
326
- }
327
- return {
328
- value: buildAtCompletionValue(mixed.item.relativePath),
329
- label: mixed.item.fileName,
330
- description: mixed.item.relativePath,
399
+ });
400
+ }
401
+
402
+ // Editor wrapper that injects FFF @-mention autocomplete alongside base provider.
403
+ // Defined inside fffExtension() so the class methods capture `getMentionItems`
404
+ // via closure. Subclasses constructed as `new Sub(tui, theme, keybindings)` by
405
+ // composability wrappers (pi-vim, pi-image-attachments) still get a working
406
+ // mention provider because the closure binding is preserved across subclassing.
407
+ class FffEditor extends CustomEditor {
408
+ private baseProvider: AutocompleteProvider | undefined;
409
+
410
+ override setAutocompleteProvider(provider: AutocompleteProvider): void {
411
+ this.baseProvider = provider;
412
+ // Create composite provider that handles @-mentions and falls back to base
413
+ const mentionProvider = createFffMentionProvider(getMentionItems);
414
+ const compositeProvider: AutocompleteProvider = {
415
+ getSuggestions: async (lines, cursorLine, cursorCol, options) => {
416
+ // Try @-mention first
417
+ const mentionResult = await mentionProvider.getSuggestions(
418
+ lines,
419
+ cursorLine,
420
+ cursorCol,
421
+ options,
422
+ );
423
+ if (mentionResult) return mentionResult;
424
+ // Fall back to base provider
425
+ return (
426
+ this.baseProvider?.getSuggestions(
427
+ lines,
428
+ cursorLine,
429
+ cursorCol,
430
+ options,
431
+ ) ?? null
432
+ );
433
+ },
434
+ applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
435
+ // Let mention provider handle @ completions, base provider for others
436
+ if (prefix?.startsWith("@")) {
437
+ return mentionProvider.applyCompletion!(
438
+ lines,
439
+ cursorLine,
440
+ cursorCol,
441
+ item,
442
+ prefix,
443
+ );
444
+ }
445
+ return (
446
+ this.baseProvider?.applyCompletion?.(
447
+ lines,
448
+ cursorLine,
449
+ cursorCol,
450
+ item,
451
+ prefix,
452
+ ) ?? { lines, cursorLine, cursorCol }
453
+ );
454
+ },
331
455
  };
332
- });
456
+ super.setAutocompleteProvider(compositeProvider);
457
+ }
333
458
  }
334
459
 
335
460
  function applyEditorMode(ctx: {
@@ -344,7 +469,7 @@ export default function fffExtension(pi: ExtensionAPI) {
344
469
  } else {
345
470
  ctx.ui.setEditorComponent(
346
471
  (tui: any, theme: any, keybindings: any) =>
347
- new FffEditor(tui, theme, keybindings, getMentionItems),
472
+ new FffEditor(tui, theme, keybindings),
348
473
  );
349
474
  }
350
475
  }
@@ -357,12 +482,14 @@ export default function fffExtension(pi: ExtensionAPI) {
357
482
  });
358
483
 
359
484
  pi.registerFlag("fff-frecency-db", {
360
- description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
485
+ description:
486
+ "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
361
487
  type: "string",
362
488
  });
363
489
 
364
490
  pi.registerFlag("fff-history-db", {
365
- description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
491
+ description:
492
+ "Path to the query history database (overrides FFF_HISTORY_DB env)",
366
493
  type: "string",
367
494
  });
368
495
 
@@ -392,15 +519,20 @@ export default function fffExtension(pi: ExtensionAPI) {
392
519
  context: any,
393
520
  maxLines = 15,
394
521
  ) => {
395
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
396
- const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
522
+ const text =
523
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
524
+ const output =
525
+ result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
397
526
  if (!output) {
398
527
  text.setText(theme.fg("muted", "No output"));
399
528
  return text;
400
529
  }
401
530
 
402
531
  const lines = output.split("\n");
403
- const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
532
+ const displayLines = lines.slice(
533
+ 0,
534
+ options.expanded ? lines.length : maxLines,
535
+ );
404
536
  let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
405
537
  if (lines.length > displayLines.length) {
406
538
  content += theme.fg(
@@ -415,44 +547,50 @@ export default function fffExtension(pi: ExtensionAPI) {
415
547
  // --- grep tool ---
416
548
 
417
549
  const grepSchema = Type.Object({
418
- pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
550
+ pattern: Type.String({
551
+ description: "Search pattern (literal text or regex)",
552
+ }),
419
553
  path: Type.Optional(
420
554
  Type.String({
421
555
  description:
422
- "Directory or file constraint, e.g. 'src/' or '*.ts' (default: project root)",
556
+ "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.",
557
+ }),
558
+ ),
559
+ exclude: Type.Optional(
560
+ Type.Union([Type.String(), Type.Array(Type.String())], {
561
+ description:
562
+ "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/'.",
423
563
  }),
424
564
  ),
425
- literal: Type.Optional(
565
+ caseSensitive: Type.Optional(
426
566
  Type.Boolean({
427
- description: "Treat pattern as literal string instead of regex (default: true)",
567
+ description:
568
+ "Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
428
569
  }),
429
570
  ),
430
571
  context: Type.Optional(
431
- Type.Number({
432
- description: "Number of lines to show before and after each match (default: 0)",
433
- }),
572
+ Type.Number({ description: "Context lines before+after each match" }),
434
573
  ),
435
574
  limit: Type.Optional(
436
575
  Type.Number({
437
- description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
576
+ description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
438
577
  }),
439
578
  ),
440
579
  cursor: Type.Optional(
441
- Type.String({ description: "Cursor from previous result for pagination" }),
580
+ Type.String({ description: "Pagination cursor from previous result" }),
442
581
  ),
443
582
  });
444
583
 
445
584
  pi.registerTool({
446
585
  name: toolNames.grep,
447
586
  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)",
587
+ 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}.`,
588
+ promptSnippet: "Grep contents",
451
589
  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/'.",
590
+ "Prefer bare identifiers as patterns. Literal queries are most efficient.",
591
+ "Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
592
+ "caseSensitive: true when you need exact case (smart-case otherwise).",
593
+ "After 1-2 greps, read the top match instead of more greps.",
456
594
  ],
457
595
  parameters: grepSchema,
458
596
 
@@ -461,53 +599,109 @@ export default function fffExtension(pi: ExtensionAPI) {
461
599
 
462
600
  const f = await ensureFinder(activeCwd);
463
601
  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";
602
+ const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
603
+ // Auto-detect: regex if the pattern has regex metacharacters AND parses
604
+ // as a valid regex, otherwise plain literal. The fuzzy fallback below
605
+ // only kicks in for plain mode — regex queries are intentional.
606
+ const hasRegexSyntax =
607
+ params.pattern !==
608
+ params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
609
+ let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
610
+ if (mode === "regex") {
611
+ try {
612
+ new RegExp(params.pattern);
613
+ } catch {
614
+ mode = "plain";
615
+ }
616
+ }
617
+
618
+ // Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
619
+ // to try to read a whole file. That's not what grep is for — return a terse error
620
+ // steering them to a real pattern, preventing dozens of wasted retries.
621
+ const p = params.pattern.trim();
622
+ const isWildcardOnly =
623
+ hasRegexSyntax &&
624
+ /^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
625
+ p,
626
+ );
627
+
628
+ if (isWildcardOnly) {
629
+ return {
630
+ content: [
631
+ {
632
+ type: "text",
633
+ text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
634
+ },
635
+ ],
636
+ details: { totalMatched: 0, totalFiles: 0 },
637
+ };
638
+ }
639
+
640
+ // caseSensitive override flips smartCase off; omitting it keeps smart-case
641
+ // (case-insensitive when pattern is all lowercase).
642
+ const smartCase = params.caseSensitive !== true;
466
643
 
467
644
  const grepResult = f.grep(query, {
468
645
  mode,
469
- smartCase: true,
646
+ smartCase,
470
647
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
471
648
  cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
472
649
  beforeContext: params.context ?? 0,
473
650
  afterContext: params.context ?? 0,
651
+ classifyDefinitions: true,
474
652
  });
475
653
 
476
654
  if (!grepResult.ok) throw new Error(grepResult.error);
477
655
 
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;
656
+ let result = grepResult.value;
657
+ let fuzzyNotice: string | null = null;
658
+
659
+ // automatic fuzzy fallback allows to broad the queries and find different cases
660
+ if (result.items.length === 0 && !params.cursor && mode !== "regex") {
661
+ const fuzzy = f.grep(params.pattern, {
662
+ mode: "fuzzy",
663
+ smartCase,
664
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
665
+ cursor: null,
666
+ beforeContext: 0,
667
+ afterContext: 0,
668
+ classifyDefinitions: true,
669
+ });
670
+
671
+ if (fuzzy.ok && fuzzy.value.items.length > 0) {
672
+ fuzzyNotice = `0 exact matches. Maybe you meant this?`;
673
+ result = fuzzy.value;
674
+ }
675
+ }
482
676
 
677
+ let output = formatGrepOutput(result);
483
678
  const notices: string[] = [];
484
- if (result.items.length >= effectiveLimit)
679
+ if (result.regexFallbackError) {
485
680
  notices.push(
486
- `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
681
+ `Invalid regex: ${result.regexFallbackError}, used literal match`,
487
682
  );
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)
683
+ }
684
+ if (result.nextCursor) {
493
685
  notices.push(
494
- `More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
686
+ `Continue with cursor="${storeCursor(result.nextCursor)}"`,
495
687
  );
688
+ }
496
689
 
497
690
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
691
+ if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
498
692
 
499
693
  return {
500
694
  content: [{ type: "text", text: output }],
501
695
  details: {
502
696
  totalMatched: result.totalMatched,
503
697
  totalFiles: result.totalFiles,
504
- truncated: truncation.truncated,
505
698
  },
506
699
  };
507
700
  },
508
701
 
509
702
  renderCall(args, theme, context) {
510
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
703
+ const text =
704
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
511
705
  const pattern = args?.pattern ?? "";
512
706
  const path = args?.path ?? ".";
513
707
  let content =
@@ -532,28 +726,42 @@ export default function fffExtension(pi: ExtensionAPI) {
532
726
  const findSchema = Type.Object({
533
727
  pattern: Type.String({
534
728
  description:
535
- "Fuzzy search query for file names. Supports path prefixes ('src/') and globs ('*.ts').",
729
+ "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.",
536
730
  }),
537
731
  path: Type.Optional(
538
- Type.String({ description: "Directory to search in (default: project root)" }),
732
+ Type.String({
733
+ description:
734
+ "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.",
735
+ }),
736
+ ),
737
+ exclude: Type.Optional(
738
+ Type.Union([Type.String(), Type.Array(Type.String())], {
739
+ description:
740
+ "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/'.",
741
+ }),
539
742
  ),
540
743
  limit: Type.Optional(
541
744
  Type.Number({
542
- description: `Maximum number of results (default: ${DEFAULT_FIND_LIMIT})`,
745
+ description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
543
746
  }),
544
747
  ),
748
+ cursor: Type.Optional(
749
+ Type.String({ description: "Pagination cursor from previous result" }),
750
+ ),
545
751
  });
546
752
 
547
753
  pi.registerTool({
548
754
  name: toolNames.find,
549
755
  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)",
756
+ 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}.`,
757
+ promptSnippet: "Find files by path or glob",
553
758
  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.",
759
+ "Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
760
+ "Keep queries to 1-2 terms; extra words narrow.",
761
+ "Use for paths, not content. Use grep for content.",
762
+ "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.",
763
+ "To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
764
+ "Use exclude: 'test/,*.min.js' to cut noise in large repos.",
557
765
  ],
558
766
  parameters: findSchema,
559
767
 
@@ -561,43 +769,71 @@ export default function fffExtension(pi: ExtensionAPI) {
561
769
  if (signal?.aborted) throw new Error("Operation aborted");
562
770
 
563
771
  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
772
 
567
- const searchResult = f.fileSearch(query, { pageSize: effectiveLimit });
773
+ // Resume from a prior cursor if supplied — cursor owns query+pageSize so
774
+ // the agent can't accidentally mix patterns across pages.
775
+ const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
776
+ const effectiveLimit = resumed
777
+ ? resumed.pageSize
778
+ : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
779
+ const query = resumed
780
+ ? resumed.query
781
+ : buildQuery(params.path, params.pattern, params.exclude, activeCwd);
782
+ const pattern = resumed ? resumed.pattern : params.pattern;
783
+ const pageIndex = resumed?.nextPageIndex ?? 0;
784
+
785
+ const searchResult = f.fileSearch(query, {
786
+ pageIndex,
787
+ pageSize: effectiveLimit,
788
+ });
568
789
  if (!searchResult.ok) throw new Error(searchResult.error);
569
790
 
570
791
  const result = searchResult.value;
571
- let output = formatFindOutput(result, effectiveLimit);
572
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
573
- output = truncation.content;
792
+ const formatted = formatFindOutput(result, effectiveLimit, pattern);
793
+ let output = formatted.output;
794
+
795
+ // Infer hasMore: native fileSearch fills pageSize when more results
796
+ // exist, so if we got a full page AND totalMatched exceeds what we've
797
+ // shown so far there's another page to fetch.
798
+ const shownSoFar = pageIndex * effectiveLimit + result.items.length;
799
+ const hasMore =
800
+ result.items.length >= effectiveLimit &&
801
+ result.totalMatched > shownSoFar;
574
802
 
575
803
  const notices: string[] = [];
576
- if (result.items.length >= effectiveLimit)
804
+ if (formatted.weak && formatted.shownCount > 0)
577
805
  notices.push(
578
- `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
806
+ `Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
579
807
  );
580
- if (truncation.truncated)
581
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
582
- if (result.totalMatched > result.items.length)
808
+
809
+ if (!formatted.weak && hasMore) {
810
+ const remaining = result.totalMatched - shownSoFar;
811
+ const cursorId = storeFindCursor({
812
+ query,
813
+ pattern,
814
+ pageSize: effectiveLimit,
815
+ nextPageIndex: pageIndex + 1,
816
+ });
583
817
  notices.push(
584
- `${result.totalMatched} total matches (${result.totalFiles} indexed files)`,
818
+ `${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
585
819
  );
820
+ }
586
821
 
587
822
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
588
-
589
823
  return {
590
824
  content: [{ type: "text", text: output }],
591
825
  details: {
592
826
  totalMatched: result.totalMatched,
593
827
  totalFiles: result.totalFiles,
594
- truncated: truncation.truncated,
828
+ pageIndex,
829
+ hasMore,
595
830
  },
596
831
  };
597
832
  },
598
833
 
599
834
  renderCall(args, theme, context) {
600
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
835
+ const text =
836
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
601
837
  const pattern = args?.pattern ?? "";
602
838
  const path = args?.path ?? ".";
603
839
  let content =
@@ -607,6 +843,7 @@ export default function fffExtension(pi: ExtensionAPI) {
607
843
  theme.fg("toolOutput", ` in ${path}`);
608
844
  if (args?.limit !== undefined)
609
845
  content += theme.fg("toolOutput", ` (limit ${args.limit})`);
846
+ if (args?.cursor) content += theme.fg("muted", ` (page)`);
610
847
  text.setText(content);
611
848
  return text;
612
849
  },
@@ -617,121 +854,111 @@ export default function fffExtension(pi: ExtensionAPI) {
617
854
  });
618
855
 
619
856
  // --- multi_grep tool ---
857
+ // My latest tests are showing that the multi grep tool is only harmful, trying to get rid of it
858
+ const enableMultiGrep = process.env.PI_FFF_MULTIGREP === "1";
620
859
 
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({
860
+ if (enableMultiGrep) {
861
+ const multiGrepSchema = Type.Object({
862
+ patterns: Type.Array(Type.String(), {
628
863
  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})`,
864
+ "Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
640
865
  }),
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;
866
+ constraints: Type.Optional(
867
+ Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
868
+ ),
869
+ context: Type.Optional(
870
+ Type.Number({ description: "Context lines before+after" }),
871
+ ),
872
+ limit: Type.Optional(
873
+ Type.Number({
874
+ description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
875
+ }),
876
+ ),
877
+ cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
878
+ });
686
879
 
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
- );
880
+ pi.registerTool({
881
+ name: toolNames.multiGrep,
882
+ label: toolNames.multiGrep,
883
+ description:
884
+ "Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
885
+ promptSnippet: "Multi-pattern OR content search",
886
+ promptGuidelines: [
887
+ "Use when searching for several identifiers at once.",
888
+ "Include all naming-convention variants (snake/camel/Pascal).",
889
+ "Patterns are literal. Use constraints for file filters.",
890
+ ],
891
+ parameters: multiGrepSchema,
892
+
893
+ async execute(_toolCallId, params, signal) {
894
+ if (signal?.aborted) throw new Error("Operation aborted");
895
+ if (!params.patterns?.length)
896
+ throw new Error("patterns array must have at least 1 element");
897
+
898
+ const f = await ensureFinder(activeCwd);
899
+ const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
900
+
901
+ const grepResult = f.multiGrep({
902
+ patterns: params.patterns,
903
+ constraints: params.constraints,
904
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
905
+ smartCase: true,
906
+ cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
907
+ beforeContext: params.context ?? 0,
908
+ afterContext: params.context ?? 0,
909
+ });
910
+
911
+ if (!grepResult.ok) throw new Error(grepResult.error);
912
+
913
+ const result = grepResult.value;
914
+ let output = formatGrepOutput(result);
915
+
916
+ const notices: string[] = [];
917
+ if (result.items.length >= effectiveLimit)
918
+ notices.push(`${effectiveLimit}+ matches (refine patterns)`);
919
+ if (result.nextCursor)
920
+ notices.push(
921
+ `More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
922
+ );
698
923
 
699
- if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
924
+ if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
700
925
 
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
- },
926
+ return {
927
+ content: [{ type: "text", text: output }],
928
+ details: {
929
+ totalMatched: result.totalMatched,
930
+ totalFiles: result.totalFiles,
931
+ patterns: params.patterns,
932
+ },
933
+ };
934
+ },
711
935
 
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
- },
936
+ renderCall(args, theme, context) {
937
+ const text =
938
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
939
+ const patterns = args?.patterns ?? [];
940
+ const constraints = args?.constraints;
941
+ let content =
942
+ theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
943
+ " " +
944
+ theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
945
+ if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
946
+ if (args?.cursor) content += theme.fg("muted", ` (page)`);
947
+ text.setText(content);
948
+ return text;
949
+ },
725
950
 
726
- renderResult(result, options, theme, context) {
727
- return renderTextResult(result, options, theme, context, 15);
728
- },
729
- });
951
+ renderResult(result, options, theme, context) {
952
+ return renderTextResult(result, options, theme, context, 15);
953
+ },
954
+ });
955
+ } // end if (enableMultiGrep)
730
956
 
731
957
  // --- commands ---
732
958
 
733
959
  pi.registerCommand("fff-mode", {
734
- description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
960
+ description:
961
+ "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
735
962
  handler: async (args, ctx) => {
736
963
  const arg = (args || "").trim();
737
964
 
@@ -740,13 +967,19 @@ export default function fffExtension(pi: ExtensionAPI) {
740
967
  const mode = getMode();
741
968
  const flag = pi.getFlag("fff-mode") ?? "unset";
742
969
  const env = process.env.PI_FFF_MODE ?? "unset";
743
- ctx.ui.notify(`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`, "info");
970
+ ctx.ui.notify(
971
+ `Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`,
972
+ "info",
973
+ );
744
974
  return;
745
975
  }
746
976
 
747
977
  // Validate and set mode
748
978
  if (!VALID_MODES.includes(arg as FffMode)) {
749
- ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
979
+ ctx.ui.notify(
980
+ `Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
981
+ "warning",
982
+ );
750
983
  return;
751
984
  }
752
985
 
package/src/query.ts ADDED
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+
3
+ export function normalizePathConstraint(
4
+ pathConstraint: string,
5
+ cwd = process.cwd(),
6
+ ): string | null {
7
+ let trimmed = pathConstraint.trim();
8
+ if (!trimmed) return trimmed;
9
+
10
+ if (path.isAbsolute(trimmed)) {
11
+ const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
12
+ if (relative === "") return null;
13
+ if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
14
+ throw new Error(
15
+ `Path constraint must be relative to the workspace: ${pathConstraint}`,
16
+ );
17
+ }
18
+ trimmed = relative;
19
+ }
20
+
21
+ if (trimmed === "." || trimmed === "./") return null;
22
+ // Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
23
+ if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
24
+
25
+ // FFF's glob matcher can treat a hidden directory root glob such as
26
+ // `.agents/**` as empty, while the tool contract says this means "inside
27
+ // this directory". Collapse simple trailing recursive directory globs to the
28
+ // directory-prefix constraint understood by the parser. Keep real file globs
29
+ // such as `src/**/*.ts` unchanged.
30
+ const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
31
+ if (recursiveDir) {
32
+ const dir = recursiveDir[1];
33
+ if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
34
+ }
35
+
36
+ // Already signals path-constraint syntax to the parser.
37
+ if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
38
+ // Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
39
+ if (/[*?[{]/.test(trimmed)) return trimmed;
40
+ // Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
41
+ const lastSegment = trimmed.split("/").pop() ?? "";
42
+ if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
43
+ // Bare directory prefix → append `/` so the parser sees a PathSegment.
44
+ return `${trimmed}/`;
45
+ }
46
+
47
+ // Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
48
+ // understands (crates/fff-query-parser/src/parser.rs). We normalize each one
49
+ // the same way as the include path so bare dirs become PathSegment excludes.
50
+ // Tolerate callers passing already-negated forms like `!src/` by stripping
51
+ // the leading `!` before normalizing so we never double-negate (`!!src/`).
52
+ export function normalizeExcludes(
53
+ exclude: string | string[] | undefined,
54
+ cwd = process.cwd(),
55
+ ): string[] {
56
+ if (!exclude) return [];
57
+ const list = Array.isArray(exclude) ? exclude : [exclude];
58
+ const out: string[] = [];
59
+ for (const raw of list) {
60
+ const parts = raw
61
+ .split(/[,\s]+/)
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+ for (const p of parts) {
65
+ const stripped = p.startsWith("!") ? p.slice(1) : p;
66
+ const normalized = normalizePathConstraint(stripped, cwd);
67
+ if (normalized) out.push(`!${normalized}`);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function buildQuery(
74
+ path: string | undefined,
75
+ pattern: string,
76
+ exclude?: string | string[],
77
+ cwd = process.cwd(),
78
+ ): string {
79
+ const parts: string[] = [];
80
+ if (path) {
81
+ const pathConstraint = normalizePathConstraint(path, cwd);
82
+ if (pathConstraint) parts.push(pathConstraint);
83
+ }
84
+ parts.push(...normalizeExcludes(exclude, cwd));
85
+ parts.push(pattern);
86
+ return parts.join(" ");
87
+ }