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 +28 -0
- package/LICENCE.md +7 -0
- package/index.ts +290 -0
- package/package.json +23 -0
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
|
+
}
|