pi-cliffcompaction 0.1.0 → 0.2.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/lib/json.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * JSON serializers matching CPython json.dumps used by the reference
3
- * implementation: canonical (sorted, compact) for hashing, default
4
- * (insertion-order, ", "/": " separators, ensure_ascii=False) for
5
- * billable character counts.
2
+ * JavaScript-local JSON serializers. Canonical hashes use UTF-16 key order
3
+ * and ECMAScript numbers; they are not generally interchangeable with Python
4
+ * json.dumps hashes. Default formatting uses Python-style separators only.
6
5
  *
7
- * dumpsLen counts without building the string (same length as dumpsDefault).
6
+ * dumpsLen counts UTF-16 code units, not UTF-8 bytes or tokenizer tokens.
8
7
  */
9
8
 
10
9
  import {
@@ -103,7 +102,7 @@ function pushCanonical(value: JsonValue, parts: { push(chunk: string): void }):
103
102
  parts.push("}");
104
103
  }
105
104
 
106
- /** json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) */
105
+ /** Sorted compact JSON for local digest identity; not a full JCS validator. */
107
106
  export function canonicalJson(value: JsonValue): string {
108
107
  const parts: string[] = [];
109
108
  pushCanonical(value, parts);
@@ -173,7 +172,7 @@ function pushDefault(value: JsonValue, parts: string[]): void {
173
172
  parts.push("}");
174
173
  }
175
174
 
176
- /** json.dumps(obj, ensure_ascii=False) -- default separators. */
175
+ /** Insertion-order JSON with ", " / ": " separators and JS number/string semantics. */
177
176
  export function dumpsDefault(value: JsonValue): string {
178
177
  const parts: string[] = [];
179
178
  pushDefault(value, parts);
@@ -244,13 +243,9 @@ export function dumpsLen(value: JsonValue): number {
244
243
  }
245
244
 
246
245
  export function objectWithoutKey(obj: JsonObject, key: string): JsonObject {
247
- const out: JsonObject = {};
248
-
249
- for (const k of Object.keys(obj)) {
250
- if (k !== key) {
251
- out[k] = obj[k];
252
- }
253
- }
246
+ // Object spread creates data properties, including an own "__proto__" key.
247
+ const out: JsonObject = { ...obj };
248
+ delete out[key];
254
249
 
255
250
  return out;
256
251
  }
package/lib/pi-hook.ts CHANGED
@@ -5,13 +5,15 @@
5
5
  * from firstKeptEntryId. There is no separate head slot, so the verbatim
6
6
  * head (task) is folded into the summary text. Previous compaction
7
7
  * entries are skipped so we never compact a compaction: each pass
8
- * compresses only original live-session messages (Lt).
8
+ * compresses only original live-session messages (Lt), with the original
9
+ * head retained across cliffs. Multimodal heads fall back to Pi because a
10
+ * text summary cannot preserve them.
9
11
  */
10
12
 
11
- import { compact } from "./cliff.ts";
13
+ import { compact, type CompactResult } from "./cliff.ts";
12
14
  import { replaceConfig, type Config } from "./config.ts";
13
15
  import { isRecord, isString, type JsonObject } from "./decode.ts";
14
- import { SUMMARY_HEADER } from "./dialects/base.ts";
16
+ import { BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX, fitSummary, SUMMARY_HEADER } from "./dialects/base.ts";
15
17
  import { DIALECT as piDialect } from "./dialects/pi.ts";
16
18
  import { estimateTokens } from "./engine.ts";
17
19
 
@@ -20,14 +22,33 @@ export type SessionMessageRef = {
20
22
  message: JsonObject;
21
23
  };
22
24
 
25
+ export type CompactReason = "manual" | "threshold" | "overflow";
26
+
23
27
  export type CompactSessionInput = {
24
28
  live: SessionMessageRef[];
25
29
  tokensBefore: number;
26
30
  fallbackFirstKeptEntryId: string;
27
- reason: "manual" | "threshold" | "overflow";
31
+ reason: CompactReason;
28
32
  cfg: Config;
29
33
  };
30
34
 
35
+ /**
36
+ * Pi's session_before_compact event has no reason field. Auto-compaction
37
+ * emits auto_compaction_start first with threshold | overflow | idle |
38
+ * incomplete. Manual `/compact` never fires that start event.
39
+ */
40
+ export function resolveCompactReason(raw: unknown): CompactReason {
41
+ if (raw === "overflow" || raw === "incomplete") {
42
+ return "overflow";
43
+ }
44
+
45
+ if (raw === "threshold" || raw === "idle") {
46
+ return "threshold";
47
+ }
48
+
49
+ return "manual";
50
+ }
51
+
31
52
  export type CliffDetails = {
32
53
  version: number;
33
54
  method: "cliffcompaction";
@@ -39,6 +60,7 @@ export type CliffDetails = {
39
60
  liveMessages: number;
40
61
  keptMessages: number;
41
62
  estTokensOut: number;
63
+ overBudget: boolean;
42
64
  };
43
65
 
44
66
  export type CompactSessionOutput = {
@@ -77,27 +99,35 @@ function summaryBody(summary: JsonObject): string {
77
99
  return "";
78
100
  }
79
101
 
80
- function foldHeadIntoSummary(resultMessages: JsonObject[], headLen: number, body: string, cfg: Config): string {
81
- const parts: string[] = [];
82
- const headCfg = replaceConfig(cfg, { thoughtMaxChars: 0, thinkingMaxChars: 0, humanMaxChars: cfg.humanMaxChars });
102
+ function headText(message: JsonObject): string | null {
103
+ if (message.role !== "user" && message.role !== "system") return null;
83
104
 
84
- for (let i = 0; i < headLen; i++) {
85
- const piece = piDialect.summarizeMessage(resultMessages[i], headCfg);
105
+ if (isString(message.content)) return message.content;
86
106
 
87
- for (const p of piece) {
88
- parts.push(p);
89
- }
90
- }
107
+ if (!Array.isArray(message.content)) return null;
108
+ const parts: string[] = [];
91
109
 
92
- if (body) {
93
- parts.push(body);
110
+ for (const block of message.content) {
111
+ if (!isRecord(block) || block.type !== "text" || !isString(block.text)) return null;
112
+ parts.push(block.text);
94
113
  }
95
114
 
96
- if (parts.length === 0) {
97
- return SUMMARY_HEADER;
115
+ return parts.join("\n");
116
+ }
117
+
118
+ function foldHeadIntoSummary(resultMessages: JsonObject[], headLen: number, body: string): string {
119
+ const parts: string[] = [];
120
+
121
+ for (let i = 0; i < headLen; i++) {
122
+ const text = headText(resultMessages[i]);
123
+
124
+ if (text === null) throw new Error("protected head cannot be represented in a text summary");
125
+ parts.push(String(resultMessages[i].role) + ": " + text);
98
126
  }
99
127
 
100
- return SUMMARY_HEADER + "\n\n" + parts.join("\n\n---\n\n");
128
+ if (body) parts.push(body);
129
+
130
+ return parts.length === 0 ? SUMMARY_HEADER : SUMMARY_HEADER + "\n\n" + parts.join("\n\n---\n\n");
101
131
  }
102
132
 
103
133
  /**
@@ -116,71 +146,85 @@ export function compactSession(input: CompactSessionInput): CompactSessionOutput
116
146
  return null;
117
147
  }
118
148
 
149
+ const headLen = msgs.findIndex(msg => piDialect.isAssistant(msg));
150
+
151
+ if (headLen < 0) return null;
152
+ const head = msgs.slice(0, headLen);
153
+
154
+ // Pi stores a text summary plus one contiguous suffix. Let its default
155
+ // summarizer handle protected multimodal heads instead of dropping images.
156
+ if (head.some(message => headText(message) === null)) return null;
157
+
158
+ const compactLive = (knobs: Config): CompactResult | null => {
159
+ const value = compact(msgs.slice(headLen), piDialect, knobs);
160
+
161
+ if (value === null) return null;
162
+
163
+ return { ...value, messages: [...head, ...value.messages], headLen, cut: headLen + value.cut };
164
+ };
165
+
119
166
  const force = input.reason === "overflow";
167
+ const threshold = input.cfg.thresholdTokens;
120
168
  let rung = 0;
121
169
  let cfg = input.cfg;
122
- let result = compact(msgs, piDialect, cfg);
170
+ let result = compactLive(cfg);
123
171
 
124
- if (result === null && (force || input.reason === "threshold")) {
125
- rung = 1;
126
- cfg = replaceConfig(input.cfg, { keepRecent: 1 });
127
- result = compact(msgs, piDialect, cfg);
128
- }
172
+ const render = (value: CompactResult): string =>
173
+ foldHeadIntoSummary(value.messages, value.headLen, summaryBody(value.summary));
174
+
175
+ const tokens = (value: CompactResult, summary: string): number =>
176
+ estimateTokens({ messages: [piDialect.userMessage(summary), ...value.messages.slice(value.headLen + 1)] });
177
+
178
+ for (let step = 1; step <= 2; step++) {
179
+ const missing = result === null && (force || (step === 1 && input.reason === "threshold"));
180
+ // Overflow already failed the provider window. Do not wait for the library
181
+ // thresholdTokens (200k default) before tightening keepRecent / thoughts.
182
+ const oversized = result !== null && (force || (input.cfg.strict && tokens(result, render(result)) > threshold));
183
+
184
+ if (!missing && !oversized) {
185
+ break;
186
+ }
129
187
 
130
- if (result === null && force) {
131
- rung = 2;
132
188
  const cap = input.cfg.thoughtMaxChars;
133
- cfg = replaceConfig(input.cfg, {
134
- keepRecent: 1,
189
+
190
+ const keepRecent = Math.min(input.cfg.keepRecent, 1);
191
+ const knobs = replaceConfig(input.cfg, step === 1 ? { keepRecent } : {
192
+ keepRecent,
135
193
  thoughtMaxChars: cap <= 0 ? 300 : Math.min(cap, 300),
136
194
  keepThinking: false,
137
195
  });
138
- result = compact(msgs, piDialect, cfg);
196
+
197
+ const candidate = compactLive(knobs);
198
+
199
+ if (candidate !== null && (result === null || tokens(candidate, render(candidate)) <= tokens(result, render(result)))) {
200
+ result = candidate;
201
+ cfg = knobs;
202
+ rung = step;
203
+ }
139
204
  }
140
205
 
141
206
  if (result === null) {
142
207
  return null;
143
208
  }
144
209
 
145
- const body = summaryBody(result.summary);
146
- let summary = foldHeadIntoSummary(result.messages, result.headLen, body, input.cfg);
147
- const kept = result.messages.length - result.headLen - 1;
148
- const cut = result.cut;
149
- let firstKeptEntryId = input.fallbackFirstKeptEntryId;
210
+ let summary = render(result);
211
+ let est = tokens(result, summary);
150
212
 
151
- if (cut >= 0 && cut < input.live.length) {
152
- firstKeptEntryId = input.live[cut].entryId;
153
- }
154
-
155
- const outgoing: JsonObject = { messages: result.messages };
156
- let est = estimateTokens(outgoing);
157
-
158
- if (input.cfg.strict && est > input.cfg.thresholdTokens && rung < 3) {
213
+ if (input.cfg.strict && est > threshold) {
214
+ const prefix = foldHeadIntoSummary(result.messages, result.headLen, "");
215
+ const body = summaryBody(result.summary);
216
+ const parts = body ? body.split("\n\n---\n\n") : [];
217
+ const protectedResult = result;
218
+ summary = fitSummary(prefix, parts, (text) => tokens(protectedResult, text) <= threshold);
219
+ est = tokens(result, summary);
159
220
  rung = 3;
160
- const header = SUMMARY_HEADER;
161
- const rest = summary.slice(header.length).trim();
162
- const parts = rest.length > 0 ? rest.split("\n\n---\n\n") : [];
163
- const budget = input.cfg.thresholdTokens * 4 - 64 - header.length;
164
- const keptParts: string[] = [];
165
- let used = 0;
166
-
167
- for (let i = parts.length - 1; i >= 0; i--) {
168
- if (used + parts[i].length > Math.max(budget, 0)) {
169
- break;
170
- }
171
-
172
- keptParts.push(parts[i]);
173
- used += parts[i].length + 9;
174
- }
175
-
176
- keptParts.reverse();
177
- summary = keptParts.length > 0 ? header + "\n\n" + keptParts.join("\n\n---\n\n") : header;
178
- est = estimateTokens({ messages: [piDialect.userMessage(summary), ...result.messages.slice(result.headLen + 1)] });
179
221
  }
180
222
 
181
223
  return {
182
224
  summary,
183
- firstKeptEntryId,
225
+ // cut === live.length means keepRecent 0: no suffix. A found fallback id
226
+ // (Pi's preparation cut, often the head) would revive the compacted middle.
227
+ firstKeptEntryId: input.live[result.cut]?.entryId ?? "",
184
228
  tokensBefore: input.tokensBefore,
185
229
  rung,
186
230
  details: {
@@ -188,27 +232,47 @@ export function compactSession(input: CompactSessionInput): CompactSessionOutput
188
232
  method: "cliffcompaction",
189
233
  reason: input.reason,
190
234
  headLen: result.headLen,
191
- cut,
192
- keepRecent: input.cfg.keepRecent,
235
+ cut: result.cut,
236
+ keepRecent: cfg.keepRecent,
193
237
  rung,
194
238
  liveMessages: msgs.length,
195
- keptMessages: kept,
239
+ keptMessages: result.messages.length - result.headLen - 1,
196
240
  estTokensOut: est,
241
+ overBudget: est > threshold,
197
242
  },
198
243
  };
199
244
  }
200
245
 
201
246
  export type HookEntry = {
202
247
  id: string;
203
- kind: "message" | "compaction" | "other";
248
+ kind: "message" | "compaction" | "branch_summary" | "other";
204
249
  message?: JsonObject;
205
250
  firstKeptEntryId?: string;
251
+ summary?: string;
206
252
  };
207
253
 
254
+ function liveRefFromEntry(entry: HookEntry): SessionMessageRef | null {
255
+ if (entry.kind === "message" && entry.message) {
256
+ return { entryId: entry.id, message: entry.message };
257
+ }
258
+
259
+ // Pi puts /tree branch_summary entries in model context. Dropping them here
260
+ // would omit that text from both the mechanical recap and the kept suffix.
261
+ if (entry.kind === "branch_summary" && entry.summary) {
262
+ return {
263
+ entryId: entry.id,
264
+ message: { role: "user", content: BRANCH_SUMMARY_PREFIX + entry.summary + BRANCH_SUMMARY_SUFFIX },
265
+ };
266
+ }
267
+
268
+ return null;
269
+ }
270
+
208
271
  /**
209
- * Live session Lt: original messages from the previous cliff's
210
- * firstKeptEntryId (or the start of the session) forward. Compaction
211
- * entries themselves are skipped so prior summaries are discarded.
272
+ * Live session Lt: the original head plus original messages from the previous
273
+ * cliff's firstKeptEntryId forward, without duplicating overlap. Compaction
274
+ * entries themselves are skipped so prior summaries are discarded. /tree
275
+ * branch_summary entries stay, matching Pi's model context.
212
276
  */
213
277
  export function liveFromEntries(entries: HookEntry[]): SessionMessageRef[] {
214
278
  let start = 0;
@@ -239,13 +303,20 @@ export function liveFromEntries(entries: HookEntry[]): SessionMessageRef[] {
239
303
  start = idx >= 0 ? idx : i + 1;
240
304
  }
241
305
 
306
+ const firstAssistant = entries.findIndex((e) => {
307
+ const ref = liveRefFromEntry(e);
308
+
309
+ return ref !== null && piDialect.isAssistant(ref.message);
310
+ });
311
+ const headEnd = firstAssistant < 0 ? entries.length : firstAssistant;
242
312
  const live: SessionMessageRef[] = [];
243
313
 
244
- for (let i = start; i < entries.length; i++) {
245
- const e = entries[i];
314
+ for (let i = 0; i < entries.length; i++) {
315
+ if (i >= headEnd && i < start) continue;
316
+ const ref = liveRefFromEntry(entries[i]);
246
317
 
247
- if (e.kind === "message" && e.message) {
248
- live.push({ entryId: e.id, message: e.message });
318
+ if (ref) {
319
+ live.push(ref);
249
320
  }
250
321
  }
251
322
 
package/lib/store.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  * request extending prefix S (|S| = cut) is msgs[:headLen] + [summary] +
6
6
  * msgs[cut:]. Head bytes come from the CURRENT request. The store is a
7
7
  * cache: the compactor is deterministic, so lost entries are recomputed.
8
+ * Engine entries also carry the dialect and budget/configuration policy.
9
+ * The byte cap charges serialized UTF-8 summaries, not JavaScript heap overhead.
8
10
  */
9
11
 
10
- import { dumpsLen } from "./json.ts";
12
+ import { dumpsDefault } from "./json.ts";
11
13
  import type { JsonObject } from "./decode.ts";
12
14
 
13
15
  export type Entry = {
@@ -15,6 +17,8 @@ export type Entry = {
15
17
  summary: JsonObject;
16
18
  cut: number;
17
19
  size: number;
20
+ policy?: string;
21
+ dialect?: object;
18
22
  };
19
23
 
20
24
  export function makeEntry(headLen: number, summary: JsonObject, cut: number): Entry {
@@ -22,7 +26,11 @@ export function makeEntry(headLen: number, summary: JsonObject, cut: number): En
22
26
  }
23
27
 
24
28
  export function entrySize(summary: JsonObject): number {
25
- return dumpsLen(summary);
29
+ try {
30
+ return Buffer.byteLength(dumpsDefault(summary), "utf8");
31
+ } catch {
32
+ return 0;
33
+ }
26
34
  }
27
35
 
28
36
  export class PrefixStore {
@@ -48,7 +56,12 @@ export class PrefixStore {
48
56
  }
49
57
 
50
58
  put(chainHash: string, entry: Entry): void {
51
- entry.size = entrySize(entry.summary);
59
+ const size = entrySize(entry.summary);
60
+
61
+ if (this.maxEntries <= 0 || this.maxBytes <= 0 || size > this.maxBytes) {
62
+ return;
63
+ }
64
+
52
65
  const old = this.data.get(chainHash);
53
66
 
54
67
  if (old !== undefined) {
@@ -56,10 +69,11 @@ export class PrefixStore {
56
69
  this.bytes -= old.size;
57
70
  }
58
71
 
72
+ entry.size = size;
59
73
  this.data.set(chainHash, entry);
60
74
  this.bytes += entry.size;
61
75
 
62
- while (this.data.size > 1 && (this.data.size > this.maxEntries || this.bytes > this.maxBytes)) {
76
+ while (this.data.size > 0 && (this.data.size > this.maxEntries || this.bytes > this.maxBytes)) {
63
77
  const first = this.data.keys().next().value;
64
78
 
65
79
  if (first === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cliffcompaction",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CliffCompaction for Pi/OMP: mechanical autocompaction. Truncate or drop, never rephrase, never compact a compaction. Port of arXiv:2609.26779.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -36,8 +36,8 @@
36
36
  "config.example.json"
37
37
  ],
38
38
  "scripts": {
39
- "test": "node --test test/*.test.mjs",
40
- "prepublishOnly": "npm test && node ../../scripts/preflight.mjs"
39
+ "test": "node --test tests/*.test.mjs",
40
+ "prepublishOnly": "node ../../scripts/preflight.mjs"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [