@trim21/personal-pi-extensions 0.0.230 → 0.0.231

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.230",
3
+ "version": "0.0.231",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -3,8 +3,14 @@
3
3
  *
4
4
  * Split out of the former search.ts so spawn-agent subagents can load it
5
5
  * independently (declare `Grep` in the frontmatter to get only this tool).
6
+ *
7
+ * Parameter semantics and default behavior are aligned with Claude Code's
8
+ * GrepTool (ripgrep-based): hidden files searched, VCS directories excluded,
9
+ * lines capped at 500 columns, and an implicit head_limit of 250 entries.
6
10
  */
7
11
 
12
+ import { stat } from "node:fs/promises";
13
+
8
14
  import { StringEnum } from "@earendil-works/pi-ai";
9
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
16
  import { Type } from "typebox";
@@ -15,6 +21,16 @@ const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
15
21
 
16
22
  type GrepOutputMode = (typeof GREP_OUTPUT_MODES)[number];
17
23
 
24
+ /** Version control directories excluded from searches (noise in results). */
25
+ const VCS_DIRECTORIES_TO_EXCLUDE = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"] as const;
26
+
27
+ /**
28
+ * Default cap on results when head_limit is unspecified. Prevents broad
29
+ * patterns from flooding the context; pass head_limit=0 explicitly for
30
+ * unlimited results. Mirrors Claude Code's default of 250.
31
+ */
32
+ const DEFAULT_HEAD_LIMIT = 250;
33
+
18
34
  function truncateOutput(output: string, maxCharacters = 30_000): string {
19
35
  if (output.length <= maxCharacters) return output;
20
36
  return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
@@ -39,7 +55,10 @@ interface GrepParameters {
39
55
 
40
56
  export function buildGrepArguments(params: GrepParameters, cwd: string): string[] {
41
57
  const mode = params.output_mode ?? "files_with_matches";
42
- const args = ["--color=never"];
58
+ const args = ["--color=never", "--hidden", "--max-columns", "500"];
59
+ for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
60
+ args.push("--glob", `!${dir}`);
61
+ }
43
62
  switch (mode) {
44
63
  case "files_with_matches": {
45
64
  args.push("--files-with-matches");
@@ -51,7 +70,8 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
51
70
  }
52
71
  case "content": {
53
72
  args.push("--no-heading", "--with-filename");
54
- if (params["-n"] !== false) args.push("--line-number");
73
+ const showLineNumbers = params["-n"] === true || params["-n"] === undefined;
74
+ if (showLineNumbers) args.push("--line-number");
55
75
  const before = params["-B"];
56
76
  const after = params["-A"];
57
77
  const around = params.context ?? params["-C"];
@@ -72,6 +92,43 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
72
92
  return args;
73
93
  }
74
94
 
95
+ /**
96
+ * Sort a newline-separated list of file paths by modification time, most
97
+ * recent first, with file name as a tiebreaker. Files that can no longer be
98
+ * stat-ed sort last (mtime 0). Used for files_with_matches output, matching
99
+ * Claude Code's behaviour.
100
+ */
101
+ export async function sortFilesByMtime(output: string): Promise<string> {
102
+ const paths = output.replace(/\n$/, "").split("\n").filter(Boolean);
103
+ if (paths.length <= 1) return output;
104
+ const stats = await Promise.allSettled(paths.map((path) => stat(path)));
105
+ const sorted = paths
106
+ .map((path, index) => ({
107
+ path,
108
+ mtimeMs: stats[index]?.status === "fulfilled" ? stats[index].value.mtimeMs : 0,
109
+ }))
110
+ .toSorted((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
111
+ .map((entry) => entry.path);
112
+ return sorted.join("\n");
113
+ }
114
+
115
+ /**
116
+ * Append an occurrence/file summary to `filename:count` output (count mode),
117
+ * mirroring Claude Code's "Found N occurrences across M files" result.
118
+ */
119
+ export function summarizeCountOutput(output: string): string {
120
+ const lines = output.split("\n").filter((line) => line.includes(":"));
121
+ let occurrences = 0;
122
+ for (const line of lines) {
123
+ const colon = line.lastIndexOf(":");
124
+ occurrences += Number(line.slice(colon + 1)) || 0;
125
+ }
126
+ const files = lines.length;
127
+ const occurrenceLabel = occurrences === 1 ? "occurrence" : "occurrences";
128
+ const fileLabel = files === 1 ? "file" : "files";
129
+ return `${output.trimEnd()}\n\nFound ${occurrences} total ${occurrenceLabel} across ${files} ${fileLabel}.`;
130
+ }
131
+
75
132
  export function pageGrepOutput(output: string, offset = 0, headLimit = 0): string {
76
133
  const lines = output ? output.replace(/\n$/, "").split("\n") : [];
77
134
  if (offset >= lines.length && lines.length > 0) return "No entries at this offset";
@@ -154,7 +211,21 @@ export function registerGrepTool(pi: ExtensionAPI): void {
154
211
  if (result.code === 1 || result.stdout === "") {
155
212
  return { content: [{ type: "text", text: "No files found" }], details: { matches: 0 } };
156
213
  }
157
- const text = pageGrepOutput(result.stdout, params.offset ?? 0, params.head_limit ?? 0);
214
+ const mode = params.output_mode ?? "files_with_matches";
215
+ // head_limit defaults to DEFAULT_HEAD_LIMIT; an explicit 0 means unlimited.
216
+ const stdout =
217
+ mode === "files_with_matches" ? await sortFilesByMtime(result.stdout) : result.stdout;
218
+ const text = pageGrepOutput(
219
+ stdout,
220
+ params.offset ?? 0,
221
+ params.head_limit ?? DEFAULT_HEAD_LIMIT,
222
+ );
223
+ if (mode === "count") {
224
+ return {
225
+ content: [{ type: "text", text: summarizeCountOutput(text) }],
226
+ details: undefined,
227
+ };
228
+ }
158
229
  return { content: [{ type: "text", text }], details: undefined };
159
230
  },
160
231
  });