@edxeth/pi-fff 0.7.2-edxeth.0 → 0.7.3-nightly.7a199d8

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/README.md +3 -0
  2. package/package.json +3 -3
  3. package/src/index.ts +337 -107
package/README.md CHANGED
@@ -86,6 +86,7 @@ Search file contents. Smart case, plain text by default, regex optional.
86
86
  Parameters:
87
87
  - `pattern` — search text or regex
88
88
  - `path` — directory/file constraint (e.g. `src/`, `*.ts`)
89
+ - `includeIgnored` — include files matched by `.gitignore`, `.ignore`, git excludes, or global gitignore when intentionally inspecting ignored paths
89
90
  - `ignoreCase` — force case-insensitive
90
91
  - `literal` — treat as literal string (default: true)
91
92
  - `context` — context lines around matches
@@ -99,6 +100,7 @@ Fuzzy file name search. Frecency-ranked.
99
100
  Parameters:
100
101
  - `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
101
102
  - `path` — directory constraint
103
+ - `includeIgnored` — include files matched by `.gitignore`, `.ignore`, git excludes, or global gitignore when intentionally inspecting ignored paths
102
104
  - `limit` — max results (default: 200)
103
105
 
104
106
  ### `fff-multi-grep`
@@ -134,6 +136,7 @@ Mode precedence:
134
136
  - `--fff-mode <mode>` — set mode (see above)
135
137
  - `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
136
138
  - `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
139
+ - `PI_FFF_MULTIGREP=0` — disable `fff-multi-grep` (enabled by default)
137
140
 
138
141
  ## Data
139
142
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@edxeth/pi-fff",
3
3
  "public": true,
4
- "version": "0.7.2-edxeth.0",
4
+ "version": "0.7.3-nightly.7a199d8",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "@edxeth/fff-node": "0.7.2-edxeth.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@mariozechner/pi-coding-agent": "*",
47
- "@mariozechner/pi-tui": "*",
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-tui": "*",
48
48
  "@sinclair/typebox": "*"
49
49
  },
50
50
  "devDependencies": {
package/src/index.ts CHANGED
@@ -8,23 +8,24 @@
8
8
  import { execSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ Text,
15
+ type AutocompleteItem,
16
+ type AutocompleteProvider,
17
+ } from "@earendil-works/pi-tui";
18
+ import { Type } from "@sinclair/typebox";
11
19
  import type {
12
20
  GrepCursor,
13
21
  GrepMode,
14
22
  GrepResult,
23
+ InitOptions,
15
24
  MixedItem,
16
25
  SearchResult,
17
26
  } from "@edxeth/fff-node";
18
27
  import { FileFinder } from "@edxeth/fff-node";
19
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
20
- import { CustomEditor } from "@mariozechner/pi-coding-agent";
21
- import {
22
- type AutocompleteItem,
23
- type AutocompleteProvider,
24
- Text,
25
- } from "@mariozechner/pi-tui";
26
- import { Type } from "@sinclair/typebox";
27
- import { buildQuery } from "./query";
28
+ import { buildQuery, normalizeExcludes } from "./query";
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Constants
@@ -37,6 +38,7 @@ const GREP_MAX_LINE_LENGTH = 500;
37
38
  const MENTION_MAX_RESULTS = 20;
38
39
 
39
40
  type FffMode = "tools-and-ui" | "tools-only" | "override";
41
+ type FffInitOptions = InitOptions & { includeIgnored?: boolean };
40
42
 
41
43
  const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
42
44
 
@@ -65,12 +67,17 @@ function resolveToolNames(mode: FffMode): ToolNames {
65
67
  // Cursor store — simple bounded Map for pagination cursors
66
68
  // ---------------------------------------------------------------------------
67
69
 
68
- const cursorCache = new Map<string, GrepCursor>();
70
+ interface StoredGrepCursor {
71
+ cursor: GrepCursor;
72
+ includeIgnored: boolean;
73
+ }
74
+
75
+ const cursorCache = new Map<string, StoredGrepCursor>();
69
76
  let cursorCounter = 0;
70
77
 
71
- function storeCursor(cursor: GrepCursor): string {
78
+ function storeCursor(cursor: GrepCursor, includeIgnored = false): string {
72
79
  const id = `fff_c${++cursorCounter}`;
73
- cursorCache.set(id, cursor);
80
+ cursorCache.set(id, { cursor, includeIgnored });
74
81
  if (cursorCache.size > 200) {
75
82
  const first = cursorCache.keys().next().value;
76
83
  if (first) cursorCache.delete(first);
@@ -78,7 +85,7 @@ function storeCursor(cursor: GrepCursor): string {
78
85
  return id;
79
86
  }
80
87
 
81
- function getCursor(id: string): GrepCursor | undefined {
88
+ function getCursor(id: string): StoredGrepCursor | undefined {
82
89
  return cursorCache.get(id);
83
90
  }
84
91
 
@@ -91,6 +98,7 @@ interface FindCursor {
91
98
  pattern: string;
92
99
  pageSize: number;
93
100
  nextPageIndex: number;
101
+ includeIgnored: boolean;
94
102
  }
95
103
 
96
104
  const findCursorCache = new Map<string, FindCursor>();
@@ -131,6 +139,7 @@ export function fffFileAnnotation(item: {
131
139
  gitStatus?: string;
132
140
  totalFrecencyScore?: number;
133
141
  accessFrecencyScore?: number;
142
+ patternIndices?: number[];
134
143
  }): string {
135
144
  const git = item.gitStatus;
136
145
  if (git && git !== "clean" && git !== "unknown" && git !== "") {
@@ -144,6 +153,15 @@ export function fffFileAnnotation(item: {
144
153
  return "";
145
154
  }
146
155
 
156
+ /** Compact label for a single pattern index range. */
157
+ function formatPatternLabel(indices: Set<number>): string {
158
+ if (indices.size === 0) return "";
159
+ if (indices.size === 1) return ` [${[...indices][0]}]`;
160
+ // Show sorted compact range
161
+ const sorted = [...indices].sort((a, b) => a - b);
162
+ return ` [${sorted.join(",")}]`;
163
+ }
164
+
147
165
  // fff-core native definition classifier (byte-level scanner in Rust) is enabled
148
166
  // via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
149
167
  // downstream consumers; pi-fff does NOT use it to re-sort.
@@ -155,19 +173,31 @@ export function fffFileAnnotation(item: {
155
173
  // engine emits them that way.
156
174
 
157
175
  function formatGrepOutput(result: GrepResult): string {
158
- if (result.items.length === 0) return "No matches found";
176
+ if (result.items.length === 0) {
177
+ if (result.regexFallbackError) return result.regexFallbackError;
178
+ return "No matches found";
179
+ }
159
180
 
160
181
  // Build file-grouped output in the order files first appear in the result.
161
182
  // This preserves native frecency ordering across files without re-sorting.
162
183
  const lines: string[] = [];
163
184
  let currentFile = "";
185
+ let currentFilePatterns: Set<number> | null = null;
164
186
  let _shown = 0;
165
187
 
188
+ // Detect if this is a multi-pattern result by checking first match
189
+ const isMultiPattern = result.items[0]?.patternIndices !== undefined;
190
+
166
191
  for (const match of result.items) {
167
192
  if (match.relativePath !== currentFile) {
168
193
  if (lines.length > 0) lines.push("");
169
194
  currentFile = match.relativePath;
170
- lines.push(`${currentFile}${fffFileAnnotation(match)}`);
195
+ currentFilePatterns = isMultiPattern ? new Set() : null;
196
+
197
+ let header = currentFile;
198
+ const annotation = fffFileAnnotation(match);
199
+ if (annotation) header += annotation;
200
+ lines.push(header);
171
201
  }
172
202
 
173
203
  match.contextBefore?.forEach((line: string, i: number) => {
@@ -175,7 +205,22 @@ function formatGrepOutput(result: GrepResult): string {
175
205
  lines.push(` ${lineNum}- ${truncateLine(line)}`);
176
206
  });
177
207
 
178
- lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
208
+ // Build pattern prefix for this match line
209
+ let patternPrefix = "";
210
+ if (isMultiPattern && match.patternIndices) {
211
+ const uniqueIndices = new Set(match.patternIndices);
212
+ if (uniqueIndices.size === 1) {
213
+ patternPrefix = `[${[...uniqueIndices][0]}] `;
214
+ currentFilePatterns?.add([...uniqueIndices][0]);
215
+ } else {
216
+ // Multiple patterns on same line — show all
217
+ const sorted = [...uniqueIndices].sort((a, b) => a - b);
218
+ patternPrefix = `[${sorted.join("+")}] `;
219
+ sorted.forEach((i) => currentFilePatterns?.add(i));
220
+ }
221
+ }
222
+
223
+ lines.push(` ${patternPrefix}${match.lineNumber}: ${truncateLine(match.lineContent)}`);
179
224
  _shown++;
180
225
 
181
226
  match.contextAfter?.forEach((line: string, i: number) => {
@@ -334,7 +379,7 @@ function createFffMentionProvider(
334
379
 
335
380
  export default function fffExtension(pi: ExtensionAPI) {
336
381
  const finders = new Map<string, FileFinder>();
337
- let activeBasePath: string | null = null;
382
+ let activeFinderKey: string | null = null;
338
383
  // Concurrent ensureFinder() callers share in-flight promises by base path so
339
384
  // FileFinder.create() (which takes native DB locks) runs at most once per
340
385
  // base path at a time — otherwise parallel tool calls would race and
@@ -416,6 +461,27 @@ export default function fffExtension(pi: ExtensionAPI) {
416
461
  : `Path not found: ${statPath || pathConstraint}`;
417
462
  }
418
463
 
464
+ async function noResultsMessage(
465
+ base: string,
466
+ basePath: string,
467
+ pathConstraint: string | undefined,
468
+ includeIgnored: boolean,
469
+ ): Promise<string> {
470
+ if (includeIgnored || !pathConstraint) return base;
471
+
472
+ const ignored = await withFinderLease(basePath, (finder) => {
473
+ const checker = finder as FileFinder & {
474
+ isPathIgnored?: (path: string) => { ok: boolean; value?: boolean };
475
+ };
476
+ return checker.isPathIgnored?.(pathConstraint);
477
+ });
478
+
479
+ if (ignored?.ok && ignored.value === true) {
480
+ return `${base}. Path is ignored. Retry with \`includeIgnored: true\`.`;
481
+ }
482
+ return base;
483
+ }
484
+
419
485
  function absolutePathBase(pathConstraint: string): {
420
486
  basePath: string;
421
487
  pathConstraint?: string;
@@ -456,49 +522,58 @@ export default function fffExtension(pi: ExtensionAPI) {
456
522
  return { basePath: activeCwd, pathConstraint };
457
523
  }
458
524
 
525
+ function finderKey(basePath: string, includeIgnored: boolean): string {
526
+ return `${includeIgnored ? "ignored" : "normal"}:${basePath}`;
527
+ }
528
+
459
529
  function trimFinderCache() {
460
530
  while (finders.size >= MAX_CACHED_FINDERS) {
461
531
  const evictable = [...finders.entries()].find(
462
- ([basePath]) => (finderActiveOps.get(basePath) ?? 0) === 0,
532
+ ([key]) => (finderActiveOps.get(key) ?? 0) === 0,
463
533
  );
464
534
  if (!evictable) return;
465
535
 
466
- const [oldestBase, oldestFinder] = evictable;
536
+ const [oldestKey, oldestFinder] = evictable;
467
537
  if (!oldestFinder.isDestroyed) oldestFinder.destroy();
468
- finders.delete(oldestBase);
469
- if (activeBasePath === oldestBase) activeBasePath = null;
538
+ finders.delete(oldestKey);
539
+ if (activeFinderKey === oldestKey) activeFinderKey = null;
470
540
  }
471
541
  }
472
542
 
473
- function ensureFinder(basePath: string): Promise<FileFinder> {
474
- const existing = finders.get(basePath);
543
+ function ensureFinder(basePath: string, includeIgnored = false): Promise<FileFinder> {
544
+ const key = finderKey(basePath, includeIgnored);
545
+ const existing = finders.get(key);
475
546
  if (existing && !existing.isDestroyed) return Promise.resolve(existing);
476
- const pending = finderPromises.get(basePath);
547
+ const pending = finderPromises.get(key);
477
548
  if (pending) return pending;
478
549
 
479
550
  const promise = (async () => {
480
551
  trimFinderCache();
481
- const useDatabases = basePath === activeCwd;
552
+ const useDatabases = basePath === activeCwd && !includeIgnored;
553
+ const isWorkspace = basePath === activeCwd;
482
554
  const result = FileFinder.create({
483
555
  basePath,
484
556
  frecencyDbPath: useDatabases ? frecencyDbPath : undefined,
485
557
  historyDbPath: useDatabases ? historyDbPath : undefined,
486
- aiMode: true,
487
- });
558
+ aiMode: isWorkspace,
559
+ disableContentIndexing: !isWorkspace,
560
+ disableMmapCache: !isWorkspace,
561
+ includeIgnored,
562
+ } as FffInitOptions);
488
563
 
489
564
  if (!result.ok)
490
565
  throw new Error(`Failed to create FFF file finder: ${result.error}`);
491
566
 
492
567
  const finder = result.value;
493
- finders.set(basePath, finder);
494
- activeBasePath = basePath;
568
+ finders.set(key, finder);
569
+ if (!includeIgnored) activeFinderKey = key;
495
570
  await finder.waitForScan(15000);
496
571
  return finder;
497
572
  })().finally(() => {
498
- finderPromises.delete(basePath);
573
+ finderPromises.delete(key);
499
574
  });
500
575
 
501
- finderPromises.set(basePath, promise);
576
+ finderPromises.set(key, promise);
502
577
  return promise;
503
578
  }
504
579
 
@@ -509,20 +584,22 @@ export default function fffExtension(pi: ExtensionAPI) {
509
584
  finders.clear();
510
585
  finderLocks.clear();
511
586
  finderActiveOps.clear();
512
- activeBasePath = null;
587
+ activeFinderKey = null;
513
588
  }
514
589
 
515
590
  async function withFinderLease<T>(
516
591
  basePath: string,
517
592
  work: (finder: FileFinder) => T | Promise<T>,
593
+ includeIgnored = false,
518
594
  ): Promise<T> {
519
- const previous = finderLocks.get(basePath) ?? Promise.resolve();
595
+ const key = finderKey(basePath, includeIgnored);
596
+ const previous = finderLocks.get(key) ?? Promise.resolve();
520
597
  let release!: () => void;
521
598
  const current = new Promise<void>((resolve) => {
522
599
  release = resolve;
523
600
  });
524
601
  finderLocks.set(
525
- basePath,
602
+ key,
526
603
  previous.then(
527
604
  () => current,
528
605
  () => current,
@@ -530,22 +607,22 @@ export default function fffExtension(pi: ExtensionAPI) {
530
607
  );
531
608
 
532
609
  await previous.catch(() => undefined);
533
- finderActiveOps.set(basePath, (finderActiveOps.get(basePath) ?? 0) + 1);
610
+ finderActiveOps.set(key, (finderActiveOps.get(key) ?? 0) + 1);
534
611
  try {
535
- const finder = await ensureFinder(basePath);
612
+ const finder = await ensureFinder(basePath, includeIgnored);
536
613
  return await work(finder);
537
614
  } finally {
538
- const remaining = (finderActiveOps.get(basePath) ?? 1) - 1;
539
- if (remaining > 0) finderActiveOps.set(basePath, remaining);
540
- else finderActiveOps.delete(basePath);
615
+ const remaining = (finderActiveOps.get(key) ?? 1) - 1;
616
+ if (remaining > 0) finderActiveOps.set(key, remaining);
617
+ else finderActiveOps.delete(key);
541
618
  release();
542
- if (finderLocks.get(basePath) === current) finderLocks.delete(basePath);
619
+ if (finderLocks.get(key) === current) finderLocks.delete(key);
543
620
  }
544
621
  }
545
622
 
546
623
  function getActiveFinder(): FileFinder | null {
547
- if (!activeBasePath) return null;
548
- const finder = finders.get(activeBasePath);
624
+ if (!activeFinderKey) return null;
625
+ const finder = finders.get(activeFinderKey);
549
626
  return finder && !finder.isDestroyed ? finder : null;
550
627
  }
551
628
 
@@ -729,6 +806,12 @@ export default function fffExtension(pi: ExtensionAPI) {
729
806
  "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/'.",
730
807
  }),
731
808
  ),
809
+ includeIgnored: Type.Optional(
810
+ Type.Boolean({
811
+ description:
812
+ "Include files matched by .gitignore, .ignore, git excludes, and global gitignore. Default false. Use when the target file or directory exists but normal search cannot see it because it is ignored, such as node_modules or build output.",
813
+ }),
814
+ ),
732
815
  caseSensitive: Type.Optional(
733
816
  Type.Boolean({
734
817
  description:
@@ -754,11 +837,13 @@ export default function fffExtension(pi: ExtensionAPI) {
754
837
  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}.`,
755
838
  promptSnippet: "Grep contents",
756
839
  promptGuidelines: [
757
- "Prefer bare identifiers as patterns. Literal queries are most efficient.",
758
- "Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
759
- "caseSensitive: true when you need exact case (smart-case otherwise).",
760
- "Never combine paths in one call. For multiple files, make separate grep calls.",
761
- "After 1-2 greps, read the top match instead of more greps.",
840
+ "Use for content, not paths.",
841
+ "Use one literal identifier or phrase first; regex only when needed.",
842
+ "Use path for one scope and exclude for noise, e.g. path: 'src/', exclude: 'test/,*.min.js'.",
843
+ "Set includeIgnored only when intentionally searching ignored files such as node_modules or build output.",
844
+ "Set caseSensitive: true only when exact case matters; otherwise smart-case applies.",
845
+ "Use multi_grep for 2-6 literal identifiers or naming variants; grep regex alternation is OK for a simple 2-way OR.",
846
+ "After 1-2 greps, read the best match instead of widening search.",
762
847
  ],
763
848
  parameters: grepSchema,
764
849
 
@@ -818,17 +903,22 @@ export default function fffExtension(pi: ExtensionAPI) {
818
903
  // caseSensitive override flips smartCase off; omitting it keeps smart-case
819
904
  // (case-insensitive when pattern is all lowercase).
820
905
  const smartCase = params.caseSensitive !== true;
906
+ const storedCursor = params.cursor ? getCursor(params.cursor) : undefined;
907
+ const includeIgnored = storedCursor?.includeIgnored ?? params.includeIgnored === true;
821
908
 
822
- const grepResult = await withFinderLease(searchBase.basePath, (finder) =>
823
- finder.grep(query, {
824
- mode,
825
- smartCase,
826
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
827
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
828
- beforeContext: params.context ?? 0,
829
- afterContext: params.context ?? 0,
830
- classifyDefinitions: true,
831
- }),
909
+ const grepResult = await withFinderLease(
910
+ searchBase.basePath,
911
+ (finder) =>
912
+ finder.grep(query, {
913
+ mode,
914
+ smartCase,
915
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
916
+ cursor: storedCursor?.cursor ?? null,
917
+ beforeContext: params.context ?? 0,
918
+ afterContext: params.context ?? 0,
919
+ classifyDefinitions: true,
920
+ }),
921
+ includeIgnored,
832
922
  );
833
923
 
834
924
  if (!grepResult.ok) throw new Error(grepResult.error);
@@ -843,16 +933,19 @@ export default function fffExtension(pi: ExtensionAPI) {
843
933
  !params.exclude &&
844
934
  mode !== "regex"
845
935
  ) {
846
- const fuzzy = await withFinderLease(searchBase.basePath, (finder) =>
847
- finder.grep(query, {
848
- mode: "fuzzy",
849
- smartCase,
850
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
851
- cursor: null,
852
- beforeContext: 0,
853
- afterContext: 0,
854
- classifyDefinitions: true,
855
- }),
936
+ const fuzzy = await withFinderLease(
937
+ searchBase.basePath,
938
+ (finder) =>
939
+ finder.grep(query, {
940
+ mode: "fuzzy",
941
+ smartCase,
942
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
943
+ cursor: null,
944
+ beforeContext: 0,
945
+ afterContext: 0,
946
+ classifyDefinitions: true,
947
+ }),
948
+ includeIgnored,
856
949
  );
857
950
 
858
951
  if (fuzzy.ok && fuzzy.value.items.length > 0) {
@@ -861,7 +954,10 @@ export default function fffExtension(pi: ExtensionAPI) {
861
954
  }
862
955
  }
863
956
 
864
- if (result.items.length === 0) throw new Error("No matches found");
957
+ if (result.items.length === 0)
958
+ throw new Error(
959
+ await noResultsMessage("No matches found", searchBase.basePath, params.path, includeIgnored),
960
+ );
865
961
 
866
962
  let output = formatGrepOutput(result);
867
963
  const notices: string[] = [];
@@ -869,8 +965,9 @@ export default function fffExtension(pi: ExtensionAPI) {
869
965
  notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
870
966
  }
871
967
  if (result.nextCursor) {
872
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
968
+ notices.push(`Continue with cursor="${storeCursor(result.nextCursor, includeIgnored)}"`);
873
969
  }
970
+ if (includeIgnored) notices.unshift("ignored files included");
874
971
 
875
972
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
876
973
  if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
@@ -924,6 +1021,12 @@ export default function fffExtension(pi: ExtensionAPI) {
924
1021
  "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/'.",
925
1022
  }),
926
1023
  ),
1024
+ includeIgnored: Type.Optional(
1025
+ Type.Boolean({
1026
+ description:
1027
+ "Include files matched by .gitignore, .ignore, git excludes, and global gitignore. Default false. Use when the target file or directory exists but normal search cannot see it because it is ignored, such as node_modules or build output.",
1028
+ }),
1029
+ ),
927
1030
  limit: Type.Optional(
928
1031
  Type.Number({
929
1032
  description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
@@ -940,13 +1043,14 @@ export default function fffExtension(pi: ExtensionAPI) {
940
1043
  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}.`,
941
1044
  promptSnippet: "Find files by path or glob",
942
1045
  promptGuidelines: [
943
- "Matches the WHOLE path, not just the filename `profile` hits `chrome/browser/profiles/x.cc` too.",
944
- "Keep queries to 1-2 terms; extra words narrow.",
945
- "Use one path constraint only: one file, directory, or glob.",
946
- "Use for paths, not content. Use grep for content.",
947
- "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.",
948
- "To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
949
- "Use exclude: 'test/,*.min.js' to cut noise in large repos.",
1046
+ "Use for paths, not content; use grep for content.",
1047
+ "Pattern is fuzzy over the whole repo-relative path, not just the basename.",
1048
+ "Keep pattern to 1-2 terms; extra words narrow results.",
1049
+ "Put exact paths, directories, and globs in path, not pattern, e.g. path: '**/profile.h'.",
1050
+ "Use only one path constraint: one file, directory, or glob.",
1051
+ "For directory contents, use path: 'dir/**' with pattern: '' or '*'.",
1052
+ "Use exclude to cut noise, e.g. 'test/,*.min.js'.",
1053
+ "Set includeIgnored only when intentionally searching ignored files such as node_modules or build output.",
950
1054
  ],
951
1055
  parameters: findSchema,
952
1056
 
@@ -981,12 +1085,16 @@ export default function fffExtension(pi: ExtensionAPI) {
981
1085
  throw new Error(pathLikePatternMessage(pattern));
982
1086
  }
983
1087
  const pageIndex = resumed?.nextPageIndex ?? 0;
1088
+ const includeIgnored = resumed?.includeIgnored ?? params.includeIgnored === true;
984
1089
 
985
- const searchResult = await withFinderLease(basePath, (finder) =>
986
- finder.fileSearch(query, {
987
- pageIndex,
988
- pageSize: effectiveLimit,
989
- }),
1090
+ const searchResult = await withFinderLease(
1091
+ basePath,
1092
+ (finder) =>
1093
+ finder.fileSearch(query, {
1094
+ pageIndex,
1095
+ pageSize: effectiveLimit,
1096
+ }),
1097
+ includeIgnored,
990
1098
  );
991
1099
  if (!searchResult.ok) throw new Error(searchResult.error);
992
1100
 
@@ -998,11 +1106,14 @@ export default function fffExtension(pi: ExtensionAPI) {
998
1106
  params.exclude,
999
1107
  basePath,
1000
1108
  );
1001
- const fallback = await withFinderLease(basePath, (finder) =>
1002
- finder.fileSearch(scopedQuery, {
1003
- pageIndex: 0,
1004
- pageSize: Math.max(effectiveLimit, 500),
1005
- }),
1109
+ const fallback = await withFinderLease(
1110
+ basePath,
1111
+ (finder) =>
1112
+ finder.fileSearch(scopedQuery, {
1113
+ pageIndex: 0,
1114
+ pageSize: Math.max(effectiveLimit, 500),
1115
+ }),
1116
+ includeIgnored,
1006
1117
  );
1007
1118
  if (fallback.ok) {
1008
1119
  const needle = pattern.trim().toLowerCase();
@@ -1020,7 +1131,74 @@ export default function fffExtension(pi: ExtensionAPI) {
1020
1131
  }
1021
1132
  }
1022
1133
  }
1023
- if (result.items.length === 0) throw new Error("No files found matching pattern");
1134
+
1135
+ let regexFallbackUsed = false;
1136
+ let regexFallbackAlts: string[] = [];
1137
+
1138
+ // Regex alternation recovery: models commonly write "foo|bar" expecting OR,
1139
+ // but FFF fuzzy treats | as a literal character that no file path contains.
1140
+ // When | is present, search each alternative independently and merge results,
1141
+ // preserving true OR semantics instead of raw fuzzy matching.
1142
+ if (!params.cursor && !resumed && pattern.includes("|")) {
1143
+ const alternatives = pattern
1144
+ .split("|")
1145
+ .map((s) => s.trim().replace(/[()]/g, ""))
1146
+ .filter(Boolean);
1147
+ regexFallbackAlts = alternatives;
1148
+ if (alternatives.length > 1) {
1149
+ const seen = new Set<string>();
1150
+ const merged: Array<{
1151
+ relativePath: string;
1152
+ fileName?: string;
1153
+ gitStatus?: string;
1154
+ totalFrecencyScore?: number;
1155
+ accessFrecencyScore?: number;
1156
+ [key: string]: unknown;
1157
+ }> = [];
1158
+ for (const alt of alternatives) {
1159
+ const altQuery = buildQuery(
1160
+ resolvedBase.pathConstraint,
1161
+ alt,
1162
+ params.exclude,
1163
+ basePath,
1164
+ );
1165
+ const altResult = await withFinderLease(
1166
+ basePath,
1167
+ (finder) =>
1168
+ finder.fileSearch(altQuery, {
1169
+ pageIndex: 0,
1170
+ pageSize: effectiveLimit,
1171
+ }),
1172
+ includeIgnored,
1173
+ );
1174
+ if (altResult.ok) {
1175
+ for (const item of altResult.value.items) {
1176
+ if (!seen.has(item.relativePath)) {
1177
+ seen.add(item.relativePath);
1178
+ merged.push(item);
1179
+ }
1180
+ }
1181
+ }
1182
+ }
1183
+ if (merged.length > 0) {
1184
+ result = {
1185
+ items: merged,
1186
+ totalMatched: merged.length,
1187
+ } as typeof result;
1188
+ // Scores from different alternative searches aren't cross-comparable,
1189
+ // so fabricate above-threshold scores to avoid weak-match capping.
1190
+ (result as Record<string, unknown>).scores = merged.map(() => ({
1191
+ total: weakScoreThreshold(pattern) + 1,
1192
+ }));
1193
+ regexFallbackUsed = true;
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ if (result.items.length === 0)
1199
+ throw new Error(
1200
+ await noResultsMessage("No files found matching pattern", basePath, params.path, includeIgnored),
1201
+ );
1024
1202
 
1025
1203
  const formatted = formatFindOutput(result, effectiveLimit, pattern);
1026
1204
  let output = formatted.output;
@@ -1041,7 +1219,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1041
1219
  if (formatted.literalTailSuppressed && hiddenFuzzyMatches >= 1000)
1042
1220
  notices.push(`${formatted.shownCount} exact matches shown. Fuzzy tail hidden`);
1043
1221
 
1044
- if (!formatted.weak && !formatted.literalTailSuppressed && hasMore) {
1222
+ if (!formatted.weak && !formatted.literalTailSuppressed && hasMore && !regexFallbackUsed) {
1045
1223
  const remaining = result.totalMatched - shownSoFar;
1046
1224
  const cursorId = storeFindCursor({
1047
1225
  basePath,
@@ -1049,9 +1227,14 @@ export default function fffExtension(pi: ExtensionAPI) {
1049
1227
  pattern,
1050
1228
  pageSize: effectiveLimit,
1051
1229
  nextPageIndex: pageIndex + 1,
1230
+ includeIgnored,
1052
1231
  });
1053
1232
  notices.push(`${remaining} more. Next page: find cursor="${cursorId}"`);
1054
1233
  }
1234
+ if (regexFallbackUsed) {
1235
+ notices.push(`Regex alternation (|) in pattern treated as ${regexFallbackAlts.length} searches: ${regexFallbackAlts.map((s) => `"${s}"`).join(", ")}`);
1236
+ }
1237
+ if (includeIgnored) notices.unshift("ignored files included");
1055
1238
 
1056
1239
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
1057
1240
  return {
@@ -1087,15 +1270,35 @@ export default function fffExtension(pi: ExtensionAPI) {
1087
1270
  });
1088
1271
 
1089
1272
  // --- multi_grep tool ---
1090
- // My latest tests are showing that the multi grep tool is only harmful, trying to get rid of it
1091
- const enableMultiGrep = process.env.PI_FFF_MULTIGREP === "1";
1273
+ // Enabled by default. Disable with `PI_FFF_MULTIGREP=0`
1274
+ const enableMultiGrep = process.env.PI_FFF_MULTIGREP !== "0";
1092
1275
 
1093
1276
  if (enableMultiGrep) {
1094
1277
  const multiGrepSchema = Type.Object({
1095
1278
  patterns: Type.Array(Type.String(), {
1096
1279
  description:
1097
1280
  "Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
1281
+ minItems: 1,
1282
+ maxItems: 20,
1098
1283
  }),
1284
+ path: Type.Optional(
1285
+ Type.String({
1286
+ description:
1287
+ "Single path constraint: one file, one directory, or one glob. Do not pass multiple paths. Applied to the full repo-relative path.",
1288
+ }),
1289
+ ),
1290
+ exclude: Type.Optional(
1291
+ Type.Union([Type.String(), Type.Array(Type.String())], {
1292
+ description:
1293
+ "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. Example: 'test/,*.min.js,!vendor/'.",
1294
+ }),
1295
+ ),
1296
+ includeIgnored: Type.Optional(
1297
+ Type.Boolean({
1298
+ description:
1299
+ "Include files matched by .gitignore, .ignore, git excludes, and global gitignore. Default false. Use when the target file or directory exists but normal search cannot see it because it is ignored, such as node_modules or build output.",
1300
+ }),
1301
+ ),
1099
1302
  constraints: Type.Optional(
1100
1303
  Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
1101
1304
  ),
@@ -1112,12 +1315,15 @@ export default function fffExtension(pi: ExtensionAPI) {
1112
1315
  name: toolNames.multiGrep,
1113
1316
  label: toolNames.multiGrep,
1114
1317
  description:
1115
- "Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
1318
+ "Search file contents for ANY of multiple literal patterns (OR logic). Faster than regex alternation for literal text.",
1116
1319
  promptSnippet: "Multi-pattern OR content search",
1117
1320
  promptGuidelines: [
1118
- "Use when searching for several identifiers at once.",
1119
- "Include all naming-convention variants (snake/camel/Pascal).",
1120
- "Patterns are literal. Use constraints for file filters.",
1321
+ "Use for content searches with 2-6 literal identifiers or naming variants.",
1322
+ "Patterns are ORed literals, not regexes or globs.",
1323
+ "Do not use for broad concepts or unrelated keywords; run separate searches instead.",
1324
+ "Use constraints for file filters, e.g. '*.{ts,tsx} !test/'.",
1325
+ "Output tags each match with the pattern index.",
1326
+ "Set includeIgnored only when intentionally searching ignored files such as node_modules or build output.",
1121
1327
  ],
1122
1328
  parameters: multiGrepSchema,
1123
1329
 
@@ -1125,25 +1331,48 @@ export default function fffExtension(pi: ExtensionAPI) {
1125
1331
  if (signal?.aborted) throw new Error("Operation aborted");
1126
1332
  if (!params.patterns?.length)
1127
1333
  throw new Error("patterns array must have at least 1 element");
1334
+ if (params.path && pathLooksLikeMultiplePaths(params.path)) {
1335
+ throw new Error(
1336
+ "Path appears to contain multiple entries — multi_grep accepts a single path. Use separate calls or a glob pattern.",
1337
+ );
1338
+ }
1339
+ const invalidPath = params.path ? invalidPathMessage(params.path) : null;
1340
+ if (invalidPath) throw new Error(invalidPath);
1128
1341
 
1129
1342
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
1130
-
1131
- const grepResult = await withFinderLease(activeCwd, (finder) =>
1132
- finder.multiGrep({
1133
- patterns: params.patterns,
1134
- constraints: params.constraints,
1135
- maxMatchesPerFile: Math.min(effectiveLimit, 50),
1136
- smartCase: true,
1137
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
1138
- beforeContext: params.context ?? 0,
1139
- afterContext: params.context ?? 0,
1140
- }),
1343
+ const searchBase = resolveSearchBase(params.path);
1344
+ const includeIgnored = params.includeIgnored === true;
1345
+
1346
+ // Build combined constraints from pathConstraint + exclude + explicit constraints
1347
+ const constraintParts: string[] = [];
1348
+ if (searchBase.pathConstraint) constraintParts.push(searchBase.pathConstraint);
1349
+ constraintParts.push(...normalizeExcludes(params.exclude, searchBase.basePath));
1350
+ if (params.constraints) constraintParts.push(params.constraints);
1351
+ const effectiveConstraints = constraintParts.join(" ");
1352
+
1353
+ const grepResult = await withFinderLease(
1354
+ searchBase.basePath,
1355
+ (finder) =>
1356
+ finder.multiGrep({
1357
+ patterns: params.patterns,
1358
+ constraints: effectiveConstraints,
1359
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
1360
+ smartCase: true,
1361
+ cursor: (params.cursor ? getCursor(params.cursor)?.cursor : null) ?? null,
1362
+ beforeContext: params.context ?? 0,
1363
+ afterContext: params.context ?? 0,
1364
+ classifyDefinitions: true,
1365
+ }),
1366
+ includeIgnored,
1141
1367
  );
1142
1368
 
1143
1369
  if (!grepResult.ok) throw new Error(grepResult.error);
1144
1370
 
1145
1371
  const result = grepResult.value;
1146
- if (result.items.length === 0) throw new Error("No matches found");
1372
+ if (result.items.length === 0) {
1373
+ if (result.regexFallbackError) throw new Error(result.regexFallbackError);
1374
+ throw new Error("No matches found");
1375
+ }
1147
1376
 
1148
1377
  let output = formatGrepOutput(result);
1149
1378
 
@@ -1152,8 +1381,9 @@ export default function fffExtension(pi: ExtensionAPI) {
1152
1381
  notices.push(`${effectiveLimit}+ matches (refine patterns)`);
1153
1382
  if (result.nextCursor)
1154
1383
  notices.push(
1155
- `More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
1384
+ `More available. cursor="${storeCursor(result.nextCursor, includeIgnored)}" to continue`,
1156
1385
  );
1386
+ if (includeIgnored) notices.unshift("ignored files included");
1157
1387
 
1158
1388
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
1159
1389