opencastle 0.31.7 → 0.32.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.
Files changed (189) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +15 -0
  3. package/dist/cli/agents.d.ts.map +1 -1
  4. package/dist/cli/agents.js +19 -5
  5. package/dist/cli/agents.js.map +1 -1
  6. package/dist/cli/artifacts-cli.d.ts +3 -0
  7. package/dist/cli/artifacts-cli.d.ts.map +1 -0
  8. package/dist/cli/artifacts-cli.js +36 -0
  9. package/dist/cli/artifacts-cli.js.map +1 -0
  10. package/dist/cli/baselines.d.ts.map +1 -1
  11. package/dist/cli/baselines.js +11 -0
  12. package/dist/cli/baselines.js.map +1 -1
  13. package/dist/cli/convoy/artifacts.d.ts +25 -0
  14. package/dist/cli/convoy/artifacts.d.ts.map +1 -0
  15. package/dist/cli/convoy/artifacts.js +129 -0
  16. package/dist/cli/convoy/artifacts.js.map +1 -0
  17. package/dist/cli/convoy/artifacts.test.d.ts +2 -0
  18. package/dist/cli/convoy/artifacts.test.d.ts.map +1 -0
  19. package/dist/cli/convoy/artifacts.test.js +169 -0
  20. package/dist/cli/convoy/artifacts.test.js.map +1 -0
  21. package/dist/cli/convoy/compaction.d.ts +23 -0
  22. package/dist/cli/convoy/compaction.d.ts.map +1 -0
  23. package/dist/cli/convoy/compaction.js +117 -0
  24. package/dist/cli/convoy/compaction.js.map +1 -0
  25. package/dist/cli/convoy/compaction.test.d.ts +2 -0
  26. package/dist/cli/convoy/compaction.test.d.ts.map +1 -0
  27. package/dist/cli/convoy/compaction.test.js +205 -0
  28. package/dist/cli/convoy/compaction.test.js.map +1 -0
  29. package/dist/cli/convoy/contracts.d.ts +22 -0
  30. package/dist/cli/convoy/contracts.d.ts.map +1 -0
  31. package/dist/cli/convoy/contracts.js +254 -0
  32. package/dist/cli/convoy/contracts.js.map +1 -0
  33. package/dist/cli/convoy/contracts.test.d.ts +2 -0
  34. package/dist/cli/convoy/contracts.test.d.ts.map +1 -0
  35. package/dist/cli/convoy/contracts.test.js +239 -0
  36. package/dist/cli/convoy/contracts.test.js.map +1 -0
  37. package/dist/cli/convoy/dag-analysis.d.ts +40 -0
  38. package/dist/cli/convoy/dag-analysis.d.ts.map +1 -0
  39. package/dist/cli/convoy/dag-analysis.js +282 -0
  40. package/dist/cli/convoy/dag-analysis.js.map +1 -0
  41. package/dist/cli/convoy/dag-analysis.test.d.ts +2 -0
  42. package/dist/cli/convoy/dag-analysis.test.d.ts.map +1 -0
  43. package/dist/cli/convoy/dag-analysis.test.js +289 -0
  44. package/dist/cli/convoy/dag-analysis.test.js.map +1 -0
  45. package/dist/cli/convoy/effort-scaling.d.ts +20 -0
  46. package/dist/cli/convoy/effort-scaling.d.ts.map +1 -0
  47. package/dist/cli/convoy/effort-scaling.js +82 -0
  48. package/dist/cli/convoy/effort-scaling.js.map +1 -0
  49. package/dist/cli/convoy/effort-scaling.test.d.ts +2 -0
  50. package/dist/cli/convoy/effort-scaling.test.d.ts.map +1 -0
  51. package/dist/cli/convoy/effort-scaling.test.js +120 -0
  52. package/dist/cli/convoy/effort-scaling.test.js.map +1 -0
  53. package/dist/cli/convoy/engine.d.ts.map +1 -1
  54. package/dist/cli/convoy/engine.js +280 -6
  55. package/dist/cli/convoy/engine.js.map +1 -1
  56. package/dist/cli/convoy/engine.test.js +155 -18
  57. package/dist/cli/convoy/engine.test.js.map +1 -1
  58. package/dist/cli/convoy/event-schemas.d.ts.map +1 -1
  59. package/dist/cli/convoy/event-schemas.js +55 -0
  60. package/dist/cli/convoy/event-schemas.js.map +1 -1
  61. package/dist/cli/convoy/isolation.d.ts +27 -0
  62. package/dist/cli/convoy/isolation.d.ts.map +1 -0
  63. package/dist/cli/convoy/isolation.js +120 -0
  64. package/dist/cli/convoy/isolation.js.map +1 -0
  65. package/dist/cli/convoy/isolation.test.d.ts +2 -0
  66. package/dist/cli/convoy/isolation.test.d.ts.map +1 -0
  67. package/dist/cli/convoy/isolation.test.js +105 -0
  68. package/dist/cli/convoy/isolation.test.js.map +1 -0
  69. package/dist/cli/convoy/review-stages.d.ts +9 -0
  70. package/dist/cli/convoy/review-stages.d.ts.map +1 -0
  71. package/dist/cli/convoy/review-stages.js +134 -0
  72. package/dist/cli/convoy/review-stages.js.map +1 -0
  73. package/dist/cli/convoy/review-stages.test.d.ts +2 -0
  74. package/dist/cli/convoy/review-stages.test.d.ts.map +1 -0
  75. package/dist/cli/convoy/review-stages.test.js +197 -0
  76. package/dist/cli/convoy/review-stages.test.js.map +1 -0
  77. package/dist/cli/convoy/skill-refinement.d.ts +39 -0
  78. package/dist/cli/convoy/skill-refinement.d.ts.map +1 -0
  79. package/dist/cli/convoy/skill-refinement.js +239 -0
  80. package/dist/cli/convoy/skill-refinement.js.map +1 -0
  81. package/dist/cli/convoy/skill-refinement.test.d.ts +2 -0
  82. package/dist/cli/convoy/skill-refinement.test.d.ts.map +1 -0
  83. package/dist/cli/convoy/skill-refinement.test.js +230 -0
  84. package/dist/cli/convoy/skill-refinement.test.js.map +1 -0
  85. package/dist/cli/convoy/spec-builder.d.ts +1 -0
  86. package/dist/cli/convoy/spec-builder.d.ts.map +1 -1
  87. package/dist/cli/convoy/spec-builder.js +11 -0
  88. package/dist/cli/convoy/spec-builder.js.map +1 -1
  89. package/dist/cli/convoy/spec-builder.test.js +54 -0
  90. package/dist/cli/convoy/spec-builder.test.js.map +1 -1
  91. package/dist/cli/convoy/store.d.ts +3 -2
  92. package/dist/cli/convoy/store.d.ts.map +1 -1
  93. package/dist/cli/convoy/store.js +20 -2
  94. package/dist/cli/convoy/store.js.map +1 -1
  95. package/dist/cli/convoy/store.test.js +15 -15
  96. package/dist/cli/convoy/store.test.js.map +1 -1
  97. package/dist/cli/convoy/tdd-gate.d.ts +15 -0
  98. package/dist/cli/convoy/tdd-gate.d.ts.map +1 -0
  99. package/dist/cli/convoy/tdd-gate.js +119 -0
  100. package/dist/cli/convoy/tdd-gate.js.map +1 -0
  101. package/dist/cli/convoy/tdd-gate.test.d.ts +2 -0
  102. package/dist/cli/convoy/tdd-gate.test.d.ts.map +1 -0
  103. package/dist/cli/convoy/tdd-gate.test.js +227 -0
  104. package/dist/cli/convoy/tdd-gate.test.js.map +1 -0
  105. package/dist/cli/convoy/types.d.ts +91 -0
  106. package/dist/cli/convoy/types.d.ts.map +1 -1
  107. package/dist/cli/convoy/types.js +8 -0
  108. package/dist/cli/convoy/types.js.map +1 -1
  109. package/dist/cli/insights.d.ts +3 -0
  110. package/dist/cli/insights.d.ts.map +1 -0
  111. package/dist/cli/insights.js +94 -0
  112. package/dist/cli/insights.js.map +1 -0
  113. package/dist/cli/lesson.d.ts.map +1 -1
  114. package/dist/cli/lesson.js +7 -0
  115. package/dist/cli/lesson.js.map +1 -1
  116. package/dist/cli/log.d.ts.map +1 -1
  117. package/dist/cli/log.js +7 -0
  118. package/dist/cli/log.js.map +1 -1
  119. package/dist/cli/package-config.d.ts +12 -0
  120. package/dist/cli/package-config.d.ts.map +1 -0
  121. package/dist/cli/package-config.js +37 -0
  122. package/dist/cli/package-config.js.map +1 -0
  123. package/dist/cli/package.d.ts +23 -0
  124. package/dist/cli/package.d.ts.map +1 -0
  125. package/dist/cli/package.js +285 -0
  126. package/dist/cli/package.js.map +1 -0
  127. package/dist/cli/package.test.d.ts +2 -0
  128. package/dist/cli/package.test.d.ts.map +1 -0
  129. package/dist/cli/package.test.js +236 -0
  130. package/dist/cli/package.test.js.map +1 -0
  131. package/dist/cli/pipeline.d.ts +6 -0
  132. package/dist/cli/pipeline.d.ts.map +1 -1
  133. package/dist/cli/pipeline.js +15 -2
  134. package/dist/cli/pipeline.js.map +1 -1
  135. package/dist/cli/run/schema.d.ts.map +1 -1
  136. package/dist/cli/run/schema.js +32 -0
  137. package/dist/cli/run/schema.js.map +1 -1
  138. package/dist/cli/run/schema.test.js +51 -0
  139. package/dist/cli/run/schema.test.js.map +1 -1
  140. package/dist/cli/skills.d.ts +3 -0
  141. package/dist/cli/skills.d.ts.map +1 -0
  142. package/dist/cli/skills.js +107 -0
  143. package/dist/cli/skills.js.map +1 -0
  144. package/dist/cli/types.d.ts +4 -1
  145. package/dist/cli/types.d.ts.map +1 -1
  146. package/package.json +2 -1
  147. package/src/cli/agents.ts +20 -5
  148. package/src/cli/artifacts-cli.ts +41 -0
  149. package/src/cli/baselines.ts +12 -0
  150. package/src/cli/convoy/artifacts.test.ts +201 -0
  151. package/src/cli/convoy/artifacts.ts +186 -0
  152. package/src/cli/convoy/compaction.test.ts +245 -0
  153. package/src/cli/convoy/compaction.ts +164 -0
  154. package/src/cli/convoy/contracts.test.ts +279 -0
  155. package/src/cli/convoy/contracts.ts +280 -0
  156. package/src/cli/convoy/dag-analysis.test.ts +349 -0
  157. package/src/cli/convoy/dag-analysis.ts +371 -0
  158. package/src/cli/convoy/effort-scaling.test.ts +140 -0
  159. package/src/cli/convoy/effort-scaling.ts +90 -0
  160. package/src/cli/convoy/engine.test.ts +175 -18
  161. package/src/cli/convoy/engine.ts +301 -7
  162. package/src/cli/convoy/event-schemas.ts +55 -0
  163. package/src/cli/convoy/isolation.test.ts +137 -0
  164. package/src/cli/convoy/isolation.ts +165 -0
  165. package/src/cli/convoy/review-stages.test.ts +235 -0
  166. package/src/cli/convoy/review-stages.ts +166 -0
  167. package/src/cli/convoy/skill-refinement.test.ts +277 -0
  168. package/src/cli/convoy/skill-refinement.ts +306 -0
  169. package/src/cli/convoy/spec-builder.test.ts +61 -0
  170. package/src/cli/convoy/spec-builder.ts +9 -0
  171. package/src/cli/convoy/store.test.ts +15 -15
  172. package/src/cli/convoy/store.ts +26 -4
  173. package/src/cli/convoy/tdd-gate.test.ts +281 -0
  174. package/src/cli/convoy/tdd-gate.ts +154 -0
  175. package/src/cli/convoy/types.ts +51 -0
  176. package/src/cli/insights.ts +99 -0
  177. package/src/cli/lesson.ts +8 -0
  178. package/src/cli/log.ts +8 -0
  179. package/src/cli/package-config.ts +48 -0
  180. package/src/cli/package.test.ts +276 -0
  181. package/src/cli/package.ts +329 -0
  182. package/src/cli/pipeline.ts +21 -2
  183. package/src/cli/run/schema.test.ts +58 -0
  184. package/src/cli/run/schema.ts +33 -0
  185. package/src/cli/skills.ts +121 -0
  186. package/src/cli/types.ts +4 -1
  187. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  188. package/src/orchestrator/prompts/assess-complexity.prompt.md +13 -0
  189. package/src/orchestrator/prompts/generate-convoy.prompt.md +19 -0
@@ -0,0 +1,186 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { basename, join } from 'node:path'
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────────────
13
+
14
+ export interface Artifact {
15
+ task_id: string
16
+ convoy_id: string
17
+ filename: string
18
+ type: 'report' | 'code' | 'data' | 'diff' | 'log' | 'other'
19
+ size_bytes: number
20
+ summary: string
21
+ path: string
22
+ }
23
+
24
+ export interface ArtifactRef {
25
+ task_id: string
26
+ filename: string
27
+ summary: string
28
+ path: string
29
+ }
30
+
31
+ interface ArtifactMeta {
32
+ type: Artifact['type']
33
+ summary: string
34
+ size_bytes: number
35
+ created_at: string
36
+ }
37
+
38
+ // ── Sanitization ──────────────────────────────────────────────────────────────
39
+
40
+ function sanitizeSegment(input: string): string {
41
+ if (input.includes('..') || input.includes('/') || input.includes('\\')) {
42
+ throw new Error(`Invalid path segment "${input}": path traversal characters not allowed`)
43
+ }
44
+ return input.replace(/[^a-zA-Z0-9\-_.]/g, '')
45
+ }
46
+
47
+ // ── Core ──────────────────────────────────────────────────────────────────────
48
+
49
+ export function getArtifactDir(convoyId: string, taskId: string): string {
50
+ const safeConvoyId = sanitizeSegment(convoyId)
51
+ const safeTaskId = sanitizeSegment(taskId)
52
+ return join(process.cwd(), '.opencastle', 'artifacts', safeConvoyId, safeTaskId) + '/'
53
+ }
54
+
55
+ export function writeArtifact(
56
+ convoyId: string,
57
+ taskId: string,
58
+ filename: string,
59
+ content: string,
60
+ type: Artifact['type'],
61
+ ): Artifact {
62
+ const safeFilename = sanitizeSegment(filename)
63
+ const dir = getArtifactDir(convoyId, taskId)
64
+ mkdirSync(dir, { recursive: true })
65
+
66
+ const filePath = join(dir, safeFilename)
67
+ writeFileSync(filePath, content, 'utf8')
68
+
69
+ const size_bytes = Buffer.byteLength(content, 'utf8')
70
+ const firstLine = content.split('\n')[0] ?? ''
71
+ const summary = firstLine.slice(0, 120)
72
+
73
+ const meta: ArtifactMeta = { type, summary, size_bytes, created_at: new Date().toISOString() }
74
+ writeFileSync(join(dir, safeFilename + '.meta.json'), JSON.stringify(meta, null, 2), 'utf8')
75
+
76
+ return { task_id: taskId, convoy_id: convoyId, filename: safeFilename, type, size_bytes, summary, path: filePath }
77
+ }
78
+
79
+ export function listArtifacts(convoyId: string, taskId: string): ArtifactRef[] {
80
+ const dir = getArtifactDir(convoyId, taskId)
81
+ if (!existsSync(dir)) return []
82
+
83
+ const refs: ArtifactRef[] = []
84
+ for (const entry of readdirSync(dir)) {
85
+ if (entry.endsWith('.meta.json')) continue
86
+
87
+ const filePath = join(dir, entry)
88
+ const metaPath = join(dir, entry + '.meta.json')
89
+
90
+ let summary = ''
91
+ if (existsSync(metaPath)) {
92
+ try {
93
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as ArtifactMeta
94
+ summary = meta.summary
95
+ } catch { /* fallback */ }
96
+ }
97
+ if (!summary) {
98
+ try {
99
+ const firstLine = readFileSync(filePath, 'utf8').split('\n')[0] ?? ''
100
+ summary = firstLine.slice(0, 120)
101
+ } catch { /* non-critical */ }
102
+ }
103
+
104
+ refs.push({ task_id: taskId, filename: entry, summary, path: filePath })
105
+ }
106
+
107
+ return refs
108
+ }
109
+
110
+ export function readArtifact(ref: ArtifactRef): string {
111
+ return readFileSync(ref.path, 'utf8')
112
+ }
113
+
114
+ export function extractArtifactRefs(taskId: string, convoyId: string, output: string): ArtifactRef[] {
115
+ const pattern = /\[ARTIFACT:\s*([^\]]+)\]\s*(.+)/g
116
+ const refs: ArtifactRef[] = []
117
+ let match: RegExpExecArray | null
118
+
119
+ while ((match = pattern.exec(output)) !== null) {
120
+ // Use basename to prevent path traversal from untrusted agent output
121
+ const filename = basename(match[1].trim())
122
+ const summary = match[2].trim()
123
+
124
+ if (!filename || filename === '..') {
125
+ process.stderr.write('[artifacts] Warning: invalid artifact filename from agent output\n')
126
+ continue
127
+ }
128
+
129
+ const dir = getArtifactDir(convoyId, taskId)
130
+ const filePath = join(dir, filename)
131
+
132
+ if (!existsSync(filePath)) {
133
+ process.stderr.write(`[artifacts] Warning: referenced artifact not found: ${filePath}\n`)
134
+ continue
135
+ }
136
+
137
+ refs.push({ task_id: taskId, filename, summary, path: filePath })
138
+ }
139
+
140
+ return refs
141
+ }
142
+
143
+ export function pruneArtifacts(keepCount: number): { removed: number; freed_bytes: number } {
144
+ const artifactsRoot = join(process.cwd(), '.opencastle', 'artifacts')
145
+ if (!existsSync(artifactsRoot)) return { removed: 0, freed_bytes: 0 }
146
+
147
+ const convoyDirs = readdirSync(artifactsRoot)
148
+ .map(name => {
149
+ const dirPath = join(artifactsRoot, name)
150
+ try {
151
+ return { name, path: dirPath, mtime: statSync(dirPath).mtime.getTime() }
152
+ } catch {
153
+ return { name, path: dirPath, mtime: 0 }
154
+ }
155
+ })
156
+ .sort((a, b) => b.mtime - a.mtime)
157
+
158
+ const toRemove = convoyDirs.slice(keepCount)
159
+ let removed = 0
160
+ let freed_bytes = 0
161
+
162
+ for (const dir of toRemove) {
163
+ try {
164
+ freed_bytes += calcDirSize(dir.path)
165
+ rmSync(dir.path, { recursive: true, force: true })
166
+ removed++
167
+ } catch { /* non-critical */ }
168
+ }
169
+
170
+ return { removed, freed_bytes }
171
+ }
172
+
173
+ function calcDirSize(dirPath: string): number {
174
+ let total = 0
175
+ try {
176
+ for (const entry of readdirSync(dirPath)) {
177
+ const p = join(dirPath, entry)
178
+ try {
179
+ const s = statSync(p)
180
+ if (s.isDirectory()) total += calcDirSize(p)
181
+ else total += s.size
182
+ } catch { /* skip */ }
183
+ }
184
+ } catch { /* skip */ }
185
+ return total
186
+ }
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ shouldCompact,
7
+ generateCompactionPrompt,
8
+ parseCompactionSummary,
9
+ saveCompaction,
10
+ loadCompaction,
11
+ buildContinuationPrompt,
12
+ canCompact,
13
+ getMaxCompactions,
14
+ getCompactionDir,
15
+ MODEL_CONTEXT_WINDOWS,
16
+ type CompactionSummary,
17
+ } from './compaction.js'
18
+ import type { CompactionConfig } from './types.js'
19
+
20
+ const baseConfig: CompactionConfig = {
21
+ enabled: true,
22
+ token_threshold_pct: 70,
23
+ summary_max_tokens: 4096,
24
+ }
25
+
26
+ describe('shouldCompact', () => {
27
+ it('returns false when config.enabled is false', () => {
28
+ expect(shouldCompact(150_000, 'claude-sonnet-4-6', { ...baseConfig, enabled: false })).toBe(false)
29
+ })
30
+
31
+ it('returns false below threshold', () => {
32
+ // 69% of 200_000 = 138_000
33
+ expect(shouldCompact(138_000, 'claude-sonnet-4-6', baseConfig)).toBe(false)
34
+ })
35
+
36
+ it('returns true at exactly the threshold', () => {
37
+ // 70% of 200_000 = 140_000
38
+ expect(shouldCompact(140_000, 'claude-sonnet-4-6', baseConfig)).toBe(true)
39
+ })
40
+
41
+ it('returns true above threshold', () => {
42
+ expect(shouldCompact(180_000, 'claude-sonnet-4-6', baseConfig)).toBe(true)
43
+ })
44
+
45
+ it('uses DEFAULT_CONTEXT_WINDOW (128_000) for unknown model', () => {
46
+ // 70% of 128_000 = 89_600
47
+ expect(shouldCompact(90_000, 'unknown-model-xyz', baseConfig)).toBe(true)
48
+ expect(shouldCompact(88_000, 'unknown-model-xyz', baseConfig)).toBe(false)
49
+ })
50
+
51
+ it('uses correct window for gpt-5-mini (128_000)', () => {
52
+ expect(MODEL_CONTEXT_WINDOWS['gpt-5-mini']).toBe(128_000)
53
+ expect(shouldCompact(90_000, 'gpt-5-mini', baseConfig)).toBe(true)
54
+ })
55
+ })
56
+
57
+ describe('generateCompactionPrompt', () => {
58
+ it('returns a string containing COMPACTION_SUMMARY marker', () => {
59
+ const prompt = generateCompactionPrompt('task-42')
60
+ expect(prompt).toContain('COMPACTION_SUMMARY')
61
+ expect(prompt).toContain('Context Compaction Required')
62
+ expect(typeof prompt).toBe('string')
63
+ })
64
+
65
+ it('includes JSON structure placeholders', () => {
66
+ const prompt = generateCompactionPrompt('task-1')
67
+ expect(prompt).toContain('"phase"')
68
+ expect(prompt).toContain('"completed_steps"')
69
+ expect(prompt).toContain('"pending_steps"')
70
+ })
71
+ })
72
+
73
+ describe('parseCompactionSummary', () => {
74
+ it('parses valid summary from agent output', () => {
75
+ const output = [
76
+ 'Some output text',
77
+ '',
78
+ '<!-- COMPACTION_SUMMARY',
79
+ '{',
80
+ ' "phase": "implementation",',
81
+ ' "completed_steps": ["created types", "wrote tests"],',
82
+ ' "pending_steps": ["integrate with engine"],',
83
+ ' "key_decisions": ["used valibot for validation"],',
84
+ ' "files_modified": ["src/foo.ts"],',
85
+ ' "artifact_refs": [".opencastle/artifacts/convoy-1/task-1/report.md"]',
86
+ '}',
87
+ '-->',
88
+ '',
89
+ 'More text',
90
+ ].join('\n')
91
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
92
+ expect(result).not.toBeNull()
93
+ expect(result!.task_id).toBe('task-1')
94
+ expect(result!.convoy_id).toBe('convoy-1')
95
+ expect(result!.phase).toBe('implementation')
96
+ expect(result!.completed_steps).toEqual(['created types', 'wrote tests'])
97
+ expect(result!.pending_steps).toEqual(['integrate with engine'])
98
+ expect(result!.key_decisions).toEqual(['used valibot for validation'])
99
+ expect(result!.files_modified).toEqual(['src/foo.ts'])
100
+ expect(result!.artifact_refs).toEqual(['.opencastle/artifacts/convoy-1/task-1/report.md'])
101
+ expect(typeof result!.timestamp).toBe('string')
102
+ })
103
+
104
+ it('returns null when no COMPACTION_SUMMARY marker found', () => {
105
+ const result = parseCompactionSummary('just some output without the marker', 'task-1', 'convoy-1')
106
+ expect(result).toBeNull()
107
+ })
108
+
109
+ it('returns null for invalid JSON inside the marker', () => {
110
+ const output = '<!-- COMPACTION_SUMMARY\nnot valid json\n-->'
111
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
112
+ expect(result).toBeNull()
113
+ })
114
+
115
+ it('uses empty arrays for missing or non-array fields', () => {
116
+ const output = '<!-- COMPACTION_SUMMARY\n{"phase": "testing"}\n-->'
117
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
118
+ expect(result).not.toBeNull()
119
+ expect(result!.completed_steps).toEqual([])
120
+ expect(result!.pending_steps).toEqual([])
121
+ expect(result!.files_modified).toEqual([])
122
+ })
123
+ })
124
+
125
+ describe('saveCompaction / loadCompaction', () => {
126
+ let tmpDir: string
127
+
128
+ afterEach(() => {
129
+ if (tmpDir && existsSync(tmpDir)) {
130
+ rmSync(tmpDir, { recursive: true, force: true })
131
+ }
132
+ })
133
+
134
+ it('saves and restores a compaction summary', () => {
135
+ tmpDir = mkdtempSync(join(tmpdir(), 'compaction-test-'))
136
+ const summary: CompactionSummary = {
137
+ task_id: 'task-1',
138
+ convoy_id: 'convoy-abc',
139
+ phase: 'testing',
140
+ completed_steps: ['step A'],
141
+ pending_steps: ['step B'],
142
+ key_decisions: ['chose approach X'],
143
+ files_modified: ['src/foo.ts'],
144
+ artifact_refs: [],
145
+ timestamp: new Date().toISOString(),
146
+ }
147
+
148
+ const origCwd = process.cwd()
149
+ process.chdir(tmpDir)
150
+ try {
151
+ const savedPath = saveCompaction('convoy-abc', 'task-1', summary, 1)
152
+ expect(existsSync(savedPath)).toBe(true)
153
+ const loaded = loadCompaction(savedPath)
154
+ expect(loaded).not.toBeNull()
155
+ expect(loaded!.task_id).toBe('task-1')
156
+ expect(loaded!.phase).toBe('testing')
157
+ expect(loaded!.completed_steps).toEqual(['step A'])
158
+ } finally {
159
+ process.chdir(origCwd)
160
+ }
161
+ })
162
+
163
+ it('returns null for a non-existent path', () => {
164
+ const result = loadCompaction('/tmp/definitely-does-not-exist-abc123.json')
165
+ expect(result).toBeNull()
166
+ })
167
+ })
168
+
169
+ describe('buildContinuationPrompt', () => {
170
+ let tmpDir: string
171
+
172
+ afterEach(() => {
173
+ if (tmpDir && existsSync(tmpDir)) {
174
+ rmSync(tmpDir, { recursive: true, force: true })
175
+ }
176
+ })
177
+
178
+ it('includes original prompt, summary, and isolation preamble', () => {
179
+ tmpDir = mkdtempSync(join(tmpdir(), 'compaction-test-'))
180
+ const origCwd = process.cwd()
181
+ process.chdir(tmpDir)
182
+ try {
183
+ const summary: CompactionSummary = {
184
+ task_id: 'task-1',
185
+ convoy_id: 'convoy-abc',
186
+ phase: 'integration',
187
+ completed_steps: ['wrote types'],
188
+ pending_steps: ['update engine'],
189
+ key_decisions: ['decided to reuse store'],
190
+ files_modified: ['src/types.ts'],
191
+ artifact_refs: [],
192
+ timestamp: new Date().toISOString(),
193
+ }
194
+ const savedPath = saveCompaction('convoy-abc', 'task-1', summary, 1)
195
+ const result = buildContinuationPrompt('Do the remaining work', savedPath, '## Isolation preamble\n')
196
+ expect(result).toContain('## Isolation preamble')
197
+ expect(result).toContain('Do the remaining work')
198
+ expect(result).toContain('Continuation from Compacted Context')
199
+ expect(result).toContain('wrote types')
200
+ expect(result).toContain('update engine')
201
+ } finally {
202
+ process.chdir(origCwd)
203
+ }
204
+ })
205
+
206
+ it('handles missing summary file gracefully', () => {
207
+ const result = buildContinuationPrompt(
208
+ 'Do the work',
209
+ '/tmp/missing-summary-def456.json',
210
+ '## Preamble\n',
211
+ )
212
+ expect(result).toContain('Do the work')
213
+ expect(result).toContain('## Preamble')
214
+ expect(result).not.toContain('Continuation from Compacted Context')
215
+ })
216
+ })
217
+
218
+ describe('canCompact', () => {
219
+ it('returns true below max (3)', () => {
220
+ expect(canCompact(0)).toBe(true)
221
+ expect(canCompact(1)).toBe(true)
222
+ expect(canCompact(2)).toBe(true)
223
+ })
224
+
225
+ it('returns false at max (3)', () => {
226
+ expect(canCompact(3)).toBe(false)
227
+ expect(canCompact(4)).toBe(false)
228
+ })
229
+ })
230
+
231
+ describe('getMaxCompactions', () => {
232
+ it('returns 3', () => {
233
+ expect(getMaxCompactions()).toBe(3)
234
+ })
235
+ })
236
+
237
+ describe('getCompactionDir', () => {
238
+ it('returns path inside .opencastle/artifacts/{convoyId}/{taskId}', () => {
239
+ const result = getCompactionDir('convoy-abc', 'task-1')
240
+ expect(result).toContain('.opencastle')
241
+ expect(result).toContain('artifacts')
242
+ expect(result).toContain('convoy-abc')
243
+ expect(result).toContain('task-1')
244
+ })
245
+ })
@@ -0,0 +1,164 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import type { CompactionConfig } from './types.js'
4
+
5
+ // --- Types ---
6
+
7
+ export interface CompactionSummary {
8
+ task_id: string
9
+ convoy_id: string
10
+ phase: string
11
+ completed_steps: string[]
12
+ pending_steps: string[]
13
+ key_decisions: string[]
14
+ files_modified: string[]
15
+ artifact_refs: string[]
16
+ timestamp: string
17
+ }
18
+
19
+ export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
20
+ 'claude-opus-4-6': 200_000,
21
+ 'claude-sonnet-4-6': 200_000,
22
+ 'gemini-3.1-pro': 2_000_000,
23
+ 'gpt-5.3-codex': 200_000,
24
+ 'gpt-5-mini': 128_000,
25
+ }
26
+
27
+ const DEFAULT_CONTEXT_WINDOW = 128_000
28
+ const MAX_COMPACTIONS_PER_TASK = 3
29
+
30
+ // --- Threshold detection ---
31
+
32
+ export function shouldCompact(
33
+ tokensUsed: number,
34
+ model: string,
35
+ config: CompactionConfig,
36
+ ): boolean {
37
+ if (!config.enabled) return false
38
+ const contextWindow = MODEL_CONTEXT_WINDOWS[model] ?? DEFAULT_CONTEXT_WINDOW
39
+ return tokensUsed / contextWindow >= config.token_threshold_pct / 100
40
+ }
41
+
42
+ // --- Compaction prompt ---
43
+
44
+ export function generateCompactionPrompt(taskId: string): string {
45
+ return [
46
+ '## Context Compaction Required',
47
+ 'You are approaching your context limit. Before continuing, produce a COMPACTION_SUMMARY:',
48
+ '',
49
+ '<!-- COMPACTION_SUMMARY',
50
+ '{',
51
+ ' "phase": "current work phase",',
52
+ ' "completed_steps": ["step 1 done", "step 2 done"],',
53
+ ' "pending_steps": ["step 3 todo", "step 4 todo"],',
54
+ ' "key_decisions": ["chose approach A because..."],',
55
+ ' "files_modified": ["src/foo.ts", "src/bar.ts"],',
56
+ ' "artifact_refs": [".opencastle/artifacts/.../report.md"]',
57
+ '}',
58
+ '-->',
59
+ '',
60
+ 'Be concise. Focus on WHAT was decided and WHAT remains, not HOW you got here.',
61
+ ].join('\n')
62
+ }
63
+
64
+ // --- Parse compaction summary from agent output ---
65
+
66
+ export function parseCompactionSummary(
67
+ output: string,
68
+ taskId: string,
69
+ convoyId: string,
70
+ ): CompactionSummary | null {
71
+ const match = output.match(/<!--\s*COMPACTION_SUMMARY\s*\n([\s\S]*?)-->/)
72
+ if (!match) return null
73
+
74
+ try {
75
+ const parsed = JSON.parse(match[1].trim()) as Record<string, unknown>
76
+ return {
77
+ task_id: taskId,
78
+ convoy_id: convoyId,
79
+ phase: typeof parsed.phase === 'string' ? parsed.phase : 'unknown',
80
+ completed_steps: Array.isArray(parsed.completed_steps) ? parsed.completed_steps.filter((s): s is string => typeof s === 'string') : [],
81
+ pending_steps: Array.isArray(parsed.pending_steps) ? parsed.pending_steps.filter((s): s is string => typeof s === 'string') : [],
82
+ key_decisions: Array.isArray(parsed.key_decisions) ? parsed.key_decisions.filter((s): s is string => typeof s === 'string') : [],
83
+ files_modified: Array.isArray(parsed.files_modified) ? parsed.files_modified.filter((s): s is string => typeof s === 'string') : [],
84
+ artifact_refs: Array.isArray(parsed.artifact_refs) ? parsed.artifact_refs.filter((s): s is string => typeof s === 'string') : [],
85
+ timestamp: new Date().toISOString(),
86
+ }
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ // --- Save / restore ---
93
+
94
+ export function getCompactionDir(convoyId: string, taskId: string): string {
95
+ return join(resolve(process.cwd()), '.opencastle', 'artifacts', convoyId, taskId)
96
+ }
97
+
98
+ export function saveCompaction(
99
+ convoyId: string,
100
+ taskId: string,
101
+ summary: CompactionSummary,
102
+ compactionCount: number,
103
+ ): string {
104
+ const dir = getCompactionDir(convoyId, taskId)
105
+ mkdirSync(dir, { recursive: true })
106
+ const filename = `compaction-${compactionCount}.json`
107
+ const filePath = join(dir, filename)
108
+ writeFileSync(filePath, JSON.stringify(summary, null, 2))
109
+ return filePath
110
+ }
111
+
112
+ export function loadCompaction(summaryPath: string): CompactionSummary | null {
113
+ try {
114
+ const content = readFileSync(summaryPath, 'utf8')
115
+ return JSON.parse(content) as CompactionSummary
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ // --- Build continuation prompt ---
122
+
123
+ export function buildContinuationPrompt(
124
+ originalPrompt: string,
125
+ summaryPath: string,
126
+ isolationPreamble: string,
127
+ ): string {
128
+ const summary = loadCompaction(summaryPath)
129
+ if (!summary) {
130
+ return isolationPreamble + '\n\n' + originalPrompt
131
+ }
132
+
133
+ const summaryBlock = [
134
+ '## Continuation from Compacted Context',
135
+ 'You are CONTINUING a task that was compacted. Previous progress:',
136
+ '',
137
+ '**Phase:** ' + summary.phase,
138
+ '**Completed steps:**',
139
+ ...summary.completed_steps.map(s => '- ' + s),
140
+ '**Pending steps:**',
141
+ ...summary.pending_steps.map(s => '- ' + s),
142
+ '**Key decisions:**',
143
+ ...summary.key_decisions.map(s => '- ' + s),
144
+ '**Files already modified:**',
145
+ ...summary.files_modified.map(f => '- ' + f),
146
+ ...(summary.artifact_refs.length > 0
147
+ ? ['**Artifacts:**', ...summary.artifact_refs.map(a => '- ' + a)]
148
+ : []),
149
+ '',
150
+ 'Focus on the PENDING steps. Do NOT redo completed steps.',
151
+ ].join('\n')
152
+
153
+ return isolationPreamble + '\n\n' + summaryBlock + '\n\n' + originalPrompt
154
+ }
155
+
156
+ // --- Compaction count helpers ---
157
+
158
+ export function canCompact(compactionCount: number): boolean {
159
+ return compactionCount < MAX_COMPACTIONS_PER_TASK
160
+ }
161
+
162
+ export function getMaxCompactions(): number {
163
+ return MAX_COMPACTIONS_PER_TASK
164
+ }