pi-cliffcompaction 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/lib/engine.ts ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Request pipeline: match stored prefixes, substitute, compact over threshold.
3
+ *
4
+ * Clients resend the ORIGINAL history each request, so the store is keyed by
5
+ * original-prefix chain hashes; compaction runs on the substituted sequence
6
+ * and results are stored under the longer original prefix.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { compact, type CompactResult } from "./cliff.ts";
11
+ import { replaceConfig, type Config, type ConfigPatch } from "./config.ts";
12
+ import {
13
+ isArray,
14
+ isRecord,
15
+ isString,
16
+ type JsonObject,
17
+ type JsonValue,
18
+ } from "./decode.ts";
19
+ import { SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
20
+ import { digestBytes } from "./hashing.ts";
21
+ import { dumpsCount, dumpsDefault, dumpsLen, objectWithoutKey } from "./json.ts";
22
+ import { imagePayloadFromNode, tokensForPayload } from "./images.ts";
23
+ import { makeEntry, PrefixStore } from "./store.ts";
24
+
25
+ export function summaryFingerprint(summary: JsonObject): string {
26
+ const content = summary.content;
27
+ const text = isString(content) ? content : dumpsDefault(content ?? "");
28
+
29
+ return createHash("sha256").update(text).digest("hex").slice(0, 8);
30
+ }
31
+
32
+ /**
33
+ * Serialized length of `obj`, with image payloads priced by dimensions.
34
+ * The unit is characters so callers can compare against a chars/4 budget:
35
+ * an image contributes its estimated tokens x4 instead of its base64 length.
36
+ */
37
+ export function billableChars(obj: JsonValue): number {
38
+ const payloads: string[] = [];
39
+ let chars = 0;
40
+
41
+ try {
42
+ chars = dumpsCount(obj, (rec) => {
43
+ const payload = imagePayloadFromNode(rec);
44
+
45
+ if (payload !== null) {
46
+ payloads.push(payload);
47
+ }
48
+ });
49
+ } catch {
50
+ return 0;
51
+ }
52
+
53
+ for (const payload of payloads) {
54
+ chars -= payload.length;
55
+ chars += tokensForPayload(payload) * 4;
56
+ }
57
+
58
+ return Math.max(chars, 0);
59
+ }
60
+
61
+ export function estimateTokens(body: JsonValue): number {
62
+ return Math.trunc(billableChars(body) / 4);
63
+ }
64
+
65
+ export type RequestCtx = {
66
+ body: JsonObject;
67
+ dialect: Dialect;
68
+ msgs: JsonObject[];
69
+ chain: string[];
70
+ baseCut: number;
71
+ baseHead: number;
72
+ substituted: JsonObject[];
73
+ modified: boolean;
74
+ compacted: boolean;
75
+ rung: number;
76
+ overBudget: boolean;
77
+ estTokensIn: number;
78
+ estTokensOut: number;
79
+ chainSteps: number;
80
+ summaryFp: string;
81
+ outMsgs: number;
82
+ };
83
+
84
+ function outgoingBody(ctx: RequestCtx): JsonObject {
85
+ if (!ctx.modified) {
86
+ return ctx.body;
87
+ }
88
+
89
+ const out: JsonObject = {};
90
+
91
+ for (const key of Object.keys(ctx.body)) {
92
+ out[key] = ctx.body[key];
93
+ }
94
+
95
+ out[ctx.dialect.messagesKey] = ctx.substituted;
96
+
97
+ return out;
98
+ }
99
+
100
+ export function ctxOutgoingBody(ctx: RequestCtx): JsonObject {
101
+ return outgoingBody(ctx);
102
+ }
103
+
104
+ function messagesOf(body: JsonObject, dialect: Dialect): JsonObject[] {
105
+ const raw = body[dialect.messagesKey];
106
+ const out: JsonObject[] = [];
107
+
108
+ if (!isArray(raw)) {
109
+ return out;
110
+ }
111
+
112
+ for (const m of raw) {
113
+ if (isRecord(m)) {
114
+ out.push(m);
115
+ }
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ function summaryText(msg: JsonObject): string {
122
+ const content = msg.content;
123
+
124
+ if (isString(content)) {
125
+ return content;
126
+ }
127
+
128
+ if (isArray(content)) {
129
+ for (const b of content) {
130
+ if (isRecord(b) && isString(b.text)) {
131
+ return b.text;
132
+ }
133
+ }
134
+ }
135
+
136
+ return "";
137
+ }
138
+
139
+ function msgChars(msg: JsonObject): number {
140
+ return billableChars(msg) + 2;
141
+ }
142
+
143
+ export function messageChars(msg: JsonObject): number {
144
+ return msgChars(msg);
145
+ }
146
+
147
+ type PrefixDigestState = {
148
+ digests: string[];
149
+ chain: string[];
150
+ shared: number;
151
+ };
152
+
153
+ export class Engine {
154
+ readonly cfg: Config;
155
+ readonly store: PrefixStore;
156
+ private prefixMsgs: JsonObject[] = [];
157
+ private prefixDigests: string[] = [];
158
+ private prefixChain: string[] = [];
159
+ private prefixDialect: Dialect | null = null;
160
+ private prefixArrayBillable = 0;
161
+
162
+ constructor(cfg: Config, store?: PrefixStore) {
163
+ this.cfg = cfg;
164
+ this.store = store ?? new PrefixStore(cfg.storeMaxEntries, cfg.storeMaxBytes);
165
+ }
166
+
167
+ private digestGrowingPrefix(msgs: JsonObject[], dialect: Dialect): PrefixDigestState {
168
+ let shared = 0;
169
+
170
+ if (this.prefixDialect === dialect) {
171
+ const limit = Math.min(msgs.length, this.prefixMsgs.length);
172
+
173
+ while (shared < limit && msgs[shared] === this.prefixMsgs[shared]) {
174
+ shared += 1;
175
+ }
176
+ }
177
+
178
+ const digests = this.prefixDigests.slice(0, shared);
179
+
180
+ for (let i = shared; i < msgs.length; i++) {
181
+ digests.push(dialect.digestMessage(msgs[i]));
182
+ }
183
+
184
+ const chain = this.prefixChain.slice(0, shared);
185
+ let prev = shared > 0 ? chain[shared - 1] : "";
186
+
187
+ for (let i = shared; i < msgs.length; i++) {
188
+ prev = digestBytes(prev + digests[i]);
189
+ chain.push(prev);
190
+ }
191
+
192
+ this.prefixMsgs = msgs;
193
+ this.prefixDigests = digests;
194
+ this.prefixChain = chain;
195
+ this.prefixDialect = dialect;
196
+
197
+ return { digests, chain, shared };
198
+ }
199
+
200
+ private messagesArrayBillable(msgs: JsonObject[], shared: number, prevLen: number, prevBillable: number): number {
201
+ if (shared > 0 && shared === prevLen && prevBillable > 0) {
202
+ let n = prevBillable;
203
+
204
+ for (let i = shared; i < msgs.length; i++) {
205
+ n += 2 + billableChars(msgs[i]);
206
+ }
207
+
208
+ this.prefixArrayBillable = n;
209
+
210
+ return n;
211
+ }
212
+
213
+ const n = billableChars(msgs);
214
+ this.prefixArrayBillable = n;
215
+
216
+ return n;
217
+ }
218
+
219
+ prepare(body: JsonObject, dialect: Dialect): RequestCtx {
220
+ const msgs = messagesOf(body, dialect);
221
+ const prevLen = this.prefixMsgs.length;
222
+ const prevBillable = this.prefixArrayBillable;
223
+ const { chain, shared } = this.digestGrowingPrefix(msgs, dialect);
224
+ const emptyBody = objectWithoutKey(body, dialect.messagesKey);
225
+ emptyBody[dialect.messagesKey] = [];
226
+ const msgsBillable = this.messagesArrayBillable(msgs, shared, prevLen, prevBillable);
227
+ const estTokensIn = Math.trunc(Math.max(billableChars(emptyBody) - 2 + msgsBillable, 0) / 4);
228
+
229
+ const ctx: RequestCtx = {
230
+ body,
231
+ dialect,
232
+ msgs,
233
+ chain,
234
+ baseCut: 0,
235
+ baseHead: 0,
236
+ substituted: msgs,
237
+ modified: false,
238
+ compacted: false,
239
+ rung: 0,
240
+ overBudget: false,
241
+ estTokensIn,
242
+ chainSteps: 0,
243
+ summaryFp: "",
244
+ outMsgs: 0,
245
+ };
246
+
247
+ for (let i = msgs.length - 1; i >= 0; i--) {
248
+ const entry = this.store.get(chain[i]);
249
+
250
+ if (entry === undefined) {
251
+ continue;
252
+ }
253
+
254
+ if (entry.cut !== i + 1 || entry.headLen > entry.cut) {
255
+ break;
256
+ }
257
+
258
+ const substituted: JsonObject[] = [];
259
+
260
+ for (let h = 0; h < entry.headLen; h++) {
261
+ substituted.push(msgs[h]);
262
+ }
263
+
264
+ substituted.push(entry.summary);
265
+
266
+ for (let t = entry.cut; t < msgs.length; t++) {
267
+ substituted.push(msgs[t]);
268
+ }
269
+
270
+ ctx.baseCut = entry.cut;
271
+ ctx.baseHead = entry.headLen;
272
+ ctx.substituted = substituted;
273
+ ctx.modified = true;
274
+ break;
275
+ }
276
+
277
+ ctx.estTokensOut = ctx.modified ? estimateTokens(outgoingBody(ctx)) : ctx.estTokensIn;
278
+
279
+ if (ctx.estTokensOut > this.cfg.thresholdTokens) {
280
+ this.compactChain(ctx, "proactive", false, null);
281
+ const rungs = this.cfg.strict ? [1, 2, 3] : [1, 2];
282
+
283
+ for (const rung of rungs) {
284
+ if (ctx.estTokensOut <= this.cfg.thresholdTokens) {
285
+ break;
286
+ }
287
+
288
+ if (rung === 3) {
289
+ if (this.truncateSummary(ctx, "strict")) {
290
+ ctx.rung = 3;
291
+ }
292
+ } else if (this.compactChain(ctx, "escalated rung" + rung, true, this.rungCfg(rung))) {
293
+ ctx.rung = rung;
294
+ }
295
+ }
296
+
297
+ if (ctx.estTokensOut > this.cfg.thresholdTokens) {
298
+ ctx.overBudget = true;
299
+ }
300
+ }
301
+
302
+ return ctx;
303
+ }
304
+
305
+ /**
306
+ * Called on an upstream context-length error. Walks the escalation
307
+ * ladder one rung per call; returns true if the request should be
308
+ * replayed, false when out of options.
309
+ */
310
+ reactive(ctx: RequestCtx): boolean {
311
+ if (!ctx.compacted && ctx.rung === 0) {
312
+ if (this.compactChain(ctx, "reactive", true, null)) {
313
+ return true;
314
+ }
315
+ }
316
+
317
+ while (ctx.rung < 3) {
318
+ ctx.rung += 1;
319
+
320
+ if (ctx.rung < 3) {
321
+ if (this.compactChain(ctx, "reactive rung" + ctx.rung, true, this.rungCfg(ctx.rung))) {
322
+ return true;
323
+ }
324
+ } else if (this.truncateSummary(ctx, "reactive")) {
325
+ return true;
326
+ }
327
+ }
328
+
329
+ return false;
330
+ }
331
+
332
+ private rungCfg(rung: number): Config {
333
+ const patch: ConfigPatch = {
334
+ keepRecent: 1,
335
+ };
336
+
337
+ if (rung >= 2) {
338
+ const cap = this.cfg.thoughtMaxChars;
339
+ patch.thoughtMaxChars = cap <= 0 ? 300 : Math.min(cap, 300);
340
+ patch.keepThinking = false;
341
+ }
342
+
343
+ return replaceConfig(this.cfg, patch);
344
+ }
345
+
346
+ private truncateSummary(ctx: RequestCtx, reason = "reactive"): boolean {
347
+ if (!ctx.compacted || ctx.baseCut <= 0) {
348
+ return false;
349
+ }
350
+
351
+ const idx = ctx.baseHead;
352
+ const old = ctx.substituted[idx];
353
+ const text = summaryText(old);
354
+
355
+ if (!text.startsWith(SUMMARY_HEADER)) {
356
+ return false;
357
+ }
358
+
359
+ const others: JsonObject[] = [];
360
+
361
+ for (let i = 0; i < ctx.substituted.length; i++) {
362
+ if (i !== idx) {
363
+ others.push(ctx.substituted[i]);
364
+ }
365
+ }
366
+
367
+ const bodyCopy: JsonObject = {};
368
+
369
+ for (const key of Object.keys(ctx.body)) {
370
+ bodyCopy[key] = ctx.body[key];
371
+ }
372
+
373
+ bodyCopy[ctx.dialect.messagesKey] = others;
374
+ const fixed = dumpsLen(bodyCopy);
375
+ const budget = this.cfg.thresholdTokens * 4 - fixed - SUMMARY_HEADER.length - 64;
376
+ const rest = text.slice(SUMMARY_HEADER.length).trim();
377
+ const parts = rest.length > 0 ? rest.split("\n\n---\n\n") : [];
378
+ const kept: string[] = [];
379
+ let used = 0;
380
+
381
+ for (let i = parts.length - 1; i >= 0; i--) {
382
+ const part = parts[i];
383
+
384
+ if (used + part.length > Math.max(budget, 0)) {
385
+ break;
386
+ }
387
+
388
+ kept.push(part);
389
+ used += part.length + 9;
390
+ }
391
+
392
+ kept.reverse();
393
+
394
+ const newText =
395
+ kept.length > 0 ? SUMMARY_HEADER + "\n\n" + kept.join("\n\n---\n\n") : SUMMARY_HEADER;
396
+
397
+ if (newText.length >= text.length) {
398
+ return false;
399
+ }
400
+
401
+ const newSummary = ctx.dialect.userMessage(newText);
402
+ const substituted: JsonObject[] = [];
403
+
404
+ for (let i = 0; i < idx; i++) {
405
+ substituted.push(ctx.substituted[i]);
406
+ }
407
+
408
+ substituted.push(newSummary);
409
+
410
+ for (let i = idx + 1; i < ctx.substituted.length; i++) {
411
+ substituted.push(ctx.substituted[i]);
412
+ }
413
+
414
+ ctx.substituted = substituted;
415
+ ctx.modified = true;
416
+ ctx.estTokensOut = estimateTokens(outgoingBody(ctx));
417
+ this.store.put(
418
+ ctx.chain[ctx.baseCut - 1],
419
+ makeEntry(ctx.baseHead, newSummary, ctx.baseCut),
420
+ );
421
+ void reason;
422
+
423
+ return true;
424
+ }
425
+
426
+ private compactChain(
427
+ ctx: RequestCtx,
428
+ reason: string,
429
+ force: boolean,
430
+ compactCfg: Config | null,
431
+ ): boolean {
432
+ const cfg = this.cfg;
433
+ const knobs = compactCfg ?? cfg;
434
+ const msgs = ctx.msgs;
435
+ const emptyBody = objectWithoutKey(ctx.body, ctx.dialect.messagesKey);
436
+ emptyBody[ctx.dialect.messagesKey] = [];
437
+ const fixedChars = billableChars(emptyBody);
438
+ const thresholdChars = cfg.thresholdTokens * 4;
439
+
440
+ let working: JsonObject[];
441
+ let headLen: number;
442
+ let origCut: number;
443
+ let haveSummary: boolean;
444
+
445
+ if (ctx.baseCut > 0) {
446
+ working = ctx.substituted.slice(0, ctx.baseHead + 1);
447
+ headLen = ctx.baseHead;
448
+ origCut = ctx.baseCut;
449
+ haveSummary = true;
450
+ } else {
451
+ working = [];
452
+ headLen = 0;
453
+ origCut = 0;
454
+ haveSummary = false;
455
+ }
456
+
457
+ const charCache = new Map();
458
+
459
+ const cachedMsgChars = (m: JsonObject): number => {
460
+ const hit = charCache.get(m);
461
+
462
+ if (hit !== undefined) {
463
+ return hit;
464
+ }
465
+
466
+ const n = msgChars(m);
467
+ charCache.set(m, n);
468
+
469
+ return n;
470
+ };
471
+
472
+ let chars = fixedChars;
473
+
474
+ for (const m of working) {
475
+ chars += cachedMsgChars(m);
476
+ }
477
+
478
+ let lastSummary: JsonObject | null = null;
479
+ let nCompactions = 0;
480
+
481
+ const apply = (result: CompactResult): boolean => {
482
+ let newCut: number;
483
+
484
+ if (haveSummary) {
485
+ if (result.cut < headLen + 1) {
486
+ return false;
487
+ }
488
+
489
+ newCut = origCut + (result.cut - (headLen + 1));
490
+ } else {
491
+ newCut = result.cut;
492
+ }
493
+
494
+ if (!(1 <= newCut && newCut <= msgs.length)) {
495
+ return false;
496
+ }
497
+
498
+ working = result.messages;
499
+ chars = fixedChars;
500
+
501
+ for (const m of working) {
502
+ chars += cachedMsgChars(m);
503
+ }
504
+
505
+ headLen = result.headLen;
506
+ origCut = newCut;
507
+ haveSummary = true;
508
+ lastSummary = result.summary;
509
+ nCompactions += 1;
510
+
511
+ return true;
512
+ };
513
+
514
+ for (let i = origCut; i < msgs.length; i++) {
515
+ working.push(msgs[i]);
516
+ chars += cachedMsgChars(msgs[i]);
517
+
518
+ if (chars > thresholdChars) {
519
+ const result = compact(working, ctx.dialect, knobs);
520
+
521
+ if (result === null) {
522
+ continue;
523
+ }
524
+
525
+ if (!apply(result)) {
526
+ return nCompactions > 0;
527
+ }
528
+ }
529
+ }
530
+
531
+ if (nCompactions === 0 && force) {
532
+ const result = compact(working, ctx.dialect, knobs);
533
+
534
+ if (result !== null) {
535
+ apply(result);
536
+ }
537
+ }
538
+
539
+ if (nCompactions === 0) {
540
+ void reason;
541
+
542
+ return false;
543
+ }
544
+
545
+ ctx.substituted = working;
546
+ ctx.baseCut = origCut;
547
+ ctx.baseHead = headLen;
548
+ ctx.modified = true;
549
+ ctx.compacted = true;
550
+ ctx.estTokensOut = estimateTokens(outgoingBody(ctx));
551
+ ctx.chainSteps = nCompactions;
552
+ ctx.summaryFp = lastSummary ? summaryFingerprint(lastSummary) : "";
553
+ ctx.outMsgs = working.length;
554
+ this.store.put(ctx.chain[origCut - 1], makeEntry(headLen, lastSummary ?? {}, origCut));
555
+
556
+ return true;
557
+ }
558
+ }
package/lib/hashing.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Canonical digests and the prefix hash chain.
3
+ *
4
+ * h_0 = H(digest_0); h_i = H(h_{i-1} || digest_i). chain[i] identifies
5
+ * messages[0..i] independent of volatile serialization details, so prefix
6
+ * identity is a single dict lookup.
7
+ */
8
+
9
+ import { hash } from "node:crypto";
10
+ import { canonicalJson } from "./json.ts";
11
+ import type { JsonValue } from "./decode.ts";
12
+
13
+ export function digestBytes(data: Uint8Array | string): string {
14
+ return hash("sha256", data, "hex");
15
+ }
16
+
17
+ export function digestObj(obj: JsonValue): string {
18
+ return digestBytes(canonicalJson(obj));
19
+ }
20
+
21
+ export function chainHashes(messageDigests: string[]): string[] {
22
+ const chain: string[] = [];
23
+ let prev = "";
24
+
25
+ for (const d of messageDigests) {
26
+ prev = digestBytes(prev + d);
27
+ chain.push(prev);
28
+ }
29
+
30
+ return chain;
31
+ }