@ff-labs/pi-fff 0.10.5 → 0.10.6-nightly.611dd87
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 +39 -5
- package/package.json +5 -4
- package/pi-fff.schema.json +41 -0
- package/src/config.ts +103 -0
- package/src/index.ts +255 -128
- package/src/paths.ts +1 -1
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ Parameters:
|
|
|
116
116
|
|
|
117
117
|
- `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
|
|
118
118
|
- `/fff-rescan` — trigger a file rescan
|
|
119
|
-
- `/fff-mode <mode>` — switch mode (tool name
|
|
119
|
+
- `/fff-mode <mode>` — switch mode (tool name changes require `/reload`)
|
|
120
120
|
|
|
121
121
|
## Modes
|
|
122
122
|
|
|
@@ -124,10 +124,43 @@ Parameters:
|
|
|
124
124
|
- `tools-only`: additional tools only; keep pi's default `@` autocomplete
|
|
125
125
|
- `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
Startup mode precedence:
|
|
128
128
|
1. `--fff-mode <mode>` CLI flag
|
|
129
129
|
2. `PI_FFF_MODE=<mode>` environment variable
|
|
130
|
-
3.
|
|
130
|
+
3. `mode` in the global config file
|
|
131
|
+
4. default (`tools-and-ui`)
|
|
132
|
+
|
|
133
|
+
When a session resumes, its most recent `/fff-mode` selection takes precedence over the startup resolution above. Switching to or from `override` takes effect after `/reload`, when the tools are registered again.
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
For persistent global configuration, create `pi-fff.json` in pi's agent directory (`~/.pi/agent/pi-fff.json` by default; `PI_CODING_AGENT_DIR` is respected):
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"$schema": "https://raw.githubusercontent.com/dmtrKovalenko/fff/main/packages/pi-fff/pi-fff.schema.json",
|
|
142
|
+
"mode": "override",
|
|
143
|
+
"frecencyDbPath": "/path/to/frecency",
|
|
144
|
+
"historyDbPath": "/path/to/history",
|
|
145
|
+
"enableFsRootScanning": false,
|
|
146
|
+
"enableHomeDirScanning": true
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
All fields are optional:
|
|
151
|
+
|
|
152
|
+
| Field | Type | Default |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `$schema` | non-empty string | none |
|
|
155
|
+
| `mode` | `tools-and-ui`, `tools-only`, or `override` | `tools-and-ui` |
|
|
156
|
+
| `frecencyDbPath` | non-empty string | See [Data](#data) |
|
|
157
|
+
| `historyDbPath` | non-empty string | See [Data](#data) |
|
|
158
|
+
| `enableFsRootScanning` | boolean | `false` |
|
|
159
|
+
| `enableHomeDirScanning` | boolean | `true` |
|
|
160
|
+
|
|
161
|
+
CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file.
|
|
162
|
+
|
|
163
|
+
The file is global only. Project-level config cannot safely control tool names because pi decides which tools an extension registers before project configuration can be trusted.
|
|
131
164
|
|
|
132
165
|
## Flags
|
|
133
166
|
|
|
@@ -147,11 +180,12 @@ Each path is resolved independently, in this order:
|
|
|
147
180
|
|
|
148
181
|
1. CLI flag — `--fff-frecency-db` / `--fff-history-db`
|
|
149
182
|
2. Env var — `FFF_FRECENCY_DB` / `FFF_HISTORY_DB`
|
|
150
|
-
3.
|
|
183
|
+
3. Global config — `frecencyDbPath` / `historyDbPath`
|
|
184
|
+
4. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor:
|
|
151
185
|
- frecency: `$XDG_CACHE_HOME/nvim/fff_nvim`
|
|
152
186
|
- history: `$XDG_DATA_HOME/nvim/fff_queries`
|
|
153
187
|
- `XDG_CACHE_HOME` defaults to `~/.cache` and `XDG_DATA_HOME` to `~/.local/share`; on Windows both fall back under `%LOCALAPPDATA%\nvim-data`. Only directories count — a plain file at those paths is ignored.
|
|
154
|
-
|
|
188
|
+
5. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}`
|
|
155
189
|
|
|
156
190
|
The extension only reads these databases; it never records the agent's own searches into your Neovim history. If a database cannot be opened, the finder starts without persistence and pi shows a warning instead of failing.
|
|
157
191
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ff-labs/pi-fff",
|
|
3
3
|
"public": true,
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.6-nightly.611dd87",
|
|
5
5
|
"description": "pi extension: FFF-powered fuzzy file and content search",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
-
"src"
|
|
33
|
+
"src",
|
|
34
|
+
"pi-fff.schema.json"
|
|
34
35
|
],
|
|
35
36
|
"publishConfig": {
|
|
36
37
|
"access": "public"
|
|
@@ -40,8 +41,8 @@
|
|
|
40
41
|
"typecheck": "tsc --noEmit"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
|
-
"@ff-labs/fff-bun": "0.10.
|
|
44
|
-
"@ff-labs/fff-node": "0.10.
|
|
44
|
+
"@ff-labs/fff-bun": "0.10.6-nightly.611dd87",
|
|
45
|
+
"@ff-labs/fff-node": "0.10.6-nightly.611dd87"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/dmtrKovalenko/fff/main/packages/pi-fff/pi-fff.schema.json",
|
|
4
|
+
"title": "pi-fff configuration",
|
|
5
|
+
"description": "Global startup configuration for the pi-fff extension.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"minLength": 1,
|
|
12
|
+
"description": "Schema URL used by editors for validation and completion."
|
|
13
|
+
},
|
|
14
|
+
"mode": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"enum": ["tools-and-ui", "tools-only", "override"],
|
|
17
|
+
"default": "tools-and-ui",
|
|
18
|
+
"description": "Controls which FFF tools and autocomplete integrations are enabled."
|
|
19
|
+
},
|
|
20
|
+
"frecencyDbPath": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"description": "Path to the frecency database."
|
|
24
|
+
},
|
|
25
|
+
"historyDbPath": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1,
|
|
28
|
+
"description": "Path to the query history database."
|
|
29
|
+
},
|
|
30
|
+
"enableFsRootScanning": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"default": false,
|
|
33
|
+
"description": "Allows indexing when pi is launched from the filesystem root."
|
|
34
|
+
},
|
|
35
|
+
"enableHomeDirScanning": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": true,
|
|
38
|
+
"description": "Allows indexing when pi is launched from the home directory."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { piDataDir } from "./paths";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE_NAME = "pi-fff.json";
|
|
6
|
+
export const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const;
|
|
7
|
+
|
|
8
|
+
export type FffMode = (typeof VALID_MODES)[number];
|
|
9
|
+
|
|
10
|
+
export interface FffConfig {
|
|
11
|
+
$schema?: string;
|
|
12
|
+
mode?: FffMode;
|
|
13
|
+
frecencyDbPath?: string;
|
|
14
|
+
historyDbPath?: string;
|
|
15
|
+
enableFsRootScanning?: boolean;
|
|
16
|
+
enableHomeDirScanning?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CONFIG_KEYS = new Set<keyof FffConfig>([
|
|
20
|
+
"$schema",
|
|
21
|
+
"mode",
|
|
22
|
+
"frecencyDbPath",
|
|
23
|
+
"historyDbPath",
|
|
24
|
+
"enableFsRootScanning",
|
|
25
|
+
"enableHomeDirScanning",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function loadConfig(agentDir = piDataDir()): FffConfig {
|
|
29
|
+
const configPath = join(agentDir, CONFIG_FILE_NAME);
|
|
30
|
+
let contents: string;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
contents = readFileSync(configPath, "utf8");
|
|
34
|
+
} catch (error: unknown) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Could not read pi-fff config at ${configPath}: ${errorMessage(error)}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let parsed: unknown;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(contents);
|
|
44
|
+
} catch (error: unknown) {
|
|
45
|
+
throw invalidConfig(configPath, `not valid JSON (${errorMessage(error)})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!isRecord(parsed)) {
|
|
49
|
+
throw invalidConfig(configPath, "expected a JSON object");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const key of Object.keys(parsed)) {
|
|
53
|
+
if (!CONFIG_KEYS.has(key as keyof FffConfig)) {
|
|
54
|
+
throw invalidConfig(configPath, `unknown option "${key}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parsed.mode !== undefined && !VALID_MODES.includes(parsed.mode as FffMode)) {
|
|
59
|
+
throw invalidConfig(configPath, `"mode" must be one of ${VALID_MODES.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validateString(configPath, parsed, "$schema");
|
|
63
|
+
validateString(configPath, parsed, "frecencyDbPath");
|
|
64
|
+
validateString(configPath, parsed, "historyDbPath");
|
|
65
|
+
validateBoolean(configPath, parsed, "enableFsRootScanning");
|
|
66
|
+
validateBoolean(configPath, parsed, "enableHomeDirScanning");
|
|
67
|
+
|
|
68
|
+
return parsed as FffConfig;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function invalidConfig(configPath: string, reason: string): Error {
|
|
72
|
+
return new Error(`Invalid pi-fff config at ${configPath}: ${reason}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errorMessage(error: unknown): string {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateString(
|
|
84
|
+
configPath: string,
|
|
85
|
+
config: Record<string, unknown>,
|
|
86
|
+
key: "$schema" | "frecencyDbPath" | "historyDbPath",
|
|
87
|
+
): void {
|
|
88
|
+
const value = config[key];
|
|
89
|
+
if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
|
|
90
|
+
throw invalidConfig(configPath, `"${key}" must be a non-empty string`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateBoolean(
|
|
95
|
+
configPath: string,
|
|
96
|
+
config: Record<string, unknown>,
|
|
97
|
+
key: "enableFsRootScanning" | "enableHomeDirScanning",
|
|
98
|
+
): void {
|
|
99
|
+
const value = config[key];
|
|
100
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
101
|
+
throw invalidConfig(configPath, `"${key}" must be a boolean`);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import nodePath from "node:path";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
ToolDefinition,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
14
|
import {
|
|
11
15
|
type AutocompleteItem,
|
|
12
16
|
type AutocompleteProvider,
|
|
@@ -20,8 +24,9 @@ import type {
|
|
|
20
24
|
MixedItem,
|
|
21
25
|
SearchResult,
|
|
22
26
|
} from "@ff-labs/fff-node";
|
|
23
|
-
import { Type } from "@sinclair/typebox";
|
|
27
|
+
import { Type, type TSchema } from "@sinclair/typebox";
|
|
24
28
|
import { AuxFinderPool, routePathConstraint } from "./aux-finders";
|
|
29
|
+
import { type FffMode, loadConfig, VALID_MODES } from "./config";
|
|
25
30
|
import { FilePickerFactory } from "./file-picker";
|
|
26
31
|
import { isHomeDir, resolveDbPaths } from "./paths";
|
|
27
32
|
import { buildQuery } from "./query";
|
|
@@ -45,11 +50,7 @@ const GREP_TIME_BUDGET_MS = 10_000;
|
|
|
45
50
|
const HOME_SCAN_STATUS_KEY = "fff";
|
|
46
51
|
const HOME_SCAN_POLL_MS = 1_000;
|
|
47
52
|
const HOME_SCAN_DISABLE_HINT =
|
|
48
|
-
"You can prevent home dir indexing with --fff-enable-home-scan=false
|
|
49
|
-
|
|
50
|
-
type FffMode = "tools-and-ui" | "tools-only" | "override";
|
|
51
|
-
|
|
52
|
-
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
53
|
+
"You can prevent home dir indexing with --fff-enable-home-scan=false, FFF_ENABLE_HOME_SCAN=0, or enableHomeDirScanning in pi-fff.json.";
|
|
53
54
|
|
|
54
55
|
interface ToolNames {
|
|
55
56
|
grep: string;
|
|
@@ -299,52 +300,101 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
299
300
|
let finderPromise: Promise<FileFinderApi> | null = null;
|
|
300
301
|
let activeCwd = process.cwd();
|
|
301
302
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
});
|
|
303
|
+
const config = loadConfig();
|
|
304
|
+
|
|
305
|
+
// All startup options use the same flag > env > file > fallback order.
|
|
306
|
+
function getConfigValue<T>(
|
|
307
|
+
flagName: string,
|
|
308
|
+
envName: string,
|
|
309
|
+
fileValue: T | undefined,
|
|
310
|
+
fallback: T,
|
|
311
|
+
parse: (value: unknown) => T | undefined = (value) => value as T,
|
|
312
|
+
): T {
|
|
313
|
+
const flagValue = pi.getFlag(flagName);
|
|
314
|
+
if (flagValue !== undefined) {
|
|
315
|
+
const value = parse(flagValue);
|
|
316
|
+
if (value !== undefined) return value;
|
|
317
|
+
}
|
|
318
318
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (env === "0" || env === "false") return false;
|
|
327
|
-
return fallback;
|
|
319
|
+
const envValue = process.env[envName];
|
|
320
|
+
if (envValue !== undefined) {
|
|
321
|
+
const value = parse(envValue);
|
|
322
|
+
if (value !== undefined) return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return fileValue ?? fallback;
|
|
328
326
|
}
|
|
329
|
-
// Root scanning opt-in: FFF refuses to init at / unless this is set.
|
|
330
|
-
const enableFsRootScanning = resolveBoolOpt(
|
|
331
|
-
"fff-enable-root-scan",
|
|
332
|
-
"FFF_ENABLE_ROOT_SCAN",
|
|
333
|
-
);
|
|
334
|
-
// Home dir scanning is on by default (launching pi from $HOME is a normal
|
|
335
|
-
// flow), but configurable so users with huge $HOME trees can opt out.
|
|
336
|
-
const enableHomeDirScanning = resolveBoolOpt(
|
|
337
|
-
"fff-enable-home-scan",
|
|
338
|
-
"FFF_ENABLE_HOME_SCAN",
|
|
339
|
-
true,
|
|
340
|
-
);
|
|
341
327
|
|
|
342
|
-
function
|
|
343
|
-
return
|
|
328
|
+
function parseBoolean(value: unknown): boolean | undefined {
|
|
329
|
+
if (typeof value === "boolean") return value;
|
|
330
|
+
if (value === "1" || value === "true") return true;
|
|
331
|
+
if (value === "0" || value === "false") return false;
|
|
332
|
+
return undefined;
|
|
344
333
|
}
|
|
345
334
|
|
|
335
|
+
function parseMode(value: unknown): FffMode | undefined {
|
|
336
|
+
return typeof value === "string" && VALID_MODES.includes(value as FffMode)
|
|
337
|
+
? (value as FffMode)
|
|
338
|
+
: undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let currentMode: FffMode = "tools-and-ui";
|
|
342
|
+
let toolNames = resolveToolNames(currentMode);
|
|
343
|
+
let resolvedDbPaths: ReturnType<typeof resolveDbPaths>;
|
|
344
|
+
let enableFsRootScanning = false;
|
|
345
|
+
let enableHomeDirScanning = true;
|
|
346
|
+
|
|
346
347
|
function setMode(mode: FffMode): void {
|
|
347
348
|
currentMode = mode;
|
|
349
|
+
toolNames = resolveToolNames(mode);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function resolveStartupConfig(): void {
|
|
353
|
+
setMode(
|
|
354
|
+
getConfigValue(
|
|
355
|
+
"fff-mode",
|
|
356
|
+
"PI_FFF_MODE",
|
|
357
|
+
config.mode,
|
|
358
|
+
"tools-and-ui",
|
|
359
|
+
parseMode,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
resolvedDbPaths = resolveDbPaths({
|
|
363
|
+
frecency: getConfigValue(
|
|
364
|
+
"fff-frecency-db",
|
|
365
|
+
"FFF_FRECENCY_DB",
|
|
366
|
+
config.frecencyDbPath,
|
|
367
|
+
undefined,
|
|
368
|
+
),
|
|
369
|
+
history: getConfigValue(
|
|
370
|
+
"fff-history-db",
|
|
371
|
+
"FFF_HISTORY_DB",
|
|
372
|
+
config.historyDbPath,
|
|
373
|
+
undefined,
|
|
374
|
+
),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Root scanning opt-in: FFF refuses to init at / unless this is set.
|
|
378
|
+
enableFsRootScanning = getConfigValue(
|
|
379
|
+
"fff-enable-root-scan",
|
|
380
|
+
"FFF_ENABLE_ROOT_SCAN",
|
|
381
|
+
config.enableFsRootScanning,
|
|
382
|
+
false,
|
|
383
|
+
parseBoolean,
|
|
384
|
+
);
|
|
385
|
+
// Home dir scanning is on by default (launching pi from $HOME is a normal
|
|
386
|
+
// flow), but configurable so users with huge $HOME trees can opt out.
|
|
387
|
+
enableHomeDirScanning = getConfigValue(
|
|
388
|
+
"fff-enable-home-scan",
|
|
389
|
+
"FFF_ENABLE_HOME_SCAN",
|
|
390
|
+
config.enableHomeDirScanning,
|
|
391
|
+
true,
|
|
392
|
+
parseBoolean,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getMode(): FffMode {
|
|
397
|
+
return currentMode;
|
|
348
398
|
}
|
|
349
399
|
|
|
350
400
|
function shouldEnableMentions(): boolean {
|
|
@@ -368,22 +418,28 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
368
418
|
);
|
|
369
419
|
}
|
|
370
420
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
historyDbPath: resolvedDbPaths.history,
|
|
374
|
-
onDbFailure: (error) =>
|
|
375
|
-
uiCtx?.ui.notify(
|
|
376
|
-
`(fff): Failed to open frecency/history database (${error}). Continuing without frecency persistence.`,
|
|
377
|
-
"error",
|
|
378
|
-
),
|
|
379
|
-
});
|
|
421
|
+
let pickers: FilePickerFactory | null = null;
|
|
422
|
+
let auxPool: AuxFinderPool | null = null;
|
|
380
423
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
424
|
+
function initializeFinderFactories(): void {
|
|
425
|
+
if (pickers) return;
|
|
426
|
+
|
|
427
|
+
pickers = new FilePickerFactory({
|
|
428
|
+
frecencyDbPath: resolvedDbPaths.frecency,
|
|
429
|
+
historyDbPath: resolvedDbPaths.history,
|
|
430
|
+
onDbFailure: (error) =>
|
|
431
|
+
uiCtx?.ui.notify(
|
|
432
|
+
`(fff): Failed to open frecency/history database (${error}). Continuing without frecency persistence.`,
|
|
433
|
+
"error",
|
|
434
|
+
),
|
|
435
|
+
});
|
|
436
|
+
auxPool = new AuxFinderPool({
|
|
437
|
+
enableFsRootScanning,
|
|
438
|
+
enableHomeDirScanning,
|
|
439
|
+
onHomeDirScan: warnHomeDirScan,
|
|
440
|
+
pickers,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
387
443
|
|
|
388
444
|
// in case cwd changes we need to figure this out
|
|
389
445
|
function ensureFinder(cwd: string): Promise<FileFinderApi> {
|
|
@@ -401,6 +457,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
401
457
|
|
|
402
458
|
// if the dbs can't be opened the factory falls back to a db-less picker,
|
|
403
459
|
// e.g. when some other process corrupts the lock
|
|
460
|
+
if (!pickers) throw new Error("FFF picker factory is not initialized");
|
|
404
461
|
mainFinder = await pickers.create({
|
|
405
462
|
basePath: cwd,
|
|
406
463
|
enableHomeDirScanning,
|
|
@@ -455,9 +512,9 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
455
512
|
finderCwd = null;
|
|
456
513
|
}
|
|
457
514
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
515
|
+
auxPool?.destroy();
|
|
516
|
+
auxPool = null;
|
|
517
|
+
pickers = null;
|
|
461
518
|
}
|
|
462
519
|
|
|
463
520
|
async function resolveFinderForPath(
|
|
@@ -467,6 +524,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
467
524
|
): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
|
|
468
525
|
const route = routePathConstraint(pathParam, activeCwd);
|
|
469
526
|
if (!route) return null;
|
|
527
|
+
if (!auxPool) throw new Error("FFF auxiliary finder pool is not initialized");
|
|
470
528
|
const aux = await auxPool.acquire(route.root);
|
|
471
529
|
// A broader covering picker may have been reused; rebase the suffix so the
|
|
472
530
|
// constraint stays relative to the picker's actual root.
|
|
@@ -547,6 +605,45 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
547
605
|
});
|
|
548
606
|
}
|
|
549
607
|
|
|
608
|
+
type PendingToolDefinition<
|
|
609
|
+
TParams extends TSchema,
|
|
610
|
+
TDetails = unknown,
|
|
611
|
+
TState = any,
|
|
612
|
+
> = Omit<
|
|
613
|
+
ToolDefinition<TParams, TDetails, TState>,
|
|
614
|
+
"name" | "label" | "promptGuidelines"
|
|
615
|
+
> & {
|
|
616
|
+
promptGuidelines?: (names: ToolNames) => string[];
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const pendingTools: (() => string)[] = [];
|
|
620
|
+
let toolsRegistered = false;
|
|
621
|
+
|
|
622
|
+
function queueTool<TParams extends TSchema, TDetails = unknown, TState = any>(
|
|
623
|
+
resolveName: () => string,
|
|
624
|
+
definition: PendingToolDefinition<TParams, TDetails, TState>,
|
|
625
|
+
): void {
|
|
626
|
+
pendingTools.push(() => {
|
|
627
|
+
const { promptGuidelines, ...tool } = definition;
|
|
628
|
+
const resolvedName = resolveName();
|
|
629
|
+
pi.registerTool({
|
|
630
|
+
...tool,
|
|
631
|
+
name: resolvedName,
|
|
632
|
+
label: resolvedName,
|
|
633
|
+
promptGuidelines: promptGuidelines?.(toolNames),
|
|
634
|
+
});
|
|
635
|
+
return resolvedName;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function registerPendingTools(): void {
|
|
640
|
+
if (toolsRegistered) return;
|
|
641
|
+
|
|
642
|
+
const registeredNames = pendingTools.map((register) => register());
|
|
643
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...registeredNames])]);
|
|
644
|
+
toolsRegistered = true;
|
|
645
|
+
}
|
|
646
|
+
|
|
550
647
|
// --- Flags / lifecycle ---
|
|
551
648
|
|
|
552
649
|
pi.registerFlag("fff-mode", {
|
|
@@ -576,34 +673,48 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
576
673
|
type: "boolean",
|
|
577
674
|
});
|
|
578
675
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
)
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
676
|
+
function reportInitFailure(ctx: ExtensionContext, error: unknown): void {
|
|
677
|
+
ctx.ui.notify(
|
|
678
|
+
`FFF init failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
679
|
+
"error",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function prepareSession(ctx: ExtensionContext): void {
|
|
684
|
+
activeCwd = ctx.cwd;
|
|
685
|
+
uiCtx = ctx;
|
|
686
|
+
if (toolsRegistered) return;
|
|
687
|
+
|
|
688
|
+
// Pi populates extension flag values after loading extensions.
|
|
689
|
+
resolveStartupConfig();
|
|
690
|
+
|
|
691
|
+
// Restore persisted mode before registering tools so a saved override
|
|
692
|
+
// can safely change their names after /reload or session resume.
|
|
693
|
+
const entries = ctx.sessionManager?.getEntries();
|
|
694
|
+
if (entries) {
|
|
695
|
+
const modeEntry = [...entries]
|
|
696
|
+
.reverse()
|
|
697
|
+
.find(
|
|
698
|
+
(e: { type: string; customType?: string }) =>
|
|
699
|
+
e.type === "custom" && e.customType === "fff-mode",
|
|
700
|
+
);
|
|
701
|
+
if (
|
|
702
|
+
modeEntry &&
|
|
703
|
+
typeof (modeEntry as any).data?.mode === "string" &&
|
|
704
|
+
VALID_MODES.includes((modeEntry as any).data.mode as FffMode)
|
|
705
|
+
) {
|
|
706
|
+
const restored = (modeEntry as any).data.mode as FffMode;
|
|
707
|
+
if (restored !== currentMode) setMode(restored);
|
|
605
708
|
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
initializeFinderFactories();
|
|
712
|
+
registerPendingTools();
|
|
713
|
+
}
|
|
606
714
|
|
|
715
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
716
|
+
try {
|
|
717
|
+
prepareSession(ctx);
|
|
607
718
|
registerAutocompleteProvider(ctx);
|
|
608
719
|
await ensureFinder(activeCwd);
|
|
609
720
|
|
|
@@ -621,11 +732,19 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
621
732
|
// waitForScan() also resolves on timeout, so poll until the scan really
|
|
622
733
|
// settles before clearing the footer.
|
|
623
734
|
if (atHome) trackHomeScanStatus();
|
|
624
|
-
} catch (
|
|
625
|
-
ctx
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
735
|
+
} catch (error: unknown) {
|
|
736
|
+
reportInitFailure(ctx, error);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// SDK callers can prompt without binding session_start. Prepare on the first
|
|
741
|
+
// agent turn as a fallback so the tools still reach that turn's tool set.
|
|
742
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
743
|
+
if (toolsRegistered) return;
|
|
744
|
+
try {
|
|
745
|
+
prepareSession(ctx);
|
|
746
|
+
} catch (error: unknown) {
|
|
747
|
+
reportInitFailure(ctx, error);
|
|
629
748
|
}
|
|
630
749
|
});
|
|
631
750
|
|
|
@@ -701,16 +820,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
701
820
|
),
|
|
702
821
|
});
|
|
703
822
|
|
|
704
|
-
|
|
705
|
-
name: toolNames.grep,
|
|
706
|
-
label: toolNames.grep,
|
|
823
|
+
queueTool(() => toolNames.grep, {
|
|
707
824
|
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}.`,
|
|
708
825
|
promptSnippet: "Grep contents",
|
|
709
|
-
promptGuidelines: [
|
|
710
|
-
`${
|
|
711
|
-
`${
|
|
712
|
-
`${
|
|
713
|
-
`${
|
|
826
|
+
promptGuidelines: (names) => [
|
|
827
|
+
`${names.grep}: prefer bare identifiers as patterns. Literal queries are most efficient.`,
|
|
828
|
+
`${names.grep}: use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').`,
|
|
829
|
+
`${names.grep}: caseSensitive: true when you need exact case (smart-case otherwise).`,
|
|
830
|
+
`${names.grep}: after 1-2 greps, read the top match instead of more greps.`,
|
|
714
831
|
],
|
|
715
832
|
parameters: grepSchema,
|
|
716
833
|
|
|
@@ -892,18 +1009,16 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
892
1009
|
),
|
|
893
1010
|
});
|
|
894
1011
|
|
|
895
|
-
|
|
896
|
-
name: toolNames.find,
|
|
897
|
-
label: toolNames.find,
|
|
1012
|
+
queueTool(() => toolNames.find, {
|
|
898
1013
|
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}.`,
|
|
899
1014
|
promptSnippet: "Find files by path or glob",
|
|
900
|
-
promptGuidelines: [
|
|
901
|
-
`${
|
|
902
|
-
`${
|
|
903
|
-
`${
|
|
904
|
-
`${
|
|
905
|
-
`${
|
|
906
|
-
`${
|
|
1015
|
+
promptGuidelines: (names) => [
|
|
1016
|
+
`${names.find}: matches the WHOLE path, not just the filename — \`profile\` hits \`chrome/browser/profiles/x.cc\` too.`,
|
|
1017
|
+
`${names.find}: keep queries to 1-2 terms; extra words narrow.`,
|
|
1018
|
+
`${names.find}: use for paths, not content. Use ${names.grep} for content.`,
|
|
1019
|
+
`${names.find}: 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.`,
|
|
1020
|
+
`${names.find}: to list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.`,
|
|
1021
|
+
`${names.find}: use exclude: 'test/,*.min.js' to cut noise in large repos.`,
|
|
907
1022
|
],
|
|
908
1023
|
parameters: findSchema,
|
|
909
1024
|
|
|
@@ -912,10 +1027,12 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
912
1027
|
|
|
913
1028
|
// if resumed we use the same picker as before
|
|
914
1029
|
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
1030
|
+
const pool = auxPool;
|
|
1031
|
+
if (!pool) throw new Error("FFF auxiliary finder pool is not initialized");
|
|
915
1032
|
const aux = resumed
|
|
916
1033
|
? resumed.auxRoot
|
|
917
1034
|
? {
|
|
918
|
-
finder: (await
|
|
1035
|
+
finder: (await pool.acquire(resumed.auxRoot, { exact: true })).finder,
|
|
919
1036
|
root: resumed.auxRoot,
|
|
920
1037
|
}
|
|
921
1038
|
: null
|
|
@@ -1032,16 +1149,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1032
1149
|
cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
|
|
1033
1150
|
});
|
|
1034
1151
|
|
|
1035
|
-
|
|
1036
|
-
name: toolNames.multiGrep,
|
|
1037
|
-
label: toolNames.multiGrep,
|
|
1152
|
+
queueTool(() => toolNames.multiGrep, {
|
|
1038
1153
|
description:
|
|
1039
1154
|
"Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
|
|
1040
1155
|
promptSnippet: "Multi-pattern OR content search",
|
|
1041
|
-
promptGuidelines: [
|
|
1042
|
-
`${
|
|
1043
|
-
`${
|
|
1044
|
-
`${
|
|
1156
|
+
promptGuidelines: (names) => [
|
|
1157
|
+
`${names.multiGrep}: use when searching for several identifiers at once.`,
|
|
1158
|
+
`${names.multiGrep}: include all naming-convention variants (snake/camel/Pascal).`,
|
|
1159
|
+
`${names.multiGrep}: patterns are literal. Use constraints for file filters.`,
|
|
1045
1160
|
],
|
|
1046
1161
|
parameters: multiGrepSchema,
|
|
1047
1162
|
|
|
@@ -1116,6 +1231,15 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1116
1231
|
pi.registerCommand("fff-mode", {
|
|
1117
1232
|
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
1118
1233
|
handler: async (args, ctx) => {
|
|
1234
|
+
if (!toolsRegistered) {
|
|
1235
|
+
try {
|
|
1236
|
+
prepareSession(ctx);
|
|
1237
|
+
} catch (error: unknown) {
|
|
1238
|
+
reportInitFailure(ctx, error);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1119
1243
|
const arg = (args || "").trim();
|
|
1120
1244
|
|
|
1121
1245
|
// No args - show current mode
|
|
@@ -1134,15 +1258,18 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1134
1258
|
|
|
1135
1259
|
const newMode = arg as FffMode;
|
|
1136
1260
|
const oldMode = getMode();
|
|
1137
|
-
setMode(newMode);
|
|
1138
|
-
|
|
1139
1261
|
pi.appendEntry("fff-mode", { mode: newMode });
|
|
1140
1262
|
|
|
1141
|
-
|
|
1142
|
-
(
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1263
|
+
if ((oldMode === "override") !== (newMode === "override")) {
|
|
1264
|
+
ctx.ui.notify(
|
|
1265
|
+
`Mode '${newMode}' saved. Run /reload to apply the tool name change.`,
|
|
1266
|
+
"info",
|
|
1267
|
+
);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
setMode(newMode);
|
|
1272
|
+
ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'`, "info");
|
|
1146
1273
|
},
|
|
1147
1274
|
});
|
|
1148
1275
|
|
package/src/paths.ts
CHANGED
|
@@ -52,7 +52,7 @@ function nvimDataDir(): string {
|
|
|
52
52
|
return path.join(HOME_DIR, ".local", "share", "nvim");
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function piDataDir(): string {
|
|
55
|
+
export function piDataDir(): string {
|
|
56
56
|
return process.env.PI_CODING_AGENT_DIR ?? path.join(HOME_DIR, ".pi", "agent");
|
|
57
57
|
}
|
|
58
58
|
|