@ff-labs/pi-fff 0.11.0 → 0.11.1-nightly.12142b0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -3
  3. package/src/index.ts +135 -26
package/README.md CHANGED
@@ -83,8 +83,11 @@ This extension registers FFF-powered tools (`fffind`, `ffgrep`, `fff-multi-grep`
83
83
 
84
84
  Search file contents. Smart case, plain text by default, regex optional.
85
85
 
86
+ Set `definitions_only: true` to search for matching declarations. If the completed search finds none, the tool reports that and returns ordinary matches. Cursors remember the search or fallback state. The optional `fff-multi-grep` tool accepts the same flag. Omitting the flag keeps ordinary search behavior.
87
+
86
88
  Parameters:
87
89
  - `pattern` — search text or regex
90
+ - `definitions_only` — prefer declarations; fall back to ordinary matches with a notice when none exist
88
91
  - `path` — directory/file constraint (e.g. `src/`, `*.ts`)
89
92
  - `ignoreCase` — force case-insensitive
90
93
  - `literal` — treat as literal string (default: true)
@@ -107,6 +110,7 @@ OR-logic multi-pattern content search. SIMD-accelerated Aho-Corasick.
107
110
 
108
111
  Parameters:
109
112
  - `patterns` — array of literal patterns (OR logic)
113
+ - `definitions_only` — prefer declarations with the same fallback behavior
110
114
  - `constraints` — file constraints (e.g. `*.{ts,tsx} !test/`)
111
115
  - `context` — context lines
112
116
  - `limit` — max matches (default: 100)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ff-labs/pi-fff",
3
3
  "public": true,
4
- "version": "0.11.0",
4
+ "version": "0.11.1-nightly.12142b0",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -41,8 +41,8 @@
41
41
  "typecheck": "tsc --noEmit"
42
42
  },
43
43
  "dependencies": {
44
- "@ff-labs/fff-bun": "0.11.0",
45
- "@ff-labs/fff-node": "0.11.0"
44
+ "@ff-labs/fff-bun": "0.11.1-nightly.12142b0",
45
+ "@ff-labs/fff-node": "0.11.1-nightly.12142b0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "*",
package/src/index.ts CHANGED
@@ -89,10 +89,19 @@ function toolNameList(names: ToolNames): string[] {
89
89
  // Cursor store — simple bounded Map for pagination cursors
90
90
  // ---------------------------------------------------------------------------
91
91
 
92
- const cursorCache = new Map<string, GrepCursor>();
92
+ interface ToolGrepCursor {
93
+ cursor: GrepCursor;
94
+ finder: FileFinderApi;
95
+ definitionsOnly: boolean;
96
+ foundDefinitions: boolean;
97
+ mode?: GrepMode;
98
+ query?: string;
99
+ }
100
+
101
+ const cursorCache = new Map<string, ToolGrepCursor>();
93
102
  let cursorCounter = 0;
94
103
 
95
- function storeCursor(cursor: GrepCursor): string {
104
+ function storeCursor(cursor: ToolGrepCursor): string {
96
105
  const id = `fff_c${++cursorCounter}`;
97
106
  cursorCache.set(id, cursor);
98
107
  if (cursorCache.size > 200) {
@@ -102,7 +111,7 @@ function storeCursor(cursor: GrepCursor): string {
102
111
  return id;
103
112
  }
104
113
 
105
- function getCursor(id: string): GrepCursor | undefined {
114
+ function getCursor(id: string): ToolGrepCursor | undefined {
106
115
  return cursorCache.get(id);
107
116
  }
108
117
 
@@ -546,7 +555,6 @@ export default function fffExtension(pi: ExtensionAPI) {
546
555
  }
547
556
 
548
557
  function destroyFinder() {
549
- stopHomeScanStatus();
550
558
  if (mainFinder && !mainFinder.isDestroyed) {
551
559
  mainFinder.destroy();
552
560
  mainFinder = null;
@@ -556,6 +564,8 @@ export default function fffExtension(pi: ExtensionAPI) {
556
564
  auxPool?.destroy();
557
565
  auxPool = null;
558
566
  pickers = null;
567
+ // UI teardown last: a throwing ctx must not leak the native finders (#948).
568
+ stopHomeScanStatus();
559
569
  }
560
570
 
561
571
  async function resolveFinderForPath(
@@ -854,8 +864,11 @@ export default function fffExtension(pi: ExtensionAPI) {
854
864
  }
855
865
  });
856
866
 
857
- pi.on("session_shutdown", async () => {
867
+ // A ctx captured from a replaced session is stale and throws on access (#948).
868
+ pi.on("session_shutdown", async (_event, ctx) => {
869
+ uiCtx = ctx;
858
870
  destroyFinder();
871
+ uiCtx = null;
859
872
  });
860
873
 
861
874
  // --- Shared render helpers ---
@@ -954,7 +967,15 @@ export default function fffExtension(pi: ExtensionAPI) {
954
967
 
955
968
  // --- grep tool ---
956
969
 
970
+ const definitionsOnlySchema = Type.Optional(
971
+ Type.Boolean({
972
+ description:
973
+ "Return matching declarations. If none are found, report that and return other matches.",
974
+ }),
975
+ );
976
+
957
977
  const grepSchema = Type.Object({
978
+ definitions_only: definitionsOnlySchema,
958
979
  pattern: Type.String({
959
980
  description: "Search pattern (literal text or regex)",
960
981
  }),
@@ -996,6 +1017,7 @@ export default function fffExtension(pi: ExtensionAPI) {
996
1017
  promptSnippet: "Grep contents",
997
1018
  promptGuidelines: (names) => [
998
1019
  `${names.grep}: prefer bare identifiers as patterns. Literal queries are most efficient.`,
1020
+ `${names.grep}: use definitions_only: true to find declarations; if none are found, the tool returns other matches with a notice.`,
999
1021
  `${names.grep}: use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').`,
1000
1022
  `${names.grep}: caseSensitive: true when you need exact case (smart-case otherwise).`,
1001
1023
  `${names.grep}: after 1-2 greps, read the top match instead of more greps.`,
@@ -1006,25 +1028,31 @@ export default function fffExtension(pi: ExtensionAPI) {
1006
1028
  if (signal?.aborted) throw new Error("Operation aborted");
1007
1029
 
1008
1030
  const pattern = params.pattern;
1009
- const aux = await resolveFinderForPath(params.path, pattern, params.exclude);
1031
+ const resumed = params.cursor ? getCursor(params.cursor) : undefined;
1032
+ const aux = resumed
1033
+ ? null
1034
+ : await resolveFinderForPath(params.path, pattern, params.exclude);
1010
1035
 
1011
- const picker = aux ? aux.finder : await ensureFinder(activeCwd);
1036
+ const picker =
1037
+ resumed?.finder ?? (aux ? aux.finder : await ensureFinder(activeCwd));
1038
+ if (picker.isDestroyed)
1039
+ throw new Error("Search cursor expired. Start a new search without the cursor.");
1012
1040
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
1013
1041
  // pageSize caps TOTAL matches across all files (soft cap: the current file
1014
1042
  // is always finished first). maxMatchesPerFile stays decoupled at the engine
1015
1043
  // default so same-file overflow remains reachable via cursor (#825).
1016
1044
  const pageSize = Math.min(effectiveLimit, GREP_PAGE_SIZE_MAX);
1017
1045
  const context = clampContext(params.context);
1018
- const query = aux
1019
- ? aux.query
1020
- : buildQuery(params.path, pattern, params.exclude, activeCwd);
1046
+ let query =
1047
+ resumed?.query ??
1048
+ (aux ? aux.query : buildQuery(params.path, pattern, params.exclude, activeCwd));
1021
1049
 
1022
1050
  // Auto-detect: regex if the pattern has regex metacharacters AND parses
1023
1051
  // as a valid regex, otherwise plain literal. The fuzzy fallback below
1024
1052
  // only kicks in for plain mode — regex queries are intentional.
1025
1053
  const hasRegexSyntax = pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1026
1054
 
1027
- let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
1055
+ let mode: GrepMode = resumed?.mode ?? (hasRegexSyntax ? "regex" : "plain");
1028
1056
  if (mode === "regex") {
1029
1057
  try {
1030
1058
  new RegExp(pattern);
@@ -1059,21 +1087,42 @@ export default function fffExtension(pi: ExtensionAPI) {
1059
1087
  // (case-insensitive when pattern is all lowercase).
1060
1088
  const smartCase = params.caseSensitive !== true;
1061
1089
 
1062
- const grepResult = picker.grep(query, {
1090
+ let definitionsOnly = resumed?.definitionsOnly ?? params.definitions_only ?? false;
1091
+ const preferDefinitions = definitionsOnly;
1092
+ const foundDefinitions = resumed?.foundDefinitions ?? false;
1093
+ const options = {
1063
1094
  mode,
1064
1095
  smartCase,
1065
1096
  maxMatchesPerFile: GREP_MAX_MATCHES_PER_FILE,
1066
1097
  pageSize,
1067
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
1098
+ cursor: resumed?.cursor ?? null,
1068
1099
  beforeContext: context,
1069
1100
  afterContext: context,
1070
- classifyDefinitions: true,
1071
1101
  timeBudgetMs: GREP_TIME_BUDGET_MS,
1102
+ };
1103
+ const grepResult = picker.grep(query, {
1104
+ ...options,
1105
+ classifyDefinitions: definitionsOnly ? "definition_only" : "classify",
1072
1106
  });
1073
1107
 
1074
1108
  if (!grepResult.ok) throw new Error(grepResult.error);
1075
1109
 
1076
1110
  let result = grepResult.value;
1111
+ const definitionsFallback =
1112
+ definitionsOnly &&
1113
+ !foundDefinitions &&
1114
+ result.items.length === 0 &&
1115
+ !result.nextCursor;
1116
+ if (definitionsFallback) {
1117
+ const fallback = picker.grep(query, {
1118
+ ...options,
1119
+ cursor: null,
1120
+ classifyDefinitions: "classify",
1121
+ });
1122
+ if (!fallback.ok) throw new Error(fallback.error);
1123
+ result = fallback.value;
1124
+ definitionsOnly = false;
1125
+ }
1077
1126
  let fuzzyNotice: string | null = null;
1078
1127
 
1079
1128
  // if we hit the timeout do not run the fuzzy fallback
@@ -1081,7 +1130,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1081
1130
  if (
1082
1131
  result.items.length === 0 &&
1083
1132
  !result.nextCursor &&
1084
- !params.cursor &&
1133
+ (!params.cursor || definitionsFallback) &&
1085
1134
  mode !== "regex"
1086
1135
  ) {
1087
1136
  // When the caller pinned a specific file (path has an extension), the
@@ -1091,7 +1140,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1091
1140
  // excluded / out-of-scope directories.
1092
1141
  const lastSeg = params.path?.split(/[\\/]/).pop() ?? "";
1093
1142
  const pathTargetsFile = /\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSeg);
1094
- const fuzzyQuery = pathTargetsFile ? pattern : query;
1143
+ const fuzzyQuery = pathTargetsFile && !preferDefinitions ? pattern : query;
1095
1144
  const fuzzy = picker.grep(fuzzyQuery, {
1096
1145
  mode: "fuzzy",
1097
1146
  smartCase,
@@ -1100,13 +1149,15 @@ export default function fffExtension(pi: ExtensionAPI) {
1100
1149
  cursor: null,
1101
1150
  beforeContext: 0,
1102
1151
  afterContext: 0,
1103
- classifyDefinitions: true,
1152
+ classifyDefinitions: definitionsOnly ? "definition_only" : "classify",
1104
1153
  timeBudgetMs: GREP_TIME_BUDGET_MS,
1105
1154
  });
1106
1155
 
1107
1156
  if (fuzzy.ok && fuzzy.value.items.length > 0) {
1108
1157
  fuzzyNotice = `0 exact matches. Maybe you meant this?`;
1109
1158
  result = fuzzy.value;
1159
+ mode = "fuzzy";
1160
+ query = fuzzyQuery;
1110
1161
  }
1111
1162
  }
1112
1163
 
@@ -1116,11 +1167,27 @@ export default function fffExtension(pi: ExtensionAPI) {
1116
1167
  notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
1117
1168
  }
1118
1169
  if (result.nextCursor) {
1119
- notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
1170
+ const cursor = storeCursor({
1171
+ cursor: result.nextCursor,
1172
+ finder: picker,
1173
+ definitionsOnly,
1174
+ foundDefinitions:
1175
+ foundDefinitions || (definitionsOnly && result.items.length > 0),
1176
+ mode,
1177
+ query,
1178
+ });
1179
+ notices.push(`Continue with cursor="${cursor}"`);
1120
1180
  }
1121
1181
 
1122
1182
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
1123
1183
  if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
1184
+ if (definitionsFallback) {
1185
+ const notice =
1186
+ result.items.length > 0 || result.nextCursor
1187
+ ? "No matching definitions found. Showing other matches:"
1188
+ : "No matching definitions or other matches found.";
1189
+ output = `[${notice}]\n${output}`;
1190
+ }
1124
1191
 
1125
1192
  return {
1126
1193
  content: [{ type: "text", text: output }],
@@ -1300,6 +1367,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1300
1367
 
1301
1368
  if (enableMultiGrep) {
1302
1369
  const multiGrepSchema = Type.Object({
1370
+ definitions_only: definitionsOnlySchema,
1303
1371
  patterns: Type.Array(Type.String(), {
1304
1372
  description:
1305
1373
  "Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
@@ -1328,6 +1396,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1328
1396
  `${names.multiGrep}: use when searching for several identifiers at once.`,
1329
1397
  `${names.multiGrep}: include all naming-convention variants (snake/camel/Pascal).`,
1330
1398
  `${names.multiGrep}: patterns are literal. Use constraints for file filters.`,
1399
+ `${names.multiGrep}: use definitions_only: true to find declarations; if none are found, the tool returns other matches with a notice.`,
1331
1400
  ],
1332
1401
  parameters: multiGrepSchema,
1333
1402
 
@@ -1336,36 +1405,76 @@ export default function fffExtension(pi: ExtensionAPI) {
1336
1405
  if (!params.patterns?.length)
1337
1406
  throw new Error("patterns array must have at least 1 element");
1338
1407
 
1339
- const f = await ensureFinder(activeCwd);
1408
+ const resumed = params.cursor ? getCursor(params.cursor) : undefined;
1409
+ const f = resumed?.finder ?? (await ensureFinder(activeCwd));
1410
+ if (f.isDestroyed)
1411
+ throw new Error(
1412
+ "Search cursor expired. Start a new search without the cursor.",
1413
+ );
1340
1414
  const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
1341
1415
  const pageSize = Math.min(effectiveLimit, GREP_PAGE_SIZE_MAX);
1342
1416
  const context = clampContext(params.context);
1343
1417
 
1344
- const grepResult = f.multiGrep({
1418
+ let definitionsOnly =
1419
+ resumed?.definitionsOnly ?? params.definitions_only ?? false;
1420
+ const foundDefinitions = resumed?.foundDefinitions ?? false;
1421
+ const options = {
1345
1422
  patterns: params.patterns,
1346
1423
  constraints: params.constraints,
1347
1424
  maxMatchesPerFile: GREP_MAX_MATCHES_PER_FILE,
1348
1425
  pageSize,
1349
1426
  smartCase: true,
1350
- cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
1427
+ cursor: resumed?.cursor ?? null,
1351
1428
  beforeContext: context,
1352
1429
  afterContext: context,
1430
+ };
1431
+ const grepResult = f.multiGrep({
1432
+ ...options,
1433
+ classifyDefinitions: definitionsOnly ? "definition_only" : "off",
1353
1434
  });
1354
1435
 
1355
1436
  if (!grepResult.ok) throw new Error(grepResult.error);
1356
1437
 
1357
- const result = grepResult.value;
1438
+ let result = grepResult.value;
1439
+ const definitionsFallback =
1440
+ definitionsOnly &&
1441
+ !foundDefinitions &&
1442
+ result.items.length === 0 &&
1443
+ !result.nextCursor;
1444
+ if (definitionsFallback) {
1445
+ const fallback = f.multiGrep({
1446
+ ...options,
1447
+ cursor: null,
1448
+ classifyDefinitions: "off",
1449
+ });
1450
+ if (!fallback.ok) throw new Error(fallback.error);
1451
+ result = fallback.value;
1452
+ definitionsOnly = false;
1453
+ }
1358
1454
  let output = formatGrepOutput(result);
1359
1455
 
1360
1456
  const notices: string[] = [];
1361
1457
  if (result.items.length >= effectiveLimit)
1362
1458
  notices.push(`${effectiveLimit}+ matches (refine patterns)`);
1363
- if (result.nextCursor)
1364
- notices.push(
1365
- `More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
1366
- );
1459
+ if (result.nextCursor) {
1460
+ const cursor = storeCursor({
1461
+ cursor: result.nextCursor,
1462
+ finder: f,
1463
+ definitionsOnly,
1464
+ foundDefinitions:
1465
+ foundDefinitions || (definitionsOnly && result.items.length > 0),
1466
+ });
1467
+ notices.push(`More available. cursor="${cursor}" to continue`);
1468
+ }
1367
1469
 
1368
1470
  if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
1471
+ if (definitionsFallback) {
1472
+ const notice =
1473
+ result.items.length > 0 || result.nextCursor
1474
+ ? "No matching definitions found. Showing other matches:"
1475
+ : "No matching definitions or other matches found.";
1476
+ output = `[${notice}]\n${output}`;
1477
+ }
1369
1478
 
1370
1479
  return {
1371
1480
  content: [{ type: "text", text: output }],