@xynogen/pix-find 0.2.4 → 0.3.1

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/find.ts +153 -59
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-find",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Pi tool — glob file search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/find.ts CHANGED
@@ -4,6 +4,17 @@ import type {
4
4
  ToolRenderResultOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
7
+ import {
8
+ BATCH_MAX_BYTES,
9
+ type BatchSection,
10
+ capSections,
11
+ formatBatchIndex,
12
+ formatCallTargets,
13
+ joinSectionBodies,
14
+ resolveBatchStrings,
15
+ sliceBatchTargets,
16
+ withOptionalStringArray,
17
+ } from "@xynogen/pix-pretty/batch";
7
18
  import type { ToolContext } from "@xynogen/pix-pretty/context";
8
19
  import type {
9
20
  FindParams,
@@ -32,6 +43,11 @@ import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse"
32
43
 
33
44
  export const DEFAULT_FIND_LIMIT = 200;
34
45
 
46
+ const FILE_NOUNS = ["file", "files"] as const;
47
+
48
+ /** FindParams plus the optional batch `patterns` array (added to the schema at runtime). */
49
+ type FindBatchParams = FindParams & { patterns?: string[] };
50
+
35
51
  export function applyFindDefaults(params: FindParams): FindParams {
36
52
  return params.limit === undefined ? { ...params, limit: DEFAULT_FIND_LIMIT } : params;
37
53
  }
@@ -67,88 +83,165 @@ export function registerFindTool(
67
83
  ...origFind,
68
84
  name: "find",
69
85
  description:
70
- "Find files by glob pattern. Defaults to 200 paths; use limit to request more. Respects .gitignore and remains capped by Pi's 50KB hard limit.",
86
+ "Find files by glob pattern. Defaults to 200 paths; use limit to request more. Respects .gitignore and remains capped by Pi's 50KB hard limit. Pass `patterns` to search several known globs in one call.",
87
+ parameters: withOptionalStringArray(
88
+ origFind.parameters,
89
+ "patterns",
90
+ "Known glob patterns to search in one call (each capped, one combined result). Use instead of `pattern` for multiple known globs.",
91
+ ["pattern"],
92
+ ),
71
93
  renderShell: "self",
72
94
 
73
95
  async execute(
74
96
  tid: string,
75
- params: FindParams,
97
+ params: FindBatchParams,
76
98
  sig: AbortSignal | undefined,
77
99
  upd: unknown,
78
100
  toolCtx: ExtensionContext,
79
101
  ) {
80
- const effectiveParams = applyFindDefaults(params);
102
+ // Search one glob (FFF-accelerated, SDK fallback) → structured find result.
103
+ const runOne = async (
104
+ pattern: string,
105
+ callId: string,
106
+ ): Promise<ToolResultLike<FindResultDetails>> => {
107
+ const { patterns: _patterns, ...rest } = params;
108
+ const effectiveParams = applyFindDefaults({ ...rest, pattern });
81
109
 
82
- // Try FFF first (frecency-ranked, SIMD-accelerated)
83
- if (fffState.finder && !fffState.finder.isDestroyed) {
84
- try {
85
- const effectiveLimit = Math.max(1, effectiveParams.limit ?? DEFAULT_FIND_LIMIT);
86
- let query = effectiveParams.pattern;
87
- if (effectiveParams.path) query = `${effectiveParams.path} ${query}`;
88
-
89
- const searchResult = fffState.finder.fileSearch(query, {
90
- pageSize: effectiveLimit,
91
- });
92
- if (searchResult.ok) {
93
- const { items, totalMatched } = searchResult.value;
94
- const trimmed = items.slice(0, effectiveLimit);
95
- const notices: string[] = [];
96
- if (fffState.partialIndex) notices.push("Warning: partial file index");
97
- if (trimmed.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
98
- if (totalMatched > trimmed.length) notices.push(`${totalMatched} total matches`);
99
-
100
- const textContent = appendNotices(
101
- trimmed.map((item) => item.relativePath).join("\n"),
102
- notices,
103
- );
104
- return makeTextResult<FindResultDetails>(textContent, {
105
- _type: "findResult",
106
- text: textContent,
107
- pattern: effectiveParams.pattern,
108
- path: effectiveParams.path,
109
- matchCount: trimmed.length,
110
+ // Try FFF first (frecency-ranked, SIMD-accelerated)
111
+ if (fffState.finder && !fffState.finder.isDestroyed) {
112
+ try {
113
+ const effectiveLimit = Math.max(1, effectiveParams.limit ?? DEFAULT_FIND_LIMIT);
114
+ let query = effectiveParams.pattern;
115
+ if (effectiveParams.path) query = `${effectiveParams.path} ${query}`;
116
+
117
+ const searchResult = fffState.finder.fileSearch(query, {
118
+ pageSize: effectiveLimit,
110
119
  });
120
+ if (searchResult.ok) {
121
+ const { items, totalMatched } = searchResult.value;
122
+ const trimmed = items.slice(0, effectiveLimit);
123
+ const notices: string[] = [];
124
+ if (fffState.partialIndex) notices.push("Warning: partial file index");
125
+ if (trimmed.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
126
+ if (totalMatched > trimmed.length) notices.push(`${totalMatched} total matches`);
127
+
128
+ const textContent = appendNotices(
129
+ trimmed.map((item) => item.relativePath).join("\n"),
130
+ notices,
131
+ );
132
+ return makeTextResult<FindResultDetails>(textContent, {
133
+ _type: "findResult",
134
+ text: textContent,
135
+ pattern: effectiveParams.pattern,
136
+ path: effectiveParams.path,
137
+ matchCount: trimmed.length,
138
+ });
139
+ }
140
+ } catch {
141
+ /* fall through to SDK */
111
142
  }
112
- } catch {
113
- /* fall through to SDK */
114
143
  }
115
- }
116
144
 
117
- // SDK fallback
118
- try {
119
- const result = await origFind.execute(tid, effectiveParams, sig, upd as never, toolCtx);
145
+ // SDK fallback
146
+ const result = await origFind.execute(callId, effectiveParams, sig, upd as never, toolCtx);
120
147
  const textContent = getTextContent(result);
121
- const matchCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
122
-
123
148
  setResultDetails<FindResultDetails>(result, {
124
149
  _type: "findResult",
125
150
  text: textContent,
126
- pattern: params.pattern,
151
+ pattern,
152
+ path: effectiveParams.path,
153
+ matchCount: textContent ? textContent.trim().split("\n").filter(Boolean).length : 0,
154
+ });
155
+ return result as ToolResultLike<FindResultDetails>;
156
+ };
157
+
158
+ const { targets, omitted } = sliceBatchTargets(
159
+ resolveBatchStrings(params.pattern, params.patterns),
160
+ );
161
+ if (targets.length === 0) {
162
+ return makeTextResult<FindResultDetails>("pattern or patterns required", {
163
+ _type: "findResult",
164
+ text: "pattern or patterns required",
165
+ pattern: "",
127
166
  path: params.path,
128
- matchCount,
167
+ matchCount: 0,
129
168
  });
169
+ }
130
170
 
131
- return result;
132
- } catch (error) {
133
- const text = error instanceof Error ? error.message : String(error);
134
- if (sig?.aborted || /aborted/i.test(text)) throw error;
135
- return {
136
- content: [{ type: "text" as const, text }],
137
- details: {
138
- _type: "findResult" as const,
139
- text,
140
- pattern: params.pattern,
141
- path: params.path,
142
- matchCount: 0,
143
- },
144
- isError: true,
145
- };
171
+ // Single glob → preserve the original single-search result shape.
172
+ if (targets.length === 1 && omitted === 0) {
173
+ try {
174
+ return await runOne(targets[0] ?? "", tid);
175
+ } catch (error) {
176
+ const text = error instanceof Error ? error.message : String(error);
177
+ if (sig?.aborted || /aborted/i.test(text)) throw error;
178
+ return {
179
+ content: [{ type: "text" as const, text }],
180
+ details: {
181
+ _type: "findResult" as const,
182
+ text,
183
+ pattern: params.pattern ?? "",
184
+ path: params.path,
185
+ matchCount: 0,
186
+ },
187
+ isError: true,
188
+ };
189
+ }
146
190
  }
191
+
192
+ // Batch: search every glob in parallel, cap the combined output.
193
+ const settled = await Promise.all(
194
+ targets.map(async (pattern, i) => {
195
+ try {
196
+ return { pattern, result: await runOne(pattern, `${tid}:${i}`) };
197
+ } catch (error) {
198
+ if (sig?.aborted) throw error;
199
+ return { pattern, error: error instanceof Error ? error.message : String(error) };
200
+ }
201
+ }),
202
+ );
203
+ const sections: BatchSection[] = settled.map((entry) => {
204
+ if ("error" in entry && entry.error) {
205
+ return { id: entry.pattern, body: "", units: 0, nouns: FILE_NOUNS, error: entry.error };
206
+ }
207
+ const details = (entry as { result: ToolResultLike<FindResultDetails> }).result.details;
208
+ const body =
209
+ details?._type === "findResult"
210
+ ? details.text
211
+ : getTextContent((entry as { result: ToolResultLike }).result);
212
+ const units =
213
+ details?._type === "findResult"
214
+ ? details.matchCount
215
+ : body.trim().split("\n").filter(Boolean).length;
216
+ return { id: entry.pattern, body, units, nouns: FILE_NOUNS };
217
+ });
218
+ const { text } = capSections(
219
+ sections,
220
+ BATCH_MAX_BYTES,
221
+ params.limit ?? DEFAULT_FIND_LIMIT,
222
+ omitted,
223
+ );
224
+ const matchCount = sections.reduce((sum, s) => sum + (s.error ? 0 : s.units), 0);
225
+ const full = [formatBatchIndex(sections, omitted), joinSectionBodies(sections)]
226
+ .filter(Boolean)
227
+ .join("\n\n");
228
+ return makeTextResult<FindResultDetails>(text, {
229
+ _type: "findResult",
230
+ text: full,
231
+ pattern: targets.join(", "),
232
+ patterns: targets,
233
+ path: params.path,
234
+ matchCount,
235
+ });
147
236
  },
148
237
 
149
- renderCall(args: FindParams, theme: ThemeLike, renderCtx: RenderContextLike) {
238
+ renderCall(args: FindBatchParams, theme: ThemeLike, renderCtx: RenderContextLike) {
150
239
  resolveBaseBackground(theme);
151
- const pattern = args.pattern ?? "";
240
+ const batchPatterns = resolveBatchStrings(args.pattern, args.patterns);
241
+ const pattern =
242
+ batchPatterns.length > 1
243
+ ? formatCallTargets(batchPatterns, 3, "patterns")
244
+ : (args.pattern ?? "");
152
245
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
153
246
  const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
154
247
  if (
@@ -215,7 +308,8 @@ export function registerFindTool(
215
308
  renderDimPreview(output, theme, {
216
309
  frame: !isPartial,
217
310
  paint: (s: string) => theme.fg(renderCtx.isError ? "error" : "success", s),
218
- highlight: d?._type === "findResult" ? globHighlight(d.pattern) : undefined,
311
+ highlight:
312
+ d?._type === "findResult" && !d.patterns ? globHighlight(d.pattern) : undefined,
219
313
  }),
220
314
  );
221
315
  return text;