pi-condense 2.10.2 → 2.10.4
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 +17 -0
- package/PRUNING.md +28 -3
- package/README.md +2 -2
- package/index.ts +34 -1
- package/package.json +1 -1
- package/src/commands.test.ts +49 -0
- package/src/commands.ts +14 -13
- package/src/config.test.ts +99 -1
- package/src/config.ts +74 -17
- package/src/protected.test.ts +8 -1
- package/src/protected.ts +6 -1
- package/src/pruner.test.ts +74 -1
- package/src/pruner.ts +19 -1
- package/src/reload-rearm.integration.test.ts +518 -0
- package/src/supersede.test.ts +302 -0
- package/src/supersede.ts +117 -0
package/src/config.ts
CHANGED
|
@@ -115,22 +115,58 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
export class SettingsReadError extends Error {
|
|
119
|
+
constructor(
|
|
120
|
+
public readonly path: string,
|
|
121
|
+
public readonly reason: string,
|
|
122
|
+
) {
|
|
123
|
+
super(`settings.json unreadable at ${path}: ${reason}`);
|
|
124
|
+
this.name = "SettingsReadError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Single classifier for settings.json read outcomes. Only ENOENT means "no
|
|
130
|
+
* file"; every other failure throws so a save never starts from `{}` over a
|
|
131
|
+
* file it could not read.
|
|
132
|
+
*/
|
|
133
|
+
async function readJsonObject(
|
|
134
|
+
path: string,
|
|
135
|
+
read: typeof readFile = readFile,
|
|
136
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
137
|
+
let raw: string;
|
|
119
138
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
raw = await read(path, "utf-8");
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const e = err as NodeJS.ErrnoException;
|
|
142
|
+
if (e.code === "ENOENT") return undefined;
|
|
143
|
+
throw new SettingsReadError(path, e.code ?? e.message);
|
|
144
|
+
}
|
|
145
|
+
let parsed: unknown;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(raw);
|
|
126
148
|
} catch {
|
|
127
|
-
|
|
149
|
+
throw new SettingsReadError(path, "invalid JSON");
|
|
150
|
+
}
|
|
151
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
152
|
+
return parsed as Record<string, unknown>;
|
|
128
153
|
}
|
|
154
|
+
throw new SettingsReadError(path, "not a JSON object");
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or
|
|
159
|
+
* defaults. Fail-soft: an unreadable or malformed file yields defaults, since
|
|
160
|
+
* a broken settings.json is pi-wide and not this extension's to report.
|
|
161
|
+
*/
|
|
132
162
|
export async function loadConfig(): Promise<ContextPruneConfig> {
|
|
133
|
-
|
|
163
|
+
let main: Record<string, unknown> | undefined;
|
|
164
|
+
try {
|
|
165
|
+
main = await readJsonObject(settingsPath());
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof SettingsReadError) return { ...DEFAULT_CONFIG };
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
134
170
|
const namespaced = main?.[SETTINGS_KEY];
|
|
135
171
|
if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
|
|
136
172
|
return normalize(namespaced as Partial<ContextPruneConfig>);
|
|
@@ -140,18 +176,39 @@ export async function loadConfig(): Promise<ContextPruneConfig> {
|
|
|
140
176
|
|
|
141
177
|
/**
|
|
142
178
|
* Writes the full config back to `<agent-dir>/settings.json` under
|
|
143
|
-
* {@link SETTINGS_KEY}, preserving every other top-level key in the file.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* last-write-wins race
|
|
179
|
+
* {@link SETTINGS_KEY}, preserving every other top-level key in the file.
|
|
180
|
+
* Tmp-file + atomic rename, so a concurrent reader never observes a partial
|
|
181
|
+
* file. A file that cannot be read as a JSON object is never replaced: the
|
|
182
|
+
* read throws {@link SettingsReadError} before anything is written. Concurrent
|
|
183
|
+
* saves (ours or pi's own) are last-write-wins; that race is not coordinated.
|
|
148
184
|
*/
|
|
149
|
-
export async function saveConfig(config: ContextPruneConfig): Promise<void> {
|
|
185
|
+
export async function saveConfig(config: ContextPruneConfig, read: typeof readFile = readFile): Promise<void> {
|
|
150
186
|
const path = settingsPath();
|
|
151
|
-
const current = (await readJsonObject(path)) ?? {};
|
|
187
|
+
const current = (await readJsonObject(path, read)) ?? {};
|
|
152
188
|
const next = { ...current, [SETTINGS_KEY]: config };
|
|
153
189
|
await mkdir(dirname(path), { recursive: true });
|
|
154
190
|
const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
155
191
|
await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
156
192
|
await rename(tmpPath, path);
|
|
157
193
|
}
|
|
194
|
+
|
|
195
|
+
type Notify = (message: string, type?: "info" | "warning" | "error") => void;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Saves and reports failure through `notify` instead of rejecting, so callers
|
|
199
|
+
* can fire-and-forget. The in-memory change stands; only persistence failed.
|
|
200
|
+
*/
|
|
201
|
+
export async function persistConfig(
|
|
202
|
+
notify: Notify,
|
|
203
|
+
config: ContextPruneConfig,
|
|
204
|
+
save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
try {
|
|
207
|
+
await save(config);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const reason = err instanceof SettingsReadError
|
|
210
|
+
? err.reason
|
|
211
|
+
: ((err as NodeJS.ErrnoException | null | undefined)?.code ?? String(err));
|
|
212
|
+
notify(`Could not save settings to ${settingsPath()}: ${reason}. Change applies to this session only.`, "error");
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/protected.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { globToRegExp, isProtected } from "./protected.js";
|
|
2
|
+
import { globToRegExp, isProtected, normalizePath } from "./protected.js";
|
|
3
3
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
4
4
|
|
|
5
5
|
describe("globToRegExp", () => {
|
|
@@ -82,3 +82,10 @@ describe("isProtected", () => {
|
|
|
82
82
|
expect(isProtected("read", { path: "skills/a/SKILL.md" }, { protectedTools: [], protectedPaths: [] })).toBe(false);
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
describe("normalizePath", () => {
|
|
87
|
+
test("normalizePath turns backslashes into forward slashes and nothing else", () => {
|
|
88
|
+
expect(normalizePath("h\\skills\\x\\SKILL.md")).toBe("h/skills/x/SKILL.md");
|
|
89
|
+
expect(normalizePath("./a/../b.md")).toBe("./a/../b.md");
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/protected.ts
CHANGED
|
@@ -41,11 +41,16 @@ export function globToRegExp(pattern: string): RegExp {
|
|
|
41
41
|
return compiled;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Identity normalization shared by protection matching and supersession: slash direction only, no resolution. */
|
|
45
|
+
export function normalizePath(path: string): string {
|
|
46
|
+
return path.replace(/\\/g, "/");
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
export function isProtected(toolName: string, args: unknown, config: ProtectionConfig): boolean {
|
|
45
50
|
if (config.protectedTools.includes(toolName)) return true;
|
|
46
51
|
if (config.protectedPaths.length === 0) return false;
|
|
47
52
|
const path = (args as Record<string, unknown> | null | undefined)?.path;
|
|
48
53
|
if (typeof path !== "string") return false;
|
|
49
|
-
const normalized = path
|
|
54
|
+
const normalized = normalizePath(path);
|
|
50
55
|
return config.protectedPaths.some((p) => globToRegExp(p).test(normalized));
|
|
51
56
|
}
|
package/src/pruner.test.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { ToolCallIndexer } from "./indexer.js";
|
|
|
4
4
|
import { CUSTOM_TYPE_INDEX } from "./types.js";
|
|
5
5
|
import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
|
|
6
6
|
import { DiagnosticSink } from "./diagnostics.js";
|
|
7
|
-
import { pruneWithZeroSweepAssertion } from "./test-support.js";
|
|
7
|
+
import { pruneWithZeroSweepAssertion, expectNoOrphanToolResults } from "./test-support.js";
|
|
8
|
+
import { createSupersedeState, supersededStub } from "./supersede.js";
|
|
9
|
+
import { isProtected } from "./protected.js";
|
|
8
10
|
|
|
9
11
|
// Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
|
|
10
12
|
// `hasLegacyBareRecord` defaults to the bare `summarized` set: most of the fixture
|
|
@@ -1053,3 +1055,74 @@ describe("G4/C3: orphan-sweep zero-fire proof across pruner fixtures", () => {
|
|
|
1053
1055
|
it(`zero orphan sweeps: ${name}`, run);
|
|
1054
1056
|
}
|
|
1055
1057
|
});
|
|
1058
|
+
|
|
1059
|
+
describe("pruneMessages phase 1b (supersede)", () => {
|
|
1060
|
+
const protection = { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] };
|
|
1061
|
+
const isProt = (n: string, a: unknown) => isProtected(n, a, protection);
|
|
1062
|
+
const SKILL = "/h/skills/x/SKILL.md";
|
|
1063
|
+
|
|
1064
|
+
function twoReads(): any[] {
|
|
1065
|
+
return [
|
|
1066
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] },
|
|
1067
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "r1", name: "read", input: { path: SKILL } }] },
|
|
1068
|
+
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "FIRST" }], isError: false, timestamp: 3 },
|
|
1069
|
+
{ role: "assistant", timestamp: 4, content: [{ type: "text", text: "ok" }] },
|
|
1070
|
+
{ role: "user", timestamp: 5, content: [{ type: "text", text: "again" }] },
|
|
1071
|
+
{ role: "assistant", timestamp: 6, content: [{ type: "toolCall", id: "r2", name: "read", input: { path: SKILL } }] },
|
|
1072
|
+
{ role: "toolResult", toolCallId: "r2", toolName: "read", content: [{ type: "text", text: "SECOND" }], isError: false, timestamp: 7 },
|
|
1073
|
+
{ role: "assistant", timestamp: 8, content: [{ type: "text", text: "done" }] },
|
|
1074
|
+
];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
it("supersede param absent -> output identical to today", () => {
|
|
1078
|
+
const msgs = twoReads();
|
|
1079
|
+
const { messages: out, pruned } = pruneMessages(msgs, makeMockIndexer(), undefined, undefined, protection);
|
|
1080
|
+
expect(pruned).toBe(false);
|
|
1081
|
+
expect(out).toBe(msgs);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("nothing activated -> input reference, pruned false", () => {
|
|
1085
|
+
const msgs = twoReads();
|
|
1086
|
+
const state = createSupersedeState();
|
|
1087
|
+
const { messages: out, pruned } = pruneMessages(msgs, makeMockIndexer(), undefined, undefined, protection, 0, undefined, { state, isProtected: isProt });
|
|
1088
|
+
expect(pruned).toBe(false);
|
|
1089
|
+
expect(out).toBe(msgs);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it("one activation -> fresh array, pruned true, input untouched, newest verbatim, metadata kept", () => {
|
|
1093
|
+
const msgs = twoReads();
|
|
1094
|
+
const before = JSON.stringify(msgs);
|
|
1095
|
+
const state = createSupersedeState();
|
|
1096
|
+
state.floor = 0;
|
|
1097
|
+
const { messages: out, pruned } = pruneMessages(msgs, makeMockIndexer(), undefined, undefined, protection, 0, undefined, { state, isProtected: isProt });
|
|
1098
|
+
expect(pruned).toBe(true);
|
|
1099
|
+
expect(out).not.toBe(msgs);
|
|
1100
|
+
expect(JSON.stringify(msgs)).toBe(before);
|
|
1101
|
+
expect(out[2]).toEqual({ ...msgs[2], content: [{ type: "text", text: supersededStub(SKILL) }] });
|
|
1102
|
+
expect(out[6]).toBe(msgs[6]);
|
|
1103
|
+
expectNoOrphanToolResults(out);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("superseded read inside a compressed chain relocates as the stub", () => {
|
|
1107
|
+
const msgs = twoReads();
|
|
1108
|
+
const entry = {
|
|
1109
|
+
blockId: "b1",
|
|
1110
|
+
startUserTimestamp: 1,
|
|
1111
|
+
droppedToolCallIds: ["r1"],
|
|
1112
|
+
protectedToolCallIds: ["r1"],
|
|
1113
|
+
finalAssistantTimestamp: 4,
|
|
1114
|
+
toolRefs: [],
|
|
1115
|
+
compressedAt: 100,
|
|
1116
|
+
} as any;
|
|
1117
|
+
const indexer = makeMockIndexer({ chainEntries: [entry], summaryBodyMap: new Map([["r1", "SUMMARY"]]) });
|
|
1118
|
+
const state = createSupersedeState();
|
|
1119
|
+
state.floor = 0;
|
|
1120
|
+
const { messages: out } = pruneMessages(msgs, indexer, enabledCC, undefined, protection, 0, undefined, { state, isProtected: isProt });
|
|
1121
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
1122
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="read">');
|
|
1123
|
+
expect(synthetic.content[0].text).toContain(supersededStub(SKILL));
|
|
1124
|
+
expect(synthetic.content[0].text).not.toContain("FIRST");
|
|
1125
|
+
expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "r2").content[0].text).toBe("SECOND");
|
|
1126
|
+
expectNoOrphanToolResults(out);
|
|
1127
|
+
});
|
|
1128
|
+
});
|
package/src/pruner.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
|
|
|
8
8
|
import { occKey } from "./occurrence-key.js";
|
|
9
9
|
import { sweepOrphanToolResults } from "./orphan-sweep.js";
|
|
10
10
|
import type { DiagnosticSink } from "./diagnostics.js";
|
|
11
|
+
import { applySupersede, type SupersedeState } from "./supersede.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Estimate of a message array's context weight. Serializing the whole array
|
|
@@ -20,7 +21,7 @@ export function sizeMessages(messages: any[]): number {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* Transforms the `context` event message array in
|
|
24
|
+
* Transforms the `context` event message array in five phases:
|
|
24
25
|
*
|
|
25
26
|
* Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
|
|
26
27
|
* replaced with short stubs pointing the model at `context_tree_query`.
|
|
@@ -37,6 +38,13 @@ export function sizeMessages(messages: any[]): number {
|
|
|
37
38
|
* to recovery is present on the toolResult itself, not only in the
|
|
38
39
|
* separate summary message.
|
|
39
40
|
*
|
|
41
|
+
* Phase 1b — supersede: protected reads (never indexed) whose `args.path`
|
|
42
|
+
* is read again later in the same context are replaced with a one-line
|
|
43
|
+
* "superseded" stub, but only once `SupersedeState.floor` says the pruner
|
|
44
|
+
* is rewriting at/before their position anyway (or the cache is cold).
|
|
45
|
+
* See src/supersede.ts. Runs before phase 3 so a superseded read inside a
|
|
46
|
+
* compressed chain relocates as the stub, not the verbatim body.
|
|
47
|
+
*
|
|
40
48
|
* Phase 2 — error purge: replaces failed toolCall arg bodies with stubs after a
|
|
41
49
|
* cooldown, reclaiming context from large `write`/`edit` arguments that will
|
|
42
50
|
* never succeed. The toolResult error message stays visible.
|
|
@@ -78,6 +86,7 @@ export function pruneMessages(
|
|
|
78
86
|
protection?: ProtectionConfig,
|
|
79
87
|
recoveryGraceTurns: number = 0,
|
|
80
88
|
diagnostics?: DiagnosticSink,
|
|
89
|
+
supersede?: { state: SupersedeState; isProtected: (toolName: string, args: unknown) => boolean },
|
|
81
90
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
82
91
|
// Phase 1: stub-replace summarized tool results
|
|
83
92
|
let pruned = false;
|
|
@@ -136,6 +145,15 @@ export function pruneMessages(
|
|
|
136
145
|
|
|
137
146
|
let current: any[] = pruned ? next : messages;
|
|
138
147
|
|
|
148
|
+
// Phase 1b: supersede older protected reads of a re-read path
|
|
149
|
+
if (supersede) {
|
|
150
|
+
const afterSupersede = applySupersede(current, supersede.state, supersede.isProtected);
|
|
151
|
+
if (afterSupersede !== current) {
|
|
152
|
+
current = afterSupersede;
|
|
153
|
+
pruned = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
// Phase 2: error purge — replace failed toolCall arg bodies after cooldown
|
|
140
158
|
if (errorPurge?.enabled) {
|
|
141
159
|
const afterPurge = purgeErroredArgs(current, errorPurge);
|