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/images.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Token estimation for image blocks.
3
+ *
4
+ * Text estimates at chars/4. Images do not: the block is base64 of a
5
+ * compressed file, and token cost is set by dimensions. Formula is
6
+ * Anthropic's 28x28-pixel patches, capped at the high-resolution tier.
7
+ * Overcounting is deliberate: compact a little early rather than send a
8
+ * request the provider refuses.
9
+ */
10
+
11
+ import {
12
+ asObject,
13
+ asString,
14
+ isArray,
15
+ isRecord,
16
+ isString,
17
+ type JsonObject,
18
+ type JsonValue,
19
+ } from "./decode.ts";
20
+
21
+ export const PATCH_PX = 28;
22
+
23
+ export const MAX_IMAGE_TOKENS = 4_784;
24
+
25
+ export const DEFAULT_IMAGE_TOKENS = 1_568;
26
+
27
+ const HEADER_B64_CHARS = 8192;
28
+
29
+ function readUInt32BE(raw: Uint8Array, offset: number): number {
30
+ return (
31
+ ((raw[offset] << 24) | (raw[offset + 1] << 16) | (raw[offset + 2] << 8) | raw[offset + 3]) >>> 0
32
+ );
33
+ }
34
+
35
+ function readUInt16BE(raw: Uint8Array, offset: number): number {
36
+ return (raw[offset] << 8) | raw[offset + 1];
37
+ }
38
+
39
+ function readUInt16LE(raw: Uint8Array, offset: number): number {
40
+ return raw[offset] | (raw[offset + 1] << 8);
41
+ }
42
+
43
+ function startsWith(raw: Uint8Array, bytes: number[], offset = 0): boolean {
44
+ if (raw.length < offset + bytes.length) {
45
+ return false;
46
+ }
47
+
48
+ for (let i = 0; i < bytes.length; i++) {
49
+ if (raw[offset + i] !== bytes[i]) {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ return true;
55
+ }
56
+
57
+ function pngDimensions(raw: Uint8Array): [number, number] | null {
58
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
59
+
60
+ if (!startsWith(raw, sig) || raw.length < 24) {
61
+ return null;
62
+ }
63
+
64
+ const width = readUInt32BE(raw, 16);
65
+ const height = readUInt32BE(raw, 20);
66
+
67
+ return [width, height];
68
+ }
69
+
70
+ function gifDimensions(raw: Uint8Array): [number, number] | null {
71
+ const gif87 = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
72
+ const gif89 = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
73
+
74
+ if ((!startsWith(raw, gif87) && !startsWith(raw, gif89)) || raw.length < 10) {
75
+ return null;
76
+ }
77
+
78
+ return [readUInt16LE(raw, 6), readUInt16LE(raw, 8)];
79
+ }
80
+
81
+ function jpegDimensions(raw: Uint8Array): [number, number] | null {
82
+ if (raw.length < 2 || raw[0] !== 0xff || raw[1] !== 0xd8) {
83
+ return null;
84
+ }
85
+
86
+ let i = 2;
87
+ const n = raw.length;
88
+
89
+ while (i + 9 < n) {
90
+ if (raw[i] !== 0xff) {
91
+ i += 1;
92
+ continue;
93
+ }
94
+
95
+ const marker = raw[i + 1];
96
+
97
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
98
+ i += 2;
99
+ continue;
100
+ }
101
+
102
+ const segLen = readUInt16BE(raw, i + 2);
103
+
104
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
105
+ const height = readUInt16BE(raw, i + 5);
106
+ const width = readUInt16BE(raw, i + 9 - 2);
107
+
108
+ return [width, height];
109
+ }
110
+
111
+ if (segLen < 2) {
112
+ return null;
113
+ }
114
+
115
+ i += 2 + segLen;
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ function webpDimensions(raw: Uint8Array): [number, number] | null {
122
+ const riff = [0x52, 0x49, 0x46, 0x46];
123
+ const webp = [0x57, 0x45, 0x42, 0x50];
124
+
125
+ if (!startsWith(raw, riff) || !startsWith(raw, webp, 8) || raw.length < 30) {
126
+ return null;
127
+ }
128
+
129
+ const fmt = String.fromCharCode(raw[12], raw[13], raw[14], raw[15]);
130
+
131
+ if (fmt === "VP8X") {
132
+ const w = (raw[24] | (raw[25] << 8) | (raw[26] << 16)) + 1;
133
+ const h = (raw[27] | (raw[28] << 8) | (raw[29] << 16)) + 1;
134
+
135
+ return [w, h];
136
+ }
137
+
138
+ if (fmt === "VP8 ") {
139
+ return [readUInt16LE(raw, 26) & 0x3fff, readUInt16LE(raw, 28) & 0x3fff];
140
+ }
141
+
142
+ if (fmt === "VP8L" && raw.length >= 25) {
143
+ const bits = raw[21] | (raw[22] << 8) | (raw[23] << 16) | (raw[24] << 24);
144
+
145
+ return [(bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1];
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ export function dimensions(raw: Uint8Array): [number, number] | null {
152
+ const readers = [pngDimensions, jpegDimensions, gifDimensions, webpDimensions];
153
+
154
+ for (const reader of readers) {
155
+ try {
156
+ const dims = reader(raw);
157
+
158
+ if (dims && dims[0] > 0 && dims[1] > 0) {
159
+ return dims;
160
+ }
161
+ } catch {
162
+ continue;
163
+ }
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ function headerBytes(payload: string): Uint8Array | null {
170
+ const comma = payload.startsWith("data:") ? payload.indexOf(",") : -1;
171
+ let b64 = comma >= 0 ? payload.slice(comma + 1) : payload;
172
+ b64 = b64.slice(0, HEADER_B64_CHARS);
173
+ b64 = b64.slice(0, b64.length - (b64.length % 4));
174
+
175
+ if (!b64) {
176
+ return null;
177
+ }
178
+
179
+ try {
180
+ return Buffer.from(b64, "base64");
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function ceilDiv(n: number, d: number): number {
187
+ return Math.trunc((n + d - 1) / d);
188
+ }
189
+
190
+ export function tokensForPayload(payload: string): number {
191
+ if (!payload.startsWith("data:")) {
192
+ return DEFAULT_IMAGE_TOKENS;
193
+ }
194
+
195
+ const raw = headerBytes(payload);
196
+ const dims = raw ? dimensions(raw) : null;
197
+
198
+ if (dims === null) {
199
+ return DEFAULT_IMAGE_TOKENS;
200
+ }
201
+
202
+ const patches = ceilDiv(dims[0], PATCH_PX) * ceilDiv(dims[1], PATCH_PX);
203
+
204
+ return Math.max(1, Math.min(patches, MAX_IMAGE_TOKENS));
205
+ }
206
+
207
+ export function imagePayloadFromNode(obj: JsonObject): string | null {
208
+ const kind = asString(obj.type);
209
+
210
+ if (kind === "image") {
211
+ const source = asObject(obj.source);
212
+
213
+ if (source && isString(source.data)) {
214
+ return source.data;
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ if (kind === "input_image" || kind === "image_url") {
221
+ const urlField = obj.image_url;
222
+
223
+ if (isString(urlField)) {
224
+ return urlField;
225
+ }
226
+
227
+ const nested = asObject(urlField);
228
+
229
+ if (nested && isString(nested.url)) {
230
+ return nested.url;
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ return null;
237
+ }
238
+
239
+ export function imagePayloads(obj: JsonValue): string[] {
240
+ const out: string[] = [];
241
+ collectPayloads(obj, out);
242
+
243
+ return out;
244
+ }
245
+
246
+ function collectPayloads(obj: JsonValue, out: string[]): void {
247
+ if (isRecord(obj)) {
248
+ const payload = imagePayloadFromNode(obj);
249
+
250
+ if (payload !== null) {
251
+ out.push(payload);
252
+
253
+ return;
254
+ }
255
+
256
+ for (const key of Object.keys(obj)) {
257
+ collectPayloads(obj[key], out);
258
+ }
259
+
260
+ return;
261
+ }
262
+
263
+ if (isArray(obj)) {
264
+ for (const item of obj) {
265
+ collectPayloads(item, out);
266
+ }
267
+ }
268
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CliffCompaction library: mechanical, high-precision context compaction.
3
+ *
4
+ * Nguyen, Cho, Chen & Dettmers, arXiv:2609.26779.
5
+ */
6
+
7
+ export { makeConfig, replaceConfig, parseConfig, loadConfig, configFromEnv, DEFAULT_CONFIG } from "./config.ts";
8
+
9
+ export type { Config, ConfigPatch } from "./config.ts";
10
+
11
+ export { compact, groupTurns } from "./cliff.ts";
12
+
13
+ export type { CompactResult } from "./cliff.ts";
14
+
15
+ export { Engine, estimateTokens, billableChars, messageChars, ctxOutgoingBody, summaryFingerprint } from "./engine.ts";
16
+
17
+ export type { RequestCtx } from "./engine.ts";
18
+
19
+ export { PrefixStore, makeEntry, entrySize } from "./store.ts";
20
+
21
+ export type { Entry } from "./store.ts";
22
+
23
+ export { chainHashes, digestObj, digestBytes } from "./hashing.ts";
24
+
25
+ export { canonicalJson, dumpsDefault } from "./json.ts";
26
+
27
+ export {
28
+ dimensions,
29
+ tokensForPayload,
30
+ imagePayloads,
31
+ MAX_IMAGE_TOKENS,
32
+ DEFAULT_IMAGE_TOKENS,
33
+ PATCH_PX,
34
+ } from "./images.ts";
35
+
36
+ export {
37
+ detect,
38
+ anthropicDialect,
39
+ openaiChatDialect,
40
+ openaiResponsesDialect,
41
+ piDialect,
42
+ SUMMARY_HEADER,
43
+ PI_COMPACTION_PREFIX,
44
+ truncate,
45
+ stripTaskNotifications,
46
+ } from "./dialects/index.ts";
47
+
48
+ export type { Dialect } from "./dialects/index.ts";
49
+
50
+ export { compactSession, liveFromEntries } from "./pi-hook.ts";
51
+
52
+ export type {
53
+ CompactSessionInput,
54
+ CompactSessionOutput,
55
+ SessionMessageRef,
56
+ HookEntry,
57
+ CliffDetails,
58
+ } from "./pi-hook.ts";
59
+
60
+ export type { JsonObject, JsonValue, JsonArray } from "./decode.ts";
61
+
62
+ export { isString, isRecord, isArray, asString, asObject, asArray } from "./decode.ts";
package/lib/json.ts ADDED
@@ -0,0 +1,256 @@
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.
6
+ *
7
+ * dumpsLen counts without building the string (same length as dumpsDefault).
8
+ */
9
+
10
+ import {
11
+ isArray,
12
+ isBoolean,
13
+ isNull,
14
+ isNumber,
15
+ isRecord,
16
+ isString,
17
+ type JsonObject,
18
+ type JsonValue,
19
+ } from "./decode.ts";
20
+
21
+ const KEY_JSON_LEN = new Map();
22
+
23
+ function emitString(value: string): string {
24
+ return JSON.stringify(value);
25
+ }
26
+
27
+ function keyJsonLen(key: string): number {
28
+ const hit = KEY_JSON_LEN.get(key);
29
+
30
+ if (hit !== undefined) {
31
+ return hit;
32
+ }
33
+
34
+ const n = emitString(key).length;
35
+ KEY_JSON_LEN.set(key, n);
36
+
37
+ return n;
38
+ }
39
+
40
+ function emitNumber(value: number): string {
41
+ return JSON.stringify(value);
42
+ }
43
+
44
+ function pushCanonical(value: JsonValue, parts: { push(chunk: string): void }): void {
45
+ if (isNull(value)) {
46
+ parts.push("null");
47
+
48
+ return;
49
+ }
50
+
51
+ if (isBoolean(value)) {
52
+ parts.push(value ? "true" : "false");
53
+
54
+ return;
55
+ }
56
+
57
+ if (isNumber(value)) {
58
+ parts.push(emitNumber(value));
59
+
60
+ return;
61
+ }
62
+
63
+ if (isString(value)) {
64
+ parts.push(emitString(value));
65
+
66
+ return;
67
+ }
68
+
69
+ if (isArray(value)) {
70
+ parts.push("[");
71
+
72
+ for (let i = 0; i < value.length; i++) {
73
+ if (i !== 0) {
74
+ parts.push(",");
75
+ }
76
+
77
+ pushCanonical(value[i], parts);
78
+ }
79
+
80
+ parts.push("]");
81
+
82
+ return;
83
+ }
84
+
85
+ if (!isRecord(value)) {
86
+ throw new TypeError("canonicalJson: not JSON");
87
+ }
88
+
89
+ const keys = Object.keys(value).sort();
90
+ parts.push("{");
91
+
92
+ for (let i = 0; i < keys.length; i++) {
93
+ if (i !== 0) {
94
+ parts.push(",");
95
+ }
96
+
97
+ const key = keys[i];
98
+ parts.push(emitString(key));
99
+ parts.push(":");
100
+ pushCanonical(value[key], parts);
101
+ }
102
+
103
+ parts.push("}");
104
+ }
105
+
106
+ /** json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) */
107
+ export function canonicalJson(value: JsonValue): string {
108
+ const parts: string[] = [];
109
+ pushCanonical(value, parts);
110
+
111
+ return parts.join("");
112
+ }
113
+
114
+ function pushDefault(value: JsonValue, parts: string[]): void {
115
+ if (isNull(value)) {
116
+ parts.push("null");
117
+
118
+ return;
119
+ }
120
+
121
+ if (isBoolean(value)) {
122
+ parts.push(value ? "true" : "false");
123
+
124
+ return;
125
+ }
126
+
127
+ if (isNumber(value)) {
128
+ parts.push(emitNumber(value));
129
+
130
+ return;
131
+ }
132
+
133
+ if (isString(value)) {
134
+ parts.push(emitString(value));
135
+
136
+ return;
137
+ }
138
+
139
+ if (isArray(value)) {
140
+ parts.push("[");
141
+
142
+ for (let i = 0; i < value.length; i++) {
143
+ if (i !== 0) {
144
+ parts.push(", ");
145
+ }
146
+
147
+ pushDefault(value[i], parts);
148
+ }
149
+
150
+ parts.push("]");
151
+
152
+ return;
153
+ }
154
+
155
+ if (!isRecord(value)) {
156
+ throw new TypeError("dumpsDefault: not JSON");
157
+ }
158
+
159
+ const keys = Object.keys(value);
160
+ parts.push("{");
161
+
162
+ for (let i = 0; i < keys.length; i++) {
163
+ if (i !== 0) {
164
+ parts.push(", ");
165
+ }
166
+
167
+ const key = keys[i];
168
+ parts.push(emitString(key));
169
+ parts.push(": ");
170
+ pushDefault(value[key], parts);
171
+ }
172
+
173
+ parts.push("}");
174
+ }
175
+
176
+ /** json.dumps(obj, ensure_ascii=False) -- default separators. */
177
+ export function dumpsDefault(value: JsonValue): string {
178
+ const parts: string[] = [];
179
+ pushDefault(value, parts);
180
+
181
+ return parts.join("");
182
+ }
183
+
184
+ /** Character count of dumpsDefault(value), without allocating the string. */
185
+ export function dumpsCount(value: JsonValue, onRecord?: (obj: JsonObject) => void): number {
186
+ if (isNull(value)) {
187
+ return 4;
188
+ }
189
+
190
+ if (isBoolean(value)) {
191
+ return value ? 4 : 5;
192
+ }
193
+
194
+ if (isNumber(value)) {
195
+ return emitNumber(value).length;
196
+ }
197
+
198
+ if (isString(value)) {
199
+ return emitString(value).length;
200
+ }
201
+
202
+ if (isArray(value)) {
203
+ let n = 2;
204
+
205
+ for (let i = 0; i < value.length; i++) {
206
+ if (i !== 0) {
207
+ n += 2;
208
+ }
209
+
210
+ n += dumpsCount(value[i], onRecord);
211
+ }
212
+
213
+ return n;
214
+ }
215
+
216
+ if (!isRecord(value)) {
217
+ throw new TypeError("dumpsDefault: not JSON");
218
+ }
219
+
220
+ if (onRecord) {
221
+ onRecord(value);
222
+ }
223
+
224
+ const keys = Object.keys(value);
225
+ let n = 2;
226
+
227
+ for (let i = 0; i < keys.length; i++) {
228
+ if (i !== 0) {
229
+ n += 2;
230
+ }
231
+
232
+ n += keyJsonLen(keys[i]) + 2 + dumpsCount(value[keys[i]], onRecord);
233
+ }
234
+
235
+ return n;
236
+ }
237
+
238
+ export function dumpsLen(value: JsonValue): number {
239
+ try {
240
+ return dumpsCount(value);
241
+ } catch {
242
+ return 0;
243
+ }
244
+ }
245
+
246
+ 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
+ }
254
+
255
+ return out;
256
+ }