pi-mono-context-guard 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-context-guard
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,290 @@
1
+ /**
2
+ * context-guard — keep the LLM context window lean.
3
+ *
4
+ * Intercepts tool calls before they execute and applies three guards:
5
+ *
6
+ * 1. `read` without `limit`
7
+ * → auto-injects `limit: DEFAULT_READ_LIMIT` and notifies the user.
8
+ * The model can paginate with `offset` if it needs more.
9
+ *
10
+ * 2. `read` for a file already seen this session (mtime unchanged)
11
+ * → blocks the call and returns a stub:
12
+ * "File unchanged since last read — refer to the earlier result."
13
+ * (~20 tokens vs re-sending the full content). Evicted when
14
+ * multi-edit emits `context-guard:file-modified`.
15
+ *
16
+ * 3. `bash` using `rg` without any output-bounding operator
17
+ * → appends `| head -N` so grep dumps don't fill the context window.
18
+ *
19
+ * Guards 1–2 can be configured or disabled via `/context-guard`.
20
+ */
21
+
22
+ import { stat } from "node:fs/promises";
23
+ import { resolve } from "node:path";
24
+
25
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
26
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Defaults
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const DEFAULTS = {
33
+ readLimit: 120,
34
+ rgHeadLimit: 60,
35
+ readGuard: true,
36
+ dedupGuard: true,
37
+ rgGuard: true,
38
+ };
39
+
40
+ type Config = typeof DEFAULTS;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Read dedup cache
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** What we remember about a past read. */
47
+ type ReadEntry = {
48
+ /** mtime in milliseconds at the time of the read. */
49
+ mtimeMs: number;
50
+ /** offset used (undefined = start of file). */
51
+ offset: number | undefined;
52
+ /** limit used (undefined = whole file). */
53
+ limit: number | undefined;
54
+ };
55
+
56
+ const FILE_UNCHANGED_STUB =
57
+ "File unchanged since last read. The content from the earlier Read " +
58
+ "tool_result in this conversation is still current — refer to that " +
59
+ "instead of re-reading.";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function usesUnboundedRg(cmd: string): boolean {
66
+ if (!/(?:^|[|;&\s])rg\s/.test(cmd)) return false;
67
+ if (/\|\s*(?:head|tail|wc|less|more|grep\s+-c)/.test(cmd)) return false;
68
+ if (/\brg\b[^|]*\s(?:-l|--files-with-matches|-c|--count|--json)\b/.test(cmd)) return false;
69
+ return true;
70
+ }
71
+
72
+ function appendHead(cmd: string, n: number): string {
73
+ return `${cmd.trimEnd().replace(/;+$/, "").trimEnd()} | head -${n}`;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Extension
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export default function (pi: ExtensionAPI): void {
81
+ const cfg: Config = { ...DEFAULTS };
82
+
83
+ /** Session-scoped read cache: absolute path → last-seen read metadata. */
84
+ const readCache = new Map<string, ReadEntry>();
85
+
86
+ // -------------------------------------------------------------------------
87
+ // Cache invalidation — fired by multi-edit after every real file write
88
+ // -------------------------------------------------------------------------
89
+ pi.events.on("context-guard:file-modified", (data: { path: string }) => {
90
+ if (data?.path) {
91
+ readCache.delete(resolve(data.path));
92
+ }
93
+ });
94
+
95
+ // -------------------------------------------------------------------------
96
+ // Reset cache on new session
97
+ // -------------------------------------------------------------------------
98
+ pi.on("session_start", async () => {
99
+ readCache.clear();
100
+ });
101
+
102
+ // -------------------------------------------------------------------------
103
+ // Guard 1 + 2: read — limit injection + dedup
104
+ // -------------------------------------------------------------------------
105
+ pi.on("tool_call", async (event, ctx) => {
106
+ if (!isToolCallEventType("read", event)) return;
107
+
108
+ // Guard 1: inject limit if missing
109
+ if (cfg.readGuard && event.input.limit === undefined) {
110
+ event.input.limit = cfg.readLimit;
111
+ ctx.ui.notify(
112
+ `[context-guard] read: auto-limit=${cfg.readLimit} (use offset to paginate)`,
113
+ "info",
114
+ );
115
+ }
116
+
117
+ // Guard 2: dedup — block if file is unchanged since last read
118
+ if (!cfg.dedupGuard) return;
119
+
120
+ const rawPath = event.input.path;
121
+ if (!rawPath) return;
122
+
123
+ // Normalise path the same way pi does (strip leading @, resolve relative)
124
+ const absolutePath = resolve(
125
+ ctx.cwd,
126
+ rawPath.startsWith("@") ? rawPath.slice(1) : rawPath,
127
+ );
128
+
129
+ const entry = readCache.get(absolutePath);
130
+ if (!entry) return;
131
+
132
+ // Only dedup exact range matches
133
+ const sameOffset = entry.offset === (event.input.offset ?? undefined);
134
+ const sameLimit = entry.limit === (event.input.limit ?? undefined);
135
+ if (!sameOffset || !sameLimit) return;
136
+
137
+ // Check mtime — if the file changed on disk, let it through
138
+ try {
139
+ const { mtimeMs } = await stat(absolutePath);
140
+ if (mtimeMs !== entry.mtimeMs) {
141
+ readCache.delete(absolutePath);
142
+ return;
143
+ }
144
+ } catch {
145
+ // stat failed (file deleted, permission error, etc.) — let tool handle it
146
+ readCache.delete(absolutePath);
147
+ return;
148
+ }
149
+
150
+ // File is unchanged — block the call and return the stub
151
+ ctx.ui.notify(`[context-guard] read dedup: ${rawPath} unchanged`, "info");
152
+ return {
153
+ block: true,
154
+ reason: FILE_UNCHANGED_STUB,
155
+ };
156
+ });
157
+
158
+ // -------------------------------------------------------------------------
159
+ // Populate cache after a successful read
160
+ // -------------------------------------------------------------------------
161
+ pi.on("tool_result", async (event) => {
162
+ if (!cfg.dedupGuard) return;
163
+ if (event.toolName !== "read") return;
164
+ if (event.isError) return;
165
+
166
+ const rawPath = (event.input as { path?: string }).path;
167
+ if (!rawPath) return;
168
+
169
+ // Only cache full successful text reads (not images, PDFs, etc.)
170
+ const resultText = event.content
171
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
172
+ .map((b) => b.text)
173
+ .join("");
174
+
175
+ // Skip stubs injected by us — don't overwrite the real entry
176
+ if (resultText === FILE_UNCHANGED_STUB) return;
177
+
178
+ const absolutePath = resolve(
179
+ (event.input as { path?: string; cwd?: string }).cwd ?? "",
180
+ rawPath.startsWith("@") ? rawPath.slice(1) : rawPath,
181
+ );
182
+
183
+ try {
184
+ const { mtimeMs } = await stat(absolutePath);
185
+ readCache.set(absolutePath, {
186
+ mtimeMs,
187
+ offset: (event.input as { offset?: number }).offset ?? undefined,
188
+ limit: (event.input as { limit?: number }).limit ?? undefined,
189
+ });
190
+ } catch {
191
+ // best-effort only
192
+ }
193
+ });
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Guard 3: bash — rg without head/tail/wc
197
+ // -------------------------------------------------------------------------
198
+ pi.on("tool_call", async (event, ctx) => {
199
+ if (!cfg.rgGuard) return;
200
+ if (!isToolCallEventType("bash", event)) return;
201
+
202
+ const cmd = event.input.command ?? "";
203
+ if (usesUnboundedRg(cmd)) {
204
+ event.input.command = appendHead(cmd, cfg.rgHeadLimit);
205
+ ctx.ui.notify(
206
+ `[context-guard] bash: appended | head -${cfg.rgHeadLimit} to rg`,
207
+ "info",
208
+ );
209
+ }
210
+ });
211
+
212
+ // -------------------------------------------------------------------------
213
+ // /context-guard command
214
+ // -------------------------------------------------------------------------
215
+ pi.registerCommand("context-guard", {
216
+ description: "Show or toggle context-guard settings",
217
+ handler: async (args, ctx) => {
218
+ const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
219
+ const sub = parts[0]?.toLowerCase();
220
+
221
+ if (!sub || sub === "status") {
222
+ ctx.ui.notify(
223
+ [
224
+ "[context-guard] status:",
225
+ ` read guard: ${cfg.readGuard ? "on" : "off"} (limit=${cfg.readLimit})`,
226
+ ` dedup guard: ${cfg.dedupGuard ? "on" : "off"} (cache size=${readCache.size})`,
227
+ ` rg guard: ${cfg.rgGuard ? "on" : "off"} (head=${cfg.rgHeadLimit})`,
228
+ ].join("\n"),
229
+ "info",
230
+ );
231
+ return;
232
+ }
233
+
234
+ if (sub === "off") {
235
+ cfg.readGuard = cfg.dedupGuard = cfg.rgGuard = false;
236
+ ctx.ui.notify("[context-guard] all guards disabled", "warning");
237
+ return;
238
+ }
239
+
240
+ if (sub === "on") {
241
+ cfg.readGuard = cfg.dedupGuard = cfg.rgGuard = true;
242
+ ctx.ui.notify("[context-guard] all guards enabled", "info");
243
+ return;
244
+ }
245
+
246
+ if (sub === "read") {
247
+ const n = parseInt(parts[1] ?? "", 10);
248
+ if (!isNaN(n) && n > 0) {
249
+ cfg.readLimit = n;
250
+ ctx.ui.notify(`[context-guard] read limit → ${n}`, "info");
251
+ } else {
252
+ cfg.readGuard = !cfg.readGuard;
253
+ ctx.ui.notify(`[context-guard] read guard ${cfg.readGuard ? "on" : "off"}`, "info");
254
+ }
255
+ return;
256
+ }
257
+
258
+ if (sub === "dedup") {
259
+ cfg.dedupGuard = !cfg.dedupGuard;
260
+ if (!cfg.dedupGuard) readCache.clear();
261
+ ctx.ui.notify(`[context-guard] dedup guard ${cfg.dedupGuard ? "on" : "off"}`, "info");
262
+ return;
263
+ }
264
+
265
+ if (sub === "rg") {
266
+ const n = parseInt(parts[1] ?? "", 10);
267
+ if (!isNaN(n) && n > 0) {
268
+ cfg.rgHeadLimit = n;
269
+ ctx.ui.notify(`[context-guard] rg head limit → ${n}`, "info");
270
+ } else {
271
+ cfg.rgGuard = !cfg.rgGuard;
272
+ ctx.ui.notify(`[context-guard] rg guard ${cfg.rgGuard ? "on" : "off"}`, "info");
273
+ }
274
+ return;
275
+ }
276
+
277
+ ctx.ui.notify(
278
+ [
279
+ "[context-guard] usage:",
280
+ " /context-guard — show status",
281
+ " /context-guard on|off — enable/disable all guards",
282
+ " /context-guard read [N] — toggle read guard or set limit",
283
+ " /context-guard dedup — toggle read dedup",
284
+ " /context-guard rg [N] — toggle rg guard or set head limit",
285
+ ].join("\n"),
286
+ "info",
287
+ );
288
+ },
289
+ });
290
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "pi-mono-context-guard",
3
+ "version": "1.1.0",
4
+ "description": "Pi extension that guards context window growth by auto-limiting read and rg output",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension"
8
+ ],
9
+ "peerDependencies": {
10
+ "@mariozechner/pi-coding-agent": "*",
11
+ "@sinclair/typebox": "*"
12
+ },
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/emanuelcasco/pi-extensions.git",
21
+ "directory": "extensions/context-guard"
22
+ }
23
+ }