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/CHANGELOG.md +4 -0
- package/README.md +16 -15
- package/index.ts +36 -3
- package/lib/cliff.ts +32 -2
- package/lib/config.ts +15 -3
- package/lib/decode.ts +5 -5
- package/lib/dialects/anthropic.ts +50 -3
- package/lib/dialects/base.ts +28 -0
- package/lib/dialects/index.ts +8 -1
- package/lib/dialects/openai-chat.ts +39 -10
- package/lib/dialects/openai-responses.ts +99 -10
- package/lib/engine.ts +120 -108
- package/lib/images.ts +29 -10
- package/lib/index.ts +4 -1
- package/lib/json.ts +9 -14
- package/lib/pi-hook.ts +144 -73
- package/lib/store.ts +18 -4
- package/package.json +3 -3
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type JsonObject,
|
|
19
19
|
type JsonValue,
|
|
20
20
|
} from "../decode.ts";
|
|
21
|
-
import { digestObj } from "../hashing.ts";
|
|
21
|
+
import { digestBytes, digestObj } from "../hashing.ts";
|
|
22
22
|
import { canonicalJson } from "../json.ts";
|
|
23
23
|
import {
|
|
24
24
|
makeDialect,
|
|
@@ -32,7 +32,15 @@ const MODEL_ITEM_TYPES = new Set([
|
|
|
32
32
|
"reasoning",
|
|
33
33
|
"function_call",
|
|
34
34
|
"custom_tool_call",
|
|
35
|
+
"apply_patch_call",
|
|
36
|
+
"computer_call",
|
|
37
|
+
"code_interpreter_call",
|
|
38
|
+
"mcp_call",
|
|
39
|
+
"mcp_list_tools",
|
|
40
|
+
"mcp_approval_request",
|
|
35
41
|
"local_shell_call",
|
|
42
|
+
"shell_call",
|
|
43
|
+
"file_search_call",
|
|
36
44
|
"web_search_call",
|
|
37
45
|
"tool_search_call",
|
|
38
46
|
"image_generation_call",
|
|
@@ -55,6 +63,11 @@ function isModelOutput(item: JsonObject): boolean {
|
|
|
55
63
|
return asString(item.role) === "assistant";
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
// Server discovery is part of the model run; a client reply closes it.
|
|
67
|
+
if (t === "tool_search_output") {
|
|
68
|
+
return asString(item.execution) === "server";
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
return MODEL_ITEM_TYPES.has(t);
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -75,6 +88,8 @@ function contentText(content: JsonValue | undefined): string {
|
|
|
75
88
|
|
|
76
89
|
if (t === "input_text" || t === "output_text" || t === "text") {
|
|
77
90
|
parts.push(asString(p.text));
|
|
91
|
+
} else if (t === "refusal") {
|
|
92
|
+
parts.push(asString(p.refusal));
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
95
|
|
|
@@ -88,6 +103,48 @@ function contentText(content: JsonValue | undefined): string {
|
|
|
88
103
|
return String(content);
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
function callArgs(item: JsonObject, type: string): JsonValue | undefined {
|
|
107
|
+
if (type === "custom_tool_call") return item.input;
|
|
108
|
+
|
|
109
|
+
if (type === "apply_patch_call") return item.operation;
|
|
110
|
+
|
|
111
|
+
if (type === "computer_call") return item.actions ?? item.action;
|
|
112
|
+
|
|
113
|
+
if (type === "local_shell_call" || type === "shell_call" || type === "web_search_call") return item.action;
|
|
114
|
+
|
|
115
|
+
if (type === "file_search_call") return item.queries;
|
|
116
|
+
|
|
117
|
+
if (type === "code_interpreter_call") return item.code;
|
|
118
|
+
|
|
119
|
+
if (type === "image_generation_call") return item.revised_prompt;
|
|
120
|
+
|
|
121
|
+
return item.arguments;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function codeLogs(outputs: JsonValue | undefined): string {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
|
|
127
|
+
for (const output of asArray(outputs)) {
|
|
128
|
+
if (isRecord(output) && asString(output.type) === "logs") {
|
|
129
|
+
parts.push(asString(output.logs));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return parts.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fileSearchText(results: JsonValue | undefined): string {
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (const result of asArray(results)) {
|
|
140
|
+
if (isRecord(result) && isString(result.text)) {
|
|
141
|
+
parts.push(result.text);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parts.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
91
148
|
function canonContent(content: JsonValue | undefined): JsonArray {
|
|
92
149
|
if (isString(content)) {
|
|
93
150
|
return [["text", content]];
|
|
@@ -128,6 +185,7 @@ export function digestMessage(item: JsonObject): string {
|
|
|
128
185
|
"reasoning",
|
|
129
186
|
asString(item.encrypted_content),
|
|
130
187
|
canonicalJson(item.summary ?? []),
|
|
188
|
+
canonicalJson(item.content ?? []),
|
|
131
189
|
]);
|
|
132
190
|
}
|
|
133
191
|
|
|
@@ -138,17 +196,29 @@ export function digestMessage(item: JsonObject): string {
|
|
|
138
196
|
out = canonicalJson(out ?? null);
|
|
139
197
|
}
|
|
140
198
|
|
|
141
|
-
|
|
199
|
+
// Textual JSON and content blocks have different recap semantics.
|
|
200
|
+
return digestObj(["function_call_output", asString(item.call_id), isString(item.output), out]);
|
|
142
201
|
}
|
|
143
202
|
|
|
144
203
|
if (t.endsWith("_call") || t === "function_call") {
|
|
145
|
-
let args = item
|
|
204
|
+
let args = callArgs(item, t);
|
|
146
205
|
|
|
147
206
|
if (!isString(args)) {
|
|
148
207
|
args = canonicalJson(args ?? "");
|
|
149
208
|
}
|
|
150
209
|
|
|
151
|
-
|
|
210
|
+
const fields: JsonArray = [t, asString(item.call_id), asString(item.name), args];
|
|
211
|
+
|
|
212
|
+
if (t === "mcp_call") fields.push(asString(item.output), asString(item.error));
|
|
213
|
+
|
|
214
|
+
if (t === "code_interpreter_call") fields.push(canonicalJson(item.outputs ?? []));
|
|
215
|
+
|
|
216
|
+
if (t === "file_search_call") fields.push(canonicalJson(item.results ?? []));
|
|
217
|
+
|
|
218
|
+
// Image changes can invalidate the cliff's trigger even without textual excerpts.
|
|
219
|
+
if (t === "image_generation_call") fields.push(isString(item.result) ? digestBytes(item.result) : null);
|
|
220
|
+
|
|
221
|
+
return digestObj(fields);
|
|
152
222
|
}
|
|
153
223
|
|
|
154
224
|
const stripped: JsonObject = {};
|
|
@@ -263,7 +333,7 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
|
|
|
263
333
|
|
|
264
334
|
const bits: string[] = [];
|
|
265
335
|
|
|
266
|
-
for (const p of asArray(item.summary)) {
|
|
336
|
+
for (const p of asArray(item.summary).concat(asArray(item.content))) {
|
|
267
337
|
if (isRecord(p)) {
|
|
268
338
|
bits.push(asString(p.text));
|
|
269
339
|
}
|
|
@@ -278,11 +348,15 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
|
|
|
278
348
|
return ["thinking: " + truncate(text, cfg.thinkingMaxChars)];
|
|
279
349
|
}
|
|
280
350
|
|
|
281
|
-
if (t === "function_call_output") {
|
|
351
|
+
if (t === "function_call_output" || t === "custom_tool_call_output" || t === "local_shell_call_output" || t === "shell_call_output" || t === "apply_patch_call_output") {
|
|
282
352
|
let out = item.output;
|
|
283
353
|
|
|
284
|
-
if (
|
|
285
|
-
|
|
354
|
+
if (t === "shell_call_output") {
|
|
355
|
+
// Native shell arrays contain command results, not content attachments.
|
|
356
|
+
out = canonicalJson(out ?? []);
|
|
357
|
+
} else if (!isString(out)) {
|
|
358
|
+
// Attachment-only content arrays must not become textual JSON excerpts.
|
|
359
|
+
out = contentText(out) || (isArray(out) ? "" : canonicalJson(out ?? null));
|
|
286
360
|
}
|
|
287
361
|
|
|
288
362
|
out = out.trim();
|
|
@@ -295,7 +369,7 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
|
|
|
295
369
|
}
|
|
296
370
|
|
|
297
371
|
if (t.endsWith("_call")) {
|
|
298
|
-
let args = item
|
|
372
|
+
let args = callArgs(item, t);
|
|
299
373
|
|
|
300
374
|
if (!isString(args)) {
|
|
301
375
|
args = canonicalJson(args ?? "");
|
|
@@ -303,7 +377,22 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
|
|
|
303
377
|
|
|
304
378
|
const name = asString(item.name) || t;
|
|
305
379
|
|
|
306
|
-
|
|
380
|
+
const lines = ["[" + name + "] " + truncate(args, cfg.cmdMaxChars)];
|
|
381
|
+
|
|
382
|
+
if (t === "mcp_call" || t === "code_interpreter_call" || t === "file_search_call") {
|
|
383
|
+
const outputs = t === "mcp_call" ? [asString(item.output), asString(item.error)]
|
|
384
|
+
: [t === "file_search_call" ? fileSearchText(item.results) : codeLogs(item.outputs)];
|
|
385
|
+
|
|
386
|
+
for (const result of outputs) {
|
|
387
|
+
const output = result.trim();
|
|
388
|
+
|
|
389
|
+
if (output && output.length <= cfg.resultMaxChars) {
|
|
390
|
+
lines.push("result: " + output);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return lines;
|
|
307
396
|
}
|
|
308
397
|
|
|
309
398
|
return [];
|
package/lib/engine.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { compact, type CompactResult } from "./cliff.ts";
|
|
10
|
+
import { compact, groupTurns, type CompactResult } from "./cliff.ts";
|
|
11
11
|
import { replaceConfig, type Config, type ConfigPatch } from "./config.ts";
|
|
12
12
|
import {
|
|
13
13
|
isArray,
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
type JsonObject,
|
|
17
17
|
type JsonValue,
|
|
18
18
|
} from "./decode.ts";
|
|
19
|
-
import { SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
|
|
19
|
+
import { fitSummary, SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
|
|
20
20
|
import { digestBytes } from "./hashing.ts";
|
|
21
|
-
import { dumpsCount, dumpsDefault,
|
|
21
|
+
import { canonicalJson, dumpsCount, dumpsDefault, objectWithoutKey } from "./json.ts";
|
|
22
22
|
import { imagePayloadFromNode, tokensForPayload } from "./images.ts";
|
|
23
23
|
import { makeEntry, PrefixStore } from "./store.ts";
|
|
24
24
|
|
|
@@ -67,6 +67,8 @@ export type RequestCtx = {
|
|
|
67
67
|
dialect: Dialect;
|
|
68
68
|
msgs: JsonObject[];
|
|
69
69
|
chain: string[];
|
|
70
|
+
cacheChain: string[];
|
|
71
|
+
policy: string;
|
|
70
72
|
baseCut: number;
|
|
71
73
|
baseHead: number;
|
|
72
74
|
substituted: JsonObject[];
|
|
@@ -86,12 +88,8 @@ function outgoingBody(ctx: RequestCtx): JsonObject {
|
|
|
86
88
|
return ctx.body;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for (const key of Object.keys(ctx.body)) {
|
|
92
|
-
out[key] = ctx.body[key];
|
|
93
|
-
}
|
|
94
|
-
|
|
91
|
+
// Spread creates own data properties even for JSON keys like __proto__.
|
|
92
|
+
const out: JsonObject = { ...ctx.body };
|
|
95
93
|
out[ctx.dialect.messagesKey] = ctx.substituted;
|
|
96
94
|
|
|
97
95
|
return out;
|
|
@@ -118,6 +116,18 @@ function messagesOf(body: JsonObject, dialect: Dialect): JsonObject[] {
|
|
|
118
116
|
return out;
|
|
119
117
|
}
|
|
120
118
|
|
|
119
|
+
function turnEnds(msgs: JsonObject[], dialect: Dialect): Set<number> {
|
|
120
|
+
const ends = new Set<number>();
|
|
121
|
+
let end = 0;
|
|
122
|
+
|
|
123
|
+
for (const turn of groupTurns(msgs, dialect)) {
|
|
124
|
+
end += turn.length;
|
|
125
|
+
ends.add(end);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return ends;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
function summaryText(msg: JsonObject): string {
|
|
122
132
|
const content = msg.content;
|
|
123
133
|
|
|
@@ -144,93 +154,86 @@ export function messageChars(msg: JsonObject): number {
|
|
|
144
154
|
return msgChars(msg);
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
type
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
type MessageObservation = {
|
|
158
|
+
snapshot: string;
|
|
159
|
+
digest: string;
|
|
160
|
+
chain: string;
|
|
161
|
+
cacheChain: string;
|
|
162
|
+
chars: number;
|
|
151
163
|
};
|
|
152
164
|
|
|
153
165
|
export class Engine {
|
|
154
166
|
readonly cfg: Config;
|
|
155
167
|
readonly store: PrefixStore;
|
|
156
|
-
private
|
|
157
|
-
private
|
|
158
|
-
private prefixChain: string[] = [];
|
|
159
|
-
private prefixDialect: Dialect | null = null;
|
|
160
|
-
private prefixArrayBillable = 0;
|
|
168
|
+
private observations: MessageObservation[] = [];
|
|
169
|
+
private observedDialect: Dialect | null = null;
|
|
161
170
|
|
|
162
171
|
constructor(cfg: Config, store?: PrefixStore) {
|
|
163
172
|
this.cfg = cfg;
|
|
164
173
|
this.store = store ?? new PrefixStore(cfg.storeMaxEntries, cfg.storeMaxBytes);
|
|
165
174
|
}
|
|
166
175
|
|
|
167
|
-
private
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
176
|
+
private observe(msgs: JsonObject[], dialect: Dialect): MessageObservation[] {
|
|
177
|
+
const prior = this.observedDialect === dialect ? this.observations : [];
|
|
178
|
+
const observed: MessageObservation[] = [];
|
|
179
|
+
let prefix = "";
|
|
180
|
+
let cachePrefix = "";
|
|
181
|
+
let samePrefix = true;
|
|
182
|
+
let sameCachePrefix = true;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
185
|
+
// Inputs are JSON data. Compare current serialized content, never object
|
|
186
|
+
// identity; nested edits and noncanonical budget metadata remain visible.
|
|
187
|
+
const snapshot = JSON.stringify(msgs[i]);
|
|
188
|
+
const old = prior[i];
|
|
189
|
+
const unchanged = old !== undefined && old.snapshot === snapshot;
|
|
190
|
+
const digest = unchanged ? old.digest : dialect.digestMessage(msgs[i]);
|
|
191
|
+
samePrefix = samePrefix && old !== undefined && old.digest === digest;
|
|
192
|
+
prefix = samePrefix ? old.chain : digestBytes(prefix + digest);
|
|
193
|
+
const chars = unchanged ? old.chars : billableChars(msgs[i]);
|
|
194
|
+
// Encoding overhead can move an earlier cliff without changing canonical identity.
|
|
195
|
+
sameCachePrefix = sameCachePrefix && samePrefix && old !== undefined && old.chars === chars;
|
|
196
|
+
cachePrefix = sameCachePrefix ? old.cacheChain : digestBytes(cachePrefix + digest + ":" + chars);
|
|
197
|
+
observed.push({
|
|
198
|
+
snapshot: unchanged ? old.snapshot : snapshot,
|
|
199
|
+
digest,
|
|
200
|
+
chain: prefix,
|
|
201
|
+
cacheChain: cachePrefix,
|
|
202
|
+
chars,
|
|
203
|
+
});
|
|
211
204
|
}
|
|
212
205
|
|
|
213
|
-
|
|
214
|
-
this.
|
|
206
|
+
// Retain only the latest request's observations, not every branch visited.
|
|
207
|
+
this.observations = observed;
|
|
208
|
+
this.observedDialect = dialect;
|
|
215
209
|
|
|
216
|
-
return
|
|
210
|
+
return observed;
|
|
217
211
|
}
|
|
218
212
|
|
|
219
213
|
prepare(body: JsonObject, dialect: Dialect): RequestCtx {
|
|
220
214
|
const msgs = messagesOf(body, dialect);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
215
|
+
const observed = this.observe(msgs, dialect);
|
|
216
|
+
const chain = observed.map((entry) => entry.chain);
|
|
217
|
+
const cacheChain = observed.map((entry) => entry.cacheChain);
|
|
224
218
|
const emptyBody = objectWithoutKey(body, dialect.messagesKey);
|
|
225
219
|
emptyBody[dialect.messagesKey] = [];
|
|
226
|
-
const
|
|
227
|
-
const
|
|
220
|
+
const fixedChars = billableChars(emptyBody);
|
|
221
|
+
const raw = body[dialect.messagesKey];
|
|
222
|
+
|
|
223
|
+
// Array punctuation belongs to the request, not to individual messages.
|
|
224
|
+
const inputChars = fixedChars + observed.reduce((sum, entry) => sum + entry.chars, 0)
|
|
225
|
+
+ Math.max(0, msgs.length - 1) * 2;
|
|
226
|
+
|
|
227
|
+
const estTokensIn = isArray(raw) && raw.length === msgs.length
|
|
228
|
+
? Math.trunc(inputChars / 4) : estimateTokens(body);
|
|
228
229
|
|
|
229
230
|
const ctx: RequestCtx = {
|
|
230
231
|
body,
|
|
231
232
|
dialect,
|
|
232
233
|
msgs,
|
|
233
234
|
chain,
|
|
235
|
+
cacheChain,
|
|
236
|
+
policy: canonicalJson([this.cfg, fixedChars]),
|
|
234
237
|
baseCut: 0,
|
|
235
238
|
baseHead: 0,
|
|
236
239
|
substituted: msgs,
|
|
@@ -244,10 +247,23 @@ export class Engine {
|
|
|
244
247
|
outMsgs: 0,
|
|
245
248
|
};
|
|
246
249
|
|
|
250
|
+
// Canonical-equivalent encodings can cross the trigger without changing
|
|
251
|
+
// the chain. Cached cliffs must not replace an already-fitting request.
|
|
252
|
+
if (ctx.estTokensIn <= this.cfg.thresholdTokens) {
|
|
253
|
+
ctx.estTokensOut = ctx.estTokensIn;
|
|
254
|
+
|
|
255
|
+
return ctx;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// With no protected tail, a cached final call can become an interior
|
|
259
|
+
// item when a later request appends its result or another model-run item.
|
|
260
|
+
const safeCuts = this.cfg.keepRecent === 0 ? turnEnds(msgs, dialect) : null;
|
|
261
|
+
|
|
247
262
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
248
|
-
const entry = this.store.get(
|
|
263
|
+
const entry = this.store.get(cacheChain[i]);
|
|
249
264
|
|
|
250
|
-
if (entry === undefined
|
|
265
|
+
if (entry === undefined || entry.policy !== ctx.policy || entry.dialect !== dialect
|
|
266
|
+
|| (safeCuts !== null && !safeCuts.has(entry.cut))) {
|
|
251
267
|
continue;
|
|
252
268
|
}
|
|
253
269
|
|
|
@@ -261,7 +277,8 @@ export class Engine {
|
|
|
261
277
|
substituted.push(msgs[h]);
|
|
262
278
|
}
|
|
263
279
|
|
|
264
|
-
|
|
280
|
+
// Outgoing JSON belongs to this request, not to the shared prefix cache.
|
|
281
|
+
substituted.push(structuredClone(entry.summary));
|
|
265
282
|
|
|
266
283
|
for (let t = entry.cut; t < msgs.length; t++) {
|
|
267
284
|
substituted.push(msgs[t]);
|
|
@@ -331,7 +348,8 @@ export class Engine {
|
|
|
331
348
|
|
|
332
349
|
private rungCfg(rung: number): Config {
|
|
333
350
|
const patch: ConfigPatch = {
|
|
334
|
-
keepRecent:
|
|
351
|
+
// Never raise keepRecent: 0 means "summarize the tail too".
|
|
352
|
+
keepRecent: Math.min(this.cfg.keepRecent, 1),
|
|
335
353
|
};
|
|
336
354
|
|
|
337
355
|
if (rung >= 2) {
|
|
@@ -344,7 +362,7 @@ export class Engine {
|
|
|
344
362
|
}
|
|
345
363
|
|
|
346
364
|
private truncateSummary(ctx: RequestCtx, reason = "reactive"): boolean {
|
|
347
|
-
if (
|
|
365
|
+
if (ctx.baseCut <= 0) {
|
|
348
366
|
return false;
|
|
349
367
|
}
|
|
350
368
|
|
|
@@ -364,35 +382,15 @@ export class Engine {
|
|
|
364
382
|
}
|
|
365
383
|
}
|
|
366
384
|
|
|
367
|
-
const bodyCopy: JsonObject = {};
|
|
368
|
-
|
|
369
|
-
for (const key of Object.keys(ctx.body)) {
|
|
370
|
-
bodyCopy[key] = ctx.body[key];
|
|
371
|
-
}
|
|
372
|
-
|
|
385
|
+
const bodyCopy: JsonObject = { ...ctx.body };
|
|
373
386
|
bodyCopy[ctx.dialect.messagesKey] = others;
|
|
374
|
-
const fixed =
|
|
375
|
-
const budget = this.cfg.thresholdTokens * 4 - fixed - SUMMARY_HEADER.length - 64;
|
|
387
|
+
const fixed = billableChars(bodyCopy) + (others.length > 0 ? 2 : 0);
|
|
376
388
|
const rest = text.slice(SUMMARY_HEADER.length).trim();
|
|
377
389
|
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
390
|
|
|
384
|
-
|
|
385
|
-
|
|
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;
|
|
391
|
+
const newText = fitSummary(SUMMARY_HEADER, parts, (candidate) =>
|
|
392
|
+
Math.trunc((fixed + billableChars(ctx.dialect.userMessage(candidate))) / 4) <= this.cfg.thresholdTokens,
|
|
393
|
+
);
|
|
396
394
|
|
|
397
395
|
if (newText.length >= text.length) {
|
|
398
396
|
return false;
|
|
@@ -414,10 +412,7 @@ export class Engine {
|
|
|
414
412
|
ctx.substituted = substituted;
|
|
415
413
|
ctx.modified = true;
|
|
416
414
|
ctx.estTokensOut = estimateTokens(outgoingBody(ctx));
|
|
417
|
-
this.
|
|
418
|
-
ctx.chain[ctx.baseCut - 1],
|
|
419
|
-
makeEntry(ctx.baseHead, newSummary, ctx.baseCut),
|
|
420
|
-
);
|
|
415
|
+
ctx.overBudget = ctx.estTokensOut > this.cfg.thresholdTokens;
|
|
421
416
|
void reason;
|
|
422
417
|
|
|
423
418
|
return true;
|
|
@@ -435,14 +430,15 @@ export class Engine {
|
|
|
435
430
|
const emptyBody = objectWithoutKey(ctx.body, ctx.dialect.messagesKey);
|
|
436
431
|
emptyBody[ctx.dialect.messagesKey] = [];
|
|
437
432
|
const fixedChars = billableChars(emptyBody);
|
|
438
|
-
const thresholdChars = cfg.thresholdTokens * 4;
|
|
439
433
|
|
|
440
434
|
let working: JsonObject[];
|
|
441
435
|
let headLen: number;
|
|
442
436
|
let origCut: number;
|
|
443
437
|
let haveSummary: boolean;
|
|
444
438
|
|
|
445
|
-
|
|
439
|
+
// Escalation knobs must recap the original history. Reusing the previous
|
|
440
|
+
// summary cannot apply thought caps: compact() refuses a same-length rewrite.
|
|
441
|
+
if (ctx.baseCut > 0 && compactCfg === null) {
|
|
446
442
|
working = ctx.substituted.slice(0, ctx.baseHead + 1);
|
|
447
443
|
headLen = ctx.baseHead;
|
|
448
444
|
origCut = ctx.baseCut;
|
|
@@ -511,11 +507,19 @@ export class Engine {
|
|
|
511
507
|
return true;
|
|
512
508
|
};
|
|
513
509
|
|
|
510
|
+
// keepRecent 0 consumes the newest turn too. Wait for its entire run
|
|
511
|
+
// before rewriting, otherwise its following tool result becomes orphaned.
|
|
512
|
+
const safeCuts = knobs.keepRecent === 0 ? turnEnds(msgs, ctx.dialect) : null;
|
|
513
|
+
|
|
514
514
|
for (let i = origCut; i < msgs.length; i++) {
|
|
515
515
|
working.push(msgs[i]);
|
|
516
516
|
chars += cachedMsgChars(msgs[i]);
|
|
517
517
|
|
|
518
|
-
if (
|
|
518
|
+
if (safeCuts !== null && !safeCuts.has(i + 1)) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (Math.trunc((chars - (working.length > 0 ? 2 : 0)) / 4) > cfg.thresholdTokens) {
|
|
519
523
|
const result = compact(working, ctx.dialect, knobs);
|
|
520
524
|
|
|
521
525
|
if (result === null) {
|
|
@@ -551,7 +555,15 @@ export class Engine {
|
|
|
551
555
|
ctx.chainSteps = nCompactions;
|
|
552
556
|
ctx.summaryFp = lastSummary ? summaryFingerprint(lastSummary) : "";
|
|
553
557
|
ctx.outMsgs = working.length;
|
|
554
|
-
|
|
558
|
+
ctx.overBudget = ctx.estTokensOut > cfg.thresholdTokens;
|
|
559
|
+
|
|
560
|
+
// Only deterministic proactive compaction is reusable by sibling requests.
|
|
561
|
+
if (!force && compactCfg === null) {
|
|
562
|
+
const entry = makeEntry(headLen, structuredClone(lastSummary ?? {}), origCut);
|
|
563
|
+
entry.policy = ctx.policy;
|
|
564
|
+
entry.dialect = ctx.dialect;
|
|
565
|
+
this.store.put(ctx.cacheChain[origCut - 1], entry);
|
|
566
|
+
}
|
|
555
567
|
|
|
556
568
|
return true;
|
|
557
569
|
}
|
package/lib/images.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const PATCH_PX = 28;
|
|
|
22
22
|
|
|
23
23
|
export const MAX_IMAGE_TOKENS = 4_784;
|
|
24
24
|
|
|
25
|
-
export const DEFAULT_IMAGE_TOKENS =
|
|
25
|
+
export const DEFAULT_IMAGE_TOKENS = MAX_IMAGE_TOKENS;
|
|
26
26
|
|
|
27
27
|
const HEADER_B64_CHARS = 8192;
|
|
28
28
|
|
|
@@ -94,6 +94,12 @@ function jpegDimensions(raw: Uint8Array): [number, number] | null {
|
|
|
94
94
|
|
|
95
95
|
const marker = raw[i + 1];
|
|
96
96
|
|
|
97
|
+
// JPEG permits repeated 0xff fill bytes before a marker code.
|
|
98
|
+
if (marker === 0xff) {
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
98
104
|
i += 2;
|
|
99
105
|
continue;
|
|
@@ -122,20 +128,20 @@ function webpDimensions(raw: Uint8Array): [number, number] | null {
|
|
|
122
128
|
const riff = [0x52, 0x49, 0x46, 0x46];
|
|
123
129
|
const webp = [0x57, 0x45, 0x42, 0x50];
|
|
124
130
|
|
|
125
|
-
if (!startsWith(raw, riff) || !startsWith(raw, webp, 8) || raw.length <
|
|
131
|
+
if (!startsWith(raw, riff) || !startsWith(raw, webp, 8) || raw.length < 25) {
|
|
126
132
|
return null;
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
const fmt = String.fromCharCode(raw[12], raw[13], raw[14], raw[15]);
|
|
130
136
|
|
|
131
|
-
if (fmt === "VP8X") {
|
|
137
|
+
if (fmt === "VP8X" && raw.length >= 30) {
|
|
132
138
|
const w = (raw[24] | (raw[25] << 8) | (raw[26] << 16)) + 1;
|
|
133
139
|
const h = (raw[27] | (raw[28] << 8) | (raw[29] << 16)) + 1;
|
|
134
140
|
|
|
135
141
|
return [w, h];
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
if (fmt === "VP8 ") {
|
|
144
|
+
if (fmt === "VP8 " && raw.length >= 30) {
|
|
139
145
|
return [readUInt16LE(raw, 26) & 0x3fff, readUInt16LE(raw, 28) & 0x3fff];
|
|
140
146
|
}
|
|
141
147
|
|
|
@@ -188,10 +194,6 @@ function ceilDiv(n: number, d: number): number {
|
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
export function tokensForPayload(payload: string): number {
|
|
191
|
-
if (!payload.startsWith("data:")) {
|
|
192
|
-
return DEFAULT_IMAGE_TOKENS;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
197
|
const raw = headerBytes(payload);
|
|
196
198
|
const dims = raw ? dimensions(raw) : null;
|
|
197
199
|
|
|
@@ -207,6 +209,10 @@ export function tokensForPayload(payload: string): number {
|
|
|
207
209
|
export function imagePayloadFromNode(obj: JsonObject): string | null {
|
|
208
210
|
const kind = asString(obj.type);
|
|
209
211
|
|
|
212
|
+
if (kind === "image_generation_call") {
|
|
213
|
+
return isString(obj.result) ? obj.result : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
210
216
|
if (kind === "image") {
|
|
211
217
|
const source = asObject(obj.source);
|
|
212
218
|
|
|
@@ -214,10 +220,18 @@ export function imagePayloadFromNode(obj: JsonObject): string | null {
|
|
|
214
220
|
return source.data;
|
|
215
221
|
}
|
|
216
222
|
|
|
217
|
-
|
|
223
|
+
if (source && isString(source.url)) {
|
|
224
|
+
return source.url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (isString(obj.data)) {
|
|
228
|
+
return obj.data;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return isString(obj.url) ? obj.url : null;
|
|
218
232
|
}
|
|
219
233
|
|
|
220
|
-
if (kind === "input_image" || kind === "image_url") {
|
|
234
|
+
if (kind === "input_image" || kind === "image_url" || kind === "computer_screenshot") {
|
|
221
235
|
const urlField = obj.image_url;
|
|
222
236
|
|
|
223
237
|
if (isString(urlField)) {
|
|
@@ -230,6 +244,11 @@ export function imagePayloadFromNode(obj: JsonObject): string | null {
|
|
|
230
244
|
return nested.url;
|
|
231
245
|
}
|
|
232
246
|
|
|
247
|
+
// Uploaded image references have no local dimensions, like hosted URLs.
|
|
248
|
+
if ((kind === "input_image" || kind === "computer_screenshot") && isString(obj.file_id)) {
|
|
249
|
+
return obj.file_id;
|
|
250
|
+
}
|
|
251
|
+
|
|
233
252
|
return null;
|
|
234
253
|
}
|
|
235
254
|
|
package/lib/index.ts
CHANGED
|
@@ -41,15 +41,18 @@ export {
|
|
|
41
41
|
piDialect,
|
|
42
42
|
SUMMARY_HEADER,
|
|
43
43
|
PI_COMPACTION_PREFIX,
|
|
44
|
+
BRANCH_SUMMARY_PREFIX,
|
|
45
|
+
BRANCH_SUMMARY_SUFFIX,
|
|
44
46
|
truncate,
|
|
45
47
|
stripTaskNotifications,
|
|
46
48
|
} from "./dialects/index.ts";
|
|
47
49
|
|
|
48
50
|
export type { Dialect } from "./dialects/index.ts";
|
|
49
51
|
|
|
50
|
-
export { compactSession, liveFromEntries } from "./pi-hook.ts";
|
|
52
|
+
export { compactSession, liveFromEntries, resolveCompactReason } from "./pi-hook.ts";
|
|
51
53
|
|
|
52
54
|
export type {
|
|
55
|
+
CompactReason,
|
|
53
56
|
CompactSessionInput,
|
|
54
57
|
CompactSessionOutput,
|
|
55
58
|
SessionMessageRef,
|