@aparte/plugin-compaction 0.24.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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { aparteGlobalConfig, uuid, revokeAttachmentUrls, resolveConfig, contentToText } from "@aparte/core";
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
- return (messages) => {
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 windowBudget = splitHistoryBudget(budget.historyBudget, budget.config).window;
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 = estimateTokens(textOf(messages[i]));
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: what the user wants and why, the decisions taken and their reasons, the tasks still open, the facts and tool results that still matter (file names, values, errors, outcomes), and anything the assistant would otherwise have to ask again. Lines marked [tool …] are tool calls with their result. Write in the third person, factually, 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.";
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
- if (typeof el.getMessages === "function" && typeof el.clearAll === "function" && typeof el.appendMessage === "function") {
167
- return el;
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: () => config.getCurrentModel()?.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 (config.getCurrentModel()?.contextWindow) return budgeted(messages);
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
- function buildRequest(drop, prompt, modelId) {
195
- const history = drop.filter((m) => !inFlight(m)).map((m) => ({ role: m.role, content: transcriptForSummary(m) })).filter((m) => contentToText(m.content).length > 0);
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
- ...history,
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: `${prompt}
289
+ { role: "user", content: last?.role === "user" ? `${contentToText(last.content)}
216
290
 
217
- Please summarize the conversation above.` }
291
+ ${ask}` : ask }
218
292
  ],
219
293
  modelId,
220
- stream: false,
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
- let running = null;
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
- void controller.compact(e.detail?.targetId);
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
- if (targetId && (running.targetId ? targetId !== running.targetId : !addressed(e))) return;
276
- controller.abort();
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 !== null;
632
+ return running.size > 0;
290
633
  },
291
634
  abort() {
292
- running?.abort.abort();
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
- async compact(requested) {
318
- const targetId = requested ?? scope;
319
- const fail = (error) => {
320
- console.warn(`[aparte/plugin-compaction] compaction failed: ${error}`);
321
- if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error, targetId } }));
322
- return { ok: false, error, targetId };
323
- };
324
- const skip = (reason) => {
325
- if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true, reason, targetId } }));
326
- return { ok: true, skipped: true, reason, targetId };
327
- };
328
- if (disposed) return fail("This compaction setup was disposed");
329
- if (running) return skip("running");
330
- const target = resolveTarget(targetId);
331
- if (!target) return fail(targetId ? `No chat with id "${targetId}" to compact` : "No chat found to compact");
332
- const messages = target.getMessages();
333
- if (messages.length === 0) return skip("empty");
334
- if (messages.some(inFlight)) return skip("streaming");
335
- const { keep, drop } = select(messages);
336
- if (drop.length === 0) return skip("nothing-to-drop");
337
- const modelConfig = config.getModelConfig();
338
- const providerId = modelConfig.defaultProvider;
339
- let provider;
340
- if (!options.summarize) {
341
- if (!providerId) return fail("No provider configured");
342
- provider = config.getAIProvider(providerId);
343
- if (!provider) return fail(`Provider '${providerId}' not found`);
344
- }
345
- const request = buildRequest(drop, options.prompt ?? DEFAULT_COMPACTION_PROMPT, modelConfig.defaultModel || "");
346
- if (request.messages.length <= 1) return fail("Nothing summarisable in the dropped turns");
347
- const abort = new AbortController();
348
- running = { targetId, abort };
349
- if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-start", { detail: { targetId } }));
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,