@ff-labs/pi-fff 0.6.4 → 0.6.5-nightly.0f5ead1

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