@tanstack/ai-compaction 0.0.0 → 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * `@tanstack/ai-compaction` — context-window compaction as a `chat()`
3
+ * middleware. `withCompaction({ maxTokens, strategy })` runs before each model
4
+ * call: when the working message set grows past `maxTokens`, the chosen
5
+ * `CompactionStrategy` rewrites the messages. Because it runs every call,
6
+ * compaction is incremental and rolling.
7
+ *
8
+ * Strategies are pluggable, mirroring `AgentLoopStrategy`. Three are built in:
9
+ * {@link evictOldest}, {@link summarizeOldest}, and {@link clearToolResults}.
10
+ * Write your own by passing any {@link CompactionStrategy}.
11
+ *
12
+ * The system prompt is never touched — `chat()` keeps it separate from
13
+ * `messages`.
14
+ */
15
+ import { MetadataCapability, getMetadata } from '@tanstack/ai'
16
+ import type {
17
+ ChatMiddleware,
18
+ ChatMiddlewareContext,
19
+ ModelMessage,
20
+ } from '@tanstack/ai'
21
+
22
+ /** CUSTOM stream event: compaction is about to run. */
23
+ export const COMPACTION_STARTED_EVENT = 'compaction:started'
24
+ /** CUSTOM stream event: compaction result (counts and previews). */
25
+ export const COMPACTION_STATE_EVENT = 'compaction:state'
26
+ /** CUSTOM stream event: compaction finished. */
27
+ export const COMPACTION_ENDED_EVENT = 'compaction:ended'
28
+
29
+ export type CompactionStreamEventName =
30
+ | typeof COMPACTION_STARTED_EVENT
31
+ | typeof COMPACTION_STATE_EVENT
32
+ | typeof COMPACTION_ENDED_EVENT
33
+
34
+ const PREVIEW_CHARS = 4000
35
+ const MAX_PREVIEWS = 24
36
+
37
+ /** One message in a `compaction:state` preview list. */
38
+ export interface CompactionMessagePreview {
39
+ role: string
40
+ tokens: number
41
+ text: string
42
+ }
43
+
44
+ /** Payload of {@link COMPACTION_STARTED_EVENT}. */
45
+ export interface CompactionStartedEventValue {
46
+ before: number
47
+ messagesBefore: number
48
+ reusedCheckpoint: boolean
49
+ maxTokens: number
50
+ strategyKey?: string
51
+ }
52
+
53
+ /** Payload of {@link COMPACTION_STATE_EVENT}. */
54
+ export interface CompactionStateEventValue {
55
+ before: number
56
+ after: number
57
+ messagesBefore: number
58
+ messagesAfter: number
59
+ reusedCheckpoint: boolean
60
+ maxTokens: number
61
+ strategyKey?: string
62
+ /** Messages removed or rewritten. */
63
+ dropped?: Array<CompactionMessagePreview>
64
+ /** Messages the model will see after compaction. */
65
+ result?: Array<CompactionMessagePreview>
66
+ }
67
+
68
+ /** Payload of {@link COMPACTION_ENDED_EVENT}. */
69
+ export interface CompactionEndedEventValue {
70
+ after: number
71
+ messagesAfter: number
72
+ reusedCheckpoint: boolean
73
+ maxTokens: number
74
+ durationMs: number
75
+ strategyKey?: string
76
+ }
77
+
78
+ function emitCompactionStarted(
79
+ ctx: ChatMiddlewareContext,
80
+ value: CompactionStartedEventValue,
81
+ ) {
82
+ ctx.emitCustomEvent(COMPACTION_STARTED_EVENT, value)
83
+ }
84
+
85
+ function emitCompactionState(
86
+ ctx: ChatMiddlewareContext,
87
+ value: CompactionStateEventValue,
88
+ ) {
89
+ ctx.emitCustomEvent(COMPACTION_STATE_EVENT, value)
90
+ }
91
+
92
+ function emitCompactionEnded(
93
+ ctx: ChatMiddlewareContext,
94
+ value: CompactionEndedEventValue,
95
+ ) {
96
+ ctx.emitCustomEvent(COMPACTION_ENDED_EVENT, value)
97
+ }
98
+
99
+ const strategyKeys = new WeakMap<CompactionStrategy, string>()
100
+ const CHECKPOINT_NAMESPACE = '@tanstack/ai-compaction'
101
+
102
+ interface CompactionCheckpoint {
103
+ schemaVersion: 1
104
+ sourceMessageCount: number
105
+ sourceHash: string
106
+ strategyKey: string
107
+ compactedMessages: Array<ModelMessage>
108
+ }
109
+
110
+ function identifyStrategy(
111
+ strategy: CompactionStrategy,
112
+ key: string | undefined,
113
+ ): CompactionStrategy {
114
+ if (key) strategyKeys.set(strategy, key)
115
+ return strategy
116
+ }
117
+
118
+ async function hashMessages(
119
+ messages: ReadonlyArray<ModelMessage>,
120
+ ): Promise<string> {
121
+ const bytes = new TextEncoder().encode(JSON.stringify(messages))
122
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
123
+ return Array.from(new Uint8Array(digest), (byte) =>
124
+ byte.toString(16).padStart(2, '0'),
125
+ ).join('')
126
+ }
127
+
128
+ function isModelMessage(value: unknown): value is ModelMessage {
129
+ return (
130
+ typeof value === 'object' &&
131
+ value !== null &&
132
+ 'role' in value &&
133
+ (value.role === 'user' ||
134
+ value.role === 'assistant' ||
135
+ value.role === 'tool') &&
136
+ 'content' in value
137
+ )
138
+ }
139
+
140
+ function isCompactionCheckpoint(value: unknown): value is CompactionCheckpoint {
141
+ return (
142
+ typeof value === 'object' &&
143
+ value !== null &&
144
+ 'schemaVersion' in value &&
145
+ value.schemaVersion === 1 &&
146
+ 'sourceMessageCount' in value &&
147
+ typeof value.sourceMessageCount === 'number' &&
148
+ Number.isInteger(value.sourceMessageCount) &&
149
+ value.sourceMessageCount >= 0 &&
150
+ 'sourceHash' in value &&
151
+ typeof value.sourceHash === 'string' &&
152
+ 'strategyKey' in value &&
153
+ typeof value.strategyKey === 'string' &&
154
+ 'compactedMessages' in value &&
155
+ Array.isArray(value.compactedMessages) &&
156
+ value.compactedMessages.every(isModelMessage)
157
+ )
158
+ }
159
+
160
+ function messagePreviewText(message: ModelMessage): string {
161
+ if (typeof message.content === 'string') return message.content
162
+ return JSON.stringify(message.content ?? '')
163
+ }
164
+
165
+ function toMessagePreview(
166
+ message: ModelMessage,
167
+ estimate: (message: ModelMessage) => number,
168
+ ): CompactionMessagePreview {
169
+ const text = messagePreviewText(message)
170
+ return {
171
+ role: message.role,
172
+ tokens: estimate(message),
173
+ text:
174
+ text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}…` : text,
175
+ }
176
+ }
177
+
178
+ function previewList(
179
+ messages: ReadonlyArray<ModelMessage>,
180
+ estimate: (message: ModelMessage) => number,
181
+ ): Array<CompactionMessagePreview> {
182
+ const mapped = messages.map((message) => toMessagePreview(message, estimate))
183
+ if (mapped.length <= MAX_PREVIEWS) return mapped
184
+ return mapped.slice(0, MAX_PREVIEWS)
185
+ }
186
+
187
+ function droppedMessages(
188
+ before: ReadonlyArray<ModelMessage>,
189
+ after: ReadonlyArray<ModelMessage>,
190
+ ): Array<ModelMessage> {
191
+ const afterKeys = new Set(after.map((message) => JSON.stringify(message)))
192
+ return before.filter((message) => !afterKeys.has(JSON.stringify(message)))
193
+ }
194
+
195
+ function compactionStateValue(args: {
196
+ before: number
197
+ after: number
198
+ messagesBefore: number
199
+ messagesAfter: number
200
+ reusedCheckpoint: boolean
201
+ maxTokens: number
202
+ strategyKey?: string
203
+ beforeMessages?: ReadonlyArray<ModelMessage>
204
+ afterMessages?: ReadonlyArray<ModelMessage>
205
+ estimate: (message: ModelMessage) => number
206
+ }): CompactionStateEventValue {
207
+ const value: CompactionStateEventValue = {
208
+ before: args.before,
209
+ after: args.after,
210
+ messagesBefore: args.messagesBefore,
211
+ messagesAfter: args.messagesAfter,
212
+ reusedCheckpoint: args.reusedCheckpoint,
213
+ maxTokens: args.maxTokens,
214
+ ...(args.strategyKey ? { strategyKey: args.strategyKey } : {}),
215
+ }
216
+ if (args.afterMessages) {
217
+ value.result = previewList(args.afterMessages, args.estimate)
218
+ }
219
+ if (args.beforeMessages && args.afterMessages) {
220
+ value.dropped = previewList(
221
+ droppedMessages(args.beforeMessages, args.afterMessages),
222
+ args.estimate,
223
+ )
224
+ }
225
+ return value
226
+ }
227
+
228
+ /** Rough token estimate for one message. Default: characters / 4. */
229
+ export function estimateMessageTokens(message: ModelMessage): number {
230
+ let text = messagePreviewText(message)
231
+ if (message.toolCalls?.length) text += JSON.stringify(message.toolCalls)
232
+ return Math.ceil(text.length / 4)
233
+ }
234
+
235
+ /** What a {@link CompactionStrategy} receives alongside the messages. */
236
+ export interface CompactionContext {
237
+ /** The `maxTokens` budget from `withCompaction`. */
238
+ maxTokens: number
239
+ /** The shared token estimator (default {@link estimateMessageTokens}). */
240
+ estimate: (message: ModelMessage) => number
241
+ }
242
+
243
+ /**
244
+ * Shrinks a message list. Called only when the estimate is over budget.
245
+ * Return the rewritten messages, or `null` to leave them unchanged.
246
+ */
247
+ export type CompactionStrategy = (
248
+ messages: ReadonlyArray<ModelMessage>,
249
+ ctx: CompactionContext,
250
+ ) => Array<ModelMessage> | null | Promise<Array<ModelMessage> | null>
251
+
252
+ /** Reported to `onCompact` after each compaction event. */
253
+ export interface CompactionInfo {
254
+ /** Estimated tokens before compaction. */
255
+ before: number
256
+ /** Estimated tokens after compaction. */
257
+ after: number
258
+ /** Message count before compaction. */
259
+ messagesBefore: number
260
+ /** Message count after compaction (unchanged for {@link clearToolResults}). */
261
+ messagesAfter: number
262
+ }
263
+
264
+ export interface CompactionOptions {
265
+ /** Compact when estimated tokens across `messages` exceed this. */
266
+ maxTokens: number
267
+ /** How to shrink the messages. Default: {@link evictOldest}. */
268
+ strategy?: CompactionStrategy
269
+ /** Per-message token estimator. Default: {@link estimateMessageTokens}. */
270
+ estimateTokens?: (message: ModelMessage) => number
271
+ /**
272
+ * Stable identity for persisted checkpoints. Set this for custom strategies
273
+ * or estimators, and change it when their output can change.
274
+ */
275
+ strategyKey?: string
276
+ /** Observe each compaction (logging, metrics). */
277
+ onCompact?: (info: CompactionInfo) => void
278
+ }
279
+
280
+ const sum = (
281
+ messages: ReadonlyArray<ModelMessage>,
282
+ estimate: (m: ModelMessage) => number,
283
+ ) => messages.reduce((total, m) => total + estimate(m), 0)
284
+
285
+ /**
286
+ * Find the split point that keeps the most recent messages up to
287
+ * `keepRecentTokens`, then moves the cut forward past any leading tool result
288
+ * so the kept tail never starts with an orphan (its tool call would be dropped).
289
+ * Returns the index where the tail begins (head is `messages[0..cut)`).
290
+ */
291
+ function splitAtRecent(
292
+ messages: ReadonlyArray<ModelMessage>,
293
+ estimate: (m: ModelMessage) => number,
294
+ keepRecentTokens: number,
295
+ ): number {
296
+ let kept = 0
297
+ let cut = messages.length
298
+ while (cut > 0) {
299
+ const prev = messages[cut - 1]
300
+ if (!prev) break
301
+ const size = estimate(prev)
302
+ if (kept + size > keepRecentTokens) break
303
+ kept += size
304
+ cut--
305
+ }
306
+ // Always keep at least the last message.
307
+ if (cut >= messages.length) cut = messages.length - 1
308
+ while (cut < messages.length && messages[cut]?.role === 'tool') cut++
309
+ // Trailing tool results: skipping orphans would drop the whole tail (the
310
+ // normal agent-loop state). Keep those results and the message that owns them.
311
+ if (cut >= messages.length) {
312
+ cut = messages.length
313
+ while (cut > 0 && messages[cut - 1]?.role === 'tool') cut--
314
+ if (cut > 0) cut--
315
+ }
316
+ return cut
317
+ }
318
+
319
+ /**
320
+ * Drop the oldest messages and replace them with a short marker. Cheapest
321
+ * strategy — no extra model call. This is the default.
322
+ */
323
+ export function evictOldest(
324
+ options: {
325
+ /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */
326
+ keepRecentTokens?: number
327
+ /** Build the marker that replaces the dropped head. */
328
+ marker?: (droppedCount: number) => string
329
+ } = {},
330
+ ): CompactionStrategy {
331
+ const strategy: CompactionStrategy = (messages, ctx) => {
332
+ const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)
333
+ const cut = splitAtRecent(messages, ctx.estimate, keep)
334
+ // Can't shrink past the recent window; raise keepRecentTokens or lower
335
+ // maxTokens if compaction never fires.
336
+ if (cut <= 0) return null
337
+ const marker =
338
+ options.marker?.(cut) ??
339
+ `[${cut} earlier message(s) omitted to save context.]`
340
+ return [{ role: 'user', content: marker }, ...messages.slice(cut)]
341
+ }
342
+ return identifyStrategy(
343
+ strategy,
344
+ options.marker
345
+ ? undefined
346
+ : `evict-oldest:${options.keepRecentTokens ?? 'half'}`,
347
+ )
348
+ }
349
+
350
+ /**
351
+ * Drop the oldest messages and replace them with an LLM summary. Keeps the gist
352
+ * of old turns at the cost of one summarization call. Wire `summarize` to
353
+ * `summarize()` or any model call.
354
+ */
355
+ export function summarizeOldest(options: {
356
+ summarize: (messages: Array<ModelMessage>) => Promise<string>
357
+ /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */
358
+ keepRecentTokens?: number
359
+ /** Role of the injected summary message. Default `'assistant'`. */
360
+ summaryRole?: 'user' | 'assistant'
361
+ }): CompactionStrategy {
362
+ const strategy: CompactionStrategy = async (messages, ctx) => {
363
+ const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)
364
+ const cut = splitAtRecent(messages, ctx.estimate, keep)
365
+ if (cut <= 0) return null
366
+ const summary = await options.summarize(messages.slice(0, cut))
367
+ return [
368
+ {
369
+ role: options.summaryRole ?? 'assistant',
370
+ content: `<untrusted-conversation-summary>\n${summary}\n</untrusted-conversation-summary>`,
371
+ },
372
+ ...messages.slice(cut),
373
+ ]
374
+ }
375
+ return identifyStrategy(
376
+ strategy,
377
+ `summarize-oldest:${options.keepRecentTokens ?? 'half'}:${options.summaryRole ?? 'assistant'}`,
378
+ )
379
+ }
380
+
381
+ /**
382
+ * Replace the content of old tool-result messages with a stub, keeping every
383
+ * message and its tool-call pairing in place. Best for agent loops where tool
384
+ * output (file reads, command output) dominates the token count — it clears the
385
+ * bulk without disturbing the conversation shape. No extra model call.
386
+ */
387
+ export function clearToolResults(
388
+ options: {
389
+ /** Number of most-recent tool results to keep verbatim. Default `3`. */
390
+ keepRecentToolResults?: number
391
+ /** Text that replaces a cleared tool result. */
392
+ stub?: string
393
+ } = {},
394
+ ): CompactionStrategy {
395
+ const keepN = options.keepRecentToolResults ?? 3
396
+ const stub = options.stub ?? '[tool output cleared to save context]'
397
+ const strategy: CompactionStrategy = (messages) => {
398
+ const toolIndexes: Array<number> = []
399
+ messages.forEach((m, i) => {
400
+ if (m.role === 'tool') toolIndexes.push(i)
401
+ })
402
+ if (toolIndexes.length <= keepN) return null
403
+ const clearBefore = toolIndexes[toolIndexes.length - keepN] ?? 0
404
+ let changed = false
405
+ const next = messages.map((m, i) => {
406
+ if (m.role === 'tool' && i < clearBefore && m.content !== stub) {
407
+ changed = true
408
+ return { ...m, content: stub }
409
+ }
410
+ return m
411
+ })
412
+ return changed ? next : null
413
+ }
414
+ return identifyStrategy(strategy, `clear-tool-results:${keepN}:${stub}`)
415
+ }
416
+
417
+ /**
418
+ * Run several strategies in order, escalating: stop as soon as the running
419
+ * estimate is back under `maxTokens`. Put the cheap, targeted strategy first
420
+ * (for example {@link clearToolResults}) and a broad fallback last (for example
421
+ * {@link evictOldest}) — the fallback only runs when clearing was not enough.
422
+ * A strategy that returns `null` (no change) is skipped and the next one runs.
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * withCompaction({
427
+ * maxTokens: 100_000,
428
+ * strategy: composeStrategies(clearToolResults(), evictOldest()),
429
+ * })
430
+ * ```
431
+ */
432
+ export function composeStrategies(
433
+ ...strategies: Array<CompactionStrategy>
434
+ ): CompactionStrategy {
435
+ const strategy: CompactionStrategy = async (messages, ctx) => {
436
+ let current: ReadonlyArray<ModelMessage> = messages
437
+ let result: Array<ModelMessage> | null = null
438
+ for (const itemStrategy of strategies) {
439
+ if (sum(current, ctx.estimate) <= ctx.maxTokens) break
440
+ const out = await itemStrategy(current, ctx)
441
+ if (out) {
442
+ current = out
443
+ result = out
444
+ }
445
+ }
446
+ return result
447
+ }
448
+ const keys = strategies.map((item) => strategyKeys.get(item))
449
+ return identifyStrategy(
450
+ strategy,
451
+ keys.every((key) => key !== undefined) ? keys.join('|') : undefined,
452
+ )
453
+ }
454
+
455
+ /**
456
+ * Context-compaction middleware. Add to `chat({ middleware: [...] })`.
457
+ *
458
+ * @example
459
+ * ```ts
460
+ * chat({
461
+ * adapter,
462
+ * messages,
463
+ * middleware: [withCompaction({ maxTokens: 100_000 })], // evictOldest by default
464
+ * })
465
+ * ```
466
+ */
467
+ export function withCompaction(options: CompactionOptions): ChatMiddleware {
468
+ const estimate = options.estimateTokens ?? estimateMessageTokens
469
+ const strategy = options.strategy ?? evictOldest()
470
+ const strategyKey =
471
+ options.strategyKey ??
472
+ (options.estimateTokens ? undefined : strategyKeys.get(strategy))
473
+ const checkpointStrategyKey = strategyKey
474
+ ? `${strategyKey}:maxTokens=${options.maxTokens}`
475
+ : undefined
476
+
477
+ return {
478
+ name: 'compaction',
479
+ optionalRequires: [MetadataCapability],
480
+ async onConfig(ctx, config) {
481
+ // init is discarded by the engine rebuild and can run before persistence
482
+ // hydrates the thread. Compact only on model-bound phases.
483
+ if (ctx.phase === 'init') return
484
+
485
+ const startedAt = Date.now()
486
+ const { messages } = config
487
+ const inputMessages = config.providerMessages ?? messages
488
+ const metadata = getMetadata(ctx, { optional: true })
489
+ let workingMessages = inputMessages
490
+ let reusedCheckpoint = false
491
+
492
+ if (metadata && checkpointStrategyKey && inputMessages === messages) {
493
+ const stored = await metadata.get(CHECKPOINT_NAMESPACE, ctx.threadId)
494
+ if (
495
+ isCompactionCheckpoint(stored) &&
496
+ stored.strategyKey === checkpointStrategyKey &&
497
+ stored.sourceMessageCount <= messages.length &&
498
+ stored.sourceHash ===
499
+ (await hashMessages(messages.slice(0, stored.sourceMessageCount)))
500
+ ) {
501
+ workingMessages = [
502
+ ...stored.compactedMessages,
503
+ ...messages.slice(stored.sourceMessageCount),
504
+ ]
505
+ reusedCheckpoint = true
506
+ }
507
+ }
508
+
509
+ const before = sum(workingMessages, estimate)
510
+ const startedValue: CompactionStartedEventValue = {
511
+ before,
512
+ messagesBefore: workingMessages.length,
513
+ reusedCheckpoint,
514
+ maxTokens: options.maxTokens,
515
+ ...(checkpointStrategyKey
516
+ ? { strategyKey: checkpointStrategyKey }
517
+ : {}),
518
+ }
519
+
520
+ if (before <= options.maxTokens) {
521
+ if (reusedCheckpoint) {
522
+ emitCompactionStarted(ctx, startedValue)
523
+ const stateValue = compactionStateValue({
524
+ before,
525
+ after: before,
526
+ messagesBefore: workingMessages.length,
527
+ messagesAfter: workingMessages.length,
528
+ reusedCheckpoint: true,
529
+ maxTokens: options.maxTokens,
530
+ strategyKey: checkpointStrategyKey,
531
+ afterMessages: workingMessages,
532
+ estimate,
533
+ })
534
+ emitCompactionState(ctx, stateValue)
535
+ emitCompactionEnded(ctx, {
536
+ after: before,
537
+ messagesAfter: workingMessages.length,
538
+ reusedCheckpoint: true,
539
+ maxTokens: options.maxTokens,
540
+ durationMs: Date.now() - startedAt,
541
+ ...(checkpointStrategyKey
542
+ ? { strategyKey: checkpointStrategyKey }
543
+ : {}),
544
+ })
545
+ return { providerMessages: workingMessages }
546
+ }
547
+ return
548
+ }
549
+
550
+ emitCompactionStarted(ctx, startedValue)
551
+ const next = await strategy(workingMessages, {
552
+ maxTokens: options.maxTokens,
553
+ estimate,
554
+ })
555
+ if (!next || next === workingMessages) {
556
+ emitCompactionEnded(ctx, {
557
+ after: before,
558
+ messagesAfter: workingMessages.length,
559
+ reusedCheckpoint,
560
+ maxTokens: options.maxTokens,
561
+ durationMs: Date.now() - startedAt,
562
+ ...(checkpointStrategyKey
563
+ ? { strategyKey: checkpointStrategyKey }
564
+ : {}),
565
+ })
566
+ if (reusedCheckpoint) {
567
+ return { providerMessages: workingMessages }
568
+ }
569
+ return
570
+ }
571
+
572
+ const info = {
573
+ before,
574
+ after: sum(next, estimate),
575
+ messagesBefore: workingMessages.length,
576
+ messagesAfter: next.length,
577
+ }
578
+ options.onCompact?.(info)
579
+ emitCompactionState(
580
+ ctx,
581
+ compactionStateValue({
582
+ ...info,
583
+ reusedCheckpoint,
584
+ maxTokens: options.maxTokens,
585
+ strategyKey: checkpointStrategyKey,
586
+ beforeMessages: workingMessages,
587
+ afterMessages: next,
588
+ estimate,
589
+ }),
590
+ )
591
+ emitCompactionEnded(ctx, {
592
+ after: info.after,
593
+ messagesAfter: info.messagesAfter,
594
+ reusedCheckpoint,
595
+ maxTokens: options.maxTokens,
596
+ durationMs: Date.now() - startedAt,
597
+ ...(checkpointStrategyKey
598
+ ? { strategyKey: checkpointStrategyKey }
599
+ : {}),
600
+ })
601
+
602
+ if (metadata && checkpointStrategyKey && inputMessages === messages) {
603
+ const checkpoint: CompactionCheckpoint = {
604
+ schemaVersion: 1,
605
+ sourceMessageCount: messages.length,
606
+ sourceHash: await hashMessages(messages),
607
+ strategyKey: checkpointStrategyKey,
608
+ compactedMessages: next,
609
+ }
610
+ if (!ctx.signal?.aborted) {
611
+ await metadata.set(CHECKPOINT_NAMESPACE, ctx.threadId, checkpoint)
612
+ }
613
+ }
614
+
615
+ return { providerMessages: next }
616
+ },
617
+ }
618
+ }