@aparte/plugin-compaction 0.16.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aparté
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @aparte/plugin-compaction
2
+
3
+ Conversation **compaction** for [aparté](https://github.com/apartejs/aparte): summarise the turns
4
+ that no longer fit the model's window, keep the recent ones verbatim, and answer the context
5
+ gauge's `aparte-compact`. Nothing in core compacts by itself — no UI kit does, and every agent
6
+ SDK ships it as an opt-in module — so this is where it lives.
7
+
8
+ ```bash
9
+ npm install @aparte/plugin-compaction @aparte/core
10
+ ```
11
+
12
+ ```ts
13
+ import { setupCompaction } from '@aparte/plugin-compaction';
14
+
15
+ const compaction = setupCompaction(); // the global config, the current model's budget
16
+ ```
17
+
18
+ ```html
19
+ <aparte-composer-toolbar>
20
+ <!-- asks for a compaction on reaching 90 % of the window; the plugin answers -->
21
+ <aparte-context auto-compact style="flex: 1"></aparte-context>
22
+ </aparte-composer-toolbar>
23
+ ```
24
+
25
+ That is the whole wiring. A compaction resolves the chat, selects what to summarise —
26
+ by default the budget-aware selector over the current model's `contextWindow`, the system
27
+ prompt and the tools, keeping the newest turns that still fit; the last two exchanges when
28
+ the model declares no window — summarises it through the config's transport (the request
29
+ carries `_meta: { compaction: true }`, so a backend can route it to a cheaper model), then
30
+ puts back the summary as a **notice** (`compaction: true` — centred, no avatar, no actions;
31
+ sent to the model under a preamble saying what it is) followed by the kept turns verbatim.
32
+
33
+ ```ts
34
+ await compaction.compact(); // or from a button; returns the outcome, never throws
35
+ compaction.abort(); // the summarisation in flight; the transcript is untouched
36
+ compaction.running; // true meanwhile
37
+ compaction.dispose(); // remove the listeners
38
+ ```
39
+
40
+ **Options** — `selector` (your own `(messages) => { keep, drop }`; `createCompactionSelector`
41
+ builds the budget-aware one over a window you choose), `prompt` (the summariser's
42
+ instruction), `keyResolver` (the resolver you gave `AparteClient`, when the key is not on the
43
+ config), `summarize` (replace the model call entirely: your endpoint, a cheaper model),
44
+ `resolveTarget` (a transcript that lives in a store rather than in the DOM),
45
+ `scopeToTargetId` (one setup per chat on a multi-chat page), `keepWithoutWindow`, `listen`.
46
+ The config comes last, like every `setup*`: `setupCompaction(options, config)`.
47
+
48
+ **Events**, on `window`, each naming the chat: `aparte-compact-start`, `aparte-compact-done`
49
+ (`{ summary, kept, dropped }`, or `{ skipped: true, reason }` — `empty`, `nothing-to-drop`,
50
+ `running`, `streaming`), `aparte-compact-error` (`{ error }`).
51
+
52
+ **The budget**, exported for a host that wants the numbers: `computeHistoryBudget`,
53
+ `splitHistoryBudget`, `estimateTokens`, `estimateTokensJson`, `DEFAULT_COMPACTION_CONFIG`;
54
+ and `transcriptForSummary` / `messageText` / `DEFAULT_COMPACTION_PROMPT` for a `summarize`
55
+ of your own that wants the same transcript.
56
+
57
+ `@aparte/core` is the only **peer dependency**. No element, no DOM at import: the same entry
58
+ serves the browser and Node.
59
+
60
+ > ESM-only. Part of the aparté monorepo.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * budget.ts — the context-window budget.
3
+ *
4
+ * One question, answered without a tokenizer: how much of the model's window is left
5
+ * for the conversation once the fixed costs are paid — the system prompt, the tools,
6
+ * the room reserved for the reply and for thinking, a buffer, a margin. The answer is
7
+ * what `createCompactionSelector` (selector.ts) walks the history against.
8
+ *
9
+ * Budget = contextWindow − systemPrompt − tools − reservedThinking
10
+ * − reservedGeneration − autocompactBuffer − safetyMargin
11
+ *
12
+ * The budget is then split in two: a slot for the running summary and the sliding
13
+ * window of verbatim turns.
14
+ *
15
+ * It lived in `@aparte/engine` until 0.16.0. Nothing in the loop read it — the loop
16
+ * reports usage and lets the caller decide — so it moved here, next to the one thing
17
+ * that does: the compaction this plugin performs. Zero deps, a char-count heuristic.
18
+ */
19
+ export interface CompactionConfig {
20
+ /** Total context window of the active model, in tokens. */
21
+ contextWindow: number;
22
+ /** Reserved budget for the model's thinking/reasoning block, in tokens. */
23
+ reservedThinking: number;
24
+ /** Reserved budget for the assistant response (max_new_tokens cap). */
25
+ reservedGeneration: number;
26
+ /** Fraction of context_window kept as autocompact buffer (0..1). */
27
+ autocompactBufferPct: number;
28
+ /** Hard safety margin in tokens. */
29
+ safetyMargin: number;
30
+ /** Floor for history budget — never compact below this. */
31
+ minHistoryBudget: number;
32
+ /** Ratio of history budget allocated to the summary block. */
33
+ summaryRatio: number;
34
+ /** Hard cap for summary tokens. */
35
+ summaryMaxTokens: number;
36
+ }
37
+ export interface BudgetBreakdown {
38
+ contextWindow: number;
39
+ systemPrompt: number;
40
+ tools: number;
41
+ reservedThinking: number;
42
+ reservedGeneration: number;
43
+ autocompactBuffer: number;
44
+ safetyMargin: number;
45
+ historyAvailable: number;
46
+ }
47
+ export interface BudgetResult {
48
+ historyBudget: number;
49
+ breakdown: BudgetBreakdown;
50
+ config: CompactionConfig;
51
+ }
52
+ /** The history budget, split: what the running summary may take, what the verbatim window gets. */
53
+ export interface SplitBudget {
54
+ summary: number;
55
+ window: number;
56
+ }
57
+ export declare const DEFAULT_COMPACTION_CONFIG: CompactionConfig;
58
+ /**
59
+ * Token heuristic (FR ~3.5 chars/tok, EN ~4 chars/tok).
60
+ * Accurate to ±10% — enough for budgeting, and it avoids `tokenizer.encode()`,
61
+ * which costs ~5ms per message × N (too slow to run on every turn).
62
+ */
63
+ export declare function estimateTokens(text: string | null | undefined): number;
64
+ /**
65
+ * Estimate tokens for a JSON-serializable structure (e.g. tools array).
66
+ */
67
+ export declare function estimateTokensJson(obj: unknown): number;
68
+ /**
69
+ * Compute the available history budget after subtracting fixed costs.
70
+ */
71
+ export declare function computeHistoryBudget(input: {
72
+ systemPrompt: string;
73
+ toolsArray?: unknown;
74
+ config?: Partial<CompactionConfig>;
75
+ }): BudgetResult;
76
+ /**
77
+ * Split the history budget: the running summary's slot (capped), and the rest for
78
+ * the verbatim window.
79
+ */
80
+ export declare function splitHistoryBudget(historyBudget: number, cfg?: CompactionConfig): SplitBudget;
81
+ //# sourceMappingURL=budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../src/budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,MAAM,WAAW,gBAAgB;IAC7B,2DAA2D;IAC3D,aAAa,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,gBAAgB,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,kBAAkB,EAAE,MAAM,CAAC;IAC3B,oEAAoE;IACpE,oBAAoB,EAAE,MAAM,CAAC;IAC7B,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,gBAAgB,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;IACrB,mCAAmC;IACnC,gBAAgB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,YAAY;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,eAAe,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC5B;AAED,mGAAmG;AACnG,MAAM,WAAW,WAAW;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAClB;AAID,eAAO,MAAM,yBAAyB,EAAE,gBAYvC,CAAC;AAIF;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGtE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAOvD;AAID;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE;IACxC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACtC,GAAG,YAAY,CA2Bf;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAC9B,aAAa,EAAE,MAAM,EACrB,GAAG,GAAE,gBAA4C,GAClD,WAAW,CAGb"}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * compaction.ts — `setupCompaction`: the controller that answers `aparte-compact`.
3
+ *
4
+ * One compaction is: resolve the chat, select what to summarise (the budget-aware
5
+ * selector over the current model by default), summarise it through the config's
6
+ * transport — or a summariser of the host's — then replace the transcript with the
7
+ * summary as a notice followed by the kept turns verbatim. Every step that can fail
8
+ * reports on `window` (`aparte-compact-start` / `-done` / `-error`, each naming the
9
+ * chat) AND in the returned outcome, so a host that called `compact()` itself never
10
+ * has to listen for the answer to its own call.
11
+ *
12
+ * What this file is careful about, each learned on the client's version of it:
13
+ * - one compaction at a time per setup — a second request while one runs is
14
+ * reported skipped, not started (two paid summaries, both replacing the transcript);
15
+ * - a transcript with a turn in flight is left alone — summarising under a streaming
16
+ * reply would drop it;
17
+ * - the summarisation has its OWN abort controller, reached by `abort()` and by an
18
+ * `aparte-abort` addressed to the chat, so a summary the user cancelled stops there;
19
+ * - and a summary nobody cancelled is refused too when the transcript it was written
20
+ * from is gone: if not one selected turn is left on the target when the model
21
+ * answers, the conversation was switched (or reset) underneath, and the summary
22
+ * would land over a transcript it never read;
23
+ * - what arrived while the summary was being written is kept: the replacement is
24
+ * summary, then the kept turns, then anything newer than the selection;
25
+ * - a chat is addressed by id through the same rule the gauge and the client use, and
26
+ * `scopeToTargetId` makes a setup answer one chat on a page that has several.
27
+ */
28
+ import { type AparteConfig, type AparteMessage, type AparteChatRequest } from '@aparte/core';
29
+ import { type CompactionSelection } from './selector.js';
30
+ /**
31
+ * The three things a chat must expose to be compacted: read the active path, empty
32
+ * it, append to it. `<aparte-chat-viewport>` does; an `<aparte-chat>` shell hands over
33
+ * its viewport. A host whose transcript lives elsewhere (a framework store) gives
34
+ * `resolveTarget` an object of its own.
35
+ */
36
+ export interface CompactionTarget {
37
+ getMessages(): AparteMessage[];
38
+ /**
39
+ * Empty the transcript. A compaction passes `{ revokeAttachments: false }`,
40
+ * because it puts the kept turns straight back: `<aparte-chat-viewport>` releases
41
+ * every message's object URLs on the way out, which killed the images and files of
42
+ * the turns the compaction was keeping. It revokes the summarised-away ones itself
43
+ * afterwards. A target that ignores the argument keeps working — it just leaks the
44
+ * URLs of what it drops, which is what it did before this existed.
45
+ */
46
+ clearAll(options?: {
47
+ revokeAttachments?: boolean;
48
+ }): void;
49
+ appendMessage(message: AparteMessage): void;
50
+ }
51
+ /** Which messages are summarised (`drop`) and which stay verbatim (`keep`). Pure — no model call. */
52
+ export type CompactionMessageSelector = (messages: AparteMessage[]) => CompactionSelection<AparteMessage>;
53
+ /** The key (or record of settings) for a provider — the same shape `AparteClientOptions.keyResolver` takes. */
54
+ export type CompactionKeyResolver = (providerId: string) => string | Record<string, string> | undefined | null | Promise<string | Record<string, string> | undefined | null>;
55
+ /** Replaces the model call: given the summarisation request, return the summary text. */
56
+ export type CompactionSummarizer = (request: AparteChatRequest, signal: AbortSignal) => Promise<string>;
57
+ /** Why a compaction did nothing. */
58
+ export type CompactionSkipReason =
59
+ /** The transcript is empty. */
60
+ 'empty'
61
+ /** The selector kept everything — within budget, or nothing older than what is kept. */
62
+ | 'nothing-to-drop'
63
+ /** A compaction is already running for this setup. */
64
+ | 'running'
65
+ /** A turn is in flight in the transcript; compacting under it would drop it. */
66
+ | 'streaming';
67
+ export type CompactionOutcome = {
68
+ ok: true;
69
+ skipped: true;
70
+ reason: CompactionSkipReason;
71
+ targetId?: string;
72
+ } | {
73
+ ok: true;
74
+ skipped: false;
75
+ summary: string;
76
+ kept: number;
77
+ dropped: number;
78
+ targetId?: string;
79
+ } | {
80
+ ok: false;
81
+ error: string;
82
+ targetId?: string;
83
+ };
84
+ export interface CompactionSetupOptions {
85
+ /**
86
+ * Which messages are summarised away and which stay verbatim.
87
+ *
88
+ * Default: `createCompactionSelector` over the current model — its `contextWindow`,
89
+ * the resolved system prompt and the registered tools set the budget, and the
90
+ * newest turns that fit the window stay. When the current model declares no
91
+ * window there is no budget to walk, so the last `keepWithoutWindow` messages
92
+ * stay and the rest is summarised.
93
+ */
94
+ selector?: CompactionMessageSelector;
95
+ /** How many of the newest messages stay when the model declares no window. Default 4 — the last two exchanges. */
96
+ keepWithoutWindow?: number;
97
+ /**
98
+ * The summariser's system prompt. English by default — an instruction to the
99
+ * model, not a string the user reads — asking for the decisions, the open tasks
100
+ * and the tool results that still matter. Replace it to steer the summary (a
101
+ * language, a domain, a length).
102
+ */
103
+ prompt?: string;
104
+ /**
105
+ * The key for the provider, when it is not on the config (`config.setKeyProvider`)
106
+ * — the resolver an `AparteClient` was given can be passed here as is. Consulted
107
+ * first; `config.getKey(providerId)` is the fallback.
108
+ */
109
+ keyResolver?: CompactionKeyResolver;
110
+ /**
111
+ * Replace the model call entirely. The request carries the summariser's system
112
+ * prompt, the transcript of the dropped turns and `_meta: { compaction: true }`;
113
+ * return the summary text. For a host with an endpoint of its own, or a cheaper
114
+ * model for summaries. With one, no provider needs to be configured at all.
115
+ */
116
+ summarize?: CompactionSummarizer;
117
+ /**
118
+ * Resolve the chat to compact. Default: the element whose id is the `targetId`
119
+ * (or, unnamed, the first `<aparte-chat>` / `<aparte-chat-viewport>` /
120
+ * `[data-aparte-chat]` on the page), taking its viewport when the element itself
121
+ * cannot render. Return your own object for a transcript that lives in a store.
122
+ */
123
+ resolveTarget?: (targetId?: string) => CompactionTarget | null;
124
+ /**
125
+ * Answer only the `aparte-compact` and `aparte-abort` events that name this chat
126
+ * id, and compact it when `compact()` is called without one. For a page with
127
+ * several chats and one setup per chat.
128
+ */
129
+ scopeToTargetId?: string;
130
+ /** Listen for `aparte-compact` and `aparte-abort` on `window`. Default `true`; `false` for a host that calls `compact()` itself. */
131
+ listen?: boolean;
132
+ }
133
+ export interface CompactionController {
134
+ /**
135
+ * Compact one chat — the one `targetId` names, else the scoped one, else the
136
+ * first on the page. Never rejects: the outcome says what happened, and the
137
+ * same information goes out as `aparte-compact-done` / `aparte-compact-error`.
138
+ */
139
+ compact(targetId?: string): Promise<CompactionOutcome>;
140
+ /** Abort the summarisation in flight, if any. The transcript is left untouched. */
141
+ abort(): void;
142
+ /** `true` while a summarisation is in flight. */
143
+ readonly running: boolean;
144
+ /** Remove the listeners and forget this setup; an in-flight summarisation is aborted. */
145
+ dispose(): void;
146
+ }
147
+ /**
148
+ * Install compaction on a config. Returns the controller; the same call on the same
149
+ * config replaces the previous setup (its listeners are removed first).
150
+ *
151
+ * ```ts
152
+ * import { setupCompaction } from '@aparte/plugin-compaction';
153
+ *
154
+ * const compaction = setupCompaction(); // the global config, the current model's budget
155
+ * // `<aparte-context auto-compact>` now asks for a compaction on reaching 90 %;
156
+ * // or ask yourself:
157
+ * await compaction.compact();
158
+ * ```
159
+ */
160
+ export declare function setupCompaction(options?: CompactionSetupOptions, config?: AparteConfig): CompactionController;
161
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../src/compaction.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@aparte/plugin-compaction` — conversation compaction: summarise the turns that no
3
+ * longer fit the model's window, keep the recent ones verbatim.
4
+ *
5
+ * Core owns the seams — the `aparte-compact` command `<aparte-context>` dispatches on
6
+ * reaching its `danger` threshold, the `compaction: true` message the viewport draws as
7
+ * a notice and the history sends under a preamble, the events a host listens for — and
8
+ * this plugin is what answers the command: a selector over the model's budget, a
9
+ * summariser through the config's transport, and the replacement in the transcript.
10
+ * Nothing in core compacts by itself; no UI kit does, every agent SDK ships it as an
11
+ * opt-in module, and so does aparté.
12
+ *
13
+ * No element, so one entry serves the browser and Node: `setupCompaction` touches
14
+ * `window` when called, never at import.
15
+ */
16
+ export { setupCompaction } from './compaction.js';
17
+ export type { CompactionSetupOptions, CompactionController, CompactionOutcome, CompactionSkipReason, CompactionTarget, CompactionMessageSelector, CompactionKeyResolver, CompactionSummarizer, } from './compaction.js';
18
+ export { createCompactionSelector } from './selector.js';
19
+ export type { CompactionSelectorOptions, CompactableMessage, CompactionSelection, CompactionSelector } from './selector.js';
20
+ export { computeHistoryBudget, splitHistoryBudget, estimateTokens, estimateTokensJson, DEFAULT_COMPACTION_CONFIG, } from './budget.js';
21
+ export type { CompactionConfig, BudgetBreakdown, BudgetResult, SplitBudget } from './budget.js';
22
+ export { transcriptForSummary, messageText, DEFAULT_COMPACTION_PROMPT } from './transcript.js';
23
+ export type { AparteCompactEventDetail, AparteCompactStartEventDetail, AparteCompactDoneEventDetail, AparteCompactErrorEventDetail, } from '@aparte/core';
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,YAAY,EACR,sBAAsB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,oBAAoB,EACrF,gBAAgB,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,oBAAoB,GAC3F,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,YAAY,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAC5H,OAAO,EACH,oBAAoB,EAAE,kBAAkB,EAAE,cAAc,EAAE,kBAAkB,EAAE,yBAAyB,GAC1G,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAChG,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AAC/F,YAAY,EACR,wBAAwB,EAAE,6BAA6B,EAAE,4BAA4B,EAAE,6BAA6B,GACvH,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,355 @@
1
+ import { aparteGlobalConfig, uuid, revokeAttachmentUrls, resolveConfig, contentToText } from "@aparte/core";
2
+ const DEFAULT_COMPACTION_CONFIG = {
3
+ // Conservative model-agnostic defaults — the consuming app overrides
4
+ // `contextWindow` / `reservedThinking` with its model's real values.
5
+ contextWindow: 8192,
6
+ reservedThinking: 0,
7
+ reservedGeneration: 2e3,
8
+ autocompactBufferPct: 0.1,
9
+ // ~3300 tok
10
+ safetyMargin: 500,
11
+ minHistoryBudget: 1e3,
12
+ summaryRatio: 0.1,
13
+ summaryMaxTokens: 400
14
+ };
15
+ function estimateTokens(text) {
16
+ if (!text) return 0;
17
+ return Math.ceil(text.length / 3.8);
18
+ }
19
+ function estimateTokensJson(obj) {
20
+ if (!obj) return 0;
21
+ try {
22
+ return estimateTokens(JSON.stringify(obj));
23
+ } catch {
24
+ return 0;
25
+ }
26
+ }
27
+ function computeHistoryBudget(input) {
28
+ const cfg = { ...DEFAULT_COMPACTION_CONFIG, ...input.config ?? {} };
29
+ const systemTokens = estimateTokens(input.systemPrompt);
30
+ const toolsTokens = estimateTokensJson(input.toolsArray);
31
+ const autocompactBuffer = Math.floor(cfg.contextWindow * cfg.autocompactBufferPct);
32
+ const fixed = systemTokens + toolsTokens + cfg.reservedThinking + cfg.reservedGeneration + autocompactBuffer + cfg.safetyMargin;
33
+ const historyBudget = Math.max(cfg.minHistoryBudget, cfg.contextWindow - fixed);
34
+ return {
35
+ historyBudget,
36
+ breakdown: {
37
+ contextWindow: cfg.contextWindow,
38
+ systemPrompt: systemTokens,
39
+ tools: toolsTokens,
40
+ reservedThinking: cfg.reservedThinking,
41
+ reservedGeneration: cfg.reservedGeneration,
42
+ autocompactBuffer,
43
+ safetyMargin: cfg.safetyMargin,
44
+ historyAvailable: historyBudget
45
+ },
46
+ config: cfg
47
+ };
48
+ }
49
+ function splitHistoryBudget(historyBudget, cfg = DEFAULT_COMPACTION_CONFIG) {
50
+ const summary = Math.min(cfg.summaryMaxTokens, Math.floor(historyBudget * cfg.summaryRatio));
51
+ return { summary, window: historyBudget - summary };
52
+ }
53
+ const read = (value) => typeof value === "function" ? value() : value;
54
+ const textOf = (message) => {
55
+ if (typeof message.content === "string" && message.content.length > 0) return message.content;
56
+ return (message.segments ?? []).map((segment) => {
57
+ const content = segment.content;
58
+ return typeof content === "string" ? content : "";
59
+ }).join("\n");
60
+ };
61
+ function createCompactionSelector(options) {
62
+ const minKeep = Math.max(0, options.minKeep ?? 2);
63
+ return (messages) => {
64
+ const contextWindow = read(options.contextWindow);
65
+ if (!contextWindow || contextWindow <= 0) return { keep: messages, drop: [] };
66
+ const budget = computeHistoryBudget({
67
+ systemPrompt: read(options.systemPrompt) ?? "",
68
+ toolsArray: read(options.tools),
69
+ config: { ...options.config, contextWindow }
70
+ });
71
+ const windowBudget = splitHistoryBudget(budget.historyBudget, budget.config).window;
72
+ let used = 0;
73
+ let cut = messages.length;
74
+ for (let i = messages.length - 1; i >= 0; i--) {
75
+ const cost = estimateTokens(textOf(messages[i]));
76
+ const kept = messages.length - 1 - i;
77
+ if (used + cost > windowBudget && kept >= minKeep) break;
78
+ used += cost;
79
+ cut = i;
80
+ }
81
+ if (cut === 0) return { keep: messages, drop: [] };
82
+ return { keep: messages.slice(cut), drop: messages.slice(0, cut) };
83
+ };
84
+ }
85
+ 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.";
86
+ const clip = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
87
+ const safeJson = (value) => {
88
+ try {
89
+ return JSON.stringify(value) ?? "";
90
+ } catch {
91
+ return String(value);
92
+ }
93
+ };
94
+ function messageText(message) {
95
+ const parts = [];
96
+ for (const segment of message.segments ?? []) {
97
+ const content = segment.content;
98
+ if (segment.type === "text") {
99
+ if (typeof content === "string" && content) parts.push(content);
100
+ } else if (segment.type === "code") {
101
+ const lang = segment.language ?? "";
102
+ parts.push(`\`\`\`${lang}
103
+ ${typeof content === "string" ? content : ""}
104
+ \`\`\``);
105
+ } else if (segment.type === "thinking" || segment.type === "tool_call" || segment.type === "error") {
106
+ continue;
107
+ } else if (typeof content === "string" && content) {
108
+ parts.push(content);
109
+ } else {
110
+ const fallback = segment.fallback;
111
+ if (typeof fallback === "string" && fallback) parts.push(fallback);
112
+ }
113
+ }
114
+ const rendered = parts.join("\n").trim();
115
+ if (rendered) return rendered;
116
+ return typeof message.content === "string" ? message.content : "";
117
+ }
118
+ function transcriptForSummary(message) {
119
+ const lines = [];
120
+ const text = messageText(message);
121
+ if (text) lines.push(text);
122
+ for (const segment of message.segments ?? []) {
123
+ if (segment.type === "tool_call") {
124
+ const call = segment.toolCall;
125
+ const input = clip(safeJson(call.input), 300);
126
+ const outcome = segment.result !== void 0 ? `→ ${clip(segment.result, 600)}` : `(${segment.status})`;
127
+ lines.push(`[tool ${call.name}] ${input} ${outcome}`);
128
+ } else if (segment.type === "error") {
129
+ const content = segment.content;
130
+ if (content) lines.push(`[error] ${clip(content, 300)}`);
131
+ }
132
+ }
133
+ return lines.join("\n");
134
+ }
135
+ const ABORTED = "Compaction aborted";
136
+ const withAbort = (work, signal) => new Promise((resolve, reject) => {
137
+ const onAbort = () => reject(new Error(ABORTED));
138
+ if (signal.aborted) {
139
+ onAbort();
140
+ return;
141
+ }
142
+ signal.addEventListener("abort", onAbort, { once: true });
143
+ work.then(
144
+ (value) => {
145
+ signal.removeEventListener("abort", onAbort);
146
+ resolve(value);
147
+ },
148
+ (error) => {
149
+ signal.removeEventListener("abort", onAbort);
150
+ reject(error);
151
+ }
152
+ );
153
+ });
154
+ const controllers = /* @__PURE__ */ new WeakMap();
155
+ const asTarget = (candidate, depth = 0) => {
156
+ if (!candidate || typeof candidate !== "object" || depth > 1) return null;
157
+ const el = candidate;
158
+ if (typeof el.getMessages === "function" && typeof el.clearAll === "function" && typeof el.appendMessage === "function") {
159
+ return el;
160
+ }
161
+ return asTarget(el.viewport, depth + 1);
162
+ };
163
+ const resolveDomTarget = (targetId) => {
164
+ if (typeof document === "undefined") return null;
165
+ if (targetId) return asTarget(document.getElementById(targetId));
166
+ for (const el of document.querySelectorAll("aparte-chat, aparte-chat-viewport, [data-aparte-chat]")) {
167
+ const target = asTarget(el);
168
+ if (target) return target;
169
+ }
170
+ return null;
171
+ };
172
+ const hasWindow = typeof window !== "undefined";
173
+ const inFlight = (message) => message.status === "streaming" || message.status === "pending";
174
+ function defaultSelector(config, keepWithoutWindow) {
175
+ const budgeted = createCompactionSelector({
176
+ contextWindow: () => config.getCurrentModel()?.contextWindow,
177
+ systemPrompt: () => config.resolveSystemPrompt(),
178
+ tools: () => config.getTools()
179
+ });
180
+ return (messages) => {
181
+ if (config.getCurrentModel()?.contextWindow) return budgeted(messages);
182
+ const cut = Math.max(0, messages.length - keepWithoutWindow);
183
+ return { keep: messages.slice(cut), drop: messages.slice(0, cut) };
184
+ };
185
+ }
186
+ function buildRequest(drop, prompt, modelId) {
187
+ const history = drop.filter((m) => !inFlight(m)).map((m) => ({ role: m.role, content: transcriptForSummary(m) })).filter((m) => contentToText(m.content).length > 0);
188
+ return {
189
+ messages: [
190
+ { role: "system", content: prompt },
191
+ ...history,
192
+ { role: "user", content: "Please summarize this conversation." }
193
+ ],
194
+ modelId,
195
+ stream: false,
196
+ // Named as what it is, so a backend can route it to a cheaper model.
197
+ _meta: { compaction: true }
198
+ };
199
+ }
200
+ async function summarizeThroughTransport(config, provider, request, signal, keyResolver) {
201
+ const resolved = keyResolver ? await keyResolver(provider.id) : void 0;
202
+ const auth = resolved || await config.getKey(provider.id) || void 0;
203
+ if (signal.aborted) throw new Error(ABORTED);
204
+ const response = await config.getTransport().chat(provider, request, auth, { providerId: provider.id, signal });
205
+ if (typeof response === "string") return response;
206
+ const reader = response.getReader();
207
+ const chunks = [];
208
+ try {
209
+ while (true) {
210
+ const { done, value } = await reader.read();
211
+ if (done) break;
212
+ if (value.type === "text") chunks.push(value.delta);
213
+ }
214
+ } finally {
215
+ reader.releaseLock();
216
+ }
217
+ return chunks.join("");
218
+ }
219
+ function setupCompaction(options = {}, config = aparteGlobalConfig) {
220
+ controllers.get(config)?.dispose();
221
+ const keepWithoutWindow = Math.max(0, options.keepWithoutWindow ?? 4);
222
+ const select = options.selector ?? defaultSelector(config, keepWithoutWindow);
223
+ const resolveTarget = options.resolveTarget ?? resolveDomTarget;
224
+ const scope = options.scopeToTargetId;
225
+ let running = null;
226
+ let disposed = false;
227
+ const addressed = (e) => {
228
+ const targetId = e.detail?.targetId;
229
+ if (scope) return targetId === scope;
230
+ if (config === aparteGlobalConfig || typeof document === "undefined") return true;
231
+ const el = targetId ? document.getElementById(targetId) : document.querySelector("aparte-chat, aparte-chat-viewport, [data-aparte-chat]");
232
+ if (!el) return true;
233
+ const owner = resolveConfig(el);
234
+ return owner === config || owner === aparteGlobalConfig;
235
+ };
236
+ const onCompact = (e) => {
237
+ if (!addressed(e)) return;
238
+ void controller.compact(e.detail?.targetId);
239
+ };
240
+ const onAbort = (e) => {
241
+ if (!addressed(e) || !running) return;
242
+ const targetId = e.detail?.targetId;
243
+ if (targetId && running.targetId && targetId !== running.targetId) return;
244
+ controller.abort();
245
+ };
246
+ const listening = options.listen !== false && typeof window !== "undefined";
247
+ if (listening) {
248
+ window.addEventListener("aparte-compact", onCompact);
249
+ window.addEventListener("aparte-abort", onAbort);
250
+ }
251
+ const controller = {
252
+ get running() {
253
+ return running !== null;
254
+ },
255
+ abort() {
256
+ running?.abort.abort();
257
+ },
258
+ dispose() {
259
+ if (disposed) return;
260
+ disposed = true;
261
+ controller.abort();
262
+ if (listening) {
263
+ window.removeEventListener("aparte-compact", onCompact);
264
+ window.removeEventListener("aparte-abort", onAbort);
265
+ }
266
+ if (controllers.get(config) === controller) controllers.delete(config);
267
+ },
268
+ async compact(requested) {
269
+ const targetId = requested ?? scope;
270
+ const fail = (error) => {
271
+ console.warn(`[aparte/plugin-compaction] compaction failed: ${error}`);
272
+ if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error, targetId } }));
273
+ return { ok: false, error, targetId };
274
+ };
275
+ const skip = (reason) => {
276
+ if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true, reason, targetId } }));
277
+ return { ok: true, skipped: true, reason, targetId };
278
+ };
279
+ if (disposed) return fail("This compaction setup was disposed");
280
+ if (running) return skip("running");
281
+ const target = resolveTarget(targetId);
282
+ if (!target) return fail(targetId ? `No chat with id "${targetId}" to compact` : "No chat found to compact");
283
+ const messages = target.getMessages();
284
+ if (messages.length === 0) return skip("empty");
285
+ if (messages.some(inFlight)) return skip("streaming");
286
+ const { keep, drop } = select(messages);
287
+ if (drop.length === 0) return skip("nothing-to-drop");
288
+ const modelConfig = config.getModelConfig();
289
+ const providerId = modelConfig.defaultProvider;
290
+ let provider;
291
+ if (!options.summarize) {
292
+ if (!providerId) return fail("No provider configured");
293
+ provider = config.getAIProvider(providerId);
294
+ if (!provider) return fail(`Provider '${providerId}' not found`);
295
+ }
296
+ const request = buildRequest(drop, options.prompt ?? DEFAULT_COMPACTION_PROMPT, modelConfig.defaultModel || "");
297
+ if (request.messages.length <= 2) return fail("Nothing summarisable in the dropped turns");
298
+ const abort = new AbortController();
299
+ running = { targetId, abort };
300
+ if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-start", { detail: { targetId } }));
301
+ try {
302
+ const raw = await withAbort(
303
+ options.summarize ? options.summarize(request, abort.signal) : summarizeThroughTransport(config, provider, request, abort.signal, options.keyResolver),
304
+ abort.signal
305
+ );
306
+ const summary = raw.trim();
307
+ if (!summary) throw new Error("Empty summary returned by model");
308
+ const selectedIds = new Set(messages.map((m) => m.id));
309
+ const keptIds = new Set(keep.map((m) => m.id));
310
+ const live = target.getMessages();
311
+ if (!live.some((m) => selectedIds.has(m.id))) return fail("The transcript changed while the summary was being written");
312
+ const kept = live.filter((m) => keptIds.has(m.id));
313
+ const arrived = live.filter((m) => !selectedIds.has(m.id));
314
+ target.clearAll({ revokeAttachments: false });
315
+ target.appendMessage({
316
+ id: uuid(),
317
+ role: "user",
318
+ compaction: true,
319
+ content: `**${config.t("compactionSummaryTitle")}**
320
+
321
+ ${summary}`,
322
+ timestamp: Date.now(),
323
+ status: "completed"
324
+ });
325
+ for (const message of kept) target.appendMessage(message);
326
+ for (const message of arrived) target.appendMessage(message);
327
+ const survivors = new Set([...kept, ...arrived].map((m) => m.id));
328
+ for (const message of drop) if (!survivors.has(message.id)) revokeAttachmentUrls(message.attachments);
329
+ const outcome = { ok: true, skipped: false, summary, kept: kept.length, dropped: drop.length, targetId };
330
+ if (hasWindow) window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { summary, kept: kept.length, dropped: drop.length, targetId } }));
331
+ return outcome;
332
+ } catch (err) {
333
+ const aborted = abort.signal.aborted || err instanceof Error && err.name === "AbortError";
334
+ return fail(aborted ? ABORTED : err instanceof Error ? err.message : String(err));
335
+ } finally {
336
+ running = null;
337
+ }
338
+ }
339
+ };
340
+ controllers.set(config, controller);
341
+ return controller;
342
+ }
343
+ export {
344
+ DEFAULT_COMPACTION_CONFIG,
345
+ DEFAULT_COMPACTION_PROMPT,
346
+ computeHistoryBudget,
347
+ createCompactionSelector,
348
+ estimateTokens,
349
+ estimateTokensJson,
350
+ messageText,
351
+ setupCompaction,
352
+ splitHistoryBudget,
353
+ transcriptForSummary
354
+ };
355
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/budget.ts","../src/selector.ts","../src/transcript.ts","../src/compaction.ts"],"sourcesContent":["/**\n * budget.ts — the context-window budget.\n *\n * One question, answered without a tokenizer: how much of the model's window is left\n * for the conversation once the fixed costs are paid — the system prompt, the tools,\n * the room reserved for the reply and for thinking, a buffer, a margin. The answer is\n * what `createCompactionSelector` (selector.ts) walks the history against.\n *\n * Budget = contextWindow − systemPrompt − tools − reservedThinking\n * − reservedGeneration − autocompactBuffer − safetyMargin\n *\n * The budget is then split in two: a slot for the running summary and the sliding\n * window of verbatim turns.\n *\n * It lived in `@aparte/engine` until 0.16.0. Nothing in the loop read it — the loop\n * reports usage and lets the caller decide — so it moved here, next to the one thing\n * that does: the compaction this plugin performs. Zero deps, a char-count heuristic.\n */\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface CompactionConfig {\n /** Total context window of the active model, in tokens. */\n contextWindow: number;\n /** Reserved budget for the model's thinking/reasoning block, in tokens. */\n reservedThinking: number;\n /** Reserved budget for the assistant response (max_new_tokens cap). */\n reservedGeneration: number;\n /** Fraction of context_window kept as autocompact buffer (0..1). */\n autocompactBufferPct: number;\n /** Hard safety margin in tokens. */\n safetyMargin: number;\n /** Floor for history budget — never compact below this. */\n minHistoryBudget: number;\n /** Ratio of history budget allocated to the summary block. */\n summaryRatio: number;\n /** Hard cap for summary tokens. */\n summaryMaxTokens: number;\n}\n\nexport interface BudgetBreakdown {\n contextWindow: number;\n systemPrompt: number;\n tools: number;\n reservedThinking: number;\n reservedGeneration: number;\n autocompactBuffer: number;\n safetyMargin: number;\n historyAvailable: number;\n}\n\nexport interface BudgetResult {\n historyBudget: number;\n breakdown: BudgetBreakdown;\n config: CompactionConfig;\n}\n\n/** The history budget, split: what the running summary may take, what the verbatim window gets. */\nexport interface SplitBudget {\n summary: number;\n window: number;\n}\n\n// ─── Constants ──────────────────────────────────────────────────────────────\n\nexport const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {\n // Conservative model-agnostic defaults — the consuming app overrides\n // `contextWindow` / `reservedThinking` with its model's real values.\n contextWindow: 8192,\n reservedThinking: 0,\n reservedGeneration: 2000,\n autocompactBufferPct: 0.10, // ~3300 tok\n safetyMargin: 500,\n minHistoryBudget: 1000,\n\n summaryRatio: 0.10,\n summaryMaxTokens: 400,\n};\n\n// ─── Token estimation ──────────────────────────────────────────────────────\n\n/**\n * Token heuristic (FR ~3.5 chars/tok, EN ~4 chars/tok).\n * Accurate to ±10% — enough for budgeting, and it avoids `tokenizer.encode()`,\n * which costs ~5ms per message × N (too slow to run on every turn).\n */\nexport function estimateTokens(text: string | null | undefined): number {\n if (!text) return 0;\n return Math.ceil(text.length / 3.8);\n}\n\n/**\n * Estimate tokens for a JSON-serializable structure (e.g. tools array).\n */\nexport function estimateTokensJson(obj: unknown): number {\n if (!obj) return 0;\n try {\n return estimateTokens(JSON.stringify(obj));\n } catch {\n return 0;\n }\n}\n\n// ─── Budget computation ────────────────────────────────────────────────────\n\n/**\n * Compute the available history budget after subtracting fixed costs.\n */\nexport function computeHistoryBudget(input: {\n systemPrompt: string;\n toolsArray?: unknown;\n config?: Partial<CompactionConfig>;\n}): BudgetResult {\n const cfg: CompactionConfig = { ...DEFAULT_COMPACTION_CONFIG, ...(input.config ?? {}) };\n\n const systemTokens = estimateTokens(input.systemPrompt);\n const toolsTokens = estimateTokensJson(input.toolsArray);\n const autocompactBuffer = Math.floor(cfg.contextWindow * cfg.autocompactBufferPct);\n\n const fixed = systemTokens + toolsTokens\n + cfg.reservedThinking + cfg.reservedGeneration\n + autocompactBuffer + cfg.safetyMargin;\n\n const historyBudget = Math.max(cfg.minHistoryBudget, cfg.contextWindow - fixed);\n\n return {\n historyBudget,\n breakdown: {\n contextWindow: cfg.contextWindow,\n systemPrompt: systemTokens,\n tools: toolsTokens,\n reservedThinking: cfg.reservedThinking,\n reservedGeneration: cfg.reservedGeneration,\n autocompactBuffer,\n safetyMargin: cfg.safetyMargin,\n historyAvailable: historyBudget,\n },\n config: cfg,\n };\n}\n\n/**\n * Split the history budget: the running summary's slot (capped), and the rest for\n * the verbatim window.\n */\nexport function splitHistoryBudget(\n historyBudget: number,\n cfg: CompactionConfig = DEFAULT_COMPACTION_CONFIG,\n): SplitBudget {\n const summary = Math.min(cfg.summaryMaxTokens, Math.floor(historyBudget * cfg.summaryRatio));\n return { summary, window: historyBudget - summary };\n}\n","/**\n * selector.ts — the budget-aware selector: which messages are summarised, which stay.\n *\n * A compaction decides what to summarise through one function:\n * `(messages) => { keep, drop }`. This is the default one: the newest turns that still\n * fit the history budget stay verbatim, the older ones are dropped for summarising.\n *\n * The budget is `budget.ts`'s (`computeHistoryBudget` + `splitHistoryBudget`), so the\n * gauge a page shows, the selection the compaction uses and the window the model\n * declares all speak the same numbers. The window is read through a getter at each\n * call — a model change is picked up on the next compaction, never guessed.\n */\n\nimport { computeHistoryBudget, estimateTokens, splitHistoryBudget, type CompactionConfig } from './budget.js';\n\n/**\n * The least a message must carry to be costed: its text, or segments with text.\n * Structural on purpose, so a host's own message type fits without a cast — core's\n * `AparteMessage` satisfies it.\n */\nexport interface CompactableMessage {\n content?: string;\n /**\n * `unknown` elements, read defensively below: core's segment union includes a\n * segment with no `content` at all, and TypeScript refuses a type with no property\n * in common with the all-optional `{ content?: unknown }` (a \"weak type\"), so that\n * tighter shape made `AparteMessage` unassignable here.\n */\n segments?: ReadonlyArray<unknown>;\n}\n\n/** What a compaction asks for: the messages kept verbatim and the ones to summarise. */\nexport interface CompactionSelection<M> {\n keep: M[];\n drop: M[];\n}\n\n/**\n * The selector itself — generic in the MESSAGE type. The generic sits on the returned\n * function, not on the factory: a factory-level `<M>` has no inference site at\n * `createCompactionSelector({...})` and falls back to the bare `CompactableMessage`.\n */\nexport type CompactionSelector = <M extends CompactableMessage>(messages: M[]) => CompactionSelection<M>;\n\nexport interface CompactionSelectorOptions {\n /**\n * The active model's context window, in tokens — a number, or a getter read at\n * each call so a model change is picked up (`() => aparteGlobalConfig.getCurrentModel()?.contextWindow`).\n * Unknown (`undefined`) means nothing is dropped: without a window there is no\n * budget to be over.\n */\n contextWindow: number | (() => number | undefined);\n /** The system prompt the request carries, for the budget. A string or a getter. Default none. */\n systemPrompt?: string | (() => string | null | undefined);\n /** The tools declared to the model, for the budget. A value or a getter. Default none. */\n tools?: unknown | (() => unknown);\n /** Partial override of the budget's config — reserves, ratios, floors. */\n config?: Partial<Omit<CompactionConfig, 'contextWindow'>>;\n /** Never summarise fewer than this many of the newest messages. Default 2 — the last exchange. */\n minKeep?: number;\n}\n\nconst read = <T>(value: T | (() => T)): T => (typeof value === 'function' ? (value as () => T)() : value);\n\n/** What a message costs: its text, or the text of its segments when it has no `content`. */\nconst textOf = (message: CompactableMessage): string => {\n if (typeof message.content === 'string' && message.content.length > 0) return message.content;\n return (message.segments ?? [])\n .map((segment) => {\n const content = (segment as { content?: unknown }).content;\n return typeof content === 'string' ? content : '';\n })\n .join('\\n');\n};\n\n/**\n * Build the selector. `setupCompaction` builds this one itself over the current model;\n * build your own to close over a budget only the app knows:\n *\n * ```ts\n * setupCompaction({\n * selector: createCompactionSelector({\n * contextWindow: 32_000, // yours, not the model's\n * systemPrompt: () => aparteGlobalConfig.resolveSystemPrompt(),\n * minKeep: 6, // never summarise the last three exchanges\n * }),\n * });\n * ```\n *\n * Walks the history from the newest message: what fits the sliding window's budget\n * is kept verbatim, the rest is dropped for summarising. When everything fits,\n * `drop` is empty and the compaction reports `{ skipped: true }` — nothing to do.\n */\nexport function createCompactionSelector(options: CompactionSelectorOptions): CompactionSelector {\n const minKeep = Math.max(0, options.minKeep ?? 2);\n return <M extends CompactableMessage>(messages: M[]): CompactionSelection<M> => {\n const contextWindow = read(options.contextWindow);\n if (!contextWindow || contextWindow <= 0) return { keep: messages, drop: [] };\n\n const budget = computeHistoryBudget({\n systemPrompt: read(options.systemPrompt) ?? '',\n toolsArray: read(options.tools),\n config: { ...options.config, contextWindow },\n });\n const windowBudget = splitHistoryBudget(budget.historyBudget, budget.config).window;\n\n let used = 0;\n let cut = messages.length;\n for (let i = messages.length - 1; i >= 0; i--) {\n const cost = estimateTokens(textOf(messages[i]!));\n const kept = messages.length - 1 - i;\n if (used + cost > windowBudget && kept >= minKeep) break;\n used += cost;\n cut = i;\n }\n if (cut === 0) return { keep: messages, drop: [] };\n return { keep: messages.slice(cut), drop: messages.slice(0, cut) };\n };\n}\n","/**\n * transcript.ts — what the summariser reads.\n *\n * A message as the model should read it before summarising: its text, then one line\n * per tool call — the name, the input, the result or the status — and one per error.\n * The history the loop sends leaves tool calls out on purpose (it already carries them\n * as a call and a result); a summary is where they would otherwise be lost, and a long\n * session of tool work used to compact into a summary that had never seen a tool run.\n */\n\nimport type { AparteMessage } from '@aparte/core';\n\n/**\n * The summariser's instruction. English, because it is addressed to the model and not\n * to the user; a host that wants another language or emphasis passes `prompt`.\n */\nexport const DEFAULT_COMPACTION_PROMPT =\n 'You are compacting a conversation between a user and an assistant so that it can continue ' +\n 'with less context. Write a summary the assistant can pick the work up from: what the user ' +\n 'wants and why, the decisions taken and their reasons, the tasks still open, the facts and ' +\n 'tool results that still matter (file names, values, errors, outcomes), and anything the ' +\n 'assistant would otherwise have to ask again. Lines marked [tool …] are tool calls with their ' +\n 'result. Write in the third person, factually, as compact as completeness allows. No preamble.';\n\nconst clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max)}…` : text);\nconst safeJson = (value: unknown): string => {\n try {\n return JSON.stringify(value) ?? '';\n } catch {\n return String(value);\n }\n};\n\n/**\n * A message's text — the rule core's history serializer follows, so the summariser\n * reads what the model would have read: streamed replies keep their text in\n * `segments` (fences and the language tag kept, a type this plugin does not know by\n * its `content` else its `fallback`), and `content` is the fallback for a\n * non-streaming reply that wrote no segments at all.\n */\nexport function messageText(message: AparteMessage): string {\n const parts: string[] = [];\n for (const segment of message.segments ?? []) {\n const content = (segment as { content?: unknown }).content;\n if (segment.type === 'text') {\n if (typeof content === 'string' && content) parts.push(content);\n } else if (segment.type === 'code') {\n const lang = (segment as { language?: string }).language ?? '';\n parts.push(`\\`\\`\\`${lang}\\n${typeof content === 'string' ? content : ''}\\n\\`\\`\\``);\n } else if (segment.type === 'thinking' || segment.type === 'tool_call' || segment.type === 'error') {\n continue;\n } else if (typeof content === 'string' && content) {\n parts.push(content);\n } else {\n const fallback = (segment as { fallback?: unknown }).fallback;\n if (typeof fallback === 'string' && fallback) parts.push(fallback);\n }\n }\n const rendered = parts.join('\\n').trim();\n if (rendered) return rendered;\n return typeof message.content === 'string' ? message.content : '';\n}\n\n/**\n * The transcript line(s) of one message for the summariser: the text, then\n * `[tool name] input → result` per tool call (the input clipped at 300 characters, the\n * result at 600) and `[error] …` per error segment.\n */\nexport function transcriptForSummary(message: AparteMessage): string {\n const lines: string[] = [];\n const text = messageText(message);\n if (text) lines.push(text);\n for (const segment of message.segments ?? []) {\n if (segment.type === 'tool_call') {\n const call = segment.toolCall;\n const input = clip(safeJson(call.input), 300);\n const outcome = segment.result !== undefined\n ? `→ ${clip(segment.result, 600)}`\n : `(${segment.status})`;\n lines.push(`[tool ${call.name}] ${input} ${outcome}`);\n } else if (segment.type === 'error') {\n const content = (segment as { content?: string }).content;\n if (content) lines.push(`[error] ${clip(content, 300)}`);\n }\n }\n return lines.join('\\n');\n}\n","/**\n * compaction.ts — `setupCompaction`: the controller that answers `aparte-compact`.\n *\n * One compaction is: resolve the chat, select what to summarise (the budget-aware\n * selector over the current model by default), summarise it through the config's\n * transport — or a summariser of the host's — then replace the transcript with the\n * summary as a notice followed by the kept turns verbatim. Every step that can fail\n * reports on `window` (`aparte-compact-start` / `-done` / `-error`, each naming the\n * chat) AND in the returned outcome, so a host that called `compact()` itself never\n * has to listen for the answer to its own call.\n *\n * What this file is careful about, each learned on the client's version of it:\n * - one compaction at a time per setup — a second request while one runs is\n * reported skipped, not started (two paid summaries, both replacing the transcript);\n * - a transcript with a turn in flight is left alone — summarising under a streaming\n * reply would drop it;\n * - the summarisation has its OWN abort controller, reached by `abort()` and by an\n * `aparte-abort` addressed to the chat, so a summary the user cancelled stops there;\n * - and a summary nobody cancelled is refused too when the transcript it was written\n * from is gone: if not one selected turn is left on the target when the model\n * answers, the conversation was switched (or reset) underneath, and the summary\n * would land over a transcript it never read;\n * - what arrived while the summary was being written is kept: the replacement is\n * summary, then the kept turns, then anything newer than the selection;\n * - a chat is addressed by id through the same rule the gauge and the client use, and\n * `scopeToTargetId` makes a setup answer one chat on a page that has several.\n */\n\nimport {\n aparteGlobalConfig, contentToText, resolveConfig, revokeAttachmentUrls, uuid,\n type AparteConfig, type AparteMessage, type AparteChatMessage, type AparteChatRequest, type AparteStreamEvent,\n type AparteAIProvider, type AparteCompactDoneEventDetail, type AparteCompactErrorEventDetail,\n type AparteCompactStartEventDetail,\n} from '@aparte/core';\nimport { createCompactionSelector, type CompactionSelection } from './selector.js';\nimport { DEFAULT_COMPACTION_PROMPT, transcriptForSummary } from './transcript.js';\n\n/**\n * The three things a chat must expose to be compacted: read the active path, empty\n * it, append to it. `<aparte-chat-viewport>` does; an `<aparte-chat>` shell hands over\n * its viewport. A host whose transcript lives elsewhere (a framework store) gives\n * `resolveTarget` an object of its own.\n */\nexport interface CompactionTarget {\n getMessages(): AparteMessage[];\n /**\n * Empty the transcript. A compaction passes `{ revokeAttachments: false }`,\n * because it puts the kept turns straight back: `<aparte-chat-viewport>` releases\n * every message's object URLs on the way out, which killed the images and files of\n * the turns the compaction was keeping. It revokes the summarised-away ones itself\n * afterwards. A target that ignores the argument keeps working — it just leaks the\n * URLs of what it drops, which is what it did before this existed.\n */\n clearAll(options?: { revokeAttachments?: boolean }): void;\n appendMessage(message: AparteMessage): void;\n}\n\n/** Which messages are summarised (`drop`) and which stay verbatim (`keep`). Pure — no model call. */\nexport type CompactionMessageSelector = (messages: AparteMessage[]) => CompactionSelection<AparteMessage>;\n\n/** The key (or record of settings) for a provider — the same shape `AparteClientOptions.keyResolver` takes. */\nexport type CompactionKeyResolver = (\n providerId: string,\n) => string | Record<string, string> | undefined | null | Promise<string | Record<string, string> | undefined | null>;\n\n/** Replaces the model call: given the summarisation request, return the summary text. */\nexport type CompactionSummarizer = (request: AparteChatRequest, signal: AbortSignal) => Promise<string>;\n\n/** Why a compaction did nothing. */\nexport type CompactionSkipReason =\n /** The transcript is empty. */\n | 'empty'\n /** The selector kept everything — within budget, or nothing older than what is kept. */\n | 'nothing-to-drop'\n /** A compaction is already running for this setup. */\n | 'running'\n /** A turn is in flight in the transcript; compacting under it would drop it. */\n | 'streaming';\n\nexport type CompactionOutcome =\n | { ok: true; skipped: true; reason: CompactionSkipReason; targetId?: string }\n | { ok: true; skipped: false; summary: string; kept: number; dropped: number; targetId?: string }\n | { ok: false; error: string; targetId?: string };\n\nexport interface CompactionSetupOptions {\n /**\n * Which messages are summarised away and which stay verbatim.\n *\n * Default: `createCompactionSelector` over the current model — its `contextWindow`,\n * the resolved system prompt and the registered tools set the budget, and the\n * newest turns that fit the window stay. When the current model declares no\n * window there is no budget to walk, so the last `keepWithoutWindow` messages\n * stay and the rest is summarised.\n */\n selector?: CompactionMessageSelector;\n /** How many of the newest messages stay when the model declares no window. Default 4 — the last two exchanges. */\n keepWithoutWindow?: number;\n /**\n * The summariser's system prompt. English by default — an instruction to the\n * model, not a string the user reads — asking for the decisions, the open tasks\n * and the tool results that still matter. Replace it to steer the summary (a\n * language, a domain, a length).\n */\n prompt?: string;\n /**\n * The key for the provider, when it is not on the config (`config.setKeyProvider`)\n * — the resolver an `AparteClient` was given can be passed here as is. Consulted\n * first; `config.getKey(providerId)` is the fallback.\n */\n keyResolver?: CompactionKeyResolver;\n /**\n * Replace the model call entirely. The request carries the summariser's system\n * prompt, the transcript of the dropped turns and `_meta: { compaction: true }`;\n * return the summary text. For a host with an endpoint of its own, or a cheaper\n * model for summaries. With one, no provider needs to be configured at all.\n */\n summarize?: CompactionSummarizer;\n /**\n * Resolve the chat to compact. Default: the element whose id is the `targetId`\n * (or, unnamed, the first `<aparte-chat>` / `<aparte-chat-viewport>` /\n * `[data-aparte-chat]` on the page), taking its viewport when the element itself\n * cannot render. Return your own object for a transcript that lives in a store.\n */\n resolveTarget?: (targetId?: string) => CompactionTarget | null;\n /**\n * Answer only the `aparte-compact` and `aparte-abort` events that name this chat\n * id, and compact it when `compact()` is called without one. For a page with\n * several chats and one setup per chat.\n */\n scopeToTargetId?: string;\n /** Listen for `aparte-compact` and `aparte-abort` on `window`. Default `true`; `false` for a host that calls `compact()` itself. */\n listen?: boolean;\n}\n\nexport interface CompactionController {\n /**\n * Compact one chat — the one `targetId` names, else the scoped one, else the\n * first on the page. Never rejects: the outcome says what happened, and the\n * same information goes out as `aparte-compact-done` / `aparte-compact-error`.\n */\n compact(targetId?: string): Promise<CompactionOutcome>;\n /** Abort the summarisation in flight, if any. The transcript is left untouched. */\n abort(): void;\n /** `true` while a summarisation is in flight. */\n readonly running: boolean;\n /** Remove the listeners and forget this setup; an in-flight summarisation is aborted. */\n dispose(): void;\n}\n\nconst ABORTED = 'Compaction aborted';\n\n/**\n * The model call, settled by the signal as well as by itself: an abort resolves the\n * compaction NOW, whether or not the transport (or a host's `summarize`) honours the\n * signal — a late result is discarded. Without this, a transport that ignored the\n * signal kept `running` true for as long as it pleased.\n */\nconst withAbort = <T>(work: Promise<T>, signal: AbortSignal): Promise<T> =>\n new Promise<T>((resolve, reject) => {\n const onAbort = (): void => reject(new Error(ABORTED));\n if (signal.aborted) {\n onAbort();\n return;\n }\n signal.addEventListener('abort', onAbort, { once: true });\n work.then(\n (value) => { signal.removeEventListener('abort', onAbort); resolve(value); },\n (error: unknown) => { signal.removeEventListener('abort', onAbort); reject(error); },\n );\n });\n\n/** The setup per config, so a hot-reloading host does not stack listeners. */\nconst controllers = new WeakMap<AparteConfig, CompactionController>();\n\nconst asTarget = (candidate: unknown, depth = 0): CompactionTarget | null => {\n if (!candidate || typeof candidate !== 'object' || depth > 1) return null;\n const el = candidate as Partial<CompactionTarget> & { viewport?: unknown };\n if (typeof el.getMessages === 'function' && typeof el.clearAll === 'function' && typeof el.appendMessage === 'function') {\n return el as CompactionTarget;\n }\n // The `<aparte-chat>` shell matches the selectors but renders through its viewport.\n return asTarget(el.viewport, depth + 1);\n};\n\n/** The default target: by id, else the first chat host on the page that can render. */\nconst resolveDomTarget = (targetId?: string): CompactionTarget | null => {\n if (typeof document === 'undefined') return null;\n if (targetId) return asTarget(document.getElementById(targetId));\n for (const el of document.querySelectorAll('aparte-chat, aparte-chat-viewport, [data-aparte-chat]')) {\n const target = asTarget(el);\n if (target) return target;\n }\n return null;\n};\n\n/** A host without a document (Node, a test of the selection alone) gets the outcome and no events. */\nconst hasWindow = typeof window !== 'undefined';\n\nconst inFlight = (message: AparteMessage): boolean => message.status === 'streaming' || message.status === 'pending';\n\n/** The default selector: the engine budget over the current model, read at each call. */\nfunction defaultSelector(config: AparteConfig, keepWithoutWindow: number): CompactionMessageSelector {\n const budgeted = createCompactionSelector({\n contextWindow: () => config.getCurrentModel()?.contextWindow,\n systemPrompt: () => config.resolveSystemPrompt(),\n tools: () => config.getTools(),\n });\n return (messages) => {\n if (config.getCurrentModel()?.contextWindow) return budgeted(messages);\n const cut = Math.max(0, messages.length - keepWithoutWindow);\n return { keep: messages.slice(cut), drop: messages.slice(0, cut) };\n };\n}\n\n/**\n * The summarisation request: the instruction, the dropped turns as a transcript, the ask.\n *\n * Every turn being deleted is summarised, minus the ones still in flight — the same\n * `inFlight` predicate `compact()` guards the whole transcript with, so the two cannot\n * disagree. The clause here used to be hand-written and demanded `status: 'completed'`\n * on an assistant turn, which meant a host appending its own messages (no `status` at\n * all — the shape the docs teach) had its replies deleted without ever reaching the\n * summariser. A turn that ended in an error is summarised too: the user read it, and it\n * is about to be deleted.\n */\nfunction buildRequest(drop: AparteMessage[], prompt: string, modelId: string): AparteChatRequest {\n const history: AparteChatMessage[] = drop\n .filter((m) => !inFlight(m))\n .map((m) => ({ role: m.role, content: transcriptForSummary(m) }))\n .filter((m) => contentToText(m.content).length > 0);\n return {\n messages: [\n { role: 'system', content: prompt },\n ...history,\n { role: 'user', content: 'Please summarize this conversation.' },\n ],\n modelId,\n stream: false,\n // Named as what it is, so a backend can route it to a cheaper model.\n _meta: { compaction: true },\n };\n}\n\n/** The model call through the config's transport, non-streaming, draining a stream if one comes back anyway. */\nasync function summarizeThroughTransport(\n config: AparteConfig,\n provider: AparteAIProvider,\n request: AparteChatRequest,\n signal: AbortSignal,\n keyResolver: CompactionKeyResolver | undefined,\n): Promise<string> {\n const resolved = keyResolver ? await keyResolver(provider.id) : undefined;\n const auth = resolved || (await config.getKey(provider.id)) || undefined;\n // The key was resolved asynchronously; an abort may have landed meanwhile.\n if (signal.aborted) throw new Error(ABORTED);\n const response = await config.getTransport().chat(provider, request, auth, { providerId: provider.id, signal });\n if (typeof response === 'string') return response;\n const reader = (response as ReadableStream<AparteStreamEvent>).getReader();\n const chunks: string[] = [];\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value.type === 'text') chunks.push(value.delta);\n }\n } finally {\n reader.releaseLock();\n }\n return chunks.join('');\n}\n\n/**\n * Install compaction on a config. Returns the controller; the same call on the same\n * config replaces the previous setup (its listeners are removed first).\n *\n * ```ts\n * import { setupCompaction } from '@aparte/plugin-compaction';\n *\n * const compaction = setupCompaction(); // the global config, the current model's budget\n * // `<aparte-context auto-compact>` now asks for a compaction on reaching 90 %;\n * // or ask yourself:\n * await compaction.compact();\n * ```\n */\nexport function setupCompaction(options: CompactionSetupOptions = {}, config: AparteConfig = aparteGlobalConfig): CompactionController {\n controllers.get(config)?.dispose();\n\n const keepWithoutWindow = Math.max(0, options.keepWithoutWindow ?? 4);\n const select = options.selector ?? defaultSelector(config, keepWithoutWindow);\n const resolveTarget = options.resolveTarget ?? resolveDomTarget;\n const scope = options.scopeToTargetId;\n\n let running: { targetId: string | undefined; abort: AbortController } | null = null;\n let disposed = false;\n\n /**\n * Is a window event for this setup? Scoped: only the chat it names. On the global\n * config: every event — the single-chat page, which must need no wiring. On a\n * config of its own, the client's rule: answer a chat unless it demonstrably\n * belongs to ANOTHER instance — one whose boundary (`attachConfig`) resolves a\n * different, non-global config. Without this, two setups on two configs both\n * answered every `aparte-compact`: two summaries, one of them from the wrong model.\n */\n const addressed = (e: Event): boolean => {\n const targetId = (e as CustomEvent<{ targetId?: string } | undefined>).detail?.targetId;\n if (scope) return targetId === scope;\n if (config === aparteGlobalConfig || typeof document === 'undefined') return true;\n const el = targetId\n ? document.getElementById(targetId)\n : document.querySelector<HTMLElement>('aparte-chat, aparte-chat-viewport, [data-aparte-chat]');\n if (!el) return true;\n const owner = resolveConfig(el);\n return owner === config || owner === aparteGlobalConfig;\n };\n const onCompact = (e: Event): void => {\n if (!addressed(e)) return;\n void controller.compact((e as CustomEvent<{ targetId?: string } | undefined>).detail?.targetId);\n };\n const onAbort = (e: Event): void => {\n if (!addressed(e) || !running) return;\n // An abort that names another chat is not ours; an unnamed one stops whatever runs.\n const targetId = (e as CustomEvent<{ targetId?: string } | undefined>).detail?.targetId;\n if (targetId && running.targetId && targetId !== running.targetId) return;\n controller.abort();\n };\n const listening = options.listen !== false && typeof window !== 'undefined';\n if (listening) {\n window.addEventListener('aparte-compact', onCompact);\n window.addEventListener('aparte-abort', onAbort);\n }\n\n const controller: CompactionController = {\n get running() {\n return running !== null;\n },\n\n abort() {\n running?.abort.abort();\n },\n\n dispose() {\n if (disposed) return;\n disposed = true;\n controller.abort();\n if (listening) {\n window.removeEventListener('aparte-compact', onCompact);\n window.removeEventListener('aparte-abort', onAbort);\n }\n if (controllers.get(config) === controller) controllers.delete(config);\n },\n\n async compact(requested?: string): Promise<CompactionOutcome> {\n const targetId = requested ?? scope;\n // The events are written out literally, one per site: the docs' events\n // reference reads the dispatch itself to say where an event goes out from.\n const fail = (error: string): CompactionOutcome => {\n // A failure is also said on the console: the documented way to ask is a\n // fire-and-forget `aparte-compact`, and a page with no listener on the\n // error event would otherwise fail in total silence.\n console.warn(`[aparte/plugin-compaction] compaction failed: ${error}`);\n if (hasWindow) window.dispatchEvent(new CustomEvent<AparteCompactErrorEventDetail>('aparte-compact-error', { detail: { error, targetId } }));\n return { ok: false, error, targetId };\n };\n const skip = (reason: CompactionSkipReason): CompactionOutcome => {\n if (hasWindow) window.dispatchEvent(new CustomEvent<AparteCompactDoneEventDetail>('aparte-compact-done', { detail: { skipped: true, reason, targetId } }));\n return { ok: true, skipped: true, reason, targetId };\n };\n\n if (disposed) return fail('This compaction setup was disposed');\n if (running) return skip('running');\n\n const target = resolveTarget(targetId);\n if (!target) return fail(targetId ? `No chat with id \"${targetId}\" to compact` : 'No chat found to compact');\n\n const messages = target.getMessages();\n if (messages.length === 0) return skip('empty');\n if (messages.some(inFlight)) return skip('streaming');\n\n const { keep, drop } = select(messages);\n if (drop.length === 0) return skip('nothing-to-drop');\n\n const modelConfig = config.getModelConfig();\n const providerId = modelConfig.defaultProvider;\n let provider: AparteAIProvider | undefined;\n if (!options.summarize) {\n if (!providerId) return fail('No provider configured');\n provider = config.getAIProvider(providerId);\n if (!provider) return fail(`Provider '${providerId}' not found`);\n }\n\n const request = buildRequest(drop, options.prompt ?? DEFAULT_COMPACTION_PROMPT, modelConfig.defaultModel || '');\n // The instruction and the ask, and nothing between them: the turns being\n // deleted say nothing a summary could carry. Paying a model to summarise an\n // empty transcript and then replacing the conversation with the answer is\n // worse than doing nothing.\n if (request.messages.length <= 2) return fail('Nothing summarisable in the dropped turns');\n const abort = new AbortController();\n running = { targetId, abort };\n if (hasWindow) window.dispatchEvent(new CustomEvent<AparteCompactStartEventDetail>('aparte-compact-start', { detail: { targetId } }));\n\n try {\n const raw = await withAbort(\n options.summarize\n ? options.summarize(request, abort.signal)\n : summarizeThroughTransport(config, provider!, request, abort.signal, options.keyResolver),\n abort.signal,\n );\n const summary = raw.trim();\n if (!summary) throw new Error('Empty summary returned by model');\n\n // Replace, in one pass over the live transcript: the summary as a notice\n // (`compaction: true` — the viewport draws it centred without avatar or\n // actions, the history sends it under a preamble saying what it is; its role\n // is `user` because it is context handed to the model, and a `system`\n // message mid-conversation is refused by some providers), then the kept\n // turns AS THEY ARE NOW, then whatever arrived while the summary was written.\n // By id, not by object: the repository replaces a message's object on every\n // update, so a kept turn touched meanwhile would have read as \"arrived\" and\n // gone in twice.\n const selectedIds = new Set(messages.map((m) => m.id));\n const keptIds = new Set(keep.map((m) => m.id));\n const live = target.getMessages();\n // Nothing selected survived: this is not the transcript that was summarised.\n // A conversation switch (or a reset) replaces the whole active path, and the\n // summary of A would otherwise be written over B — and persisted with it.\n if (!live.some((m) => selectedIds.has(m.id))) return fail('The transcript changed while the summary was being written');\n const kept = live.filter((m) => keptIds.has(m.id));\n const arrived = live.filter((m) => !selectedIds.has(m.id));\n // Not a plain `clearAll()`: the viewport revokes the object URLs of\n // everything it drops, and the kept turns are going straight back in.\n target.clearAll({ revokeAttachments: false });\n target.appendMessage({\n id: uuid(),\n role: 'user',\n compaction: true,\n content: `**${config.t('compactionSummaryTitle')}**\\n\\n${summary}`,\n timestamp: Date.now(),\n status: 'completed',\n });\n for (const message of kept) target.appendMessage(message);\n for (const message of arrived) target.appendMessage(message);\n // Now the real casualties: a summarised-away turn is off the screen for\n // good, so its `blob:` URLs are released here rather than leaked.\n const survivors = new Set([...kept, ...arrived].map((m) => m.id));\n for (const message of drop) if (!survivors.has(message.id)) revokeAttachmentUrls(message.attachments);\n\n const outcome: CompactionOutcome = { ok: true, skipped: false, summary, kept: kept.length, dropped: drop.length, targetId };\n if (hasWindow) window.dispatchEvent(new CustomEvent<AparteCompactDoneEventDetail>('aparte-compact-done', { detail: { summary, kept: kept.length, dropped: drop.length, targetId } }));\n return outcome;\n } catch (err: unknown) {\n const aborted = abort.signal.aborted || (err instanceof Error && err.name === 'AbortError');\n return fail(aborted ? ABORTED : err instanceof Error ? err.message : String(err));\n } finally {\n running = null;\n }\n },\n };\n\n controllers.set(config, controller);\n return controller;\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * selector.ts — the budget-aware selector: which messages are summarised, which stay.
3
+ *
4
+ * A compaction decides what to summarise through one function:
5
+ * `(messages) => { keep, drop }`. This is the default one: the newest turns that still
6
+ * fit the history budget stay verbatim, the older ones are dropped for summarising.
7
+ *
8
+ * The budget is `budget.ts`'s (`computeHistoryBudget` + `splitHistoryBudget`), so the
9
+ * gauge a page shows, the selection the compaction uses and the window the model
10
+ * declares all speak the same numbers. The window is read through a getter at each
11
+ * call — a model change is picked up on the next compaction, never guessed.
12
+ */
13
+ import { type CompactionConfig } from './budget.js';
14
+ /**
15
+ * The least a message must carry to be costed: its text, or segments with text.
16
+ * Structural on purpose, so a host's own message type fits without a cast — core's
17
+ * `AparteMessage` satisfies it.
18
+ */
19
+ export interface CompactableMessage {
20
+ content?: string;
21
+ /**
22
+ * `unknown` elements, read defensively below: core's segment union includes a
23
+ * segment with no `content` at all, and TypeScript refuses a type with no property
24
+ * in common with the all-optional `{ content?: unknown }` (a "weak type"), so that
25
+ * tighter shape made `AparteMessage` unassignable here.
26
+ */
27
+ segments?: ReadonlyArray<unknown>;
28
+ }
29
+ /** What a compaction asks for: the messages kept verbatim and the ones to summarise. */
30
+ export interface CompactionSelection<M> {
31
+ keep: M[];
32
+ drop: M[];
33
+ }
34
+ /**
35
+ * The selector itself — generic in the MESSAGE type. The generic sits on the returned
36
+ * function, not on the factory: a factory-level `<M>` has no inference site at
37
+ * `createCompactionSelector({...})` and falls back to the bare `CompactableMessage`.
38
+ */
39
+ export type CompactionSelector = <M extends CompactableMessage>(messages: M[]) => CompactionSelection<M>;
40
+ export interface CompactionSelectorOptions {
41
+ /**
42
+ * The active model's context window, in tokens — a number, or a getter read at
43
+ * each call so a model change is picked up (`() => aparteGlobalConfig.getCurrentModel()?.contextWindow`).
44
+ * Unknown (`undefined`) means nothing is dropped: without a window there is no
45
+ * budget to be over.
46
+ */
47
+ contextWindow: number | (() => number | undefined);
48
+ /** The system prompt the request carries, for the budget. A string or a getter. Default none. */
49
+ systemPrompt?: string | (() => string | null | undefined);
50
+ /** The tools declared to the model, for the budget. A value or a getter. Default none. */
51
+ tools?: unknown | (() => unknown);
52
+ /** Partial override of the budget's config — reserves, ratios, floors. */
53
+ config?: Partial<Omit<CompactionConfig, 'contextWindow'>>;
54
+ /** Never summarise fewer than this many of the newest messages. Default 2 — the last exchange. */
55
+ minKeep?: number;
56
+ }
57
+ /**
58
+ * Build the selector. `setupCompaction` builds this one itself over the current model;
59
+ * build your own to close over a budget only the app knows:
60
+ *
61
+ * ```ts
62
+ * setupCompaction({
63
+ * selector: createCompactionSelector({
64
+ * contextWindow: 32_000, // yours, not the model's
65
+ * systemPrompt: () => aparteGlobalConfig.resolveSystemPrompt(),
66
+ * minKeep: 6, // never summarise the last three exchanges
67
+ * }),
68
+ * });
69
+ * ```
70
+ *
71
+ * Walks the history from the newest message: what fits the sliding window's budget
72
+ * is kept verbatim, the rest is dropped for summarising. When everything fits,
73
+ * `drop` is empty and the compaction reports `{ skipped: true }` — nothing to do.
74
+ */
75
+ export declare function createCompactionSelector(options: CompactionSelectorOptions): CompactionSelector;
76
+ //# sourceMappingURL=selector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selector.d.ts","sourceRoot":"","sources":["../src/selector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAA4D,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE9G;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;CACrC;AAED,wFAAwF;AACxF,MAAM,WAAW,mBAAmB,CAAC,CAAC;IAClC,IAAI,EAAE,CAAC,EAAE,CAAC;IACV,IAAI,EAAE,CAAC,EAAE,CAAC;CACb;AAED;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,SAAS,kBAAkB,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,mBAAmB,CAAC,CAAC,CAAC,CAAC;AAEzG,MAAM,WAAW,yBAAyB;IACtC;;;;;OAKG;IACH,aAAa,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC;IACnD,iGAAiG;IACjG,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAC1D,0FAA0F;IAC1F,KAAK,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAC;IAClC,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC,CAAC;IAC1D,kGAAkG;IAClG,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AAeD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,CAyB/F"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * transcript.ts — what the summariser reads.
3
+ *
4
+ * A message as the model should read it before summarising: its text, then one line
5
+ * per tool call — the name, the input, the result or the status — and one per error.
6
+ * The history the loop sends leaves tool calls out on purpose (it already carries them
7
+ * as a call and a result); a summary is where they would otherwise be lost, and a long
8
+ * session of tool work used to compact into a summary that had never seen a tool run.
9
+ */
10
+ import type { AparteMessage } from '@aparte/core';
11
+ /**
12
+ * The summariser's instruction. English, because it is addressed to the model and not
13
+ * to the user; a host that wants another language or emphasis passes `prompt`.
14
+ */
15
+ export declare const DEFAULT_COMPACTION_PROMPT: string;
16
+ /**
17
+ * A message's text — the rule core's history serializer follows, so the summariser
18
+ * reads what the model would have read: streamed replies keep their text in
19
+ * `segments` (fences and the language tag kept, a type this plugin does not know by
20
+ * its `content` else its `fallback`), and `content` is the fallback for a
21
+ * non-streaming reply that wrote no segments at all.
22
+ */
23
+ export declare function messageText(message: AparteMessage): string;
24
+ /**
25
+ * The transcript line(s) of one message for the summariser: the text, then
26
+ * `[tool name] input → result` per tool call (the input clipped at 300 characters, the
27
+ * result at 600) and `[error] …` per error segment.
28
+ */
29
+ export declare function transcriptForSummary(message: AparteMessage): string;
30
+ //# sourceMappingURL=transcript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAM6D,CAAC;AAWpG;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAqB1D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAkBnE"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@aparte/plugin-compaction",
3
+ "version": "0.16.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Conversation compaction for aparté — summarise the turns that no longer fit the model's window, keep the recent ones verbatim, and answer the context gauge's aparte-compact.",
8
+ "type": "module",
9
+ "sideEffects": false,
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "@aparte-workspace/source": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "peerDependencies": {
30
+ "@aparte/core": ">=0.16.0 <1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.4.0",
34
+ "vite": "^6.0.0",
35
+ "@aparte/core": "0.16.0"
36
+ },
37
+ "keywords": [
38
+ "aparte",
39
+ "plugin",
40
+ "compaction",
41
+ "context-window",
42
+ "summary",
43
+ "agent"
44
+ ],
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/apartejs/aparte.git",
49
+ "directory": "packages/plugins/compaction"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/apartejs/aparte/issues"
53
+ },
54
+ "scripts": {
55
+ "dev": "vite",
56
+ "build": "vite build && tsc -b --emitDeclarationOnly --force",
57
+ "preview": "vite preview",
58
+ "test": "vitest",
59
+ "test:run": "vitest run",
60
+ "test:coverage": "vitest run --coverage"
61
+ }
62
+ }