@bacnh85/pi-fff 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,25 +44,63 @@ pi install -l npm:@bacnh85/pi-fff
44
44
 
45
45
  ### `ffgrep`
46
46
 
47
- Search file contents. Smart case, plain text by default, regex optional.
47
+ Search file contents. Smart-case, auto-detects regex vs literal, git-aware. Results ranked by frecency (most-accessed files first).
48
48
 
49
49
  Parameters:
50
50
  - `pattern` — search text or regex
51
51
  - `path` — directory/file constraint (e.g. `src/`, `*.ts`)
52
- - `ignoreCase` — force case-insensitive
53
- - `literal` — treat as literal string (default: true)
52
+ - `exclude` — exclude paths (e.g. `test/,*.min.js`)
53
+ - `caseSensitive` — force case-sensitive (default: smart-case)
54
54
  - `context` — context lines around matches
55
- - `limit` — max matches (default: 100)
55
+ - `limit` — max matches (default: 20)
56
+ - `outputMode` — `"content"` (default), `"files_with_matches"` (one preview per file), or `"count"` (file match counts)
56
57
  - `cursor` — pagination cursor from previous result
57
58
 
59
+ Definition lines (function/class/interface/enum declarations) are auto-expanded with 3 lines of context for scannability.
60
+
58
61
  ### `fffind`
59
62
 
60
- Fuzzy file name search. Frecency-ranked.
63
+ Fuzzy file name search. Frecency-ranked, git-aware, multi-word AND narrowing.
61
64
 
62
65
  Parameters:
63
66
  - `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
64
67
  - `path` — directory constraint
65
- - `limit` — max results (default: 200)
68
+ - `exclude` — exclude paths
69
+ - `limit` — max results (default: 30)
70
+ - `cursor` — pagination cursor from previous result
71
+
72
+ When the top result strongly dominates (exact match or score > 2x runner-up), a `→ Read` hint is shown.
73
+
74
+ ### `resolve_file`
75
+
76
+ Resolve a vague file reference to an exact path. Auto-resolves when the top FFF candidate strongly dominates; returns ranked candidates when ambiguous.
77
+
78
+ Parameters:
79
+ - `pattern` — fuzzy file query (e.g. `"auth middleware"`, `"Chart component"`)
80
+ - `limit` — max candidates when ambiguous (default: 8)
81
+
82
+ ### `fff_multi_grep`
83
+
84
+ Search file contents for any of multiple literal patterns in one pass. Uses FFF multi-grep. Useful for renamed symbols, aliases, or spelling variants.
85
+
86
+ Parameters:
87
+ - `patterns` — array of literal patterns (1-10)
88
+ - `path` — directory/file constraint
89
+ - `exclude` — exclude paths
90
+ - `context` — context lines around matches
91
+ - `limit` — max matches (default: 20)
92
+ - `outputMode` — `"content"`, `"files_with_matches"`, or `"count"` (default: `"content"`)
93
+ - `cursor` — pagination cursor from previous result
94
+
95
+ ### `related_files`
96
+
97
+ Find companion files sharing the same base name stem. Discovers test files, type definitions, styles, stories, and other companions.
98
+
99
+ Parameters:
100
+ - `path` — reference file path (fuzzy or exact)
101
+ - `limit` — max related files (default: 8)
102
+
103
+ Example: `related_files({ path: "src/Chart.tsx" })` → `Chart.test.tsx`, `Chart.module.css`, `Chart.types.ts`
66
104
 
67
105
  ## Commands
68
106
 
@@ -13,6 +13,7 @@ import {
13
13
  } from "@earendil-works/pi-tui";
14
14
  import type {
15
15
  GrepCursor,
16
+ GrepMatch,
16
17
  GrepMode,
17
18
  GrepResult,
18
19
  MixedItem,
@@ -29,50 +30,33 @@ import { buildQuery } from "./lib/query";
29
30
  const DEFAULT_GREP_LIMIT = 20;
30
31
  const DEFAULT_FIND_LIMIT = 30;
31
32
  const GREP_MAX_LINE_LENGTH = 500;
32
- const MENTION_MAX_RESULTS = 20;
33
33
 
34
- type FffMode = "tools-and-ui" | "tools-only" | "override";
35
-
36
- const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
37
-
38
- interface ToolNames {
39
- grep: string;
40
- find: string;
41
- }
42
-
43
- const FFF_TOOL_NAMES: ToolNames = {
44
- grep: "ffgrep",
45
- find: "fffind",
46
- };
47
- const OVERRIDE_TOOL_NAMES: ToolNames = {
48
- grep: "grep",
49
- find: "find",
50
- };
51
-
52
- function resolveToolNames(mode: FffMode): ToolNames {
53
- return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
54
- }
34
+ const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const;
35
+ type FffMode = (typeof VALID_MODES)[number];
55
36
 
56
37
  // ---------------------------------------------------------------------------
57
38
  // Cursor store — simple bounded Map for pagination cursors
58
39
  // ---------------------------------------------------------------------------
59
40
 
60
- const cursorCache = new Map<string, GrepCursor>();
61
- let cursorCounter = 0;
62
-
63
- function storeCursor(cursor: GrepCursor): string {
64
- const id = `fff_c${++cursorCounter}`;
65
- cursorCache.set(id, cursor);
66
- if (cursorCache.size > 200) {
67
- const first = cursorCache.keys().next().value;
68
- if (first) cursorCache.delete(first);
41
+ class BoundedMap<V> {
42
+ private map = new Map<string, V>();
43
+ private counter = 0;
44
+ constructor(private maxSize: number) {}
45
+ store(value: V): string {
46
+ const id = `${++this.counter}`;
47
+ this.map.set(id, value);
48
+ if (this.map.size > this.maxSize) {
49
+ const first = this.map.keys().next().value;
50
+ if (first) this.map.delete(first);
51
+ }
52
+ return id;
53
+ }
54
+ get(id: string): V | undefined {
55
+ return this.map.get(id);
69
56
  }
70
- return id;
71
57
  }
72
58
 
73
- function getCursor(id: string): GrepCursor | undefined {
74
- return cursorCache.get(id);
75
- }
59
+ const cursorStore = new BoundedMap<GrepCursor>(200);
76
60
 
77
61
  // Find pagination uses a page-index cursor: native `fileSearch` takes
78
62
  // pageIndex/pageSize, so the cursor is just the next page index paired with
@@ -84,22 +68,7 @@ interface FindCursor {
84
68
  nextPageIndex: number;
85
69
  }
86
70
 
87
- const findCursorCache = new Map<string, FindCursor>();
88
- let findCursorCounter = 0;
89
-
90
- function storeFindCursor(cursor: FindCursor): string {
91
- const id = `${++findCursorCounter}`;
92
- findCursorCache.set(id, cursor);
93
- if (findCursorCache.size > 200) {
94
- const first = findCursorCache.keys().next().value;
95
- if (first) findCursorCache.delete(first);
96
- }
97
- return id;
98
- }
99
-
100
- function getFindCursor(id: string): FindCursor | undefined {
101
- return findCursorCache.get(id);
102
- }
71
+ const findCursorStore = new BoundedMap<FindCursor>(200);
103
72
 
104
73
  // ---------------------------------------------------------------------------
105
74
  // Output formatting helpers
@@ -118,7 +87,7 @@ const WARM_FRECENCY = 20;
118
87
  // git-dirty (most actionable — file is changing right now) beats frecency
119
88
  // (historically often-touched). Keeping one function ensures the two tools
120
89
  // never drift in how they surface git/frecency signal.
121
- export function fffFileAnnotation(item: {
90
+ function fffFileAnnotation(item: {
122
91
  gitStatus?: string;
123
92
  totalFrecencyScore?: number;
124
93
  accessFrecencyScore?: number;
@@ -145,13 +114,44 @@ export function fffFileAnnotation(item: {
145
114
  // provided — inside a file we keep matches in source-line order because the
146
115
  // engine emits them that way.
147
116
 
148
- function formatGrepOutput(result: GrepResult): string {
117
+ function formatGrepOutput(
118
+ result: GrepResult,
119
+ options?: { outputMode?: GrepOutputMode; explicitContext?: number; limit?: number },
120
+ ): string {
149
121
  if (result.items.length === 0) return "No matches found";
122
+ const outputMode = options?.outputMode ?? "content";
123
+
124
+ // count mode: file: count per file
125
+ if (outputMode === "count") {
126
+ const counts = new Map<string, number>();
127
+ const order: string[] = [];
128
+ for (const item of result.items) {
129
+ if (!counts.has(item.relativePath)) order.push(item.relativePath);
130
+ counts.set(item.relativePath, (counts.get(item.relativePath) ?? 0) + 1);
131
+ }
132
+ return order.map((p) => `${p}: ${counts.get(p)}`).join("\n");
133
+ }
134
+
135
+ // files_with_matches mode: one preview per file, with definition auto-expand
136
+ if (outputMode === "files_with_matches") {
137
+ const seen = new Set<string>();
138
+ const lines: string[] = [];
139
+ for (const match of result.items) {
140
+ if (!seen.has(match.relativePath)) {
141
+ seen.add(match.relativePath);
142
+ lines.push(`${match.relativePath}${fffFileAnnotation(match)}`);
143
+ lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
144
+ appendDefContext(lines, match, "|");
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+ }
150
149
 
151
- // Build file-grouped output in the order files first appear in the result.
152
- // This preserves native frecency ordering across files without re-sorting.
150
+ // content mode (default) with definition auto-expand
151
+ const explicitContext = (options?.explicitContext ?? 0) > 0;
153
152
  const lines: string[] = [];
154
153
  let currentFile = "";
154
+
155
155
  for (const match of result.items) {
156
156
  if (match.relativePath !== currentFile) {
157
157
  if (lines.length > 0) lines.push("");
@@ -166,10 +166,12 @@ function formatGrepOutput(result: GrepResult): string {
166
166
 
167
167
  lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
168
168
 
169
- match.contextAfter?.forEach((line: string, i: number) => {
170
- const lineNum = match.lineNumber + 1 + i;
171
- lines.push(` ${lineNum}- ${truncateLine(line)}`);
172
- });
169
+ if (explicitContext) {
170
+ match.contextAfter?.forEach((line: string, i: number) => {
171
+ const lineNum = match.lineNumber + 1 + i;
172
+ lines.push(` ${lineNum}- ${truncateLine(line)}`);
173
+ });
174
+ } else appendDefContext(lines, match, "-");
173
175
  }
174
176
 
175
177
  return lines.join("\n");
@@ -181,12 +183,46 @@ function formatGrepOutput(result: GrepResult): string {
181
183
  // When the top score is weak, trim output to a small sample instead of dumping
182
184
  // the full limit worth of noise into the agent's context.
183
185
  const FIND_WEAK_SAMPLE_SIZE = 5;
186
+ const DEFAULT_RESOLVE_LIMIT = 8;
184
187
 
185
188
  function weakScoreThreshold(pattern: string): number {
186
189
  const perfect = pattern.length * 12;
187
190
  return Math.floor((perfect * 50) / 100);
188
191
  }
189
192
 
193
+ type GrepOutputMode = "content" | "files_with_matches" | "count";
194
+
195
+ function appendDefContext(lines: string[], match: GrepMatch, prefix: string): void {
196
+ if (!match.isDefinition) return;
197
+ const after = match.contextAfter?.slice(0, 3) ?? [];
198
+ for (let i = 0; i < after.length; i++) {
199
+ if (after[i].trim()) lines.push(` ${match.lineNumber + 1 + i}${prefix} ${truncateLine(after[i])}`);
200
+ }
201
+ }
202
+
203
+ function scoreDominates(top?: { matchType?: string; exactMatch?: boolean; total?: number } | null, second?: { total?: number } | null): boolean {
204
+ if (!top) return false;
205
+ return top.matchType === "exact" || top.exactMatch === true || !second || (top.total ?? 0) > (second.total ?? 0) * 2;
206
+ }
207
+
208
+ type GrepResultFormat = { content: { type: "text"; text: string }[]; details: { totalMatched: number; totalFiles: number } };
209
+
210
+ function formatGrepResult(
211
+ result: GrepResult,
212
+ outputMode: GrepOutputMode | undefined,
213
+ explicitContext: number,
214
+ effectiveLimit: number,
215
+ extras?: { regexFallbackError?: string; fuzzyNotice?: string | null },
216
+ ): GrepResultFormat {
217
+ let output = formatGrepOutput(result, { outputMode, explicitContext, limit: effectiveLimit });
218
+ const notices: string[] = [];
219
+ if (extras?.regexFallbackError) notices.push(`Invalid regex: ${extras.regexFallbackError}, used literal match`);
220
+ if (result.nextCursor) notices.push(`Continue with cursor="${cursorStore.store(result.nextCursor)}"`);
221
+ if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
222
+ if (extras?.fuzzyNotice) output = `[${extras.fuzzyNotice}]\n${output}`;
223
+ return { content: [{ type: "text", text: output }], details: { totalMatched: result.totalMatched, totalFiles: result.totalFiles } };
224
+ }
225
+
190
226
  interface FormattedFind {
191
227
  output: string;
192
228
  weak: boolean;
@@ -197,6 +233,7 @@ function formatFindOutput(
197
233
  result: SearchResult,
198
234
  limit: number,
199
235
  pattern: string,
236
+ pageIndex = 0,
200
237
  ): FormattedFind {
201
238
  if (result.items.length === 0) {
202
239
  return {
@@ -213,8 +250,18 @@ function formatFindOutput(
213
250
  const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
214
251
  const shown = result.items.slice(0, effective);
215
252
 
253
+ const items: string[] = [];
254
+
255
+ // On first page, add a "→ Read" hint when the top candidate strongly dominates
256
+ if (pageIndex === 0 && shown.length > 0 && scoreDominates(result.scores[0], result.scores[1])) {
257
+ const label = result.scores[0]?.matchType === "exact" || result.scores[0]?.exactMatch ? "exact match!" : "best match";
258
+ items.push(`→ Read ${shown[0].relativePath} (${label})`);
259
+ }
260
+
261
+ items.push(...shown.map((item) => `${item.relativePath}${fffFileAnnotation(item)}`));
262
+
216
263
  return {
217
- output: shown.map((item) => `${item.relativePath}${fffFileAnnotation(item)}`).join("\n"),
264
+ output: items.join("\n"),
218
265
  weak,
219
266
  shownCount: shown.length,
220
267
  };
@@ -281,7 +328,8 @@ export default function fffExtension(pi: ExtensionAPI) {
281
328
  (process.env.PI_FFF_MODE as FffMode) ??
282
329
  "tools-and-ui";
283
330
 
284
- const toolNames = resolveToolNames(currentMode);
331
+ const grepName = currentMode === "override" ? "grep" : "ffgrep";
332
+ const findName = currentMode === "override" ? "find" : "fffind";
285
333
 
286
334
  // DB path resolution: flag > env > undefined (use fff-node defaults)
287
335
  const frecencyDbPath =
@@ -304,18 +352,6 @@ export default function fffExtension(pi: ExtensionAPI) {
304
352
  rootScanFlag === "1" ||
305
353
  (rootScanFlag == null && (rootScanEnv === "1" || rootScanEnv === "true"));
306
354
 
307
- function getMode(): FffMode {
308
- return currentMode;
309
- }
310
-
311
- function setMode(mode: FffMode): void {
312
- currentMode = mode;
313
- }
314
-
315
- function shouldEnableMentions(): boolean {
316
- return currentMode !== "tools-only";
317
- }
318
-
319
355
  function ensureFinder(cwd: string): Promise<FileFinder> {
320
356
  if (finder && !finder.isDestroyed && finderCwd === cwd)
321
357
  return Promise.resolve(finder);
@@ -367,10 +403,10 @@ export default function fffExtension(pi: ExtensionAPI) {
367
403
  const f = await ensureFinder(activeCwd);
368
404
  if (signal.aborted) return [];
369
405
 
370
- const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
406
+ const result = f.mixedSearch(query, { pageSize: 20 });
371
407
  if (!result.ok) return [];
372
408
 
373
- return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
409
+ return result.value.items.slice(0, 20).map((mixed: MixedItem) => {
374
410
  if (mixed.type === "directory") {
375
411
  return {
376
412
  value: buildAtCompletionValue(mixed.item.relativePath),
@@ -398,7 +434,7 @@ export default function fffExtension(pi: ExtensionAPI) {
398
434
 
399
435
  return {
400
436
  async getSuggestions(lines, cursorLine, cursorCol, options) {
401
- if (shouldEnableMentions()) {
437
+ if (currentMode !== "tools-only") {
402
438
  try {
403
439
  const mentionResult = await mentionProvider.getSuggestions(
404
440
  lines,
@@ -551,14 +587,19 @@ export default function fffExtension(pi: ExtensionAPI) {
551
587
  description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
552
588
  }),
553
589
  ),
590
+ outputMode: Type.Optional(
591
+ Type.String({
592
+ description: "Output format: 'content' (default), 'files_with_matches' (one preview per file), or 'count' (file match counts)",
593
+ }),
594
+ ),
554
595
  cursor: Type.Optional(
555
596
  Type.String({ description: "Pagination cursor from previous result" }),
556
597
  ),
557
598
  });
558
599
 
559
600
  pi.registerTool({
560
- name: toolNames.grep,
561
- label: toolNames.grep,
601
+ name: grepName,
602
+ label: grepName,
562
603
  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}.`,
563
604
  promptSnippet: "Grep contents",
564
605
  promptGuidelines: [
@@ -614,14 +655,16 @@ export default function fffExtension(pi: ExtensionAPI) {
614
655
  // caseSensitive override flips smartCase off; omitting it keeps smart-case
615
656
  // (case-insensitive when pattern is all lowercase).
616
657
  const smartCase = params.caseSensitive !== true;
658
+ const explicitContext = params.context ?? 0;
617
659
 
660
+ // Always request a little context so definition auto-expand can work.
618
661
  const grepResult = f.grep(query, {
619
662
  mode,
620
663
  smartCase,
621
664
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
622
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
623
- beforeContext: params.context ?? 0,
624
- afterContext: params.context ?? 0,
665
+ cursor: (params.cursor ? cursorStore.get(params.cursor) : null) ?? null,
666
+ beforeContext: explicitContext,
667
+ afterContext: Math.max(explicitContext, 3),
625
668
  classifyDefinitions: true,
626
669
  });
627
670
 
@@ -648,25 +691,8 @@ export default function fffExtension(pi: ExtensionAPI) {
648
691
  }
649
692
  }
650
693
 
651
- let output = formatGrepOutput(result);
652
- const notices: string[] = [];
653
- if (result.regexFallbackError) {
654
- notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
655
- }
656
- if (result.nextCursor) {
657
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
658
- }
659
-
660
- if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
661
- if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
662
-
663
- return {
664
- content: [{ type: "text", text: output }],
665
- details: {
666
- totalMatched: result.totalMatched,
667
- totalFiles: result.totalFiles,
668
- },
669
- };
694
+ const outputMode = params.outputMode as GrepOutputMode | undefined;
695
+ return formatGrepResult(result, outputMode, explicitContext, effectiveLimit, { regexFallbackError: result.regexFallbackError, fuzzyNotice });
670
696
  },
671
697
 
672
698
  renderCall(args, theme, context) {
@@ -674,7 +700,7 @@ export default function fffExtension(pi: ExtensionAPI) {
674
700
  const pattern = args?.pattern ?? "";
675
701
  const path = args?.path ?? ".";
676
702
  let content =
677
- theme.fg("toolTitle", theme.bold(toolNames.grep)) +
703
+ theme.fg("toolTitle", theme.bold(grepName)) +
678
704
  " " +
679
705
  theme.fg("accent", `/${pattern}/`) +
680
706
  theme.fg("toolOutput", ` in ${path}`);
@@ -720,8 +746,8 @@ export default function fffExtension(pi: ExtensionAPI) {
720
746
  });
721
747
 
722
748
  pi.registerTool({
723
- name: toolNames.find,
724
- label: toolNames.find,
749
+ name: findName,
750
+ label: findName,
725
751
  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}.`,
726
752
  promptSnippet: "Find files by path or glob",
727
753
  promptGuidelines: [
@@ -741,7 +767,7 @@ export default function fffExtension(pi: ExtensionAPI) {
741
767
 
742
768
  // Resume from a prior cursor if supplied — cursor owns query+pageSize so
743
769
  // the agent can't accidentally mix patterns across pages.
744
- const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
770
+ const resumed = params.cursor ? findCursorStore.get(params.cursor) : undefined;
745
771
  const effectiveLimit = resumed
746
772
  ? resumed.pageSize
747
773
  : Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
@@ -758,7 +784,7 @@ export default function fffExtension(pi: ExtensionAPI) {
758
784
  if (!searchResult.ok) throw new Error(searchResult.error);
759
785
 
760
786
  const result = searchResult.value;
761
- const formatted = formatFindOutput(result, effectiveLimit, pattern);
787
+ const formatted = formatFindOutput(result, effectiveLimit, pattern, pageIndex);
762
788
  let output = formatted.output;
763
789
 
764
790
  // Infer hasMore: native fileSearch fills pageSize when more results
@@ -776,7 +802,7 @@ export default function fffExtension(pi: ExtensionAPI) {
776
802
 
777
803
  if (!formatted.weak && hasMore) {
778
804
  const remaining = result.totalMatched - shownSoFar;
779
- const cursorId = storeFindCursor({
805
+ const cursorId = findCursorStore.store({
780
806
  query,
781
807
  pattern,
782
808
  pageSize: effectiveLimit,
@@ -804,7 +830,7 @@ export default function fffExtension(pi: ExtensionAPI) {
804
830
  const pattern = args?.pattern ?? "";
805
831
  const path = args?.path ?? ".";
806
832
  let content =
807
- theme.fg("toolTitle", theme.bold(toolNames.find)) +
833
+ theme.fg("toolTitle", theme.bold(findName)) +
808
834
  " " +
809
835
  theme.fg("accent", pattern) +
810
836
  theme.fg("toolOutput", ` in ${path}`);
@@ -820,6 +846,267 @@ export default function fffExtension(pi: ExtensionAPI) {
820
846
  },
821
847
  });
822
848
 
849
+ // --- resolve_file tool ---
850
+
851
+ const resolveFileSchema = Type.Object({
852
+ pattern: Type.String({
853
+ description:
854
+ "Fuzzy file path query. Turn a vague reference ('auth middleware', 'Chart component') into an exact file path.",
855
+ }),
856
+ limit: Type.Optional(
857
+ Type.Number({
858
+ description: `Max candidates when ambiguous (default ${DEFAULT_RESOLVE_LIMIT})`,
859
+ }),
860
+ ),
861
+ });
862
+
863
+ pi.registerTool({
864
+ name: "resolve_file",
865
+ label: "Resolve File",
866
+ description:
867
+ "Resolve a fuzzy file reference to an exact absolute path using FFF. Auto-resolves when the top candidate strongly dominates; returns ranked candidates when ambiguous.",
868
+ promptSnippet: "Resolve a fuzzy file reference",
869
+ promptGuidelines: [
870
+ "Use when you have a vague reference like 'auth middleware' or 'main config' instead of an exact path.",
871
+ "The tool either returns a resolved path (ready for read) or a ranked candidate list.",
872
+ "More specific queries (2-3 words) produce better results.",
873
+ ],
874
+ parameters: resolveFileSchema,
875
+
876
+ async execute(_toolCallId, params, signal) {
877
+ if (signal?.aborted) throw new Error("Operation aborted");
878
+
879
+ const f = await ensureFinder(activeCwd);
880
+ const limit = Math.max(1, params.limit ?? DEFAULT_RESOLVE_LIMIT);
881
+
882
+ const result = f.fileSearch(params.pattern, { pageSize: limit });
883
+ if (!result.ok) throw new Error(result.error);
884
+
885
+ if (result.value.items.length === 0) {
886
+ return {
887
+ content: [{ type: "text", text: `No files matched "${params.pattern}".` }],
888
+ details: { resolved: false, totalMatched: 0 },
889
+ };
890
+ }
891
+
892
+ const topResult = result.value.items[0];
893
+ const topScore = result.value.scores[0];
894
+ const secondScore = result.value.scores[1];
895
+
896
+ // Auto-resolve when top candidate dominates or is an exact match
897
+ if (scoreDominates(topScore, secondScore)) {
898
+ return {
899
+ content: [
900
+ {
901
+ type: "text",
902
+ text: `→ Read ${topResult.relativePath}${fffFileAnnotation(topResult)}`,
903
+ },
904
+ ],
905
+ details: {
906
+ resolved: true,
907
+ totalMatched: result.value.totalMatched,
908
+ },
909
+ };
910
+ }
911
+
912
+ // Ambiguous — return ranked candidates
913
+ const candidates = result.value.items
914
+ .slice(0, limit)
915
+ .map(
916
+ (item, i) =>
917
+ `${i + 1}. ${item.relativePath}${fffFileAnnotation(item)}`,
918
+ )
919
+ .join("\n");
920
+
921
+ return {
922
+ content: [
923
+ {
924
+ type: "text",
925
+ text: `Ambiguous reference. Top candidates:\n${candidates}`,
926
+ },
927
+ ],
928
+ details: {
929
+ resolved: false,
930
+ totalMatched: result.value.totalMatched,
931
+ },
932
+ };
933
+ },
934
+ });
935
+
936
+ // --- fff_multi_grep tool ---
937
+
938
+ const multiGrepSchema = Type.Object({
939
+ patterns: Type.Array(Type.String({ description: "Literal pattern to search for" }), {
940
+ minItems: 1,
941
+ maxItems: 10,
942
+ description: "Search for any of these literal patterns in one pass (useful for renamed symbols, aliases, spelling variants)",
943
+ }),
944
+ path: Type.Optional(
945
+ Type.String({
946
+ description:
947
+ "Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**).",
948
+ }),
949
+ ),
950
+ exclude: Type.Optional(
951
+ Type.Union([Type.String(), Type.Array(Type.String())], {
952
+ description:
953
+ "Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js').",
954
+ }),
955
+ ),
956
+ context: Type.Optional(
957
+ Type.Number({ description: "Context lines before+after each match" }),
958
+ ),
959
+ limit: Type.Optional(
960
+ Type.Number({
961
+ description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
962
+ }),
963
+ ),
964
+ outputMode: Type.Optional(
965
+ Type.String({
966
+ description: "Output format: 'content' (default), 'files_with_matches', or 'count'",
967
+ }),
968
+ ),
969
+ cursor: Type.Optional(
970
+ Type.String({ description: "Pagination cursor from previous result" }),
971
+ ),
972
+ });
973
+
974
+ pi.registerTool({
975
+ name: "fff_multi_grep",
976
+ label: "FFF Multi Grep",
977
+ description:
978
+ "Search file contents for any of multiple literal patterns in one pass using FFF multi-grep. Useful for renamed symbols, aliases, or common spelling variants.",
979
+ promptSnippet: "Grep for multiple patterns",
980
+ promptGuidelines: [
981
+ "Provide 2-10 literal patterns. All searches happen in one indexed pass.",
982
+ "Use for symbol renames, API migrations, or finding multiple related terms.",
983
+ "Use path/exclude to scope; use outputMode for concise results.",
984
+ ],
985
+ parameters: multiGrepSchema,
986
+
987
+ async execute(_toolCallId, params, signal) {
988
+ if (signal?.aborted) throw new Error("Operation aborted");
989
+
990
+ const f = await ensureFinder(activeCwd);
991
+ const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
992
+ const query = buildQuery(params.path, "", params.exclude, activeCwd);
993
+ const explicitContext = params.context ?? 0;
994
+
995
+ const grepResult = f.multiGrep({
996
+ patterns: params.patterns,
997
+ constraints: query || undefined,
998
+ cursor: (params.cursor ? cursorStore.get(params.cursor) : null) ?? null,
999
+ beforeContext: explicitContext,
1000
+ afterContext: Math.max(explicitContext, 3),
1001
+ maxMatchesPerFile: Math.min(effectiveLimit, 50),
1002
+ });
1003
+
1004
+ if (!grepResult.ok) throw new Error(grepResult.error);
1005
+
1006
+ const result = grepResult.value;
1007
+ const outputMode = params.outputMode as GrepOutputMode | undefined;
1008
+ return formatGrepResult(result, outputMode, explicitContext, effectiveLimit);
1009
+ },
1010
+ });
1011
+
1012
+ // --- related_files tool ---
1013
+
1014
+ const relatedFilesSchema = Type.Object({
1015
+ path: Type.String({
1016
+ description:
1017
+ "File path (relative or fuzzy) to find related companion files for (tests, types, styles, stories, etc.).",
1018
+ }),
1019
+ limit: Type.Optional(
1020
+ Type.Number({
1021
+ description: `Max related files (default ${DEFAULT_RESOLVE_LIMIT})`,
1022
+ }),
1023
+ ),
1024
+ });
1025
+
1026
+ pi.registerTool({
1027
+ name: "related_files",
1028
+ label: "Related Files",
1029
+ description:
1030
+ "Find files related to a given file by stem matching — discovers test files, type definitions, styles, and other companion files sharing the same base name.",
1031
+ promptSnippet: "Find companion files",
1032
+ promptGuidelines: [
1033
+ "Pass any file path in the project. The tool strips extensions and test/spec/stories/.d/.module suffixes to find companions.",
1034
+ "Great for finding the test file for a module, or the type definition for an implementation.",
1035
+ ],
1036
+ parameters: relatedFilesSchema,
1037
+
1038
+ async execute(_toolCallId, params, signal) {
1039
+ if (signal?.aborted) throw new Error("Operation aborted");
1040
+
1041
+ const f = await ensureFinder(activeCwd);
1042
+ const limit = Math.max(1, params.limit ?? DEFAULT_RESOLVE_LIMIT);
1043
+
1044
+ // Resolve the reference file first
1045
+ const refResult = f.fileSearch(params.path, { pageSize: limit * 2 });
1046
+ if (!refResult.ok) throw new Error(refResult.error);
1047
+ if (refResult.value.items.length === 0) {
1048
+ return {
1049
+ content: [{ type: "text", text: `No file matched "${params.path}".` }],
1050
+ details: { reference: "", related: [] },
1051
+ };
1052
+ }
1053
+
1054
+ const referencePath = refResult.value.items[0].relativePath;
1055
+
1056
+ // Extract stem: strip test/spec/stories/.d/.module suffixes, then extension
1057
+ // Extract stem: strip test/spec/stories/.d/.module suffixes, then extension
1058
+ const stem = (referencePath.split("/").pop() ?? referencePath)
1059
+ .replace(/\.(test|spec|stories)\./g, ".")
1060
+ .replace(/\.d\./g, ".")
1061
+ .replace(/\.module\./g, ".")
1062
+ .replace(/\.[^.]+$/, "");
1063
+
1064
+ // Search for files with the same stem
1065
+ const relatedResult = f.fileSearch(stem, { pageSize: limit * 3 });
1066
+ if (!relatedResult.ok) throw new Error(relatedResult.error);
1067
+
1068
+ // Filter out the reference file and limit
1069
+ const related = relatedResult.value.items
1070
+ .filter((item) => item.relativePath !== referencePath)
1071
+ .filter((item) => {
1072
+ const candidateBase = item.relativePath.split("/").pop() ?? "";
1073
+ const refDir = referencePath.substring(0, referencePath.lastIndexOf("/"));
1074
+ return (
1075
+ candidateBase.includes(stem) ||
1076
+ item.relativePath.includes(`${refDir}/${stem}`)
1077
+ );
1078
+ })
1079
+ .slice(0, limit);
1080
+
1081
+ if (related.length === 0) {
1082
+ return {
1083
+ content: [
1084
+ {
1085
+ type: "text",
1086
+ text: `No related files found for "${referencePath}".`,
1087
+ },
1088
+ ],
1089
+ details: { reference: referencePath, related: [] },
1090
+ };
1091
+ }
1092
+
1093
+ const output = [
1094
+ `Related files for ${referencePath}:`,
1095
+ ...related.map(
1096
+ (item, i) => `${i + 1}. ${item.relativePath}${fffFileAnnotation(item)}`,
1097
+ ),
1098
+ ].join("\n");
1099
+
1100
+ return {
1101
+ content: [{ type: "text", text: output }],
1102
+ details: {
1103
+ reference: referencePath,
1104
+ related: related.map((i) => i.relativePath),
1105
+ },
1106
+ };
1107
+ },
1108
+ });
1109
+
823
1110
  // --- commands ---
824
1111
 
825
1112
  pi.registerCommand("fff-mode", {
@@ -829,9 +1116,7 @@ export default function fffExtension(pi: ExtensionAPI) {
829
1116
 
830
1117
  // No args - show current mode
831
1118
  if (!arg) {
832
- const mode = getMode();
833
- const flag = pi.getFlag("fff-mode") ?? "unset";
834
- ctx.ui.notify(`Current mode: '${mode}' (flag: ${flag})`, "info");
1119
+ ctx.ui.notify(`Current mode: '${currentMode}' (flag: ${pi.getFlag("fff-mode") ?? "unset"})`, "info");
835
1120
  return;
836
1121
  }
837
1122
 
@@ -842,8 +1127,8 @@ export default function fffExtension(pi: ExtensionAPI) {
842
1127
  }
843
1128
 
844
1129
  const newMode = arg as FffMode;
845
- const oldMode = getMode();
846
- setMode(newMode);
1130
+ const oldMode = currentMode;
1131
+ currentMode = newMode;
847
1132
 
848
1133
  pi.appendEntry("fff-mode", { mode: newMode });
849
1134
 
@@ -872,7 +1157,7 @@ export default function fffExtension(pi: ExtensionAPI) {
872
1157
  const h = health.value;
873
1158
  const lines = [
874
1159
  `FFF v${h.version}`,
875
- `Mode: ${getMode()}`,
1160
+ `Mode: ${currentMode}`,
876
1161
  `Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
877
1162
  `Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
878
1163
  `Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-fff",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension: FFF-powered fuzzy file and content search.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +0,0 @@
1
- {"type":"module"}