@aparte/plugin-compaction 0.25.0 → 0.26.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/README.md +54 -35
- package/dist/budget.d.ts +14 -9
- package/dist/budget.d.ts.map +1 -1
- package/dist/compaction.d.ts +174 -78
- package/dist/compaction.d.ts.map +1 -1
- package/dist/cut.d.ts +53 -0
- package/dist/cut.d.ts.map +1 -0
- package/dist/index.d.ts +9 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +415 -104
- package/dist/index.js.map +1 -1
- package/dist/selector.d.ts +41 -1
- package/dist/selector.d.ts.map +1 -1
- package/dist/transcript.d.ts +8 -0
- package/dist/transcript.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { aparteGlobalConfig,
|
|
1
|
+
import { estimateTokens, aparteGlobalConfig, estimateWireTokens, measureContext, resolveConfig, contentToText, historyFromCheckpoint } from "@aparte/core";
|
|
2
|
+
import { estimateTokens as estimateTokens2 } from "@aparte/core";
|
|
2
3
|
const DEFAULT_COMPACTION_CONFIG = {
|
|
3
4
|
// Conservative model-agnostic defaults — the consuming app overrides
|
|
4
5
|
// `contextWindow` / `reservedThinking` with its model's real values.
|
|
@@ -12,10 +13,6 @@ const DEFAULT_COMPACTION_CONFIG = {
|
|
|
12
13
|
summaryRatio: 0.1,
|
|
13
14
|
summaryMaxTokens: 400
|
|
14
15
|
};
|
|
15
|
-
function estimateTokens(text) {
|
|
16
|
-
if (!text) return 0;
|
|
17
|
-
return Math.ceil(text.length / 3.8);
|
|
18
|
-
}
|
|
19
16
|
function estimateTokensJson(obj) {
|
|
20
17
|
if (!obj) return 0;
|
|
21
18
|
try {
|
|
@@ -50,6 +47,31 @@ function splitHistoryBudget(historyBudget, cfg = DEFAULT_COMPACTION_CONFIG) {
|
|
|
50
47
|
const summary = Math.min(cfg.summaryMaxTokens, Math.floor(historyBudget * cfg.summaryRatio));
|
|
51
48
|
return { summary, window: historyBudget - summary };
|
|
52
49
|
}
|
|
50
|
+
function cutAtUser(messages, boundary) {
|
|
51
|
+
const last = messages.length - 1;
|
|
52
|
+
if (last < 1) return null;
|
|
53
|
+
const start = Math.min(boundary, last);
|
|
54
|
+
for (let i = start; i > 0; i--) if (messages[i].role === "user") return i;
|
|
55
|
+
for (let i = start + 1; i <= last; i++) if (messages[i].role === "user") return i;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const FIRST_REQUEST_LINE = "The conversation's first request, verbatim:";
|
|
59
|
+
const FIRST_REQUEST_MAX = 1500;
|
|
60
|
+
function firstRequestBlock(text) {
|
|
61
|
+
const trimmed = text.trim();
|
|
62
|
+
const clipped = trimmed.length > FIRST_REQUEST_MAX ? `${trimmed.slice(0, FIRST_REQUEST_MAX)}…` : trimmed;
|
|
63
|
+
return `
|
|
64
|
+
|
|
65
|
+
${FIRST_REQUEST_LINE}
|
|
66
|
+
${clipped}`;
|
|
67
|
+
}
|
|
68
|
+
function splitFirstRequest(summary) {
|
|
69
|
+
const at = summary.indexOf(`
|
|
70
|
+
|
|
71
|
+
${FIRST_REQUEST_LINE}
|
|
72
|
+
`);
|
|
73
|
+
return at < 0 ? { base: summary, block: "" } : { base: summary.slice(0, at), block: summary.slice(at) };
|
|
74
|
+
}
|
|
53
75
|
const read = (value) => typeof value === "function" ? value() : value;
|
|
54
76
|
const textOf = (message) => {
|
|
55
77
|
if (typeof message.content === "string" && message.content.length > 0) return message.content;
|
|
@@ -65,7 +87,8 @@ ${typeof result === "string" ? result : ""}`;
|
|
|
65
87
|
};
|
|
66
88
|
function createCompactionSelector(options) {
|
|
67
89
|
const minKeep = Math.max(0, options.minKeep ?? 2);
|
|
68
|
-
|
|
90
|
+
const costOf = options.estimate ?? ((message) => estimateTokens(textOf(message)));
|
|
91
|
+
return (messages, limits) => {
|
|
69
92
|
const contextWindow = read(options.contextWindow);
|
|
70
93
|
if (!contextWindow || contextWindow <= 0) return { keep: messages, drop: [] };
|
|
71
94
|
const budget = computeHistoryBudget({
|
|
@@ -73,11 +96,23 @@ function createCompactionSelector(options) {
|
|
|
73
96
|
toolsArray: read(options.tools),
|
|
74
97
|
config: { ...options.config, contextWindow }
|
|
75
98
|
});
|
|
76
|
-
const
|
|
99
|
+
const split = splitHistoryBudget(budget.historyBudget, budget.config);
|
|
100
|
+
const scale = limits?.scale !== void 0 && limits.scale > 0 ? limits.scale : 1;
|
|
101
|
+
const fixed = budget.breakdown.systemPrompt + budget.breakdown.tools;
|
|
102
|
+
let windowBudget = split.window - (scale - 1) * fixed;
|
|
103
|
+
if (limits?.landAt !== void 0) {
|
|
104
|
+
const { reservedThinking, reservedGeneration, minHistoryBudget } = budget.config;
|
|
105
|
+
windowBudget = Math.min(windowBudget, Math.max(
|
|
106
|
+
minHistoryBudget,
|
|
107
|
+
limits.landAt * contextWindow - scale * fixed - reservedThinking - reservedGeneration - split.summary
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
if (limits?.ceiling !== void 0) windowBudget = Math.min(windowBudget, limits.ceiling);
|
|
111
|
+
windowBudget -= limits?.reserve ?? 0;
|
|
77
112
|
let used = 0;
|
|
78
113
|
let cut = messages.length;
|
|
79
114
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
80
|
-
const cost =
|
|
115
|
+
const cost = scale * costOf(messages[i]);
|
|
81
116
|
const kept = messages.length - 1 - i;
|
|
82
117
|
if (used + cost > windowBudget && kept >= minKeep) break;
|
|
83
118
|
used += cost;
|
|
@@ -87,7 +122,7 @@ function createCompactionSelector(options) {
|
|
|
87
122
|
return { keep: messages.slice(cut), drop: messages.slice(0, cut) };
|
|
88
123
|
};
|
|
89
124
|
}
|
|
90
|
-
const DEFAULT_COMPACTION_PROMPT = "You are compacting a conversation between a user and an assistant so that it can continue with less context. Write a summary the assistant can pick the work up from:
|
|
125
|
+
const DEFAULT_COMPACTION_PROMPT = "You are compacting a conversation between a user and an assistant so that it can continue with less context. Write a summary the assistant can pick the work up from, in these sections, each one short and factual: 1. The user's goal and constraints. 2. Decisions taken, and why. 3. Tasks still open. 4. Facts and tool results still needed: names, values, errors, outcomes. 5. The next step. Lines marked [tool …] are tool calls with their result. Write in the language of the conversation, in the third person, as compact as completeness allows. No preamble. Do not continue the conversation, do not answer a question it contains and do not call a tool: reply with the summary and nothing else.";
|
|
91
126
|
const clip = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
|
|
92
127
|
const safeJson = (value) => {
|
|
93
128
|
try {
|
|
@@ -137,7 +172,12 @@ function transcriptForSummary(message) {
|
|
|
137
172
|
}
|
|
138
173
|
return lines.join("\n");
|
|
139
174
|
}
|
|
175
|
+
const DEFAULT_THRESHOLD = 0.8;
|
|
176
|
+
const MAX_FAILURES = 2;
|
|
177
|
+
const LANDING_MARGIN = 0.2;
|
|
178
|
+
const OVERFLOW_SHRINK = 0.75;
|
|
140
179
|
const ABORTED = "Compaction aborted";
|
|
180
|
+
const CHANGED = "The conversation changed while the summary was being written";
|
|
141
181
|
const withAbort = (work, signal) => new Promise((resolve, reject) => {
|
|
142
182
|
const onAbort = () => reject(new Error(ABORTED));
|
|
143
183
|
if (signal.aborted) {
|
|
@@ -163,9 +203,8 @@ const ownResolvers = /* @__PURE__ */ new Map();
|
|
|
163
203
|
const asTarget = (candidate, depth = 0) => {
|
|
164
204
|
if (!candidate || typeof candidate !== "object" || depth > 1) return null;
|
|
165
205
|
const el = candidate;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
206
|
+
const writes = typeof el.updateMessage === "function" || typeof el.clearAll === "function" && typeof el.appendMessage === "function";
|
|
207
|
+
if (typeof el.getMessages === "function" && writes) return el;
|
|
169
208
|
return asTarget(el.viewport, depth + 1);
|
|
170
209
|
};
|
|
171
210
|
const resolveDomTarget = (targetId) => {
|
|
@@ -179,23 +218,58 @@ const resolveDomTarget = (targetId) => {
|
|
|
179
218
|
};
|
|
180
219
|
const hasWindow = typeof window !== "undefined";
|
|
181
220
|
const inFlight = (message) => message.status === "streaming" || message.status === "pending";
|
|
182
|
-
function defaultSelector(config, keepWithoutWindow) {
|
|
221
|
+
function defaultSelector(config, keepWithoutWindow, windowOf) {
|
|
183
222
|
const budgeted = createCompactionSelector({
|
|
184
|
-
contextWindow:
|
|
223
|
+
contextWindow: windowOf,
|
|
185
224
|
systemPrompt: () => config.resolveSystemPrompt(),
|
|
186
|
-
tools: () => config.getTools()
|
|
225
|
+
tools: () => config.getTools(),
|
|
226
|
+
estimate: (message) => estimateWireTokens([message])
|
|
187
227
|
});
|
|
188
|
-
return (messages) => {
|
|
189
|
-
if (
|
|
228
|
+
return (messages, limits) => {
|
|
229
|
+
if (windowOf()) return budgeted(messages, limits);
|
|
190
230
|
const cut = Math.max(0, messages.length - keepWithoutWindow);
|
|
191
231
|
return { keep: messages.slice(cut), drop: messages.slice(0, cut) };
|
|
192
232
|
};
|
|
193
233
|
}
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
const summarisable = (drop) => drop.filter((m) => !inFlight(m)).map((m) => ({ role: m.role, content: transcriptForSummary(m) })).filter((m) => contentToText(m.content).length > 0);
|
|
235
|
+
const alternating = (turns) => {
|
|
236
|
+
const merged = [];
|
|
237
|
+
for (const turn of turns) {
|
|
238
|
+
const last = merged[merged.length - 1];
|
|
239
|
+
if (last && last.role === turn.role) {
|
|
240
|
+
merged[merged.length - 1] = { role: last.role, content: `${contentToText(last.content)}
|
|
241
|
+
|
|
242
|
+
${contentToText(turn.content)}` };
|
|
243
|
+
} else {
|
|
244
|
+
merged.push(turn);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return merged;
|
|
248
|
+
};
|
|
249
|
+
const withoutThinking = (text) => text.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<think>[\s\S]*$/i, "").replace(/^[\s\S]*<\/think>/i, "").trim();
|
|
250
|
+
const askFor = (prompt, updating) => `${prompt}
|
|
251
|
+
|
|
252
|
+
${updating ? "Please write the updated summary: the summary so far, with the conversation above folded in." : "Please summarize the conversation above."}`;
|
|
253
|
+
const withSummarySoFar = (turns, summary) => {
|
|
254
|
+
const note = `Summary so far:
|
|
255
|
+
${summary}
|
|
256
|
+
|
|
257
|
+
The conversation since then:`;
|
|
258
|
+
const [first, ...rest] = turns;
|
|
259
|
+
if (first?.role === "user") return [{ role: "user", content: `${note}
|
|
260
|
+
|
|
261
|
+
${contentToText(first.content)}` }, ...rest];
|
|
262
|
+
return [{ role: "user", content: note }, ...turns];
|
|
263
|
+
};
|
|
264
|
+
function buildRequest(turns, summarySoFar, prompt, modelId, maxTokens) {
|
|
265
|
+
const body = summarySoFar === void 0 ? turns : withSummarySoFar(turns, summarySoFar);
|
|
266
|
+
const last = body[body.length - 1];
|
|
267
|
+
const ask = askFor(prompt, summarySoFar !== void 0);
|
|
196
268
|
return {
|
|
197
269
|
messages: [
|
|
198
|
-
|
|
270
|
+
// The ask joins a trailing user turn rather than following it: two user turns in a
|
|
271
|
+
// row are what `alternating` took out of the transcript.
|
|
272
|
+
...last?.role === "user" ? body.slice(0, -1) : body,
|
|
199
273
|
// The instruction rides the USER turn, not a `system` message.
|
|
200
274
|
//
|
|
201
275
|
// A system message is a persona, and a provider is entitled to own that: one
|
|
@@ -212,12 +286,17 @@ function buildRequest(drop, prompt, modelId) {
|
|
|
212
286
|
// The instruction is not a persona: it is the task of this one request, and the
|
|
213
287
|
// task belongs where every provider must look. Reported as #45 by a consumer who
|
|
214
288
|
// only found it by reading the output closely.
|
|
215
|
-
{ role: "user", content: `${
|
|
289
|
+
{ role: "user", content: last?.role === "user" ? `${contentToText(last.content)}
|
|
216
290
|
|
|
217
|
-
|
|
291
|
+
${ask}` : ask }
|
|
218
292
|
],
|
|
219
293
|
modelId,
|
|
220
|
-
|
|
294
|
+
// Streamed, so an `error` event and a `done` that says the model ran out of room
|
|
295
|
+
// reach the drain below; a non-streamed answer cut at the limit is just a string.
|
|
296
|
+
stream: true,
|
|
297
|
+
// A ceiling on the answer, so a server cannot let it run until the context is
|
|
298
|
+
// full (see `summaryAnswerCap`).
|
|
299
|
+
maxTokens,
|
|
221
300
|
// Named as what it is, so a backend can route it to a cheaper model.
|
|
222
301
|
_meta: { compaction: true }
|
|
223
302
|
};
|
|
@@ -235,19 +314,62 @@ async function summarizeThroughTransport(config, provider, request, signal, keyR
|
|
|
235
314
|
const { done, value } = await reader.read();
|
|
236
315
|
if (done) break;
|
|
237
316
|
if (value.type === "text") chunks.push(value.delta);
|
|
317
|
+
else if (value.type === "error") throw new Error(value.message);
|
|
318
|
+
else if (value.type === "done" && value.finishReason === "length") {
|
|
319
|
+
throw new Error("The summary was cut off at the model's length limit");
|
|
320
|
+
}
|
|
238
321
|
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
void reader.cancel().catch(() => void 0);
|
|
324
|
+
throw err;
|
|
239
325
|
} finally {
|
|
240
326
|
reader.releaseLock();
|
|
241
327
|
}
|
|
242
328
|
return chunks.join("");
|
|
243
329
|
}
|
|
330
|
+
function summaryAnswerCap(config, contextWindow) {
|
|
331
|
+
const { reservedGeneration } = DEFAULT_COMPACTION_CONFIG;
|
|
332
|
+
if (!contextWindow || contextWindow <= 0) return reservedGeneration;
|
|
333
|
+
const budget = computeHistoryBudget({
|
|
334
|
+
systemPrompt: config.resolveSystemPrompt() ?? "",
|
|
335
|
+
toolsArray: config.getTools(),
|
|
336
|
+
config: { contextWindow }
|
|
337
|
+
});
|
|
338
|
+
const reservation = splitHistoryBudget(budget.historyBudget, budget.config).summary;
|
|
339
|
+
return Math.max(1, reservation, Math.min(reservedGeneration, Math.floor(contextWindow / 4)));
|
|
340
|
+
}
|
|
341
|
+
function fitOldest(drop, prompt, contextWindow, maxTokens, baseTokens, scale) {
|
|
342
|
+
const room = contextWindow - maxTokens - DEFAULT_COMPACTION_CONFIG.reservedThinking - DEFAULT_COMPACTION_CONFIG.safetyMargin;
|
|
343
|
+
let used = scale * (estimateTokens(askFor(prompt, baseTokens > 0)) + baseTokens);
|
|
344
|
+
for (let i = 0; i < drop.length; i++) {
|
|
345
|
+
used += scale * summarisable([drop[i]]).reduce((sum, m) => sum + estimateTokens(contentToText(m.content)), 0);
|
|
346
|
+
if (used > room) return i;
|
|
347
|
+
}
|
|
348
|
+
return drop.length;
|
|
349
|
+
}
|
|
244
350
|
function setupCompaction(options = {}, config = aparteGlobalConfig) {
|
|
245
351
|
controllers.get(config)?.dispose();
|
|
246
352
|
const keepWithoutWindow = Math.max(0, options.keepWithoutWindow ?? 4);
|
|
247
|
-
const select = options.selector ?? defaultSelector(config, keepWithoutWindow);
|
|
248
|
-
const resolveTarget = options.resolveTarget ?? resolveDomTarget;
|
|
249
353
|
const scope = options.scopeToTargetId;
|
|
250
|
-
|
|
354
|
+
const threshold = options.threshold !== void 0 && options.threshold > 0 ? Math.min(options.threshold, 1) : DEFAULT_THRESHOLD;
|
|
355
|
+
let windowHint;
|
|
356
|
+
const windowFor = () => {
|
|
357
|
+
const declared = config.getCurrentModel()?.contextWindow;
|
|
358
|
+
if (declared && declared > 0) return declared;
|
|
359
|
+
const own = typeof options.contextWindow === "function" ? options.contextWindow() : options.contextWindow;
|
|
360
|
+
return own && own > 0 ? own : windowHint;
|
|
361
|
+
};
|
|
362
|
+
const selectDefault = defaultSelector(config, keepWithoutWindow, windowFor);
|
|
363
|
+
const resolveFor = (element, targetId) => {
|
|
364
|
+
if (options.resolveTarget) {
|
|
365
|
+
const own = options.resolveTarget(targetId);
|
|
366
|
+
if (own) return own;
|
|
367
|
+
}
|
|
368
|
+
const named = element ? asTarget(element) : null;
|
|
369
|
+
if (named) return named;
|
|
370
|
+
return options.resolveTarget ? null : resolveDomTarget(targetId);
|
|
371
|
+
};
|
|
372
|
+
const running = /* @__PURE__ */ new Map();
|
|
251
373
|
let disposed = false;
|
|
252
374
|
const addressed = (e) => {
|
|
253
375
|
const targetId = e.detail?.targetId;
|
|
@@ -256,7 +378,7 @@ function setupCompaction(options = {}, config = aparteGlobalConfig) {
|
|
|
256
378
|
if (typeof document === "undefined") return true;
|
|
257
379
|
const el = targetId ? document.getElementById(targetId) : document.querySelector("aparte-chat, aparte-chat-viewport, [data-aparte-chat]");
|
|
258
380
|
if (!el && targetId) {
|
|
259
|
-
if (options.resolveTarget && resolveTarget(targetId)) return true;
|
|
381
|
+
if (options.resolveTarget && options.resolveTarget(targetId)) return true;
|
|
260
382
|
for (const other of ownResolvers.keys()) if (other(targetId)) return false;
|
|
261
383
|
}
|
|
262
384
|
const owner = el ? resolveConfig(el) : aparteGlobalConfig;
|
|
@@ -267,29 +389,250 @@ function setupCompaction(options = {}, config = aparteGlobalConfig) {
|
|
|
267
389
|
};
|
|
268
390
|
const onCompact = (e) => {
|
|
269
391
|
if (!addressed(e)) return;
|
|
270
|
-
|
|
392
|
+
const detail = e.detail;
|
|
393
|
+
if (typeof detail?.window === "number" && detail.window > 0) windowHint = detail.window;
|
|
394
|
+
void run({
|
|
395
|
+
target: null,
|
|
396
|
+
reason: detail?.auto ? "auto" : "manual",
|
|
397
|
+
...detail?.targetId !== void 0 ? { targetId: detail.targetId } : {}
|
|
398
|
+
});
|
|
271
399
|
};
|
|
272
400
|
const onAbort = (e) => {
|
|
273
|
-
if (!running) return;
|
|
274
401
|
const targetId = e.detail?.targetId;
|
|
275
|
-
|
|
276
|
-
|
|
402
|
+
for (const [key, entry] of running) {
|
|
403
|
+
if (targetId && (key ? targetId !== key : !addressed(e))) continue;
|
|
404
|
+
entry.abort.abort();
|
|
405
|
+
}
|
|
277
406
|
};
|
|
278
407
|
const listening = options.listen !== false && typeof window !== "undefined";
|
|
408
|
+
const autoOn = options.auto !== false && listening;
|
|
409
|
+
const policies = /* @__PURE__ */ new Map();
|
|
410
|
+
const policyOf = (key) => {
|
|
411
|
+
let policy = policies.get(key);
|
|
412
|
+
if (!policy) {
|
|
413
|
+
policy = { failures: 0, suspended: false, cooling: false };
|
|
414
|
+
policies.set(key, policy);
|
|
415
|
+
}
|
|
416
|
+
return policy;
|
|
417
|
+
};
|
|
418
|
+
const canSummarise = () => {
|
|
419
|
+
if (options.summarize) return true;
|
|
420
|
+
const providerId = config.getModelConfig().defaultProvider;
|
|
421
|
+
return Boolean(providerId && config.getAIProvider(providerId));
|
|
422
|
+
};
|
|
423
|
+
const scaleOf = (messages, size) => {
|
|
424
|
+
const measure = measureContext({
|
|
425
|
+
messages,
|
|
426
|
+
config,
|
|
427
|
+
viewChanged: true,
|
|
428
|
+
...size !== void 0 ? { windowOverride: size } : {}
|
|
429
|
+
});
|
|
430
|
+
const estimate = measure.parts.system + measure.parts.tools + measure.parts.summary + measure.parts.messages;
|
|
431
|
+
return estimate > 0 && measure.used > 0 ? measure.used / estimate : 1;
|
|
432
|
+
};
|
|
433
|
+
const mayAct = (policy, beforeSend = false) => autoOn && canSummarise() && policy.failures < MAX_FAILURES && !policy.suspended && (beforeSend || !policy.cooling);
|
|
434
|
+
const onTurnDone = (e) => {
|
|
435
|
+
if (!addressed(e) || !autoOn) return;
|
|
436
|
+
const targetId = e.detail?.targetId;
|
|
437
|
+
const policy = policyOf(targetId ?? "");
|
|
438
|
+
const element = e.target instanceof Element ? e.target : null;
|
|
439
|
+
const target = resolveFor(element, targetId);
|
|
440
|
+
const size = windowFor();
|
|
441
|
+
if (!target || !size) return;
|
|
442
|
+
if (measureContext({ messages: target.getMessages(), config, windowOverride: size }).used < threshold * size) {
|
|
443
|
+
policy.cooling = false;
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!mayAct(policy)) return;
|
|
447
|
+
void run({ target: element, reason: "auto", ...targetId !== void 0 ? { targetId } : {} });
|
|
448
|
+
};
|
|
449
|
+
const onRestored = (e) => {
|
|
450
|
+
if (!e.detail?.restored || !addressed(e)) return;
|
|
451
|
+
const key = e.detail.targetId ?? "";
|
|
452
|
+
const policy = policyOf(key);
|
|
453
|
+
policy.suspended = true;
|
|
454
|
+
policy.cooling = false;
|
|
455
|
+
delete policy.lastRange;
|
|
456
|
+
running.get(key)?.abort.abort();
|
|
457
|
+
};
|
|
458
|
+
const onSwitch = () => policies.clear();
|
|
279
459
|
if (listening) {
|
|
280
460
|
window.addEventListener("aparte-compact", onCompact);
|
|
281
461
|
window.addEventListener("aparte-abort", onAbort);
|
|
462
|
+
window.addEventListener("aparte-message-done", onTurnDone);
|
|
463
|
+
window.addEventListener("aparte-compact-done", onRestored);
|
|
464
|
+
window.addEventListener("aparte-reset-done", onSwitch);
|
|
282
465
|
}
|
|
283
466
|
if (listening && !scope) answering.add(config);
|
|
284
467
|
if (listening && scope) scopedChats.set(scope, (scopedChats.get(scope) ?? 0) + 1);
|
|
285
468
|
const ownResolver = listening && !scope ? options.resolveTarget : void 0;
|
|
286
469
|
if (ownResolver) ownResolvers.set(ownResolver, (ownResolvers.get(ownResolver) ?? 0) + 1);
|
|
470
|
+
const plan = (element, targetId, exclude, reason) => {
|
|
471
|
+
const target = resolveFor(element, targetId);
|
|
472
|
+
if (!target) return { kind: "fail", error: targetId ? `No chat with id "${targetId}" to compact` : "No chat found to compact" };
|
|
473
|
+
const path = target.getMessages();
|
|
474
|
+
if (path.length === 0) return { kind: "skip", reason: "empty" };
|
|
475
|
+
if (path.some((m) => !exclude.has(m.id) && inFlight(m))) return { kind: "skip", reason: "streaming" };
|
|
476
|
+
const contextWindow = windowFor();
|
|
477
|
+
const hostSizes = options.selector || options.summarize || options.keepWithoutWindow !== void 0;
|
|
478
|
+
if (!contextWindow && !hostSizes) return { kind: "skip", reason: "no-window" };
|
|
479
|
+
const checkpointed = historyFromCheckpoint(path);
|
|
480
|
+
const holderOfPrevious = checkpointed.summary !== void 0 ? checkpointed.messages[0] : void 0;
|
|
481
|
+
const previous = holderOfPrevious?.contextSummary ? { summary: holderOfPrevious.contextSummary.summary, count: holderOfPrevious.contextSummary.count } : void 0;
|
|
482
|
+
const view = checkpointed.messages.filter((m) => !exclude.has(m.id));
|
|
483
|
+
const scale = contextWindow ? scaleOf(path, contextWindow) : 1;
|
|
484
|
+
let selected;
|
|
485
|
+
if (options.selector) {
|
|
486
|
+
selected = options.selector(view);
|
|
487
|
+
} else {
|
|
488
|
+
const reserve = Math.round(scale * estimateWireTokens(path.filter((m) => exclude.has(m.id))));
|
|
489
|
+
const limits = { scale, reserve, landAt: Math.max(0, threshold - LANDING_MARGIN) };
|
|
490
|
+
if (reason === "overflow") limits.ceiling = Math.floor(OVERFLOW_SHRINK * (scale * estimateWireTokens(view) + reserve));
|
|
491
|
+
selected = selectDefault(view, limits);
|
|
492
|
+
}
|
|
493
|
+
if (selected.drop.length === 0) return { kind: "skip", reason: "nothing-to-drop" };
|
|
494
|
+
const droppedIds = new Set(selected.drop.map((m) => m.id));
|
|
495
|
+
let boundary = 0;
|
|
496
|
+
while (boundary < view.length && droppedIds.has(view[boundary].id)) boundary++;
|
|
497
|
+
if (boundary === 0) return { kind: "skip", reason: "nothing-to-drop" };
|
|
498
|
+
const prompt = options.prompt ?? DEFAULT_COMPACTION_PROMPT;
|
|
499
|
+
const maxTokens = summaryAnswerCap(config, contextWindow);
|
|
500
|
+
const base = previous ? splitFirstRequest(previous.summary) : void 0;
|
|
501
|
+
const baseTokens = base ? estimateTokens(base.base) : 0;
|
|
502
|
+
const fit = (n) => contextWindow && !options.summarize ? fitOldest(view.slice(0, n), prompt, contextWindow, maxTokens, baseTokens, scale) : n;
|
|
503
|
+
const fits = fit(boundary);
|
|
504
|
+
if (fits === 0) return { kind: "fail", error: "The oldest turn to summarise does not fit the model's context window" };
|
|
505
|
+
const cut = cutAtUser(view, fits);
|
|
506
|
+
if (cut === null) return { kind: "skip", reason: "nothing-to-drop" };
|
|
507
|
+
if (cut > fits && fit(cut) < cut) return { kind: "skip", reason: "nothing-to-drop" };
|
|
508
|
+
const drop = view.slice(0, cut);
|
|
509
|
+
const holder = view[cut];
|
|
510
|
+
let firstRequest = base?.block ?? "";
|
|
511
|
+
if (!previous) {
|
|
512
|
+
const first = path.find((m) => m.role === "user" && !m.compaction);
|
|
513
|
+
const text = first && drop.some((m) => m.id === first.id) ? messageText(first).trim() : "";
|
|
514
|
+
if (text) firstRequest = firstRequestBlock(text);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
kind: "plan",
|
|
518
|
+
plan: {
|
|
519
|
+
target,
|
|
520
|
+
drop,
|
|
521
|
+
holder,
|
|
522
|
+
...previous ? { previous } : {},
|
|
523
|
+
...holderOfPrevious ? { previousHolderId: holderOfPrevious.id } : {},
|
|
524
|
+
firstRequest,
|
|
525
|
+
maxTokens,
|
|
526
|
+
prompt
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
const run = async (request) => {
|
|
531
|
+
const targetId = request.targetId ?? scope ?? (request.target?.id || void 0);
|
|
532
|
+
const reason = request.reason ?? "manual";
|
|
533
|
+
const key = targetId ?? "";
|
|
534
|
+
const policy = policyOf(key);
|
|
535
|
+
let messageId;
|
|
536
|
+
const fail = (error) => {
|
|
537
|
+
if (error !== ABORTED) policy.failures++;
|
|
538
|
+
console.warn(`[aparte/plugin-compaction] compaction failed: ${error}`);
|
|
539
|
+
if (hasWindow) {
|
|
540
|
+
window.dispatchEvent(new CustomEvent("aparte-compact-error", {
|
|
541
|
+
detail: { error, targetId, ...messageId ? { messageId } : {}, ...error === ABORTED ? { aborted: true } : {} }
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
return { ok: false, error, targetId, ...messageId ? { messageId } : {} };
|
|
545
|
+
};
|
|
546
|
+
const skip = (reason2) => {
|
|
547
|
+
if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true, reason: reason2, targetId } }));
|
|
548
|
+
return { ok: true, skipped: true, reason: reason2, targetId };
|
|
549
|
+
};
|
|
550
|
+
if (disposed) return fail("This compaction setup was disposed");
|
|
551
|
+
if (scope && request.targetId !== void 0 && request.targetId !== scope) {
|
|
552
|
+
return { ok: false, error: `This compaction setup only compacts the chat "${scope}"`, targetId: request.targetId };
|
|
553
|
+
}
|
|
554
|
+
if ((reason === "auto" || reason === "overflow") && !autoOn) return skip("nothing-to-drop");
|
|
555
|
+
if (reason === "manual" || reason === "retry") {
|
|
556
|
+
policy.failures = 0;
|
|
557
|
+
policy.suspended = false;
|
|
558
|
+
}
|
|
559
|
+
if (reason === "overflow") for (let busy = running.get(key); busy; busy = running.get(key)) await busy.done;
|
|
560
|
+
if (request.signal?.aborted) return fail(ABORTED);
|
|
561
|
+
if (running.has(key)) return skip("running");
|
|
562
|
+
if (reason === "auto" && !request.force && (policy.failures >= MAX_FAILURES || policy.suspended || policy.cooling && !request.beforeSend)) {
|
|
563
|
+
return skip("nothing-to-drop");
|
|
564
|
+
}
|
|
565
|
+
const planned = plan(request.target, targetId, new Set(request.exclude ?? []), reason);
|
|
566
|
+
if (planned.kind === "skip") return skip(planned.reason);
|
|
567
|
+
if (planned.kind === "fail") {
|
|
568
|
+
if (planned.messageId) messageId = planned.messageId;
|
|
569
|
+
return fail(planned.error);
|
|
570
|
+
}
|
|
571
|
+
const { target, drop, holder, previous, previousHolderId, firstRequest, maxTokens, prompt } = planned.plan;
|
|
572
|
+
messageId = holder.id;
|
|
573
|
+
const range = `${drop[0].id}..${drop[drop.length - 1].id}`;
|
|
574
|
+
if (reason === "auto" && !request.force && policy.lastRange === range) return skip("nothing-to-drop");
|
|
575
|
+
if (!target.updateMessage) return fail("This chat cannot hold a checkpoint: its transcript has no updateMessage");
|
|
576
|
+
const modelConfig = config.getModelConfig();
|
|
577
|
+
const providerId = modelConfig.defaultProvider;
|
|
578
|
+
let provider;
|
|
579
|
+
if (!options.summarize) {
|
|
580
|
+
if (!providerId) return fail("No provider configured");
|
|
581
|
+
provider = config.getAIProvider(providerId);
|
|
582
|
+
if (!provider) return fail(`Provider '${providerId}' not found`);
|
|
583
|
+
}
|
|
584
|
+
const summarySoFar = previous ? splitFirstRequest(previous.summary).base : void 0;
|
|
585
|
+
const turns = alternating(summarisable(drop));
|
|
586
|
+
if (turns.length === 0) return fail("Nothing summarisable in the dropped turns");
|
|
587
|
+
const summariseRequest = buildRequest(turns, summarySoFar, prompt, modelConfig.defaultModel || "", maxTokens);
|
|
588
|
+
const abort = new AbortController();
|
|
589
|
+
const onSignal = () => abort.abort();
|
|
590
|
+
request.signal?.addEventListener("abort", onSignal, { once: true });
|
|
591
|
+
let finished;
|
|
592
|
+
running.set(key, { abort, done: new Promise((resolve) => {
|
|
593
|
+
finished = resolve;
|
|
594
|
+
}) });
|
|
595
|
+
if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-start", { detail: { targetId, messageId } }));
|
|
596
|
+
try {
|
|
597
|
+
const raw = await withAbort(
|
|
598
|
+
options.summarize ? options.summarize(summariseRequest, abort.signal) : summarizeThroughTransport(config, provider, summariseRequest, abort.signal, options.keyResolver),
|
|
599
|
+
abort.signal
|
|
600
|
+
);
|
|
601
|
+
const modelSummary = withoutThinking(raw);
|
|
602
|
+
if (!modelSummary) throw new Error("Empty summary returned by model");
|
|
603
|
+
const replaced = estimateTokens(summarySoFar) + turns.reduce((sum, m) => sum + estimateTokens(contentToText(m.content)), 0);
|
|
604
|
+
if (estimateTokens(modelSummary) >= replaced) throw new Error("The summary is no shorter than the turns it replaces");
|
|
605
|
+
const live = target.getMessages();
|
|
606
|
+
const at = live.findIndex((m) => m.id === holder.id);
|
|
607
|
+
if (at < 0) throw new Error(CHANGED);
|
|
608
|
+
if (previousHolderId && !live.find((m) => m.id === previousHolderId)?.contextSummary) throw new Error(CHANGED);
|
|
609
|
+
const summary = `${modelSummary}${firstRequest}`;
|
|
610
|
+
const count = drop.length + (previous?.count ?? 0);
|
|
611
|
+
target.updateMessage(holder.id, { contextSummary: { summary, count, createdAt: Date.now() } });
|
|
612
|
+
policy.failures = 0;
|
|
613
|
+
policy.lastRange = range;
|
|
614
|
+
const size = windowFor();
|
|
615
|
+
const after = measureContext({ messages: target.getMessages(), config, ...size !== void 0 ? { windowOverride: size } : {} });
|
|
616
|
+
policy.cooling = after.window !== void 0 && after.used >= threshold * after.window;
|
|
617
|
+
const kept = live.length - at;
|
|
618
|
+
const outcome = { ok: true, skipped: false, summary, kept, dropped: drop.length, messageId: holder.id, count, targetId };
|
|
619
|
+
if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { summary, kept, dropped: drop.length, targetId, messageId: holder.id, count } }));
|
|
620
|
+
return outcome;
|
|
621
|
+
} catch (err) {
|
|
622
|
+
const aborted = abort.signal.aborted || err instanceof Error && err.name === "AbortError";
|
|
623
|
+
return fail(aborted ? ABORTED : err instanceof Error ? err.message : String(err));
|
|
624
|
+
} finally {
|
|
625
|
+
request.signal?.removeEventListener("abort", onSignal);
|
|
626
|
+
running.delete(key);
|
|
627
|
+
finished();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
287
630
|
const controller = {
|
|
288
631
|
get running() {
|
|
289
|
-
return running
|
|
632
|
+
return running.size > 0;
|
|
290
633
|
},
|
|
291
634
|
abort() {
|
|
292
|
-
running
|
|
635
|
+
for (const entry of running.values()) entry.abort.abort();
|
|
293
636
|
},
|
|
294
637
|
dispose() {
|
|
295
638
|
if (disposed) return;
|
|
@@ -298,6 +641,9 @@ function setupCompaction(options = {}, config = aparteGlobalConfig) {
|
|
|
298
641
|
if (listening) {
|
|
299
642
|
window.removeEventListener("aparte-compact", onCompact);
|
|
300
643
|
window.removeEventListener("aparte-abort", onAbort);
|
|
644
|
+
window.removeEventListener("aparte-message-done", onTurnDone);
|
|
645
|
+
window.removeEventListener("aparte-compact-done", onRestored);
|
|
646
|
+
window.removeEventListener("aparte-reset-done", onSwitch);
|
|
301
647
|
if (scope) {
|
|
302
648
|
const left = (scopedChats.get(scope) ?? 1) - 1;
|
|
303
649
|
if (left > 0) scopedChats.set(scope, left);
|
|
@@ -309,84 +655,49 @@ function setupCompaction(options = {}, config = aparteGlobalConfig) {
|
|
|
309
655
|
if (left > 0) ownResolvers.set(ownResolver, left);
|
|
310
656
|
else ownResolvers.delete(ownResolver);
|
|
311
657
|
}
|
|
658
|
+
if (config.getCompactor() === controller) config.setCompactor(null);
|
|
312
659
|
if (controllers.get(config) === controller) {
|
|
313
660
|
controllers.delete(config);
|
|
314
661
|
answering.delete(config);
|
|
315
662
|
}
|
|
316
663
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
try {
|
|
351
|
-
const raw = await withAbort(
|
|
352
|
-
options.summarize ? options.summarize(request, abort.signal) : summarizeThroughTransport(config, provider, request, abort.signal, options.keyResolver),
|
|
353
|
-
abort.signal
|
|
354
|
-
);
|
|
355
|
-
const summary = raw.trim();
|
|
356
|
-
if (!summary) throw new Error("Empty summary returned by model");
|
|
357
|
-
const selectedIds = new Set(messages.map((m) => m.id));
|
|
358
|
-
const keptIds = new Set(keep.map((m) => m.id));
|
|
359
|
-
const live = target.getMessages();
|
|
360
|
-
if (!live.some((m) => selectedIds.has(m.id))) return fail("The transcript changed while the summary was being written");
|
|
361
|
-
const kept = live.filter((m) => keptIds.has(m.id));
|
|
362
|
-
const arrived = live.filter((m) => !selectedIds.has(m.id));
|
|
363
|
-
target.clearAll({ revokeAttachments: false });
|
|
364
|
-
target.appendMessage({
|
|
365
|
-
id: uuid(),
|
|
366
|
-
role: "user",
|
|
367
|
-
compaction: true,
|
|
368
|
-
content: `**${config.t("compactionSummaryTitle")}**
|
|
369
|
-
|
|
370
|
-
${summary}`,
|
|
371
|
-
timestamp: Date.now(),
|
|
372
|
-
status: "completed"
|
|
373
|
-
});
|
|
374
|
-
for (const message of kept) target.appendMessage(message);
|
|
375
|
-
for (const message of arrived) target.appendMessage(message);
|
|
376
|
-
const survivors = new Set([...kept, ...arrived].map((m) => m.id));
|
|
377
|
-
for (const message of drop) if (!survivors.has(message.id)) revokeAttachmentUrls(message.attachments);
|
|
378
|
-
const outcome = { ok: true, skipped: false, summary, kept: kept.length, dropped: drop.length, targetId };
|
|
379
|
-
if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { summary, kept: kept.length, dropped: drop.length, targetId } }));
|
|
380
|
-
return outcome;
|
|
381
|
-
} catch (err) {
|
|
382
|
-
const aborted = abort.signal.aborted || err instanceof Error && err.name === "AbortError";
|
|
383
|
-
return fail(aborted ? ABORTED : err instanceof Error ? err.message : String(err));
|
|
384
|
-
} finally {
|
|
385
|
-
running = null;
|
|
386
|
-
}
|
|
664
|
+
compact(request) {
|
|
665
|
+
return run(typeof request === "object" ? request : { target: null, ...request !== void 0 ? { targetId: request } : {} });
|
|
666
|
+
},
|
|
667
|
+
async settled(targetId) {
|
|
668
|
+
const pending = () => [...running].filter(([key]) => targetId === void 0 || key === "" || key === targetId).map(([, entry]) => entry.done);
|
|
669
|
+
for (let waiting = pending(); waiting.length > 0; waiting = pending()) await Promise.all(waiting);
|
|
670
|
+
},
|
|
671
|
+
async beforeSend({ target, targetId, exclude, requestTokens, signal }) {
|
|
672
|
+
await controller.settled(targetId);
|
|
673
|
+
const id = targetId ?? scope ?? (target?.id || void 0);
|
|
674
|
+
const policy = policyOf(id ?? "");
|
|
675
|
+
const size = windowFor();
|
|
676
|
+
if (!size || !mayAct(policy, true)) return false;
|
|
677
|
+
const chat = resolveFor(target, id);
|
|
678
|
+
const scale = chat ? scaleOf(chat.getMessages(), size) : 1;
|
|
679
|
+
if (scale * requestTokens + DEFAULT_COMPACTION_CONFIG.reservedGeneration <= size) return false;
|
|
680
|
+
const outcome = await run({
|
|
681
|
+
target,
|
|
682
|
+
reason: "auto",
|
|
683
|
+
beforeSend: true,
|
|
684
|
+
exclude,
|
|
685
|
+
...id !== void 0 ? { targetId: id } : {},
|
|
686
|
+
...signal ? { signal } : {}
|
|
687
|
+
});
|
|
688
|
+
return outcome.ok && !outcome.skipped;
|
|
689
|
+
},
|
|
690
|
+
preview(request) {
|
|
691
|
+
if (scope && request.targetId !== void 0 && request.targetId !== scope) return null;
|
|
692
|
+
const id = request.targetId ?? scope ?? (request.target?.id || void 0);
|
|
693
|
+
if (disposed || running.has(id ?? "") || !canSummarise()) return null;
|
|
694
|
+
const planned = plan(request.target, id, /* @__PURE__ */ new Set(), "manual");
|
|
695
|
+
if (planned.kind !== "plan") return null;
|
|
696
|
+
return { messages: planned.plan.drop.length, tokens: estimateWireTokens(planned.plan.drop) };
|
|
387
697
|
}
|
|
388
698
|
};
|
|
389
699
|
controllers.set(config, controller);
|
|
700
|
+
config.setCompactor(controller);
|
|
390
701
|
return controller;
|
|
391
702
|
}
|
|
392
703
|
export {
|
|
@@ -394,7 +705,7 @@ export {
|
|
|
394
705
|
DEFAULT_COMPACTION_PROMPT,
|
|
395
706
|
computeHistoryBudget,
|
|
396
707
|
createCompactionSelector,
|
|
397
|
-
estimateTokens,
|
|
708
|
+
estimateTokens2 as estimateTokens,
|
|
398
709
|
estimateTokensJson,
|
|
399
710
|
messageText,
|
|
400
711
|
setupCompaction,
|