@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.
- package/README.md +3 -0
- package/package.json +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.
|
|
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
|
-
"@
|
|
47
|
-
"@
|
|
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
|
|
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
|
-
|
|
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):
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
([
|
|
532
|
+
([key]) => (finderActiveOps.get(key) ?? 0) === 0,
|
|
463
533
|
);
|
|
464
534
|
if (!evictable) return;
|
|
465
535
|
|
|
466
|
-
const [
|
|
536
|
+
const [oldestKey, oldestFinder] = evictable;
|
|
467
537
|
if (!oldestFinder.isDestroyed) oldestFinder.destroy();
|
|
468
|
-
finders.delete(
|
|
469
|
-
if (
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
494
|
-
|
|
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(
|
|
573
|
+
finderPromises.delete(key);
|
|
499
574
|
});
|
|
500
575
|
|
|
501
|
-
finderPromises.set(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
539
|
-
if (remaining > 0) finderActiveOps.set(
|
|
540
|
-
else finderActiveOps.delete(
|
|
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(
|
|
619
|
+
if (finderLocks.get(key) === current) finderLocks.delete(key);
|
|
543
620
|
}
|
|
544
621
|
}
|
|
545
622
|
|
|
546
623
|
function getActiveFinder(): FileFinder | null {
|
|
547
|
-
if (!
|
|
548
|
-
const finder = finders.get(
|
|
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
|
-
"
|
|
758
|
-
"Use
|
|
759
|
-
"
|
|
760
|
-
"
|
|
761
|
-
"
|
|
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(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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)
|
|
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
|
-
"
|
|
944
|
-
"
|
|
945
|
-
"
|
|
946
|
-
"
|
|
947
|
-
"
|
|
948
|
-
"
|
|
949
|
-
"Use exclude
|
|
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(
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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(
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1091
|
-
const enableMultiGrep = process.env.PI_FFF_MULTIGREP
|
|
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
|
|
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
|
|
1119
|
-
"
|
|
1120
|
-
"
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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)
|
|
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
|
|