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/src/config.ts CHANGED
@@ -115,22 +115,58 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
115
115
  };
116
116
  }
117
117
 
118
- async function readJsonObject(path: string): Promise<Record<string, unknown> | undefined> {
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
- const raw = await readFile(path, "utf-8");
121
- const parsed = JSON.parse(raw);
122
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
123
- return parsed as Record<string, unknown>;
124
- }
125
- return undefined;
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
- return undefined;
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
- /** Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or defaults. */
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
- const main = await readJsonObject(settingsPath());
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. Uses
144
- * a tmp-file + atomic rename so concurrent pi writes (e.g. theme changes via
145
- * `/settings`) cannot observe a partial file. We do not coordinate with pi's
146
- * own internal lock since both writers do whole-file replacements and a
147
- * last-write-wins race only loses a single change, never corrupts the file.
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
+ }
@@ -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.replace(/\\/g, "/");
54
+ const normalized = normalizePath(path);
50
55
  return config.protectedPaths.some((p) => globToRegExp(p).test(normalized));
51
56
  }
@@ -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 four phases:
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);