@bacnh85/pi-fff 0.6.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/NOTICE.md +34 -0
- package/README.md +105 -0
- package/extensions/.mocharc.yml +5 -0
- package/extensions/index.ts +910 -0
- package/extensions/lib/query.ts +78 -0
- package/extensions/package.json +1 -0
- package/extensions/query.test.ts +57 -0
- package/package.json +60 -0
package/NOTICE.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Upstream credit
|
|
2
|
+
|
|
3
|
+
`@bacnh85/pi-fff` is based on upstream `@ff-labs/pi-fff` from Dmitriy Kovalenko's FFF repository:
|
|
4
|
+
|
|
5
|
+
- Repository: https://github.com/dmtrKovalenko/fff
|
|
6
|
+
- Upstream package path: `packages/pi-fff`
|
|
7
|
+
- Snapshot used: `18f546a4fe8939d884b495a7d761f4905ad205a3`
|
|
8
|
+
- Original package: `@ff-labs/pi-fff` v0.6.0
|
|
9
|
+
|
|
10
|
+
The upstream code is MIT licensed:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
MIT License
|
|
14
|
+
|
|
15
|
+
Copyright (c) 2025 Dmitriy Kovalenko
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
18
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
19
|
+
in the Software without restriction, including without limitation the rights
|
|
20
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
21
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
22
|
+
furnished to do so, subject to the following conditions:
|
|
23
|
+
|
|
24
|
+
The above copyright notice and this permission notice shall be included in all
|
|
25
|
+
copies or substantial portions of the Software.
|
|
26
|
+
|
|
27
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
31
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
32
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
+
SOFTWARE.
|
|
34
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# @bacnh85/pi-fff
|
|
2
|
+
|
|
3
|
+
A maintained Pi package based on upstream [`@ff-labs/pi-fff`](https://github.com/dmtrKovalenko/fff/tree/main/packages/pi-fff). It replaces the built-in `find` and `grep` tools with [FFF](https://github.com/dmtrKovalenko/fff.nvim) — a Rust-native, SIMD-accelerated file finder with built-in memory.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
| Built-in tool | pi-fff replacement | Improvement |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `find` (spawns `fd`) | `fffind` (FFF `fileSearch`) | Fuzzy matching, frecency ranking, git-aware, pre-indexed |
|
|
10
|
+
| `grep` (spawns `rg`) | `ffgrep` (FFF `grep`) | SIMD-accelerated, frecency-ordered, mmap-cached, no subprocess |
|
|
11
|
+
| `@` file autocomplete (fd-backed) | `@` file autocomplete (FFF-backed, default) | Fuzzy ranking from FFF index/frecency |
|
|
12
|
+
|
|
13
|
+
### Key advantages over built-in tools
|
|
14
|
+
|
|
15
|
+
- **No subprocess spawning** — FFF is a Rust native library called through the Node binding. No `fd`/`rg` process per call.
|
|
16
|
+
- **Pre-indexed** — files are indexed in the background at session start. Searches are instant.
|
|
17
|
+
- **Frecency ranking** — files you access often rank higher. Learns across sessions.
|
|
18
|
+
- **Query history** — remembers which files were selected for which queries. Combo boost.
|
|
19
|
+
- **Git-aware** — modified/staged/untracked files are boosted in results.
|
|
20
|
+
- **Smart case** — case-insensitive when query is all lowercase, case-sensitive otherwise.
|
|
21
|
+
- **Fuzzy file search** — `find` uses fuzzy matching, not glob-only. Typo-tolerant.
|
|
22
|
+
- **Cursor pagination** — grep results include a cursor for fetching the next page.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Requirements:
|
|
27
|
+
- pi
|
|
28
|
+
|
|
29
|
+
### Install as a pi package
|
|
30
|
+
|
|
31
|
+
**Via npm (recommended):**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@bacnh85/pi-fff
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Project-local install:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pi install -l npm:@bacnh85/pi-fff
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
### `ffgrep`
|
|
46
|
+
|
|
47
|
+
Search file contents. Smart case, plain text by default, regex optional.
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
- `pattern` — search text or regex
|
|
51
|
+
- `path` — directory/file constraint (e.g. `src/`, `*.ts`)
|
|
52
|
+
- `ignoreCase` — force case-insensitive
|
|
53
|
+
- `literal` — treat as literal string (default: true)
|
|
54
|
+
- `context` — context lines around matches
|
|
55
|
+
- `limit` — max matches (default: 100)
|
|
56
|
+
- `cursor` — pagination cursor from previous result
|
|
57
|
+
|
|
58
|
+
### `fffind`
|
|
59
|
+
|
|
60
|
+
Fuzzy file name search. Frecency-ranked.
|
|
61
|
+
|
|
62
|
+
Parameters:
|
|
63
|
+
- `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
|
|
64
|
+
- `path` — directory constraint
|
|
65
|
+
- `limit` — max results (default: 200)
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
- `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
|
|
70
|
+
- `/fff-rescan` — trigger a file rescan
|
|
71
|
+
- `/fff-mode <mode>` — switch mode (tool name change requires restart)
|
|
72
|
+
|
|
73
|
+
## Modes
|
|
74
|
+
|
|
75
|
+
- `tools-and-ui` (default): registers `fffind`, `ffgrep` as additional tools + FFF-backed `@` autocomplete
|
|
76
|
+
- `tools-only`: additional tools only; keep pi's default `@` autocomplete
|
|
77
|
+
- `override`: replaces pi's built-in `find` and `grep` + FFF-backed `@` autocomplete
|
|
78
|
+
|
|
79
|
+
Mode precedence:
|
|
80
|
+
1. `--fff-mode <mode>` CLI flag
|
|
81
|
+
2. `PI_FFF_MODE=<mode>` environment variable
|
|
82
|
+
3. default (`tools-and-ui`)
|
|
83
|
+
|
|
84
|
+
## Flags
|
|
85
|
+
|
|
86
|
+
- `--fff-mode <mode>` — set mode (see above)
|
|
87
|
+
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
|
|
88
|
+
- `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
|
|
89
|
+
- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. Home directory scanning is always enabled for pi.
|
|
90
|
+
|
|
91
|
+
## Data
|
|
92
|
+
|
|
93
|
+
When database paths are provided, FFF stores:
|
|
94
|
+
- frecency database — file access frequency/recency
|
|
95
|
+
- history database — query-to-file selection history
|
|
96
|
+
|
|
97
|
+
No project files are uploaded anywhere by this extension. It runs locally and only uses the configured LLM through pi itself.
|
|
98
|
+
|
|
99
|
+
## Security
|
|
100
|
+
|
|
101
|
+
- No shell execution
|
|
102
|
+
- No network calls in the extension code
|
|
103
|
+
- No telemetry
|
|
104
|
+
- No credential handling beyond whatever pi and your configured model provider already do
|
|
105
|
+
- Search state is stored locally under `~/.pi/agent/fff/`
|
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-fff: FFF-powered file search extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Overrides built-in `find` and `grep` tools with FFF and adds FFF-backed
|
|
5
|
+
* @-mention autocomplete suggestions to the interactive editor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
type AutocompleteItem,
|
|
11
|
+
type AutocompleteProvider,
|
|
12
|
+
Text,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import type {
|
|
15
|
+
GrepCursor,
|
|
16
|
+
GrepMode,
|
|
17
|
+
GrepResult,
|
|
18
|
+
MixedItem,
|
|
19
|
+
SearchResult,
|
|
20
|
+
} from "@ff-labs/fff-node";
|
|
21
|
+
import { FileFinder } from "@ff-labs/fff-node";
|
|
22
|
+
import { Type } from "@sinclair/typebox";
|
|
23
|
+
import { buildQuery } from "./lib/query";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Constants
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const DEFAULT_GREP_LIMIT = 20;
|
|
30
|
+
const DEFAULT_FIND_LIMIT = 30;
|
|
31
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
32
|
+
const MENTION_MAX_RESULTS = 20;
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Cursor store — simple bounded Map for pagination cursors
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
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);
|
|
69
|
+
}
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getCursor(id: string): GrepCursor | undefined {
|
|
74
|
+
return cursorCache.get(id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Find pagination uses a page-index cursor: native `fileSearch` takes
|
|
78
|
+
// pageIndex/pageSize, so the cursor is just the next page index paired with
|
|
79
|
+
// the query+limit that produced it. Stored tokens are opaque IDs to the agent.
|
|
80
|
+
interface FindCursor {
|
|
81
|
+
query: string;
|
|
82
|
+
pattern: string;
|
|
83
|
+
pageSize: number;
|
|
84
|
+
nextPageIndex: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Output formatting helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const HOT_FRECENCY = 25;
|
|
114
|
+
const WARM_FRECENCY = 20;
|
|
115
|
+
|
|
116
|
+
// Shared annotation helper for both find-output paths and grep-output file
|
|
117
|
+
// headers. Returns at most ONE tag so output stays scannable. Priority:
|
|
118
|
+
// git-dirty (most actionable — file is changing right now) beats frecency
|
|
119
|
+
// (historically often-touched). Keeping one function ensures the two tools
|
|
120
|
+
// never drift in how they surface git/frecency signal.
|
|
121
|
+
export function fffFileAnnotation(item: {
|
|
122
|
+
gitStatus?: string;
|
|
123
|
+
totalFrecencyScore?: number;
|
|
124
|
+
accessFrecencyScore?: number;
|
|
125
|
+
}): string {
|
|
126
|
+
const git = item.gitStatus;
|
|
127
|
+
if (git && git !== "clean" && git !== "unknown" && git !== "") {
|
|
128
|
+
return ` [${git} in git]`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
|
|
132
|
+
if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
|
|
133
|
+
if (frecency >= WARM_FRECENCY) return " [often touched file]";
|
|
134
|
+
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// fff-core native definition classifier (byte-level scanner in Rust) is enabled
|
|
139
|
+
// via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
|
|
140
|
+
// downstream consumers; pi-fff does NOT use it to re-sort.
|
|
141
|
+
//
|
|
142
|
+
// Ordering policy: NO CUSTOM SORTING. The engine already returns items in
|
|
143
|
+
// frecency order (most-accessed files first). pi-fff only groups consecutive
|
|
144
|
+
// matches into per-file blocks and preserves whatever order the engine
|
|
145
|
+
// provided — inside a file we keep matches in source-line order because the
|
|
146
|
+
// engine emits them that way.
|
|
147
|
+
|
|
148
|
+
function formatGrepOutput(result: GrepResult): string {
|
|
149
|
+
if (result.items.length === 0) return "No matches found";
|
|
150
|
+
|
|
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.
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
let currentFile = "";
|
|
155
|
+
for (const match of result.items) {
|
|
156
|
+
if (match.relativePath !== currentFile) {
|
|
157
|
+
if (lines.length > 0) lines.push("");
|
|
158
|
+
currentFile = match.relativePath;
|
|
159
|
+
lines.push(`${currentFile}${fffFileAnnotation(match)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
match.contextBefore?.forEach((line: string, i: number) => {
|
|
163
|
+
const lineNum = match.lineNumber - match.contextBefore!.length + i;
|
|
164
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
168
|
+
|
|
169
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
170
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
171
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Weak-match threshold is derived from the query length, matching the
|
|
179
|
+
// scoring formula in crates/fff-core/src/score.rs: a perfect match scores
|
|
180
|
+
// `len * 16`, so we treat anything below 50% of that as scattered fuzzy noise.
|
|
181
|
+
// When the top score is weak, trim output to a small sample instead of dumping
|
|
182
|
+
// the full limit worth of noise into the agent's context.
|
|
183
|
+
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
184
|
+
|
|
185
|
+
function weakScoreThreshold(pattern: string): number {
|
|
186
|
+
const perfect = pattern.length * 12;
|
|
187
|
+
return Math.floor((perfect * 50) / 100);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface FormattedFind {
|
|
191
|
+
output: string;
|
|
192
|
+
weak: boolean;
|
|
193
|
+
shownCount: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatFindOutput(
|
|
197
|
+
result: SearchResult,
|
|
198
|
+
limit: number,
|
|
199
|
+
pattern: string,
|
|
200
|
+
): FormattedFind {
|
|
201
|
+
if (result.items.length === 0) {
|
|
202
|
+
return {
|
|
203
|
+
output: "No files found matching pattern",
|
|
204
|
+
weak: false,
|
|
205
|
+
shownCount: 0,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Peek at the top native score to decide whether results are scattered
|
|
210
|
+
// fuzzy noise (query length-scaled threshold from score.rs).
|
|
211
|
+
const topScore = result.scores[0]?.total ?? 0;
|
|
212
|
+
const weak = topScore < weakScoreThreshold(pattern);
|
|
213
|
+
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
214
|
+
const shown = result.items.slice(0, effective);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
output: shown.map((item) => `${item.relativePath}${fffFileAnnotation(item)}`).join("\n"),
|
|
218
|
+
weak,
|
|
219
|
+
shownCount: shown.length,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Mention autocomplete helpers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function extractAtPrefix(textBeforeCursor: string): string | null {
|
|
228
|
+
const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
|
|
229
|
+
return match?.[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildAtCompletionValue(path: string): string {
|
|
233
|
+
return path.includes(" ") ? `@"${path}"` : `@${path}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createFffMentionProvider(
|
|
237
|
+
getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
238
|
+
): AutocompleteProvider {
|
|
239
|
+
return {
|
|
240
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
241
|
+
const currentLine = lines[cursorLine] || "";
|
|
242
|
+
const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
|
|
243
|
+
if (!prefix || options.signal.aborted) return null;
|
|
244
|
+
|
|
245
|
+
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
246
|
+
const items = await getItems(query, options.signal);
|
|
247
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
248
|
+
},
|
|
249
|
+
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
250
|
+
const currentLine = _lines[cursorLine] || "";
|
|
251
|
+
const before = currentLine.slice(0, cursorCol - prefix.length);
|
|
252
|
+
const after = currentLine.slice(cursorCol);
|
|
253
|
+
const newLine = before + item.value + after;
|
|
254
|
+
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
255
|
+
return {
|
|
256
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
257
|
+
cursorLine,
|
|
258
|
+
cursorCol: newCursorCol,
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Extension
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
export default function fffExtension(pi: ExtensionAPI) {
|
|
269
|
+
let finder: FileFinder | null = null;
|
|
270
|
+
let finderCwd: string | null = null;
|
|
271
|
+
// Concurrent ensureFinder() callers share the same in-flight promise so
|
|
272
|
+
// FileFinder.create() (which takes native DB locks) runs at most once per
|
|
273
|
+
// base path at a time — otherwise parallel tool calls would race and
|
|
274
|
+
// deadlock at the native layer (issue #403).
|
|
275
|
+
let finderPromise: Promise<FileFinder> | null = null;
|
|
276
|
+
let activeCwd = process.cwd();
|
|
277
|
+
|
|
278
|
+
// Mode resolution: flag > env > default
|
|
279
|
+
let currentMode: FffMode =
|
|
280
|
+
(pi.getFlag("fff-mode") as FffMode) ??
|
|
281
|
+
(process.env.PI_FFF_MODE as FffMode) ??
|
|
282
|
+
"tools-and-ui";
|
|
283
|
+
|
|
284
|
+
const toolNames = resolveToolNames(currentMode);
|
|
285
|
+
|
|
286
|
+
// DB path resolution: flag > env > undefined (use fff-node defaults)
|
|
287
|
+
const frecencyDbPath =
|
|
288
|
+
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
289
|
+
process.env.FFF_FRECENCY_DB ??
|
|
290
|
+
undefined;
|
|
291
|
+
const historyDbPath =
|
|
292
|
+
(pi.getFlag("fff-history-db") as string | undefined) ??
|
|
293
|
+
process.env.FFF_HISTORY_DB ??
|
|
294
|
+
undefined;
|
|
295
|
+
|
|
296
|
+
// Root scanning opt-in: flag (boolean) > env ("1"/"true") > false.
|
|
297
|
+
// FFF refuses to init at / unless this is set. Home dir scanning is on by
|
|
298
|
+
// default for pi — launching pi from $HOME is a normal flow.
|
|
299
|
+
const rootScanFlag = pi.getFlag("fff-enable-root-scan");
|
|
300
|
+
const rootScanEnv = process.env.FFF_ENABLE_ROOT_SCAN;
|
|
301
|
+
const enableFsRootScanning =
|
|
302
|
+
rootScanFlag === true ||
|
|
303
|
+
rootScanFlag === "true" ||
|
|
304
|
+
rootScanFlag === "1" ||
|
|
305
|
+
(rootScanFlag == null && (rootScanEnv === "1" || rootScanEnv === "true"));
|
|
306
|
+
|
|
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
|
+
function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
320
|
+
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
321
|
+
return Promise.resolve(finder);
|
|
322
|
+
if (finderPromise) return finderPromise;
|
|
323
|
+
|
|
324
|
+
finderPromise = (async () => {
|
|
325
|
+
if (finder && !finder.isDestroyed) {
|
|
326
|
+
finder.destroy();
|
|
327
|
+
finder = null;
|
|
328
|
+
finderCwd = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const result = FileFinder.create({
|
|
332
|
+
basePath: cwd,
|
|
333
|
+
frecencyDbPath,
|
|
334
|
+
historyDbPath,
|
|
335
|
+
aiMode: true,
|
|
336
|
+
enableHomeDirScanning: true,
|
|
337
|
+
enableFsRootScanning,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!result.ok)
|
|
341
|
+
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
342
|
+
|
|
343
|
+
finder = result.value;
|
|
344
|
+
finderCwd = cwd;
|
|
345
|
+
await finder.waitForScan(15000);
|
|
346
|
+
return finder;
|
|
347
|
+
})().finally(() => {
|
|
348
|
+
finderPromise = null;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return finderPromise;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function destroyFinder() {
|
|
355
|
+
if (finder && !finder.isDestroyed) {
|
|
356
|
+
finder.destroy();
|
|
357
|
+
finder = null;
|
|
358
|
+
finderCwd = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function getMentionItems(
|
|
363
|
+
query: string,
|
|
364
|
+
signal: AbortSignal,
|
|
365
|
+
): Promise<AutocompleteItem[]> {
|
|
366
|
+
if (signal.aborted) return [];
|
|
367
|
+
const f = await ensureFinder(activeCwd);
|
|
368
|
+
if (signal.aborted) return [];
|
|
369
|
+
|
|
370
|
+
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
371
|
+
if (!result.ok) return [];
|
|
372
|
+
|
|
373
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
374
|
+
if (mixed.type === "directory") {
|
|
375
|
+
return {
|
|
376
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
377
|
+
label: mixed.item.dirName,
|
|
378
|
+
description: mixed.item.relativePath,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
383
|
+
label: mixed.item.fileName,
|
|
384
|
+
description: mixed.item.relativePath,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function registerAutocompleteProvider(ctx: {
|
|
390
|
+
ui: {
|
|
391
|
+
addAutocompleteProvider: (
|
|
392
|
+
factory: (current: AutocompleteProvider) => AutocompleteProvider,
|
|
393
|
+
) => void;
|
|
394
|
+
};
|
|
395
|
+
}) {
|
|
396
|
+
ctx.ui.addAutocompleteProvider((current) => {
|
|
397
|
+
const mentionProvider = createFffMentionProvider(getMentionItems);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
401
|
+
if (shouldEnableMentions()) {
|
|
402
|
+
try {
|
|
403
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
404
|
+
lines,
|
|
405
|
+
cursorLine,
|
|
406
|
+
cursorCol,
|
|
407
|
+
options,
|
|
408
|
+
);
|
|
409
|
+
if (mentionResult) return mentionResult;
|
|
410
|
+
} catch {
|
|
411
|
+
// Delegate when FFF lookup is unavailable.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
416
|
+
},
|
|
417
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
418
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
419
|
+
},
|
|
420
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
421
|
+
return (
|
|
422
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
|
|
423
|
+
);
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Flags / lifecycle ---
|
|
430
|
+
|
|
431
|
+
pi.registerFlag("fff-mode", {
|
|
432
|
+
description: "FFF mode: tools-and-ui | tools-only | override",
|
|
433
|
+
type: "string",
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
pi.registerFlag("fff-frecency-db", {
|
|
437
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
438
|
+
type: "string",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
pi.registerFlag("fff-history-db", {
|
|
442
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
443
|
+
type: "string",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
pi.registerFlag("fff-enable-root-scan", {
|
|
447
|
+
description:
|
|
448
|
+
"Allow indexing when launched from the filesystem root (also: FFF_ENABLE_ROOT_SCAN env)",
|
|
449
|
+
type: "boolean",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
453
|
+
try {
|
|
454
|
+
activeCwd = ctx.cwd;
|
|
455
|
+
|
|
456
|
+
// Restore persisted mode from session entries. This handles session
|
|
457
|
+
// resume after process restart where env vars are lost, and ensures
|
|
458
|
+
// the env var is set for the next /reload in the same session.
|
|
459
|
+
const entries = ctx.sessionManager?.getEntries();
|
|
460
|
+
if (entries) {
|
|
461
|
+
const modeEntry = [...entries]
|
|
462
|
+
.reverse()
|
|
463
|
+
.find(
|
|
464
|
+
(e: { type: string; customType?: string }) =>
|
|
465
|
+
e.type === "custom" && e.customType === "fff-mode",
|
|
466
|
+
);
|
|
467
|
+
if (
|
|
468
|
+
modeEntry &&
|
|
469
|
+
typeof (modeEntry as any).data?.mode === "string" &&
|
|
470
|
+
VALID_MODES.includes((modeEntry as any).data.mode as FffMode)
|
|
471
|
+
) {
|
|
472
|
+
const restored = (modeEntry as any).data.mode as FffMode;
|
|
473
|
+
if (restored !== currentMode) {
|
|
474
|
+
currentMode = restored;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
registerAutocompleteProvider(ctx);
|
|
480
|
+
await ensureFinder(activeCwd);
|
|
481
|
+
} catch (e: unknown) {
|
|
482
|
+
ctx.ui.notify(
|
|
483
|
+
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
484
|
+
"error",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
pi.on("session_shutdown", async () => {
|
|
490
|
+
destroyFinder();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// --- Shared render helpers ---
|
|
494
|
+
|
|
495
|
+
const renderTextResult = (
|
|
496
|
+
result: { content?: { type: string; text?: string }[] },
|
|
497
|
+
options: { expanded?: boolean },
|
|
498
|
+
theme: any,
|
|
499
|
+
context: any,
|
|
500
|
+
maxLines = 15,
|
|
501
|
+
) => {
|
|
502
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
503
|
+
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
504
|
+
if (!output) {
|
|
505
|
+
text.setText(theme.fg("muted", "No output"));
|
|
506
|
+
return text;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const lines = output.split("\n");
|
|
510
|
+
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
|
511
|
+
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
512
|
+
if (lines.length > displayLines.length) {
|
|
513
|
+
content += theme.fg(
|
|
514
|
+
"muted",
|
|
515
|
+
`\n... (${lines.length - displayLines.length} more lines)`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
text.setText(content);
|
|
519
|
+
return text;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// --- grep tool ---
|
|
523
|
+
|
|
524
|
+
const grepSchema = Type.Object({
|
|
525
|
+
pattern: Type.String({
|
|
526
|
+
description: "Search pattern (literal text or regex)",
|
|
527
|
+
}),
|
|
528
|
+
path: Type.Optional(
|
|
529
|
+
Type.String({
|
|
530
|
+
description:
|
|
531
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
532
|
+
}),
|
|
533
|
+
),
|
|
534
|
+
exclude: Type.Optional(
|
|
535
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
536
|
+
description:
|
|
537
|
+
"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/'.",
|
|
538
|
+
}),
|
|
539
|
+
),
|
|
540
|
+
caseSensitive: Type.Optional(
|
|
541
|
+
Type.Boolean({
|
|
542
|
+
description:
|
|
543
|
+
"Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
|
|
544
|
+
}),
|
|
545
|
+
),
|
|
546
|
+
context: Type.Optional(
|
|
547
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
548
|
+
),
|
|
549
|
+
limit: Type.Optional(
|
|
550
|
+
Type.Number({
|
|
551
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
cursor: Type.Optional(
|
|
555
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
556
|
+
),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
pi.registerTool({
|
|
560
|
+
name: toolNames.grep,
|
|
561
|
+
label: toolNames.grep,
|
|
562
|
+
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
|
+
promptSnippet: "Grep contents",
|
|
564
|
+
promptGuidelines: [
|
|
565
|
+
"Prefer bare identifiers as patterns. Literal queries are most efficient.",
|
|
566
|
+
"Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
|
|
567
|
+
"caseSensitive: true when you need exact case (smart-case otherwise).",
|
|
568
|
+
"After 1-2 greps, read the top match instead of more greps.",
|
|
569
|
+
],
|
|
570
|
+
parameters: grepSchema,
|
|
571
|
+
|
|
572
|
+
async execute(_toolCallId, params, signal) {
|
|
573
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
574
|
+
|
|
575
|
+
const f = await ensureFinder(activeCwd);
|
|
576
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
577
|
+
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
578
|
+
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
579
|
+
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
580
|
+
// only kicks in for plain mode — regex queries are intentional.
|
|
581
|
+
const hasRegexSyntax =
|
|
582
|
+
params.pattern !== params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
583
|
+
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
584
|
+
if (mode === "regex") {
|
|
585
|
+
try {
|
|
586
|
+
new RegExp(params.pattern);
|
|
587
|
+
} catch {
|
|
588
|
+
mode = "plain";
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
|
593
|
+
// to try to read a whole file. That's not what grep is for — return a terse error
|
|
594
|
+
// steering them to a real pattern, preventing dozens of wasted retries.
|
|
595
|
+
const p = params.pattern.trim();
|
|
596
|
+
const isWildcardOnly =
|
|
597
|
+
hasRegexSyntax &&
|
|
598
|
+
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
599
|
+
p,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
if (isWildcardOnly) {
|
|
603
|
+
return {
|
|
604
|
+
content: [
|
|
605
|
+
{
|
|
606
|
+
type: "text",
|
|
607
|
+
text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
details: { totalMatched: 0, totalFiles: 0 },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// caseSensitive override flips smartCase off; omitting it keeps smart-case
|
|
615
|
+
// (case-insensitive when pattern is all lowercase).
|
|
616
|
+
const smartCase = params.caseSensitive !== true;
|
|
617
|
+
|
|
618
|
+
const grepResult = f.grep(query, {
|
|
619
|
+
mode,
|
|
620
|
+
smartCase,
|
|
621
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
622
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
623
|
+
beforeContext: params.context ?? 0,
|
|
624
|
+
afterContext: params.context ?? 0,
|
|
625
|
+
classifyDefinitions: true,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
629
|
+
|
|
630
|
+
let result = grepResult.value;
|
|
631
|
+
let fuzzyNotice: string | null = null;
|
|
632
|
+
|
|
633
|
+
// automatic fuzzy fallback allows to broad the queries and find different cases
|
|
634
|
+
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
|
635
|
+
const fuzzy = f.grep(params.pattern, {
|
|
636
|
+
mode: "fuzzy",
|
|
637
|
+
smartCase,
|
|
638
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
639
|
+
cursor: null,
|
|
640
|
+
beforeContext: 0,
|
|
641
|
+
afterContext: 0,
|
|
642
|
+
classifyDefinitions: true,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
646
|
+
fuzzyNotice = `0 exact matches. Maybe you meant this?`;
|
|
647
|
+
result = fuzzy.value;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
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
|
+
};
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
renderCall(args, theme, context) {
|
|
673
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
674
|
+
const pattern = args?.pattern ?? "";
|
|
675
|
+
const path = args?.path ?? ".";
|
|
676
|
+
let content =
|
|
677
|
+
theme.fg("toolTitle", theme.bold(toolNames.grep)) +
|
|
678
|
+
" " +
|
|
679
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
680
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
681
|
+
if (args?.limit !== undefined)
|
|
682
|
+
content += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
683
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
684
|
+
text.setText(content);
|
|
685
|
+
return text;
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
renderResult(result, options, theme, context) {
|
|
689
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// --- find tool ---
|
|
694
|
+
|
|
695
|
+
const findSchema = Type.Object({
|
|
696
|
+
pattern: Type.String({
|
|
697
|
+
description:
|
|
698
|
+
"Fuzzy filename search and glob search. Frecency-ranked, git-aware. Multi-word = narrower (AND) not bound to order, use for multi word related concept search. Prefer this over ls/find/bash as the first exploration step whenever the user names a concept, feature, or symbol — it surfaces the relevant files in one call. Only use ls/read on a directory when you specifically need the alphabetical layout of an unknown repo, or when a concept search returned nothing.",
|
|
699
|
+
}),
|
|
700
|
+
path: Type.Optional(
|
|
701
|
+
Type.String({
|
|
702
|
+
description:
|
|
703
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
704
|
+
}),
|
|
705
|
+
),
|
|
706
|
+
exclude: Type.Optional(
|
|
707
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
708
|
+
description:
|
|
709
|
+
"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/'.",
|
|
710
|
+
}),
|
|
711
|
+
),
|
|
712
|
+
limit: Type.Optional(
|
|
713
|
+
Type.Number({
|
|
714
|
+
description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
|
|
715
|
+
}),
|
|
716
|
+
),
|
|
717
|
+
cursor: Type.Optional(
|
|
718
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
719
|
+
),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
pi.registerTool({
|
|
723
|
+
name: toolNames.find,
|
|
724
|
+
label: toolNames.find,
|
|
725
|
+
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
|
+
promptSnippet: "Find files by path or glob",
|
|
727
|
+
promptGuidelines: [
|
|
728
|
+
"Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
|
|
729
|
+
"Keep queries to 1-2 terms; extra words narrow.",
|
|
730
|
+
"Use for paths, not content. Use grep for content.",
|
|
731
|
+
"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.",
|
|
732
|
+
"To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
|
|
733
|
+
"Use exclude: 'test/,*.min.js' to cut noise in large repos.",
|
|
734
|
+
],
|
|
735
|
+
parameters: findSchema,
|
|
736
|
+
|
|
737
|
+
async execute(_toolCallId, params, signal) {
|
|
738
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
739
|
+
|
|
740
|
+
const f = await ensureFinder(activeCwd);
|
|
741
|
+
|
|
742
|
+
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
|
743
|
+
// the agent can't accidentally mix patterns across pages.
|
|
744
|
+
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
745
|
+
const effectiveLimit = resumed
|
|
746
|
+
? resumed.pageSize
|
|
747
|
+
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
748
|
+
const query = resumed
|
|
749
|
+
? resumed.query
|
|
750
|
+
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
751
|
+
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
752
|
+
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
753
|
+
|
|
754
|
+
const searchResult = f.fileSearch(query, {
|
|
755
|
+
pageIndex,
|
|
756
|
+
pageSize: effectiveLimit,
|
|
757
|
+
});
|
|
758
|
+
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
759
|
+
|
|
760
|
+
const result = searchResult.value;
|
|
761
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
762
|
+
let output = formatted.output;
|
|
763
|
+
|
|
764
|
+
// Infer hasMore: native fileSearch fills pageSize when more results
|
|
765
|
+
// exist, so if we got a full page AND totalMatched exceeds what we've
|
|
766
|
+
// shown so far there's another page to fetch.
|
|
767
|
+
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
768
|
+
const hasMore =
|
|
769
|
+
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
|
|
770
|
+
|
|
771
|
+
const notices: string[] = [];
|
|
772
|
+
if (formatted.weak && formatted.shownCount > 0)
|
|
773
|
+
notices.push(
|
|
774
|
+
`Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
if (!formatted.weak && hasMore) {
|
|
778
|
+
const remaining = result.totalMatched - shownSoFar;
|
|
779
|
+
const cursorId = storeFindCursor({
|
|
780
|
+
query,
|
|
781
|
+
pattern,
|
|
782
|
+
pageSize: effectiveLimit,
|
|
783
|
+
nextPageIndex: pageIndex + 1,
|
|
784
|
+
});
|
|
785
|
+
notices.push(
|
|
786
|
+
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
791
|
+
return {
|
|
792
|
+
content: [{ type: "text", text: output }],
|
|
793
|
+
details: {
|
|
794
|
+
totalMatched: result.totalMatched,
|
|
795
|
+
totalFiles: result.totalFiles,
|
|
796
|
+
pageIndex,
|
|
797
|
+
hasMore,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
renderCall(args, theme, context) {
|
|
803
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
804
|
+
const pattern = args?.pattern ?? "";
|
|
805
|
+
const path = args?.path ?? ".";
|
|
806
|
+
let content =
|
|
807
|
+
theme.fg("toolTitle", theme.bold(toolNames.find)) +
|
|
808
|
+
" " +
|
|
809
|
+
theme.fg("accent", pattern) +
|
|
810
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
811
|
+
if (args?.limit !== undefined)
|
|
812
|
+
content += theme.fg("toolOutput", ` (limit ${args.limit})`);
|
|
813
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
814
|
+
text.setText(content);
|
|
815
|
+
return text;
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
renderResult(result, options, theme, context) {
|
|
819
|
+
return renderTextResult(result, options, theme, context, 20);
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// --- commands ---
|
|
824
|
+
|
|
825
|
+
pi.registerCommand("fff-mode", {
|
|
826
|
+
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
827
|
+
handler: async (args, ctx) => {
|
|
828
|
+
const arg = (args || "").trim();
|
|
829
|
+
|
|
830
|
+
// No args - show current mode
|
|
831
|
+
if (!arg) {
|
|
832
|
+
const mode = getMode();
|
|
833
|
+
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
834
|
+
ctx.ui.notify(`Current mode: '${mode}' (flag: ${flag})`, "info");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Validate and set mode
|
|
839
|
+
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
840
|
+
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const newMode = arg as FffMode;
|
|
845
|
+
const oldMode = getMode();
|
|
846
|
+
setMode(newMode);
|
|
847
|
+
|
|
848
|
+
pi.appendEntry("fff-mode", { mode: newMode });
|
|
849
|
+
|
|
850
|
+
const note =
|
|
851
|
+
(oldMode === "override") !== (newMode === "override")
|
|
852
|
+
? " (tool name change requires /reload)"
|
|
853
|
+
: "";
|
|
854
|
+
ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'${note}`, "info");
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
pi.registerCommand("fff-health", {
|
|
859
|
+
description: "Show FFF file finder health and status",
|
|
860
|
+
handler: async (_args, ctx) => {
|
|
861
|
+
if (!finder || finder.isDestroyed) {
|
|
862
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const health = finder.healthCheck();
|
|
867
|
+
if (!health.ok) {
|
|
868
|
+
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const h = health.value;
|
|
873
|
+
const lines = [
|
|
874
|
+
`FFF v${h.version}`,
|
|
875
|
+
`Mode: ${getMode()}`,
|
|
876
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
877
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
878
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
879
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
const progress = finder.getScanProgress();
|
|
883
|
+
if (progress.ok) {
|
|
884
|
+
lines.push(
|
|
885
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
pi.registerCommand("fff-rescan", {
|
|
894
|
+
description: "Trigger FFF to rescan files",
|
|
895
|
+
handler: async (_args, ctx) => {
|
|
896
|
+
if (!finder || finder.isDestroyed) {
|
|
897
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const result = finder.scanFiles();
|
|
902
|
+
if (!result.ok) {
|
|
903
|
+
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
ctx.ui.notify("FFF rescan triggered", "info");
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function normalizePathConstraint(
|
|
4
|
+
pathConstraint: string,
|
|
5
|
+
cwd = process.cwd(),
|
|
6
|
+
): string | null {
|
|
7
|
+
let trimmed = pathConstraint.trim();
|
|
8
|
+
if (!trimmed) return trimmed;
|
|
9
|
+
|
|
10
|
+
if (path.isAbsolute(trimmed)) {
|
|
11
|
+
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
|
12
|
+
if (relative === "") return null;
|
|
13
|
+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
trimmed = relative;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (trimmed === "." || trimmed === "./") return null;
|
|
22
|
+
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
|
23
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
24
|
+
|
|
25
|
+
// FFF's glob matcher can treat a hidden directory root glob such as
|
|
26
|
+
// `.agents/**` as empty, while the tool contract says this means "inside
|
|
27
|
+
// this directory". Collapse simple trailing recursive directory globs to the
|
|
28
|
+
// directory-prefix constraint understood by the parser. Keep real file globs
|
|
29
|
+
// such as `src/**/*.ts` unchanged.
|
|
30
|
+
const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
|
|
31
|
+
if (recursiveDir) {
|
|
32
|
+
const dir = recursiveDir[1];
|
|
33
|
+
if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Already signals path-constraint syntax to the parser.
|
|
37
|
+
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
|
|
38
|
+
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
|
|
39
|
+
if (/[*?[{]/.test(trimmed)) return trimmed;
|
|
40
|
+
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
|
|
41
|
+
const lastSegment = trimmed.split("/").pop() ?? "";
|
|
42
|
+
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
|
|
43
|
+
// Bare directory prefix → append `/` so the parser sees a PathSegment.
|
|
44
|
+
return `${trimmed}/`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
|
|
48
|
+
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
|
|
49
|
+
// the same way as the include path so bare dirs become PathSegment excludes.
|
|
50
|
+
// Tolerate callers passing already-negated forms like `!src/` by stripping
|
|
51
|
+
// the leading `!` before normalizing so we never double-negate (`!!src/`).
|
|
52
|
+
export function normalizeExcludes(
|
|
53
|
+
exclude: string | string[] | undefined,
|
|
54
|
+
cwd = process.cwd(),
|
|
55
|
+
): string[] {
|
|
56
|
+
if (!exclude) return [];
|
|
57
|
+
const parts = Array.isArray(exclude) ? exclude : exclude.split(/[,\s]+/);
|
|
58
|
+
return parts.flatMap((s) => {
|
|
59
|
+
const normalized = normalizePathConstraint(s.trim().replace(/^!/, ""), cwd);
|
|
60
|
+
return normalized ? [`!${normalized}`] : [];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildQuery(
|
|
65
|
+
path: string | undefined,
|
|
66
|
+
pattern: string,
|
|
67
|
+
exclude?: string | string[],
|
|
68
|
+
cwd = process.cwd(),
|
|
69
|
+
): string {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (path) {
|
|
72
|
+
const pathConstraint = normalizePathConstraint(path, cwd);
|
|
73
|
+
if (pathConstraint) parts.push(pathConstraint);
|
|
74
|
+
}
|
|
75
|
+
parts.push(...normalizeExcludes(exclude, cwd));
|
|
76
|
+
parts.push(pattern);
|
|
77
|
+
return parts.join(" ");
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { buildQuery, normalizePathConstraint } from "./lib/query";
|
|
3
|
+
|
|
4
|
+
const cwd = "/tmp/workspace";
|
|
5
|
+
|
|
6
|
+
describe("path constraint normalization", () => {
|
|
7
|
+
it("converts absolute in-workspace paths to repo-relative constraints", () => {
|
|
8
|
+
expect(normalizePathConstraint("/tmp/workspace/.agents/**", cwd)).to.equal(
|
|
9
|
+
".agents/",
|
|
10
|
+
);
|
|
11
|
+
expect(normalizePathConstraint("/tmp/workspace/.agents/plans/**", cwd)).to.equal(
|
|
12
|
+
".agents/plans/",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects absolute paths outside the workspace", () => {
|
|
17
|
+
expect(() => normalizePathConstraint("/tmp/other/.agents/**", cwd)).to.throw(
|
|
18
|
+
"Path constraint must be relative to the workspace",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("collapses only simple trailing recursive directory globs", () => {
|
|
23
|
+
expect(normalizePathConstraint(".agents/**", cwd)).to.equal(".agents/");
|
|
24
|
+
expect(normalizePathConstraint("src/**/*", cwd)).to.equal("src/");
|
|
25
|
+
expect(normalizePathConstraint("src/**/*.ts", cwd)).to.equal("src/**/*.ts");
|
|
26
|
+
expect(normalizePathConstraint("{src,lib}/**", cwd)).to.equal("{src,lib}/**");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("builds find queries with normalized include and exclude constraints", () => {
|
|
30
|
+
expect(
|
|
31
|
+
buildQuery("/tmp/workspace/.agents/**", "*", "/tmp/workspace/test/**", cwd),
|
|
32
|
+
).to.equal(".agents/ !test/ *");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats path='.' as workspace root", () => {
|
|
36
|
+
expect(normalizePathConstraint(".", cwd)).to.equal(null);
|
|
37
|
+
expect(normalizePathConstraint("./", cwd)).to.equal(null);
|
|
38
|
+
expect(buildQuery(".", "needle", undefined, cwd)).to.equal("needle");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("treats absolute workspace root as no constraint", () => {
|
|
42
|
+
expect(normalizePathConstraint(cwd, cwd)).to.equal(null);
|
|
43
|
+
expect(buildQuery(cwd, "needle", undefined, cwd)).to.equal("needle");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("normalizes bare directories and file paths", () => {
|
|
47
|
+
expect(normalizePathConstraint("app", cwd)).to.equal("app/");
|
|
48
|
+
expect(normalizePathConstraint("src/nested", cwd)).to.equal("src/nested/");
|
|
49
|
+
expect(normalizePathConstraint("/tmp/workspace/src/main.rs", cwd)).to.equal(
|
|
50
|
+
"src/main.rs",
|
|
51
|
+
);
|
|
52
|
+
expect(normalizePathConstraint("/tmp/workspace/src", cwd)).to.equal("src/");
|
|
53
|
+
expect(normalizePathConstraint("/tmp/workspace/src/**/*.ts", cwd)).to.equal(
|
|
54
|
+
"src/**/*.ts",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bacnh85/pi-fff",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Pi extension: FFF-powered fuzzy file and content search.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/bacnh85/pi-extensions#readme",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/bacnh85/pi-extensions.git",
|
|
14
|
+
"directory": "pi-fff"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/bacnh85/pi-extensions/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"pi-extension",
|
|
22
|
+
"fff",
|
|
23
|
+
"search",
|
|
24
|
+
"grep",
|
|
25
|
+
"fuzzy-search",
|
|
26
|
+
"ai-agent"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "cd extensions && mocha",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"README.md",
|
|
34
|
+
"NOTICE.md",
|
|
35
|
+
"extensions/"
|
|
36
|
+
],
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./extensions/index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@ff-labs/fff-node": "*"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
+
"@earendil-works/pi-tui": "*",
|
|
48
|
+
"@sinclair/typebox": "*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@sinclair/typebox": "^0.34.41",
|
|
52
|
+
"@types/chai": "^4.3.20",
|
|
53
|
+
"@types/mocha": "^10.0.10",
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"chai": "^4.5.0",
|
|
56
|
+
"mocha": "^10.8.2",
|
|
57
|
+
"tsx": "^4.22.4",
|
|
58
|
+
"typescript": "^5.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|