@tinoy/pi-cache-prefix-log 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tinoy Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @tinoy/pi-cache-prefix-log
2
+
3
+ One JSONL row per cache-prefix change, so a real cache miss can be named from the log.
4
+
5
+ ```bash
6
+ pi install npm:@tinoy/pi-cache-prefix-log
7
+ ```
8
+
9
+ ## What it needs at call time
10
+
11
+ A writable state directory (`$XDG_STATE_HOME`, else `~/.local/state`).
12
+
13
+ ## Registers
14
+
15
+ no tool
16
+
17
+ ## Works better with
18
+
19
+ | Neighbour | You gain | You lose without it | Install |
20
+ | --- | --- | --- | --- |
21
+ | `@tinoy/pi-canon` | the tail-section ids canon publishes are recorded with each row | the rows carry the prefix fingerprint without the section ids | `pi install npm:@tinoy/pi-canon` |
22
+
23
+ ## Dependencies
24
+
25
+ pi-supplied imports (`@earendil-works/pi-coding-agent` or "none") are peer dependencies with a `*` range and are
26
+ never bundled. This package has no npm dependencies.
27
+
28
+ ## Licence
29
+
30
+ MIT — see the repository [LICENSE](../../LICENSE).
@@ -0,0 +1,319 @@
1
+ /**
2
+ * cache-prefix-log — permanent prompt-cache prefix logger.
3
+ *
4
+ * Records one compact JSONL row per SESSION BASELINE and per PREFIX CHANGE
5
+ * (systemHash / toolsHash delta) on the `before_provider_request` hook, so the
6
+ * next real cache miss can be NAMED (which fixed-prefix part moved, how many
7
+ * chars it moved, which run-start path built the request, and which tool names
8
+ * entered or left the provider `tools` array) without a payload dump.
9
+ *
10
+ * The fingerprint describes the bytes that are SENT. It is computed one
11
+ * macrotask after the hook, because before_provider_request handlers run in the
12
+ * (unsorted) extension-directory order and another handler may canonicalize the
13
+ * system prompt in place — reading the payload inside this handler could record
14
+ * bytes that never left the process. A row therefore always matches the request
15
+ * it names.
16
+ *
17
+ * `origin` is the run-start path attribution: `prompt` = the interactive
18
+ * prompt() path, which is the only path that fires before_agent_start;
19
+ * `injected` = a run started any other way (pi.sendMessage with triggerTurn
20
+ * while idle — async subagent completion, intercom delivery, a delivered
21
+ * answer). A row with `origin:"injected"` and a non-zero `sysDeltaChars` is
22
+ * the signature of a system prompt that only some run-start paths carry.
23
+ *
24
+ * Contract: observation-only. It registers NO tool and NEVER calls
25
+ * setActiveTools — a logger that moved the tool set would cause the very misses
26
+ * it exists to explain. It stores hashes and sizes, never message or schema
27
+ * content. All work is wrapped so a fault can never break a provider request,
28
+ * and the append is asynchronous (no blocking I/O on the request path).
29
+ *
30
+ * Scope — PREFIX questions only, NEVER token questions. A row is written on
31
+ * `before_provider_request`, where only the outgoing payload exists
32
+ * (`after_provider_response` carries status and headers, no usage either), so no
33
+ * request's token or cache accounting is reachable at this seam: do not widen a
34
+ * row with a number it cannot observe. Those figures live at `message_end`
35
+ * (`message.usage` — the seam a cost footer reads `input`/`cacheRead` from),
36
+ * which is the only place a usage row could be written.
37
+ *
38
+ * `sess` is the FULL session id, never a prefix of it: a session id starts with
39
+ * the high 32 bits of its millisecond timestamp, so a truncated key is shared by
40
+ * every session started inside the same ~65 s window and a row could not be
41
+ * attributed to exactly one session.
42
+ *
43
+ * Log: $XDG_STATE_HOME/pi/cache-prefix-log.jsonl (default
44
+ * ~/.local/state/pi/cache-prefix-log.jsonl), capped at 256 KiB with one
45
+ * rotated sibling (cache-prefix-log.1.jsonl). Override with
46
+ * PI_CACHE_PREFIX_LOG (test/debug only).
47
+ */
48
+
49
+ import { createHash } from "node:crypto";
50
+ import { appendFile, mkdirSync, rename, statSync } from "node:fs";
51
+ import { homedir } from "node:os";
52
+ import { dirname, join } from "node:path";
53
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
54
+
55
+ const CAP_BYTES = 256 * 1024;
56
+
57
+ function logPath(): string {
58
+ if (process.env.PI_CACHE_PREFIX_LOG) return process.env.PI_CACHE_PREFIX_LOG;
59
+ const state = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
60
+ return join(state, "pi", "cache-prefix-log.jsonl");
61
+ }
62
+
63
+ function sha(s: string): string {
64
+ return createHash("sha256").update(s).digest("hex").slice(0, 16);
65
+ }
66
+
67
+ function textOf(content: unknown): string {
68
+ if (typeof content === "string") return content;
69
+ if (!Array.isArray(content)) return "";
70
+ return content
71
+ .map((p) =>
72
+ p && typeof (p as { text?: unknown }).text === "string" ? (p as { text: string }).text : "",
73
+ )
74
+ .join("\n");
75
+ }
76
+
77
+ function toolName(t: unknown): string {
78
+ const o = t as { function?: { name?: string }; name?: string };
79
+ return o?.function?.name ?? o?.name ?? "?";
80
+ }
81
+
82
+ interface Prefix {
83
+ /** The full session id the request was sent under (see the scope note above). */
84
+ session: string;
85
+ sysHash: string;
86
+ sysChars: number;
87
+ toolsHash: string;
88
+ toolsChars: number;
89
+ nTools: number;
90
+ names: string[];
91
+ }
92
+
93
+ export default function (pi: ExtensionAPI) {
94
+ const PATH = logPath();
95
+ const ROTATED = `${PATH}.1`;
96
+
97
+ /**
98
+ * The tail sections canon reports as registered, kept from its own
99
+ * announcements. Presence is NOT inferable from the bytes: a section that never
100
+ * renders leaves the prefix exactly as stable as a correct one, so a live check
101
+ * that only asserts `sysDeltaChars: 0` cannot tell "frozen and injected" from
102
+ * "frozen and absent". Recording the set makes every row answer it.
103
+ */
104
+ let sectionIds: string[] = [];
105
+ pi.events.on("canon:sections", (payload: unknown) => {
106
+ try {
107
+ // canon announces the EFFECTIVE set, so a section it refused is not recorded
108
+ // here as present. Tracking the request instead would make this field report
109
+ // a section that never reached the prompt.
110
+ const ids = (payload as { ids?: unknown } | null)?.ids;
111
+ if (!Array.isArray(ids)) return;
112
+ sectionIds = ids.filter((i): i is string => typeof i === "string").sort();
113
+ } catch {
114
+ /* observation must never break anything */
115
+ }
116
+ });
117
+
118
+ // The state dir may not exist yet (fresh machine). Create it once at load —
119
+ // never on the request path — so the first append is not a silent ENOENT.
120
+ try {
121
+ mkdirSync(dirname(PATH), { recursive: true });
122
+ } catch {
123
+ /* best effort: appendFile below is guarded regardless */
124
+ }
125
+
126
+ let req = 0; // monotonic request index within this process
127
+ let bytes = 0; // tracked size of PATH (seeded once at load)
128
+ let rotating = false;
129
+
130
+ try {
131
+ bytes = statSync(PATH).size;
132
+ } catch {
133
+ bytes = 0; // file absent — starts at 0
134
+ }
135
+
136
+ // Previous emitted prefix. Kept as the last state we WROTE, so a change in any
137
+ // request (whether or not the intervening requests were hits) is detected.
138
+ let last: Prefix | null = null;
139
+ let lastTs = 0;
140
+ // System-prompt size of the immediately previous request (any status), so a
141
+ // row can state how far the prefix moved, not only that it moved.
142
+ let prevSysChars: number | null = null;
143
+
144
+ // ── run-start attribution ──
145
+ // agent_start opens a run. before_agent_start fires only for runs started by
146
+ // the interactive prompt path; for every other run-start path (an injected
147
+ // triggerTurn message, i.e. _runAgentPrompt -> agent.prompt()) the run opens
148
+ // with no before_agent_start at all. That asymmetry is exactly what makes a
149
+ // system prompt differ between requests of one session.
150
+ let run = 0;
151
+ let promptHookFired = false;
152
+ let runOrigin: "prompt" | "injected" | null = null;
153
+ pi.on("before_agent_start", () => {
154
+ promptHookFired = true;
155
+ });
156
+ pi.on("agent_start", () => {
157
+ run += 1;
158
+ runOrigin = promptHookFired ? "prompt" : "injected";
159
+ promptHookFired = false;
160
+ });
161
+
162
+ function schedule(line: string): void {
163
+ const len = Buffer.byteLength(line);
164
+ // Hard cap: once at/over CAP, rotate (async, single-flight) and DROP rows
165
+ // until the rename completes, so PATH can never exceed CAP_BYTES even under
166
+ // a burst. bytes is pinned at CAP while rotating so the drop holds until the
167
+ // callback resets it.
168
+ if (bytes + len > CAP_BYTES) {
169
+ if (!rotating) {
170
+ rotating = true;
171
+ bytes = CAP_BYTES;
172
+ rename(PATH, ROTATED, () => {
173
+ rotating = false;
174
+ bytes = 0; // next row recreates PATH
175
+ });
176
+ }
177
+ return;
178
+ }
179
+ bytes += len;
180
+ appendFile(PATH, line, () => {
181
+ /* best effort: a logger fault must never surface */
182
+ });
183
+ }
184
+
185
+ function readPrefix(payload: Record<string, unknown>, sessionId: string | null): Prefix {
186
+ const messages = Array.isArray(payload.messages) ? (payload.messages as unknown[]) : [];
187
+ const parts: string[] = [];
188
+ if (typeof payload.system === "string") parts.push(payload.system);
189
+ else if (payload.system != null) parts.push(JSON.stringify(payload.system));
190
+ for (const m of messages) {
191
+ const mm = m as { role?: string; content?: unknown };
192
+ // "developer" is the openai-completions role a reasoning model gets for
193
+ // the system prompt; without it a provider using it would look prefix-less.
194
+ if (mm?.role === "system" || mm?.role === "developer") parts.push(textOf(mm.content));
195
+ }
196
+ const system = parts.join("\n\u0000\n");
197
+
198
+ const tools = Array.isArray(payload.tools) ? (payload.tools as unknown[]) : [];
199
+ const names = tools.map(toolName);
200
+ const toolsJson = JSON.stringify(tools);
201
+
202
+ return {
203
+ session: sessionId ?? "",
204
+ sysHash: sha(system),
205
+ sysChars: system.length,
206
+ toolsHash: sha(toolsJson),
207
+ toolsChars: toolsJson.length,
208
+ nTools: tools.length,
209
+ names,
210
+ };
211
+ }
212
+
213
+ /** One captured request, fingerprinted a macrotask later (see the header). */
214
+ interface Captured {
215
+ payload: Record<string, unknown>;
216
+ req: number;
217
+ at: number;
218
+ sessionId: string | null;
219
+ }
220
+ const captured: Captured[] = [];
221
+ let flushQueued = false;
222
+
223
+ function writeRow(item: Captured): void {
224
+ try {
225
+ const p = readPrefix(item.payload, item.sessionId);
226
+ const origin = runOrigin ?? (promptHookFired ? "prompt" : "injected");
227
+ const sysDeltaChars = prevSysChars === null ? undefined : p.sysChars - prevSysChars;
228
+ prevSysChars = p.sysChars;
229
+ // The full id: a truncated one attributes a row to several sessions at once.
230
+ const sess = p.session;
231
+
232
+ const first = last === null || last.session !== p.session;
233
+ const sysChanged = last !== null && last.sysHash !== p.sysHash;
234
+ const toolsChanged = last !== null && last.toolsHash !== p.toolsHash;
235
+ if (!first && !sysChanged && !toolsChanged) return; // common case: no row
236
+
237
+ const messages = Array.isArray(item.payload.messages)
238
+ ? (item.payload.messages as unknown[])
239
+ : [];
240
+ const msgChars = JSON.stringify(
241
+ messages.filter((m) => (m as { role?: string })?.role !== "system"),
242
+ ).length;
243
+
244
+ const changed: string[] = [];
245
+ if (first) changed.push("baseline");
246
+ else {
247
+ if (sysChanged) changed.push("sys");
248
+ if (toolsChanged) changed.push("tools");
249
+ }
250
+
251
+ const prevNames = new Set(last?.names ?? []);
252
+ const nowNames = new Set(p.names);
253
+ const added = first ? undefined : p.names.filter((n) => !prevNames.has(n));
254
+ const removed = first ? undefined : (last?.names ?? []).filter((n) => !nowNames.has(n));
255
+
256
+ const row: Record<string, unknown> = {
257
+ ts: new Date(item.at).toISOString(),
258
+ req: item.req,
259
+ sess,
260
+ pid: process.pid,
261
+ run,
262
+ origin,
263
+ why: first ? "baseline" : "prefix",
264
+ sys: p.sysHash,
265
+ tools: p.toolsHash,
266
+ sysChars: p.sysChars,
267
+ toolsChars: p.toolsChars,
268
+ nTools: p.nTools,
269
+ prefixChars: p.sysChars + p.toolsChars,
270
+ msgChars,
271
+ sections: sectionIds.join(","),
272
+ changed,
273
+ };
274
+ if (sysDeltaChars !== undefined) row.sysDeltaChars = sysDeltaChars;
275
+ if (first) row.toolNames = p.names;
276
+ else {
277
+ row.added = added;
278
+ row.removed = removed;
279
+ if (lastTs) row.msSincePrev = item.at - lastTs;
280
+ }
281
+
282
+ last = p;
283
+ lastTs = item.at;
284
+ schedule(`${JSON.stringify(row)}\n`);
285
+ } catch {
286
+ /* observation must never break the request */
287
+ }
288
+ }
289
+
290
+ function flushCaptured(): void {
291
+ flushQueued = false;
292
+ const batch = captured.splice(0, captured.length);
293
+ for (const item of batch) writeRow(item);
294
+ }
295
+
296
+ pi.on("before_provider_request", (event, ctx) => {
297
+ try {
298
+ let sessionId: string | null = null;
299
+ try {
300
+ sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
301
+ } catch {
302
+ sessionId = null;
303
+ }
304
+ captured.push({
305
+ payload: (event?.payload ?? {}) as Record<string, unknown>,
306
+ req: ++req,
307
+ at: Date.now(),
308
+ sessionId,
309
+ });
310
+ if (!flushQueued) {
311
+ flushQueued = true;
312
+ setTimeout(flushCaptured, 0);
313
+ }
314
+ } catch {
315
+ /* observation must never break the request */
316
+ }
317
+ return undefined;
318
+ });
319
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tinoy/pi-cache-prefix-log",
3
+ "version": "0.1.0",
4
+ "description": "One JSONL row per cache-prefix change, so a real cache miss can be named from the log.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tinoy1336/pi-extensions.git",
9
+ "directory": "packages/cache-prefix-log"
10
+ },
11
+ "homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/cache-prefix-log#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/tinoy1336/pi-extensions/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "cache-prefix-log.ts",
17
+ "keywords": [
18
+ "pi-package"
19
+ ],
20
+ "files": [
21
+ "cache-prefix-log.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./cache-prefix-log.ts"
28
+ ]
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*"
35
+ }
36
+ }