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 +19 -0
- package/README.md +108 -0
- package/package.json +37 -0
- package/prefix-stabilizer.js +285 -0
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
|
+
};
|