pi-mono-grep 1.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # pi-mono-grep
2
+
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add context-guard and grep extensions; improve multi-edit with dedup
8
+
9
+ **New: `pi-mono-context-guard`**
10
+ Extension that keeps the LLM context window lean with three guards:
11
+
12
+ - `read` without `limit` → auto-injects `limit=120`
13
+ - Read dedup → mtime-based stub for unchanged files (~20 tokens vs full content re-send)
14
+ - `bash` with unbounded `rg` → appends `| head -60`
15
+
16
+ Listens to `context-guard:file-modified` events to invalidate the dedup cache after edits.
17
+ `/context-guard` command to inspect and toggle guards at runtime.
18
+
19
+ **New: `pi-mono-grep`**
20
+ Dedicated ripgrep wrapper tool. Replaces raw `rg` in bash with a structured tool that has
21
+ `head_limit=60` built into the schema, `output_mode` (files_with_matches / content / count),
22
+ pagination via `offset`, and automatic VCS directory exclusions.
23
+ Prompt guidelines instruct the model to always use `grep` instead of bash+rg.
24
+
25
+ **Updated: `pi-mono-multi-edit`**
26
+
27
+ - Per-call read cache in `createRealWorkspace` deduplicates disk reads within a single `execute()` invocation (preflight + real-apply)
28
+ - Emits `context-guard:file-modified` event after every real `writeText` and `deleteFile` so context-guard can evict stale dedup cache entries
package/LICENCE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Emanuel Casco
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Grep Extension — dedicated ripgrep tool for pi.
3
+ *
4
+ * Wraps `rg` with built-in output limits and pagination so the model never
5
+ * accidentally dumps thousands of lines into context. The model is instructed
6
+ * to always use this tool instead of calling rg directly via bash.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { StringEnum } from "@mariozechner/pi-ai";
11
+ import { Type } from "@sinclair/typebox";
12
+
13
+ const EXCLUDED_DIRS = [".git", ".svn", ".hg", "node_modules", ".pi"];
14
+
15
+ const grepSchema = Type.Object({
16
+ pattern: Type.String({ description: "Regex pattern to search for" }),
17
+ path: Type.Optional(Type.String({ description: "File or directory to search. Defaults to cwd." })),
18
+ glob: Type.Optional(Type.String({ description: 'Glob filter, e.g. "*.ts", "**/*.{ts,tsx}"' })),
19
+ type: Type.Optional(Type.String({ description: 'File type filter, e.g. "ts", "py", "js"' })),
20
+ output_mode: Type.Optional(
21
+ StringEnum(["content", "files_with_matches", "count"] as const, {
22
+ description: "content: matching lines. files_with_matches: file paths only (default, cheapest). count: match counts per file.",
23
+ }),
24
+ ),
25
+ head_limit: Type.Optional(
26
+ Type.Number({
27
+ description: "Max output lines/entries. Defaults to 60. Pass 0 for unlimited (use sparingly).",
28
+ }),
29
+ ),
30
+ offset: Type.Optional(Type.Number({ description: "Skip first N entries (for pagination). Defaults to 0." })),
31
+ case_insensitive: Type.Optional(Type.Boolean({ description: "Case-insensitive search (-i). Default false." })),
32
+ context_lines: Type.Optional(
33
+ Type.Number({ description: "Lines of context around each match (-C). Only applies to content mode." }),
34
+ ),
35
+ });
36
+
37
+ export default function (pi: ExtensionAPI) {
38
+ pi.registerTool({
39
+ name: "grep",
40
+ label: "Grep",
41
+ description: "Search file contents using ripgrep. Prefer this over bash+rg for all search tasks.",
42
+ promptSnippet: "Search for patterns in files using ripgrep",
43
+ promptGuidelines: [
44
+ "ALWAYS use grep for search tasks. NEVER invoke rg directly via the bash tool — raw rg output is unbounded and wastes context.",
45
+ "Use output_mode='files_with_matches' (default) when you only need to know which files match — it costs far fewer tokens than showing content.",
46
+ "Use head_limit and offset to paginate large result sets rather than reading everything at once.",
47
+ ],
48
+ parameters: grepSchema,
49
+
50
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
51
+ const {
52
+ pattern,
53
+ path: searchPath,
54
+ glob,
55
+ type,
56
+ output_mode = "files_with_matches",
57
+ head_limit = 60,
58
+ offset = 0,
59
+ case_insensitive = false,
60
+ context_lines,
61
+ } = params;
62
+
63
+ const args: string[] = [];
64
+
65
+ // Output mode flags
66
+ if (output_mode === "files_with_matches") {
67
+ args.push("-l");
68
+ } else if (output_mode === "content") {
69
+ args.push("-n");
70
+ } else if (output_mode === "count") {
71
+ args.push("--count");
72
+ }
73
+
74
+ // Case-insensitive
75
+ if (case_insensitive) {
76
+ args.push("-i");
77
+ }
78
+
79
+ // Context lines (content mode only)
80
+ if (output_mode === "content" && context_lines !== undefined && context_lines > 0) {
81
+ args.push("-C", String(context_lines));
82
+ }
83
+
84
+ // Glob filter
85
+ if (glob) {
86
+ args.push("--glob", glob);
87
+ }
88
+
89
+ // File type filter
90
+ if (type) {
91
+ args.push("--type", type);
92
+ }
93
+
94
+ // Exclude common noise directories
95
+ for (const dir of EXCLUDED_DIRS) {
96
+ args.push("--glob", `!${dir}/**`);
97
+ }
98
+
99
+ // Pattern and search root
100
+ args.push(pattern);
101
+ args.push(searchPath ?? ctx.cwd);
102
+
103
+ // Run ripgrep
104
+ let result: { stdout: string; stderr: string; code: number; killed?: boolean };
105
+ try {
106
+ result = await pi.exec("rg", args, { signal, timeout: 15000 });
107
+ } catch (err: any) {
108
+ const msg = err?.message ?? String(err);
109
+ if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("enoent")) {
110
+ return {
111
+ content: [
112
+ {
113
+ type: "text" as const,
114
+ text: "ripgrep (rg) is not installed. Install it with: brew install ripgrep",
115
+ },
116
+ ],
117
+ };
118
+ }
119
+ throw err;
120
+ }
121
+
122
+ // rg exits 1 when no matches are found — that's not an error
123
+ if (result.code === 1) {
124
+ return { content: [{ type: "text" as const, text: "No matches found." }] };
125
+ }
126
+
127
+ // Other non-zero codes → real error
128
+ if (result.code !== 0) {
129
+ const errText = result.stderr.trim() || result.stdout.trim();
130
+ if (errText.toLowerCase().includes("not found") || errText.toLowerCase().includes("no such file or directory")) {
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text" as const,
135
+ text: "ripgrep (rg) is not installed. Install it with: brew install ripgrep",
136
+ },
137
+ ],
138
+ };
139
+ }
140
+ return {
141
+ content: [{ type: "text" as const, text: `rg error (exit ${result.code}): ${errText}` }],
142
+ };
143
+ }
144
+
145
+ // Parse and paginate output
146
+ const allLines = result.stdout.split("\n").filter((l) => l.length > 0);
147
+ const total = allLines.length;
148
+ const start = offset ?? 0;
149
+ const limit = (head_limit ?? 60) === 0 ? allLines.length : (head_limit ?? 60);
150
+ const slice = allLines.slice(start, start + limit);
151
+ const shown = slice.length;
152
+
153
+ let text = slice.join("\n");
154
+
155
+ if (output_mode === "files_with_matches") {
156
+ text += `\n\n[${total} file${total === 1 ? "" : "s"} matched]`;
157
+ if (total > start + shown && limit > 0) {
158
+ text += `\n[Showing ${shown} of ${total} files. Use offset=${start + limit} for next page.]`;
159
+ }
160
+ } else {
161
+ if (total > start + shown && limit > 0) {
162
+ text += `\n\n[Showing ${shown} of ${total} lines. Use offset=${start + limit} for next page.]`;
163
+ }
164
+ }
165
+
166
+ return { content: [{ type: "text" as const, text }] };
167
+ },
168
+ });
169
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "pi-mono-grep",
3
+ "version": "1.1.0",
4
+ "description": "Pi extension adding a dedicated grep tool that wraps ripgrep with built-in output limits",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension"
8
+ ],
9
+ "peerDependencies": {
10
+ "@mariozechner/pi-coding-agent": "*",
11
+ "@mariozechner/pi-ai": "*",
12
+ "@sinclair/typebox": "*"
13
+ },
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/emanuelcasco/pi-extensions.git",
22
+ "directory": "extensions/grep"
23
+ }
24
+ }