@lota-sdk/core 0.1.5

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.
Files changed (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,909 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
4
+
5
+ import { readString } from '../utils/string'
6
+ import {
7
+ COMPACTION_CHUNK_MAX_CHARS,
8
+ CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES,
9
+ CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES,
10
+ CONTEXT_COMPACTION_THRESHOLD_RATIO,
11
+ CONTEXT_OUTPUT_RESERVE_TOKENS,
12
+ CONTEXT_SAFETY_MARGIN_TOKENS,
13
+ SUMMARY_ROLLUP_MAX_TOKENS,
14
+ } from './context-compaction-constants'
15
+ import {
16
+ StructuredCompactionOutputSchema,
17
+ WorkstreamStateDeltaSchema,
18
+ WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS,
19
+ WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS,
20
+ WORKSTREAM_STATE_MAX_ARTIFACTS,
21
+ WORKSTREAM_STATE_MAX_KEY_DECISIONS,
22
+ WORKSTREAM_STATE_MAX_OPEN_QUESTIONS,
23
+ WORKSTREAM_STATE_MAX_RISKS,
24
+ WORKSTREAM_STATE_MAX_TASKS,
25
+ WorkstreamStateSchema,
26
+ createEmptyWorkstreamState,
27
+ parseStructuredWorkstreamStateDelta,
28
+ } from './workstream-state'
29
+ import type { CompactionOutput, WorkstreamState, WorkstreamStateDelta } from './workstream-state'
30
+
31
+ export interface ContextMessage {
32
+ role: 'system' | 'user' | 'assistant'
33
+ text: string
34
+ sourceMessageId: string
35
+ }
36
+
37
+ export interface CompactionAssessment {
38
+ estimatedTokens: number
39
+ threshold: number
40
+ shouldCompact: boolean
41
+ }
42
+
43
+ export interface CompactHistoryParams {
44
+ summaryText: string
45
+ liveMessages: ChatMessage[]
46
+ tailMessageCount: number
47
+ contextSize?: number
48
+ existingState: WorkstreamState
49
+ }
50
+
51
+ export interface CompactHistoryResult {
52
+ compacted: boolean
53
+ summaryText: string
54
+ lastCompactedMessageId?: string
55
+ compactedMessages: ChatMessage[]
56
+ compactedMessageCount: number
57
+ remainingMessageCount: number
58
+ estimatedTokens: number
59
+ inputChars: number
60
+ outputChars: number
61
+ state: WorkstreamState
62
+ stateDelta: WorkstreamStateDelta
63
+ }
64
+
65
+ export interface ContextCompactionRunnerParams {
66
+ previousSummary: string
67
+ existingState: WorkstreamState
68
+ chunk: ContextMessage[]
69
+ transcript: string
70
+ }
71
+
72
+ export interface ContextCompactionPromptParams {
73
+ previousSummary: string
74
+ existingState: WorkstreamState
75
+ transcript: string
76
+ }
77
+
78
+ export interface MemoryBlockCompactionPromptParams {
79
+ previousSummary: string
80
+ newEntriesText: string
81
+ }
82
+
83
+ export type ContextCompactionRunner = (params: ContextCompactionRunnerParams) => Promise<CompactionOutput>
84
+
85
+ export interface CreateContextCompactionRuntimeOptions {
86
+ runCompacter: ContextCompactionRunner
87
+ now?: () => number
88
+ randomId?: () => string
89
+ thresholdRatio?: number
90
+ outputReserveTokens?: number
91
+ safetyMarginTokens?: number
92
+ compactionChunkMaxChars?: number
93
+ summaryRollupMaxTokens?: number
94
+ includedToolNames?: readonly string[]
95
+ includedToolPrefixes?: readonly string[]
96
+ }
97
+
98
+ const PROMPT_INJECTION_PATTERN =
99
+ /\b(ignore (all )?(previous|prior|system|developer)? instructions?|system prompt|developer prompt|tool override|jailbreak|role ?override|do not follow|bypass)\b/i
100
+
101
+ function estimateTokens(text: string): number {
102
+ return Math.ceil(text.length / 3)
103
+ }
104
+
105
+ function normalizeWhitespace(value: string): string {
106
+ return value.replace(/\s+/g, ' ').trim()
107
+ }
108
+
109
+ function sanitizeStateText(value: string): string | null {
110
+ const normalized = normalizeWhitespace(value)
111
+ if (!normalized) return null
112
+ if (PROMPT_INJECTION_PATTERN.test(normalized)) return null
113
+ return normalized
114
+ }
115
+
116
+ function createStableId(prefix: string, ...parts: Array<string | number | undefined>): string {
117
+ const payload = parts
118
+ .map((part) => (part === undefined ? '' : String(part)))
119
+ .map((part) => normalizeWhitespace(part))
120
+ .join('|')
121
+ const hash = createHash('sha1').update(`${prefix}|${payload}`).digest('hex').slice(0, 20)
122
+ return `${prefix}_${hash}`
123
+ }
124
+
125
+ function readRecord(value: unknown): Record<string, unknown> | null {
126
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
127
+ }
128
+
129
+ function stringifyUnknown(value: unknown): string | null {
130
+ if (value === undefined) return null
131
+ if (typeof value === 'string') {
132
+ const normalized = value.trim()
133
+ return normalized.length > 0 ? normalized : null
134
+ }
135
+
136
+ try {
137
+ return JSON.stringify(value)
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+
143
+ function appendUnique(values: string[], nextValues: string[]): string[] {
144
+ const seen = new Set(values.map((value) => normalizeWhitespace(value).toLowerCase()))
145
+ const merged = [...values]
146
+
147
+ for (const value of nextValues) {
148
+ const normalized = normalizeWhitespace(value)
149
+ if (!normalized) continue
150
+ const key = normalized.toLowerCase()
151
+ if (seen.has(key)) continue
152
+ seen.add(key)
153
+ merged.push(normalized)
154
+ }
155
+
156
+ return merged
157
+ }
158
+
159
+ function formatSummary(summaryText: string): string {
160
+ return `Compacted context summary:\n${summaryText.trim()}`
161
+ }
162
+
163
+ function normalizeSummary(summaryText: string): string {
164
+ return summaryText.trim()
165
+ }
166
+
167
+ function buildSyntheticSummaryPayload(
168
+ summaryText: string,
169
+ ): { role: 'system'; parts: Array<{ type: 'text'; text: string }> } | null {
170
+ const summary = normalizeSummary(summaryText)
171
+ if (!summary) return null
172
+ return { role: 'system', parts: [{ type: 'text', text: formatSummary(summary) }] }
173
+ }
174
+
175
+ function readIsCompacted(message: ChatMessage): boolean {
176
+ const metadata = message.metadata && typeof message.metadata === 'object' ? message.metadata : undefined
177
+ return metadata ? (metadata as Record<string, unknown>).isCompacted === true : false
178
+ }
179
+
180
+ function markMessageCompacted(message: ChatMessage, now: () => number): ChatMessage {
181
+ if (readIsCompacted(message)) return message
182
+
183
+ return {
184
+ ...message,
185
+ metadata:
186
+ message.metadata && typeof message.metadata === 'object'
187
+ ? { ...message.metadata, isCompacted: true }
188
+ : { createdAt: now(), isCompacted: true },
189
+ }
190
+ }
191
+
192
+ export function parseWorkstreamState(value: unknown): WorkstreamState {
193
+ const parsed = WorkstreamStateSchema.safeParse(value)
194
+ return parsed.success ? parsed.data : createEmptyWorkstreamState()
195
+ }
196
+
197
+ function pickDefined<T extends Record<string, unknown>, K extends keyof T>(
198
+ next: T,
199
+ base: T,
200
+ key: K,
201
+ ): Partial<Pick<T, K>> {
202
+ if (next[key] !== undefined) {
203
+ return { [key]: next[key] } as Partial<Pick<T, K>>
204
+ }
205
+ if (base[key] !== undefined) {
206
+ return { [key]: base[key] } as Partial<Pick<T, K>>
207
+ }
208
+ return {}
209
+ }
210
+
211
+ export function toStateFieldsUpdated(delta: WorkstreamStateDelta): string[] {
212
+ const fields: string[] = []
213
+ if (delta.currentPlan !== undefined) fields.push('currentPlan')
214
+ if ((delta.newDecisions?.length ?? 0) > 0) fields.push('keyDecisions')
215
+ if ((delta.resolvedQuestions?.length ?? 0) > 0 || (delta.newQuestions?.length ?? 0) > 0) fields.push('openQuestions')
216
+ if ((delta.newConstraints?.length ?? 0) > 0) fields.push('activeConstraints')
217
+ if ((delta.newRisks?.length ?? 0) > 0) fields.push('risks')
218
+ if ((delta.taskUpdates?.length ?? 0) > 0) fields.push('tasks')
219
+ if ((delta.artifacts?.length ?? 0) > 0) fields.push('artifacts')
220
+ if (delta.agentNote) fields.push('agentContributions')
221
+ if ((delta.conflicts?.length ?? 0) > 0) fields.push('conflicts')
222
+
223
+ if (
224
+ delta.approvedBy !== undefined ||
225
+ delta.approvedAt !== undefined ||
226
+ delta.approvalMessageId !== undefined ||
227
+ delta.approvalNote !== undefined
228
+ ) {
229
+ fields.push('approval')
230
+ }
231
+
232
+ return fields
233
+ }
234
+
235
+ function toCompactionTranscript(messages: ContextMessage[]): string {
236
+ return messages
237
+ .map((message, index) => {
238
+ const role = message.role.toUpperCase()
239
+ const content = message.text.trim() || '[no text]'
240
+ return `[${index + 1}] (id=${message.sourceMessageId}) ${role}: ${content}`
241
+ })
242
+ .join('\n\n')
243
+ }
244
+
245
+ function splitByCharBudget(messages: ContextMessage[], maxChars: number): ContextMessage[][] {
246
+ if (messages.length === 0) return []
247
+
248
+ const chunks: ContextMessage[][] = []
249
+ let current: ContextMessage[] = []
250
+ let currentChars = 0
251
+
252
+ for (const message of messages) {
253
+ const serialized = `${message.role}:${message.text}`
254
+ const nextChars = serialized.length
255
+
256
+ if (current.length > 0 && currentChars + nextChars > maxChars) {
257
+ chunks.push(current)
258
+ current = [message]
259
+ currentChars = nextChars
260
+ continue
261
+ }
262
+
263
+ current.push(message)
264
+ currentChars += nextChars
265
+ }
266
+
267
+ if (current.length > 0) chunks.push(current)
268
+ return chunks
269
+ }
270
+
271
+ function mergeDelta(base: WorkstreamStateDelta, next: WorkstreamStateDelta): WorkstreamStateDelta {
272
+ return {
273
+ ...(next.currentPlan !== undefined
274
+ ? { currentPlan: next.currentPlan }
275
+ : base.currentPlan !== undefined
276
+ ? { currentPlan: base.currentPlan }
277
+ : {}),
278
+ ...(appendUnique(base.newConstraints ?? [], next.newConstraints ?? []).length > 0
279
+ ? { newConstraints: appendUnique(base.newConstraints ?? [], next.newConstraints ?? []) }
280
+ : {}),
281
+ ...(appendUnique(base.newRisks ?? [], next.newRisks ?? []).length > 0
282
+ ? { newRisks: appendUnique(base.newRisks ?? [], next.newRisks ?? []) }
283
+ : {}),
284
+ ...(appendUnique(base.newQuestions ?? [], next.newQuestions ?? []).length > 0
285
+ ? { newQuestions: appendUnique(base.newQuestions ?? [], next.newQuestions ?? []) }
286
+ : {}),
287
+ ...(appendUnique(base.resolvedQuestions ?? [], next.resolvedQuestions ?? []).length > 0
288
+ ? { resolvedQuestions: appendUnique(base.resolvedQuestions ?? [], next.resolvedQuestions ?? []) }
289
+ : {}),
290
+ ...((base.newDecisions?.length ?? 0) + (next.newDecisions?.length ?? 0) > 0
291
+ ? { newDecisions: [...(base.newDecisions ?? []), ...(next.newDecisions ?? [])] }
292
+ : {}),
293
+ ...((base.taskUpdates?.length ?? 0) + (next.taskUpdates?.length ?? 0) > 0
294
+ ? { taskUpdates: [...(base.taskUpdates ?? []), ...(next.taskUpdates ?? [])] }
295
+ : {}),
296
+ ...((base.artifacts?.length ?? 0) + (next.artifacts?.length ?? 0) > 0
297
+ ? { artifacts: [...(base.artifacts ?? []), ...(next.artifacts ?? [])] }
298
+ : {}),
299
+ ...(next.agentNote ? { agentNote: next.agentNote } : base.agentNote ? { agentNote: base.agentNote } : {}),
300
+ ...((base.conflicts?.length ?? 0) + (next.conflicts?.length ?? 0) > 0
301
+ ? { conflicts: [...(base.conflicts ?? []), ...(next.conflicts ?? [])] }
302
+ : {}),
303
+ ...pickDefined(next, base, 'approvedBy'),
304
+ ...pickDefined(next, base, 'approvedAt'),
305
+ ...pickDefined(next, base, 'approvalMessageId'),
306
+ ...pickDefined(next, base, 'approvalNote'),
307
+ }
308
+ }
309
+
310
+ export function mergeStateDelta(
311
+ existingState: WorkstreamState,
312
+ delta: WorkstreamStateDelta,
313
+ now: () => number,
314
+ ): WorkstreamState {
315
+ const timestamp = now()
316
+ const state: WorkstreamState = {
317
+ ...existingState,
318
+ currentPlan: existingState.currentPlan ? { ...existingState.currentPlan } : null,
319
+ activeConstraints: [...existingState.activeConstraints],
320
+ keyDecisions: [...existingState.keyDecisions],
321
+ tasks: [...existingState.tasks],
322
+ openQuestions: [...existingState.openQuestions],
323
+ risks: [...existingState.risks],
324
+ artifacts: [...existingState.artifacts],
325
+ agentContributions: [...existingState.agentContributions],
326
+ lastUpdated: timestamp,
327
+ }
328
+
329
+ if (delta.currentPlan !== undefined) {
330
+ if (delta.currentPlan === null) {
331
+ state.currentPlan = null
332
+ } else {
333
+ const planText = sanitizeStateText(delta.currentPlan)
334
+ if (planText) {
335
+ const planId = createStableId('plan', planText)
336
+ const existingPlan = state.currentPlan && state.currentPlan.id === planId ? state.currentPlan : null
337
+ state.currentPlan = {
338
+ id: planId,
339
+ text: planText,
340
+ source: 'agent',
341
+ approved: existingPlan?.approved ?? false,
342
+ timestamp,
343
+ sourceMessageIds: existingPlan?.sourceMessageIds ?? [],
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ if (delta.newConstraints?.length) {
350
+ for (const rawConstraint of delta.newConstraints) {
351
+ const text = sanitizeStateText(rawConstraint)
352
+ if (!text) continue
353
+ const constraintId = createStableId('constraint', text)
354
+ const exists = state.activeConstraints.some((constraint) => constraint.id === constraintId)
355
+ if (exists) continue
356
+ state.activeConstraints.push({
357
+ id: constraintId,
358
+ text,
359
+ source: 'agent',
360
+ approved: false,
361
+ timestamp,
362
+ sourceMessageIds: [],
363
+ })
364
+ }
365
+ }
366
+
367
+ if (delta.newRisks?.length) {
368
+ state.risks = appendUnique(state.risks, delta.newRisks)
369
+ }
370
+
371
+ if (delta.resolvedQuestions?.length) {
372
+ const resolvedIds = new Set(
373
+ delta.resolvedQuestions
374
+ .map((question) => sanitizeStateText(question))
375
+ .filter((question): question is string => Boolean(question))
376
+ .map((question) => createStableId('question', question)),
377
+ )
378
+ state.openQuestions = state.openQuestions.filter((question) => !resolvedIds.has(question.id))
379
+ }
380
+
381
+ if (delta.newQuestions?.length) {
382
+ for (const rawQuestion of delta.newQuestions) {
383
+ const text = sanitizeStateText(rawQuestion)
384
+ if (!text) continue
385
+ const questionId = createStableId('question', text)
386
+ const exists = state.openQuestions.some((question) => question.id === questionId)
387
+ if (exists) continue
388
+ state.openQuestions.push({ id: questionId, text, source: 'agent', timestamp, sourceMessageIds: [] })
389
+ }
390
+ }
391
+
392
+ if (delta.newDecisions?.length) {
393
+ for (const decision of delta.newDecisions) {
394
+ const normalizedDecision = sanitizeStateText(decision.decision)
395
+ const normalizedRationale = sanitizeStateText(decision.rationale)
396
+ if (!normalizedDecision || !normalizedRationale) continue
397
+
398
+ const sourceIds = [...new Set(decision.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean))]
399
+ const decisionId = createStableId('decision', normalizedDecision, normalizedRationale, sourceIds.sort().join('|'))
400
+ const alreadyExists = state.keyDecisions.some((item) => item.id === decisionId)
401
+ if (alreadyExists) continue
402
+ state.keyDecisions.push({
403
+ id: decisionId,
404
+ decision: normalizedDecision,
405
+ rationale: normalizedRationale,
406
+ agent: normalizeWhitespace(decision.agent),
407
+ sourceMessageIds: sourceIds,
408
+ confidence: decision.confidence,
409
+ timestamp,
410
+ })
411
+ }
412
+ }
413
+
414
+ if (delta.taskUpdates?.length) {
415
+ for (const update of delta.taskUpdates) {
416
+ const title = sanitizeStateText(update.title)
417
+ if (!title) continue
418
+
419
+ const externalId = sanitizeStateText(update.externalId ?? '')
420
+ const owner = normalizeWhitespace(update.owner)
421
+ const taskId = externalId ? createStableId('task-external', externalId) : createStableId('task', title, owner)
422
+ const sourceMessageIds = [
423
+ ...new Set(update.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean)),
424
+ ]
425
+ const existingIndex = state.tasks.findIndex((task) => task.id === taskId)
426
+ const nextTask = {
427
+ id: taskId,
428
+ title,
429
+ status: update.status,
430
+ owner,
431
+ ...(externalId ? { externalId } : {}),
432
+ source: 'agent' as const,
433
+ sourceMessageIds,
434
+ timestamp,
435
+ }
436
+
437
+ if (existingIndex >= 0) {
438
+ const existingTask = state.tasks[existingIndex]
439
+ state.tasks[existingIndex] = {
440
+ ...nextTask,
441
+ sourceMessageIds: [...new Set([...existingTask.sourceMessageIds, ...sourceMessageIds])],
442
+ }
443
+ } else {
444
+ state.tasks.push(nextTask)
445
+ }
446
+ }
447
+ }
448
+
449
+ if (delta.artifacts?.length) {
450
+ for (const artifact of delta.artifacts) {
451
+ const name = sanitizeStateText(artifact.name)
452
+ const pointer = sanitizeStateText(artifact.pointer)
453
+ if (!name || !pointer) continue
454
+
455
+ const artifactId = createStableId('artifact', name, pointer)
456
+ const exists = state.artifacts.some((item) => item.id === artifactId)
457
+ if (exists) continue
458
+ state.artifacts.push({ id: artifactId, name, type: normalizeWhitespace(artifact.type), pointer, timestamp })
459
+ }
460
+ }
461
+
462
+ if (delta.agentNote) {
463
+ const agent = sanitizeStateText(delta.agentNote.agent)
464
+ const summary = sanitizeStateText(delta.agentNote.summary)
465
+ if (agent && summary) {
466
+ const noteId = createStableId('agent-note', agent, summary)
467
+ const exists = state.agentContributions.some((note) => note.id === noteId)
468
+ if (!exists) {
469
+ state.agentContributions.push({ id: noteId, agent, summary, timestamp })
470
+ }
471
+ }
472
+ }
473
+
474
+ if (delta.conflicts?.length) {
475
+ for (const conflict of delta.conflicts) {
476
+ const text = sanitizeStateText(`Conflict: ${conflict.recommendation}`)
477
+ if (!text) continue
478
+ const questionId = createStableId('question', text)
479
+ const exists = state.openQuestions.some((question) => question.id === questionId)
480
+ if (exists) continue
481
+ state.openQuestions.push({ id: questionId, text, source: 'agent', timestamp, sourceMessageIds: [] })
482
+ }
483
+ }
484
+
485
+ if (delta.approvedBy !== undefined) {
486
+ state.approvedBy = sanitizeStateText(delta.approvedBy) ?? undefined
487
+ }
488
+ if (delta.approvedAt !== undefined) {
489
+ state.approvedAt = delta.approvedAt
490
+ }
491
+ if (delta.approvalMessageId !== undefined) {
492
+ state.approvalMessageId = sanitizeStateText(delta.approvalMessageId) ?? undefined
493
+ }
494
+ if (delta.approvalNote !== undefined) {
495
+ state.approvalNote = sanitizeStateText(delta.approvalNote) ?? undefined
496
+ }
497
+
498
+ state.keyDecisions = state.keyDecisions.slice(-WORKSTREAM_STATE_MAX_KEY_DECISIONS)
499
+ state.activeConstraints = state.activeConstraints.slice(-WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS)
500
+ state.tasks = state.tasks.slice(-WORKSTREAM_STATE_MAX_TASKS)
501
+ state.openQuestions = state.openQuestions.slice(-WORKSTREAM_STATE_MAX_OPEN_QUESTIONS)
502
+ state.risks = state.risks.slice(-WORKSTREAM_STATE_MAX_RISKS)
503
+ state.artifacts = state.artifacts.slice(-WORKSTREAM_STATE_MAX_ARTIFACTS)
504
+ state.agentContributions = state.agentContributions.slice(-WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS)
505
+
506
+ return WorkstreamStateSchema.parse(state)
507
+ }
508
+
509
+ export interface ContextCompactionRuntime {
510
+ createSummaryMessage: (summaryText: string) => ChatMessage | null
511
+ prependSummaryMessage: (summaryText: string, liveMessages: ChatMessage[]) => ChatMessage[]
512
+ formatWorkstreamStateForPrompt: (state: WorkstreamState | null | undefined) => string | undefined
513
+ estimateThreshold: (contextSize?: number) => number
514
+ shouldCompactHistory: (params: {
515
+ summaryText: string
516
+ liveMessages: ChatMessage[]
517
+ contextSize?: number
518
+ }) => CompactionAssessment
519
+ compactHistory: (params: CompactHistoryParams) => Promise<CompactHistoryResult>
520
+ }
521
+
522
+ export function buildContextCompactionPrompt(params: ContextCompactionPromptParams): string {
523
+ return [
524
+ '<context-compaction-input>',
525
+ '<previous-summary>',
526
+ params.previousSummary.trim() || 'None',
527
+ '</previous-summary>',
528
+ '<existing-workstream-state>',
529
+ JSON.stringify(params.existingState),
530
+ '</existing-workstream-state>',
531
+ '<new-messages>',
532
+ params.transcript || 'None',
533
+ '</new-messages>',
534
+ '</context-compaction-input>',
535
+ '',
536
+ 'Produce a concise replacement summary and a structured state delta.',
537
+ 'Only include facts supported by the new messages.',
538
+ 'Summary format is required and must use exactly these sections in order:',
539
+ 'KEY FACTS:',
540
+ '- ...',
541
+ 'CONVERSATION FLOW:',
542
+ '- ...',
543
+ 'OPEN THREADS:',
544
+ '- ...',
545
+ 'Return every stateDelta field. Use empty arrays for unchanged list fields, null for unchanged nullable fields, and currentPlan.action to signal unchanged, clear, or set.',
546
+ ].join('\n')
547
+ }
548
+
549
+ export function buildMemoryBlockCompactionPrompt(params: MemoryBlockCompactionPromptParams): string {
550
+ return [
551
+ '<memory-block-compaction>',
552
+ 'Produce a compact replacement summary for the workstream memory block.',
553
+ 'Preserve constraints, commitments, unresolved risks, and ownership.',
554
+ 'Blend the previous summary with the new raw entries into one updated summary.',
555
+ 'Return plain text only.',
556
+ '',
557
+ '<previous-summary>',
558
+ params.previousSummary.trim() || 'None',
559
+ '</previous-summary>',
560
+ '<new-entries>',
561
+ params.newEntriesText.trim() || 'None',
562
+ '</new-entries>',
563
+ '</memory-block-compaction>',
564
+ ].join('\n')
565
+ }
566
+
567
+ export function createContextCompactionRuntime(
568
+ options: CreateContextCompactionRuntimeOptions,
569
+ ): ContextCompactionRuntime {
570
+ const now = options.now ?? (() => Date.now())
571
+ const randomId = options.randomId ?? (() => randomUUID())
572
+ const thresholdRatio = options.thresholdRatio ?? CONTEXT_COMPACTION_THRESHOLD_RATIO
573
+ const outputReserveTokens = options.outputReserveTokens ?? CONTEXT_OUTPUT_RESERVE_TOKENS
574
+ const safetyMarginTokens = options.safetyMarginTokens ?? CONTEXT_SAFETY_MARGIN_TOKENS
575
+ const compactionChunkMaxChars = options.compactionChunkMaxChars ?? COMPACTION_CHUNK_MAX_CHARS
576
+ const summaryRollupMaxTokens = options.summaryRollupMaxTokens ?? SUMMARY_ROLLUP_MAX_TOKENS
577
+ const includedToolNames = new Set(options.includedToolNames ?? CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES)
578
+ const includedToolPrefixes = options.includedToolPrefixes ?? CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES
579
+
580
+ const shouldIncludeToolInCompaction = (toolName: string): boolean => {
581
+ if (includedToolNames.has(toolName)) return true
582
+ return includedToolPrefixes.some((prefix) => toolName.startsWith(prefix))
583
+ }
584
+
585
+ const toContextTextFromChatMessage = (message: ChatMessage): string => {
586
+ const chunks: string[] = []
587
+
588
+ for (const part of message.parts) {
589
+ const record = readRecord(part)
590
+ if (!record) continue
591
+
592
+ const type = readString(record.type)
593
+ if (type === 'text') {
594
+ const text = readString(record.text)
595
+ if (text) chunks.push(text)
596
+ continue
597
+ }
598
+
599
+ if (type?.startsWith('tool-')) {
600
+ const toolName = type.slice('tool-'.length) || 'unknown'
601
+ if (!shouldIncludeToolInCompaction(toolName)) continue
602
+ const state = readString(record.state)
603
+ const output = stringifyUnknown(record.output)
604
+ const input = stringifyUnknown(record.input)
605
+ const payload = output ?? input
606
+ if (payload) {
607
+ chunks.push(`[tool:${toolName}${state ? `:${state}` : ''}] ${payload}`)
608
+ }
609
+ continue
610
+ }
611
+
612
+ if (type?.startsWith('data-')) {
613
+ const dataPayload = stringifyUnknown(record.data)
614
+ if (dataPayload) {
615
+ chunks.push(`[${type}] ${dataPayload}`)
616
+ }
617
+ }
618
+ }
619
+
620
+ return chunks.join('\n').trim()
621
+ }
622
+
623
+ const toContextMessageFromChatMessage = (message: ChatMessage): ContextMessage => {
624
+ return { role: message.role, text: toContextTextFromChatMessage(message), sourceMessageId: message.id }
625
+ }
626
+
627
+ const formatWorkstreamStateForPrompt = (state: WorkstreamState | null | undefined): string | undefined => {
628
+ if (!state) return undefined
629
+
630
+ const approvedPlan =
631
+ state.currentPlan && state.currentPlan.approved ? sanitizeStateText(state.currentPlan.text) : null
632
+ const candidatePlan =
633
+ state.currentPlan && !state.currentPlan.approved ? sanitizeStateText(state.currentPlan.text) : null
634
+
635
+ const approvedConstraints = state.activeConstraints
636
+ .filter((constraint) => constraint.approved)
637
+ .map((constraint) => ({ ...constraint, text: sanitizeStateText(constraint.text) }))
638
+ .filter((constraint): constraint is typeof constraint & { text: string } => Boolean(constraint.text))
639
+
640
+ const candidateConstraints = state.activeConstraints
641
+ .filter((constraint) => !constraint.approved)
642
+ .map((constraint) => ({ ...constraint, text: sanitizeStateText(constraint.text) }))
643
+ .filter((constraint): constraint is typeof constraint & { text: string } => Boolean(constraint.text))
644
+
645
+ const openQuestions = state.openQuestions
646
+ .map((question) => ({ ...question, text: sanitizeStateText(question.text) }))
647
+ .filter((question): question is typeof question & { text: string } => Boolean(question.text))
648
+
649
+ const decisions = state.keyDecisions
650
+ .map((decision) => ({
651
+ ...decision,
652
+ decision: sanitizeStateText(decision.decision),
653
+ rationale: sanitizeStateText(decision.rationale),
654
+ }))
655
+ .filter((decision): decision is typeof decision & { decision: string; rationale: string } =>
656
+ Boolean(decision.decision && decision.rationale),
657
+ )
658
+
659
+ const tasks = state.tasks
660
+ .map((task) => ({ ...task, title: sanitizeStateText(task.title), owner: sanitizeStateText(task.owner) }))
661
+ .filter((task): task is typeof task & { title: string; owner: string } => Boolean(task.title && task.owner))
662
+
663
+ const artifacts = state.artifacts
664
+ .map((artifact) => ({
665
+ ...artifact,
666
+ name: sanitizeStateText(artifact.name),
667
+ pointer: sanitizeStateText(artifact.pointer),
668
+ }))
669
+ .filter((artifact): artifact is typeof artifact & { name: string; pointer: string } =>
670
+ Boolean(artifact.name && artifact.pointer),
671
+ )
672
+
673
+ const agentContributions = state.agentContributions
674
+ .map((note) => ({ ...note, summary: sanitizeStateText(note.summary) }))
675
+ .filter((note): note is typeof note & { summary: string } => Boolean(note.summary))
676
+
677
+ const payload = {
678
+ policy: { approvedConstraintsAreBinding: true, candidateStateIsAdvisoryOnly: true },
679
+ approved: {
680
+ currentPlan: approvedPlan ? { text: approvedPlan, source: state.currentPlan?.source } : null,
681
+ constraints: approvedConstraints.map((constraint) => ({
682
+ id: constraint.id,
683
+ text: constraint.text,
684
+ source: constraint.source,
685
+ })),
686
+ },
687
+ candidate: {
688
+ currentPlan: candidatePlan ? { text: candidatePlan, source: state.currentPlan?.source } : null,
689
+ constraints: candidateConstraints.map((constraint) => ({
690
+ id: constraint.id,
691
+ text: constraint.text,
692
+ source: constraint.source,
693
+ })),
694
+ },
695
+ decisions,
696
+ openQuestions,
697
+ risks: state.risks.map((risk) => sanitizeStateText(risk)).filter((risk): risk is string => Boolean(risk)),
698
+ tasks,
699
+ artifacts,
700
+ agentContributions,
701
+ advisory: {
702
+ approvedBy: state.approvedBy ?? null,
703
+ approvedAt: state.approvedAt ?? null,
704
+ approvalMessageId: state.approvalMessageId ?? null,
705
+ approvalNote: state.approvalNote ?? null,
706
+ },
707
+ lastUpdated: state.lastUpdated,
708
+ }
709
+
710
+ return ['<workstream-state>', JSON.stringify(payload, null, 2), '</workstream-state>'].join('\n')
711
+ }
712
+
713
+ const createSummaryMessage = (summaryText: string): ChatMessage | null => {
714
+ const summary = normalizeSummary(summaryText)
715
+ if (!summary) return null
716
+
717
+ return {
718
+ id: `summary-${randomId()}`,
719
+ role: 'system',
720
+ parts: [{ type: 'text', text: formatSummary(summary) }],
721
+ metadata: { createdAt: now() },
722
+ }
723
+ }
724
+
725
+ const prependSummaryMessage = (summaryText: string, liveMessages: ChatMessage[]): ChatMessage[] => {
726
+ const summaryMessage = createSummaryMessage(summaryText)
727
+ return summaryMessage ? [summaryMessage, ...liveMessages] : [...liveMessages]
728
+ }
729
+
730
+ const estimateThreshold = (contextSize = 256_000): number => {
731
+ const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * 0.35))
732
+ const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * 0.1))
733
+ const reservedThreshold = contextSize - (reservedOutput + safetyMargin)
734
+ const ratioThreshold = Math.floor(contextSize * thresholdRatio)
735
+ return Math.max(1_000, Math.min(contextSize - 1, Math.min(reservedThreshold, ratioThreshold)))
736
+ }
737
+
738
+ const shouldCompactHistory = (params: {
739
+ summaryText: string
740
+ liveMessages: ChatMessage[]
741
+ contextSize?: number
742
+ }): CompactionAssessment => {
743
+ const threshold = estimateThreshold(params.contextSize)
744
+ const summaryPayload = buildSyntheticSummaryPayload(params.summaryText)
745
+ const promptPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...params.liveMessages])
746
+ const estimatedTokens = estimateTokens(promptPayload)
747
+ return { estimatedTokens, threshold, shouldCompact: estimatedTokens >= threshold }
748
+ }
749
+
750
+ const compactContextMessages = async (params: {
751
+ previousSummary: string
752
+ existingState: WorkstreamState
753
+ newMessages: ContextMessage[]
754
+ }): Promise<CompactionOutput> => {
755
+ const chunks = splitByCharBudget(params.newMessages, compactionChunkMaxChars)
756
+ let summary = normalizeSummary(params.previousSummary)
757
+ let delta: WorkstreamStateDelta = {}
758
+ let currentState = params.existingState
759
+
760
+ for (const chunk of chunks) {
761
+ const transcript = toCompactionTranscript(chunk)
762
+ const output = await options.runCompacter({
763
+ previousSummary: summary,
764
+ existingState: currentState,
765
+ chunk,
766
+ transcript,
767
+ })
768
+ summary = normalizeSummary(output.summary)
769
+ const parsedDelta = WorkstreamStateDeltaSchema.parse(output.stateDelta)
770
+ delta = mergeDelta(delta, parsedDelta)
771
+ currentState = mergeStateDelta(currentState, parsedDelta, now)
772
+ }
773
+
774
+ return { summary, stateDelta: delta }
775
+ }
776
+
777
+ const compactHistory = async (params: CompactHistoryParams): Promise<CompactHistoryResult> => {
778
+ let summaryText = normalizeSummary(params.summaryText)
779
+ let remainingMessages = [...params.liveMessages]
780
+ let state = params.existingState
781
+ let mergedDelta: WorkstreamStateDelta = {}
782
+ let compactedMessages: ChatMessage[] = []
783
+ let lastCompactedMessageId: string | undefined
784
+ const summaryPayload = buildSyntheticSummaryPayload(summaryText)
785
+ const initialPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
786
+ const inputChars = initialPayload.length
787
+
788
+ for (;;) {
789
+ const assessment = shouldCompactHistory({
790
+ summaryText,
791
+ liveMessages: remainingMessages,
792
+ contextSize: params.contextSize,
793
+ })
794
+
795
+ if (!assessment.shouldCompact) {
796
+ const summaryPayload = buildSyntheticSummaryPayload(summaryText)
797
+ const outputPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
798
+ return {
799
+ compacted: compactedMessages.length > 0,
800
+ summaryText,
801
+ ...(lastCompactedMessageId ? { lastCompactedMessageId } : {}),
802
+ compactedMessages,
803
+ compactedMessageCount: compactedMessages.length,
804
+ remainingMessageCount: remainingMessages.length,
805
+ estimatedTokens: assessment.estimatedTokens,
806
+ inputChars,
807
+ outputChars: outputPayload.length,
808
+ state,
809
+ stateDelta: mergedDelta,
810
+ }
811
+ }
812
+
813
+ const boundary = Math.max(0, remainingMessages.length - params.tailMessageCount)
814
+ if (boundary <= 0) {
815
+ const summaryPayload = buildSyntheticSummaryPayload(summaryText)
816
+ const outputPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
817
+ return {
818
+ compacted: compactedMessages.length > 0,
819
+ summaryText,
820
+ ...(lastCompactedMessageId ? { lastCompactedMessageId } : {}),
821
+ compactedMessages,
822
+ compactedMessageCount: compactedMessages.length,
823
+ remainingMessageCount: remainingMessages.length,
824
+ estimatedTokens: assessment.estimatedTokens,
825
+ inputChars,
826
+ outputChars: outputPayload.length,
827
+ state,
828
+ stateDelta: mergedDelta,
829
+ }
830
+ }
831
+
832
+ const candidatePrefix = remainingMessages.slice(0, boundary)
833
+ const messagesToCompact = candidatePrefix.filter((message) => !readIsCompacted(message))
834
+ const contextMessages = messagesToCompact.map(toContextMessageFromChatMessage)
835
+ const sourceText = toCompactionTranscript(contextMessages)
836
+
837
+ if (!normalizeWhitespace(sourceText)) {
838
+ const summaryPayload = buildSyntheticSummaryPayload(summaryText)
839
+ const outputPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
840
+ return {
841
+ compacted: compactedMessages.length > 0,
842
+ summaryText,
843
+ ...(lastCompactedMessageId ? { lastCompactedMessageId } : {}),
844
+ compactedMessages,
845
+ compactedMessageCount: compactedMessages.length,
846
+ remainingMessageCount: remainingMessages.length,
847
+ estimatedTokens: assessment.estimatedTokens,
848
+ inputChars,
849
+ outputChars: outputPayload.length,
850
+ state,
851
+ stateDelta: mergedDelta,
852
+ }
853
+ }
854
+
855
+ let compactionOutput = await compactContextMessages({
856
+ previousSummary: summaryText,
857
+ existingState: state,
858
+ newMessages: contextMessages,
859
+ })
860
+
861
+ let nextSummary = normalizeSummary(compactionOutput.summary)
862
+ let nextState = mergeStateDelta(state, compactionOutput.stateDelta, now)
863
+
864
+ if (estimateTokens(nextSummary) > summaryRollupMaxTokens) {
865
+ const rollupOutput = await compactContextMessages({
866
+ previousSummary: '',
867
+ existingState: nextState,
868
+ newMessages: [{ role: 'assistant', text: nextSummary, sourceMessageId: 'summary-rollup' }],
869
+ })
870
+ nextSummary = normalizeSummary(rollupOutput.summary)
871
+ nextState = mergeStateDelta(nextState, rollupOutput.stateDelta, now)
872
+ compactionOutput = rollupOutput
873
+ }
874
+
875
+ if (nextSummary.length >= sourceText.length) {
876
+ throw new Error('Compaction summary is not shorter than compacted source')
877
+ }
878
+
879
+ summaryText = nextSummary
880
+ state = nextState
881
+ mergedDelta = mergeDelta(mergedDelta, compactionOutput.stateDelta)
882
+ compactedMessages = [
883
+ ...compactedMessages,
884
+ ...candidatePrefix.map((message) => markMessageCompacted(message, now)),
885
+ ]
886
+ lastCompactedMessageId = candidatePrefix.at(-1)?.id ?? lastCompactedMessageId
887
+ remainingMessages = remainingMessages.slice(boundary)
888
+
889
+ if (remainingMessages.length <= params.tailMessageCount) {
890
+ continue
891
+ }
892
+ }
893
+ }
894
+
895
+ return {
896
+ createSummaryMessage,
897
+ prependSummaryMessage,
898
+ formatWorkstreamStateForPrompt,
899
+ estimateThreshold,
900
+ shouldCompactHistory,
901
+ compactHistory,
902
+ }
903
+ }
904
+
905
+ export function parseCompactionOutput(value: unknown): CompactionOutput {
906
+ const parsed = StructuredCompactionOutputSchema.parse(value)
907
+
908
+ return { summary: parsed.summary, stateDelta: parseStructuredWorkstreamStateDelta(parsed.stateDelta) }
909
+ }