@trim21/personal-pi-extensions 0.0.230 → 0.0.232

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.232",
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
  });
@@ -60,9 +60,11 @@ const MAX_PROGRESS_LINES = 5;
60
60
  * without bash need no bwrap setup (there are no commands to sandbox).
61
61
  * (Workspace write protection is embedded in the opencode write/edit tools.)
62
62
  *
63
- * Claude Code style tools (capitalized names, e.g. `Grep`/`Glob`) map to
64
- * their split claude-code files, so a subagent can enable exactly the search
65
- * tools it declares e.g. `Grep` without `Glob`.
63
+ * Claude Code style tools (capitalized names) map to their claude-code
64
+ * files, so a subagent can enable exactly the tools it declares — e.g. `Grep`
65
+ * without `Glob`. The stateful file tools (`Read`/`Edit`/`Write`) share one
66
+ * implementation file (they share a read-snapshot state); the `--tools`
67
+ * allowlist still exposes only the declared subset.
66
68
  */
67
69
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
68
70
  read: "opencode/read.ts",
@@ -71,6 +73,9 @@ const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
71
73
  bash: "opencode/bash.ts",
72
74
  Grep: "claude-code/grep.ts",
73
75
  Glob: "claude-code/glob.ts",
76
+ Read: "claude-code/files.ts",
77
+ Edit: "claude-code/files.ts",
78
+ Write: "claude-code/files.ts",
74
79
  };
75
80
 
76
81
  // ── schema ───────────────────────────────────────────────────────────────────
@@ -205,10 +210,17 @@ export function buildSubagentArgs(
205
210
  const tools = agent.tools ?? DEFAULT_TOOLS;
206
211
  // Load the opencode override for each built-in tool the agent declares
207
212
  // (read/edit/write), so the subagent uses the enhanced implementation
208
- // instead of the built-in one.
213
+ // instead of the built-in one. Several tool names can map to the same
214
+ // implementation file (e.g. cc Read/Edit/Write → claude-code/files.ts);
215
+ // loading a file twice would run its extension factory twice and create
216
+ // separate closure states, so each file is loaded at most once.
217
+ const loadedOverrideFiles = new Set<string>();
209
218
  for (const tool of tools) {
210
219
  const ext = TOOL_EXTENSION_OVERRIDES[tool];
211
- if (ext) args.push("-e", extensionPath(ext));
220
+ if (ext && !loadedOverrideFiles.has(ext)) {
221
+ loadedOverrideFiles.add(ext);
222
+ args.push("-e", extensionPath(ext));
223
+ }
212
224
  }
213
225
  args.push("--tools", tools.join(","));
214
226
  if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);