pi-prefix-stabilizer 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,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezoushen
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.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # pi-prefix-stabilizer
2
+
3
+ Keep Pi's provider request prefix byte-stable across turns, reloads, and resumes so
4
+ a server-side prefix cache can reuse it.
5
+
6
+ The extension normalizes install paths inside the system prompt, sorts tool-schema
7
+ lists by name, then fingerprints the normalized system text. It warns when that text
8
+ changes during a session because one early changed token invalidates every cached
9
+ block after it.
10
+
11
+ ## External contract
12
+
13
+ The provider must offer content-addressed prefix caching whose cache key depends on
14
+ the rendered request prefix. This extension stabilizes bytes; it does not enable or
15
+ configure the server cache.
16
+
17
+ For path normalization, `packagePathSuffix` must identify the volatile suffix in the
18
+ rendered prompt. `stablePath` is the replacement shown to the model. If tools or the
19
+ model need that path to resolve on disk, create it yourself or opt into
20
+ `createSymlink`.
21
+
22
+ ## If the contract is unmet
23
+
24
+ Without provider-side prefix caching, requests remain correct but receive no cache
25
+ benefit. If the configured package suffix is absent, no path is rewritten; tool
26
+ sorting and drift detection still run. A genuine normalized-prompt change emits a
27
+ warning for that change, while identical repeats do not re-warn.
28
+
29
+ A mention of the package suffix is only ever treated as a volatile install path -- and
30
+ so only ever rewritten to `stablePath` -- if it is absolute and actually exists on
31
+ disk. A relative mention or an absolute-looking one that does not exist is left alone:
32
+ it was never an install path, so rewriting it would change the text's meaning rather
33
+ than stabilise a fact.
34
+
35
+ Symlink creation is off by default. If explicitly enabled and the link cannot be
36
+ created, the failure is written to the configured log when logging is enabled and the
37
+ turn continues. It also never replaces an existing symlink with a bad one: if the
38
+ candidate install root does not exist on disk, whatever is already at `stablePath` is
39
+ left untouched.
40
+
41
+ In pi's print (`-p`) and json modes there is no UI to notify, so the drift warning that
42
+ would otherwise be silent there is written to stderr instead, once per process.
43
+
44
+ ## Load order
45
+
46
+ Load `pi-prefix-stabilizer` before `pi-compaction-cache`.
47
+
48
+ The stabilizer must normalize the ordinary provider payload before the compaction
49
+ extension captures it as the live cache prefix.
50
+
51
+ ```sh
52
+ pi install npm:pi-prefix-stabilizer
53
+ pi install npm:pi-compaction-cache
54
+ ```
55
+
56
+ ## Settings
57
+
58
+ The three JSON settings resolve from `pi-prefix-stabilizer.json` in the global Pi
59
+ config directory, then from a trusted project's Pi config directory, then from the
60
+ environment. Later sources win. Project settings are ignored when the project is
61
+ untrusted.
62
+
63
+ | JSON key | Default | Environment override | Meaning |
64
+ |---|---|---|---|
65
+ | `stablePath` | `$HOME/.pi/pi-home` | `PI_PREFIX_STABILIZER_STABLE_PATH` | Stable replacement for a volatile package install path. |
66
+ | `packagePathSuffix` | `node_modules/@earendil-works/pi-coding-agent` | `PI_PREFIX_STABILIZER_PACKAGE_PATH_SUFFIX` | Suffix used to find the absolute package root in prompt text. |
67
+ | `createSymlink` | `false` | `PI_PREFIX_STABILIZER_CREATE_SYMLINK=1` | Create or update `stablePath` to point at the discovered package root. |
68
+
69
+ Two environment-only controls preserve the original operational interface:
70
+
71
+ | Environment variable | Default | Meaning |
72
+ |---|---|---|
73
+ | `PI_PREFIX_STABILIZER` | enabled | Set to `0` to disable the extension. |
74
+ | `PI_PREFIX_STABILIZER_LOG` | unset | Append JSON records for rewrites, drift, and symlink operations. |
75
+
76
+ Default installation performs no filesystem writes.
77
+
78
+ ## Measurements
79
+
80
+ Measured with a large-context model behind a prefix-caching server:
81
+
82
+ | request | prompt tokens | TTFT | cache hit |
83
+ |---|---:|---:|---:|
84
+ | cold prompt | about 132,000 | 145.33s | 0.00% |
85
+ | byte-identical repeat | about 132,000 | 0.54s | 99.95% |
86
+ | first four characters changed | about 132,000 | 144.68s | 0.00% |
87
+
88
+ The stable repeat was about 270 times faster to first token. In the incident that
89
+ motivated the package, an install-path change diverged near token 2,649 of a
90
+ 118,031-token request; 97.8% of the prompt had to be prefilled again, producing an
91
+ observed 127.5–137.5s delay. At the measured 871-token/s prefill rate, the expected
92
+ delay was 132.5s.
93
+
94
+ ## Reproduce the measurements
95
+
96
+ 1. Capture one rendered provider payload and the server's prompt-token, cached-token,
97
+ and TTFT metrics.
98
+ 2. Send it cold, then repeat it byte-for-byte.
99
+ 3. Change a few characters early in the system prompt and send it again.
100
+ 4. Compare cached-token counts and TTFT across the three requests.
101
+ 5. Enable this extension, repeat the experiment across a package reinstall or other
102
+ install-path change, and confirm that the normalized payload remains identical.
103
+ 6. Reorder tool discovery without changing the tools and confirm that normalization
104
+ prevents a drift warning; then make a real system-prompt change and confirm one
105
+ warning is shown.
106
+
107
+ Cache capacity and eviction policy remain server concerns; test them separately under
108
+ the deployment's real concurrency and request mix.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-prefix-stabilizer",
3
+ "version": "0.1.0",
4
+ "description": "Keep Pi's system prompt byte-stable for prefix-cache reuse.",
5
+ "type": "module",
6
+ "main": "./prefix-stabilizer.js",
7
+ "exports": "./prefix-stabilizer.js",
8
+ "files": [
9
+ "prefix-stabilizer.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ezoushen/pi-extensions.git",
20
+ "directory": "extensions/prefix-stabilizer"
21
+ },
22
+ "homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/prefix-stabilizer#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/ezoushen/pi-extensions/issues"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./prefix-stabilizer.js"
35
+ ]
36
+ }
37
+ }
@@ -0,0 +1,285 @@
1
+ // extensions/prefix-stabilizer/prefix-stabilizer.ts
2
+ import { appendFileSync, symlinkSync, readlinkSync, rmSync, mkdirSync, existsSync as existsSync2 } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join as join2 } from "node:path";
6
+
7
+ // shared/settings.ts
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
11
+
12
+ // shared/announce.ts
13
+ var stderrAnnounced = /* @__PURE__ */ new Set();
14
+ function announce(ctx, message, level, reason = message) {
15
+ if (ctx?.hasUI === false) {
16
+ if (stderrAnnounced.has(reason)) return;
17
+ stderrAnnounced.add(reason);
18
+ try {
19
+ process.stderr.write(`${message}
20
+ `);
21
+ } catch {
22
+ }
23
+ return;
24
+ }
25
+ try {
26
+ ctx?.ui?.notify?.(message, level);
27
+ } catch {
28
+ }
29
+ }
30
+
31
+ // shared/settings.ts
32
+ var announcedConfigErrors = /* @__PURE__ */ new Set();
33
+ function readConfig(path, ctx) {
34
+ if (!existsSync(path)) return {};
35
+ try {
36
+ return JSON.parse(readFileSync(path, "utf8"));
37
+ } catch (error) {
38
+ if (!announcedConfigErrors.has(path)) {
39
+ announcedConfigErrors.add(path);
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ announce(ctx, `settings: could not parse ${path} (${message}); using defaults.`, "warning", `settings-parse:${path}`);
42
+ }
43
+ return {};
44
+ }
45
+ }
46
+ function resolveSettings(name, definitions, context, runtime = {}) {
47
+ const environment = runtime.environment ?? process.env;
48
+ const globalPath = join(runtime.agentDir ?? getAgentDir(), `${name}.json`);
49
+ const projectPath = join(context.cwd, CONFIG_DIR_NAME, `${name}.json`);
50
+ const globalConfig = readConfig(globalPath, context);
51
+ const projectConfig = context.isProjectTrusted() ? readConfig(projectPath, context) : {};
52
+ const resolved = {};
53
+ for (const key of Object.keys(definitions)) {
54
+ const definition = definitions[key];
55
+ let value = definition.default;
56
+ let provenance = { source: "default" };
57
+ if (definition.discover) {
58
+ try {
59
+ const discovered = definition.discover();
60
+ if (discovered !== void 0) {
61
+ value = discovered.value;
62
+ provenance = { source: "discovered", name: definition.discoverName ?? "discovery" };
63
+ }
64
+ } catch {
65
+ }
66
+ }
67
+ if (Object.hasOwn(globalConfig, key)) {
68
+ value = globalConfig[key];
69
+ provenance = { source: "global", path: globalPath };
70
+ }
71
+ if (Object.hasOwn(projectConfig, key)) {
72
+ value = projectConfig[key];
73
+ provenance = { source: "project", path: projectPath };
74
+ }
75
+ const environmentValue = environment[definition.env];
76
+ if (environmentValue !== void 0) {
77
+ value = definition.parseEnv ? definition.parseEnv(environmentValue) : environmentValue;
78
+ provenance = { source: "environment", name: definition.env };
79
+ }
80
+ resolved[key] = { value, provenance };
81
+ }
82
+ return resolved;
83
+ }
84
+
85
+ // extensions/prefix-stabilizer/prefix-stabilizer.ts
86
+ var DISABLED = process.env.PI_PREFIX_STABILIZER === "0";
87
+ var LOG_PATH = process.env.PI_PREFIX_STABILIZER_LOG;
88
+ var SETTINGS = {
89
+ stablePath: {
90
+ default: join2(homedir(), ".pi", "pi-home"),
91
+ env: "PI_PREFIX_STABILIZER_STABLE_PATH"
92
+ },
93
+ packagePathSuffix: {
94
+ default: "node_modules/@earendil-works/pi-coding-agent",
95
+ env: "PI_PREFIX_STABILIZER_PACKAGE_PATH_SUFFIX"
96
+ },
97
+ createSymlink: {
98
+ default: false,
99
+ env: "PI_PREFIX_STABILIZER_CREATE_SYMLINK",
100
+ parseEnv: (value) => value === "1" || value.toLowerCase() === "true"
101
+ }
102
+ };
103
+ var BOUNDARY = /[\s"'`(<\[]/;
104
+ function log(o) {
105
+ if (!LOG_PATH) return;
106
+ try {
107
+ appendFileSync(LOG_PATH, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...o }) + "\n");
108
+ } catch {
109
+ }
110
+ }
111
+ function ensureSymlink(root, stablePath) {
112
+ try {
113
+ if (!existsSync2(root)) {
114
+ log({ symlink_skip: stablePath, invalid_target: root });
115
+ return;
116
+ }
117
+ if (existsSync2(stablePath)) {
118
+ let cur = null;
119
+ try {
120
+ cur = readlinkSync(stablePath);
121
+ } catch {
122
+ cur = null;
123
+ }
124
+ if (cur === root) return;
125
+ rmSync(stablePath, { force: true });
126
+ }
127
+ mkdirSync(dirname(stablePath), { recursive: true });
128
+ symlinkSync(root, stablePath);
129
+ log({ symlink: stablePath, target: root });
130
+ } catch (e) {
131
+ log({ symlink_error: String(e) });
132
+ }
133
+ }
134
+ function normalisePaths(s, roots, packagePathSuffix, stablePath) {
135
+ let out = s;
136
+ let i = out.indexOf(packagePathSuffix);
137
+ while (i >= 0) {
138
+ let start = i;
139
+ while (start > 0 && !BOUNDARY.test(out[start - 1])) start--;
140
+ const candidate = out.slice(start, i + packagePathSuffix.length);
141
+ const isInstallRoot = candidate.startsWith("/") && existsSync2(candidate);
142
+ if (!isInstallRoot) {
143
+ i = out.indexOf(packagePathSuffix, i + packagePathSuffix.length);
144
+ continue;
145
+ }
146
+ roots.add(candidate);
147
+ out = out.slice(0, start) + stablePath + out.slice(i + packagePathSuffix.length);
148
+ i = out.indexOf(packagePathSuffix, start + stablePath.length);
149
+ }
150
+ return out;
151
+ }
152
+ function sortToolsSection(s) {
153
+ if (!s.includes("<tools>")) return s;
154
+ const lines = s.split("\n");
155
+ const idx = [];
156
+ for (let i = 0; i < lines.length; i++) if (lines[i].startsWith("- ")) idx.push(i);
157
+ if (idx.length < 2) return s;
158
+ const nameOf = (l) => l.slice(2).split(":")[0];
159
+ const sorted = idx.map((i) => lines[i]).sort((a, b) => nameOf(a) < nameOf(b) ? -1 : nameOf(a) > nameOf(b) ? 1 : 0);
160
+ idx.forEach((lineNo, k) => {
161
+ lines[lineNo] = sorted[k];
162
+ });
163
+ return lines.join("\n");
164
+ }
165
+ function toolName(t) {
166
+ if (!t || typeof t !== "object") return "";
167
+ const o = t;
168
+ return String(o.name ?? o.function?.name ?? o.custom?.name ?? "");
169
+ }
170
+ function sortToolArray(tools) {
171
+ if (!Array.isArray(tools) || tools.length < 2) return { v: tools, changed: false };
172
+ const before = tools.map(toolName);
173
+ const sorted = [...tools].sort((a, b) => {
174
+ const x = toolName(a), y = toolName(b);
175
+ return x < y ? -1 : x > y ? 1 : 0;
176
+ });
177
+ const changed = sorted.some((t, i) => t !== tools[i]);
178
+ void before;
179
+ return { v: changed ? sorted : tools, changed };
180
+ }
181
+ function walk(v, roots, packagePathSuffix, stablePath) {
182
+ if (typeof v === "string") {
183
+ let s = v;
184
+ if (s.includes(packagePathSuffix)) {
185
+ s = normalisePaths(s, roots, packagePathSuffix, stablePath);
186
+ }
187
+ s = sortToolsSection(s);
188
+ return s === v ? { v, n: 0 } : { v: s, n: 1 };
189
+ }
190
+ if (Array.isArray(v)) {
191
+ let n = 0;
192
+ const out = v.map((x) => {
193
+ const r = walk(x, roots, packagePathSuffix, stablePath);
194
+ n += r.n;
195
+ return r.v;
196
+ });
197
+ return n ? { v: out, n } : { v, n: 0 };
198
+ }
199
+ if (v && typeof v === "object") {
200
+ let n = 0;
201
+ const out = {};
202
+ for (const [k, val] of Object.entries(v)) {
203
+ const r = walk(val, roots, packagePathSuffix, stablePath);
204
+ n += r.n;
205
+ out[k] = r.v;
206
+ }
207
+ return n ? { v: out, n } : { v, n: 0 };
208
+ }
209
+ return { v, n: 0 };
210
+ }
211
+ function systemText(payload) {
212
+ const parts = [];
213
+ const push = (c) => {
214
+ if (typeof c === "string") parts.push(c);
215
+ else if (Array.isArray(c)) {
216
+ for (const b of c) if (b && typeof b === "object" && typeof b.text === "string") parts.push(b.text);
217
+ }
218
+ };
219
+ for (const f of ["system", "instructions"]) if (typeof payload[f] === "string") parts.push(payload[f]);
220
+ const msgs = payload.messages;
221
+ if (Array.isArray(msgs)) {
222
+ for (const m of msgs)
223
+ if (m && typeof m === "object" && m.role === "system") push(m.content);
224
+ }
225
+ return parts.join("\n").replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "");
226
+ }
227
+ function activate(pi, bootCtx) {
228
+ if (DISABLED) return;
229
+ let announced = false;
230
+ let fingerprint = null;
231
+ pi.on("before_provider_request", (event, ctx) => {
232
+ const payload = event?.payload;
233
+ if (!payload) return;
234
+ const settings = resolveSettings("pi-prefix-stabilizer", SETTINGS, {
235
+ cwd: ctx?.cwd ?? process.cwd(),
236
+ isProjectTrusted: () => ctx?.isProjectTrusted?.() ?? false,
237
+ hasUI: ctx?.hasUI,
238
+ ui: ctx?.ui
239
+ });
240
+ const stablePath = settings.stablePath.value;
241
+ const packagePathSuffix = settings.packagePathSuffix.value;
242
+ const roots = /* @__PURE__ */ new Set();
243
+ const out = { ...payload };
244
+ let n = 0;
245
+ for (const field of ["messages", "system", "instructions"]) {
246
+ if (!(field in payload)) continue;
247
+ const r = walk(payload[field], roots, packagePathSuffix, stablePath);
248
+ if (r.n) {
249
+ out[field] = r.v;
250
+ n += r.n;
251
+ }
252
+ }
253
+ const ts = sortToolArray(payload.tools);
254
+ if (ts.changed) {
255
+ out.tools = ts.v;
256
+ n += 1;
257
+ }
258
+ if (settings.createSymlink.value) {
259
+ for (const root of roots) if (root !== stablePath) ensureSymlink(root, stablePath);
260
+ }
261
+ if (n && !announced) {
262
+ announced = true;
263
+ log({ first_rewrite: true, replacements: n, roots: [...roots] });
264
+ }
265
+ const fp = createHash("sha1").update(systemText(n ? out : payload)).digest("hex").slice(0, 12);
266
+ if (fingerprint === null) {
267
+ fingerprint = fp;
268
+ } else if (fp !== fingerprint) {
269
+ const msg = `prefix-stabilizer: the system prompt changed mid-session (${fingerprint} -> ${fp}). The server's KV prefix cache is invalidated from that point on, so this turn re-prefills the whole context. Usual causes: a package/MCP server added or removed, rules/AGENTS.md edited, a skill added, or cwd changed.`;
270
+ log({ drift: true, from: fingerprint, to: fp });
271
+ announce(
272
+ { hasUI: ctx?.hasUI ?? bootCtx?.hasUI, ui: ctx?.ui ?? bootCtx?.ui },
273
+ msg,
274
+ void 0,
275
+ "prefix-stabilizer:drift"
276
+ );
277
+ fingerprint = fp;
278
+ }
279
+ if (!n) return;
280
+ return out;
281
+ });
282
+ }
283
+ export {
284
+ activate as default
285
+ };