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.
- package/README.md +4 -0
- package/bin/cli.mjs +15 -0
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +19 -5
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/artifacts-cli.d.ts +3 -0
- package/dist/cli/artifacts-cli.d.ts.map +1 -0
- package/dist/cli/artifacts-cli.js +36 -0
- package/dist/cli/artifacts-cli.js.map +1 -0
- package/dist/cli/baselines.d.ts.map +1 -1
- package/dist/cli/baselines.js +11 -0
- package/dist/cli/baselines.js.map +1 -1
- package/dist/cli/convoy/artifacts.d.ts +25 -0
- package/dist/cli/convoy/artifacts.d.ts.map +1 -0
- package/dist/cli/convoy/artifacts.js +129 -0
- package/dist/cli/convoy/artifacts.js.map +1 -0
- package/dist/cli/convoy/artifacts.test.d.ts +2 -0
- package/dist/cli/convoy/artifacts.test.d.ts.map +1 -0
- package/dist/cli/convoy/artifacts.test.js +169 -0
- package/dist/cli/convoy/artifacts.test.js.map +1 -0
- package/dist/cli/convoy/compaction.d.ts +23 -0
- package/dist/cli/convoy/compaction.d.ts.map +1 -0
- package/dist/cli/convoy/compaction.js +117 -0
- package/dist/cli/convoy/compaction.js.map +1 -0
- package/dist/cli/convoy/compaction.test.d.ts +2 -0
- package/dist/cli/convoy/compaction.test.d.ts.map +1 -0
- package/dist/cli/convoy/compaction.test.js +205 -0
- package/dist/cli/convoy/compaction.test.js.map +1 -0
- package/dist/cli/convoy/contracts.d.ts +22 -0
- package/dist/cli/convoy/contracts.d.ts.map +1 -0
- package/dist/cli/convoy/contracts.js +254 -0
- package/dist/cli/convoy/contracts.js.map +1 -0
- package/dist/cli/convoy/contracts.test.d.ts +2 -0
- package/dist/cli/convoy/contracts.test.d.ts.map +1 -0
- package/dist/cli/convoy/contracts.test.js +239 -0
- package/dist/cli/convoy/contracts.test.js.map +1 -0
- package/dist/cli/convoy/dag-analysis.d.ts +40 -0
- package/dist/cli/convoy/dag-analysis.d.ts.map +1 -0
- package/dist/cli/convoy/dag-analysis.js +282 -0
- package/dist/cli/convoy/dag-analysis.js.map +1 -0
- package/dist/cli/convoy/dag-analysis.test.d.ts +2 -0
- package/dist/cli/convoy/dag-analysis.test.d.ts.map +1 -0
- package/dist/cli/convoy/dag-analysis.test.js +289 -0
- package/dist/cli/convoy/dag-analysis.test.js.map +1 -0
- package/dist/cli/convoy/effort-scaling.d.ts +20 -0
- package/dist/cli/convoy/effort-scaling.d.ts.map +1 -0
- package/dist/cli/convoy/effort-scaling.js +82 -0
- package/dist/cli/convoy/effort-scaling.js.map +1 -0
- package/dist/cli/convoy/effort-scaling.test.d.ts +2 -0
- package/dist/cli/convoy/effort-scaling.test.d.ts.map +1 -0
- package/dist/cli/convoy/effort-scaling.test.js +120 -0
- package/dist/cli/convoy/effort-scaling.test.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +280 -6
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +155 -18
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -1
- package/dist/cli/convoy/event-schemas.js +55 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -1
- package/dist/cli/convoy/isolation.d.ts +27 -0
- package/dist/cli/convoy/isolation.d.ts.map +1 -0
- package/dist/cli/convoy/isolation.js +120 -0
- package/dist/cli/convoy/isolation.js.map +1 -0
- package/dist/cli/convoy/isolation.test.d.ts +2 -0
- package/dist/cli/convoy/isolation.test.d.ts.map +1 -0
- package/dist/cli/convoy/isolation.test.js +105 -0
- package/dist/cli/convoy/isolation.test.js.map +1 -0
- package/dist/cli/convoy/review-stages.d.ts +9 -0
- package/dist/cli/convoy/review-stages.d.ts.map +1 -0
- package/dist/cli/convoy/review-stages.js +134 -0
- package/dist/cli/convoy/review-stages.js.map +1 -0
- package/dist/cli/convoy/review-stages.test.d.ts +2 -0
- package/dist/cli/convoy/review-stages.test.d.ts.map +1 -0
- package/dist/cli/convoy/review-stages.test.js +197 -0
- package/dist/cli/convoy/review-stages.test.js.map +1 -0
- package/dist/cli/convoy/skill-refinement.d.ts +39 -0
- package/dist/cli/convoy/skill-refinement.d.ts.map +1 -0
- package/dist/cli/convoy/skill-refinement.js +239 -0
- package/dist/cli/convoy/skill-refinement.js.map +1 -0
- package/dist/cli/convoy/skill-refinement.test.d.ts +2 -0
- package/dist/cli/convoy/skill-refinement.test.d.ts.map +1 -0
- package/dist/cli/convoy/skill-refinement.test.js +230 -0
- package/dist/cli/convoy/skill-refinement.test.js.map +1 -0
- package/dist/cli/convoy/spec-builder.d.ts +1 -0
- package/dist/cli/convoy/spec-builder.d.ts.map +1 -1
- package/dist/cli/convoy/spec-builder.js +11 -0
- package/dist/cli/convoy/spec-builder.js.map +1 -1
- package/dist/cli/convoy/spec-builder.test.js +54 -0
- package/dist/cli/convoy/spec-builder.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +3 -2
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +20 -2
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +15 -15
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/tdd-gate.d.ts +15 -0
- package/dist/cli/convoy/tdd-gate.d.ts.map +1 -0
- package/dist/cli/convoy/tdd-gate.js +119 -0
- package/dist/cli/convoy/tdd-gate.js.map +1 -0
- package/dist/cli/convoy/tdd-gate.test.d.ts +2 -0
- package/dist/cli/convoy/tdd-gate.test.d.ts.map +1 -0
- package/dist/cli/convoy/tdd-gate.test.js +227 -0
- package/dist/cli/convoy/tdd-gate.test.js.map +1 -0
- package/dist/cli/convoy/types.d.ts +91 -0
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +8 -0
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/insights.d.ts +3 -0
- package/dist/cli/insights.d.ts.map +1 -0
- package/dist/cli/insights.js +94 -0
- package/dist/cli/insights.js.map +1 -0
- package/dist/cli/lesson.d.ts.map +1 -1
- package/dist/cli/lesson.js +7 -0
- package/dist/cli/lesson.js.map +1 -1
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +7 -0
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/package-config.d.ts +12 -0
- package/dist/cli/package-config.d.ts.map +1 -0
- package/dist/cli/package-config.js +37 -0
- package/dist/cli/package-config.js.map +1 -0
- package/dist/cli/package.d.ts +23 -0
- package/dist/cli/package.d.ts.map +1 -0
- package/dist/cli/package.js +285 -0
- package/dist/cli/package.js.map +1 -0
- package/dist/cli/package.test.d.ts +2 -0
- package/dist/cli/package.test.d.ts.map +1 -0
- package/dist/cli/package.test.js +236 -0
- package/dist/cli/package.test.js.map +1 -0
- package/dist/cli/pipeline.d.ts +6 -0
- package/dist/cli/pipeline.d.ts.map +1 -1
- package/dist/cli/pipeline.js +15 -2
- package/dist/cli/pipeline.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +32 -0
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +51 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/skills.d.ts +3 -0
- package/dist/cli/skills.d.ts.map +1 -0
- package/dist/cli/skills.js +107 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/types.d.ts +4 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/agents.ts +20 -5
- package/src/cli/artifacts-cli.ts +41 -0
- package/src/cli/baselines.ts +12 -0
- package/src/cli/convoy/artifacts.test.ts +201 -0
- package/src/cli/convoy/artifacts.ts +186 -0
- package/src/cli/convoy/compaction.test.ts +245 -0
- package/src/cli/convoy/compaction.ts +164 -0
- package/src/cli/convoy/contracts.test.ts +279 -0
- package/src/cli/convoy/contracts.ts +280 -0
- package/src/cli/convoy/dag-analysis.test.ts +349 -0
- package/src/cli/convoy/dag-analysis.ts +371 -0
- package/src/cli/convoy/effort-scaling.test.ts +140 -0
- package/src/cli/convoy/effort-scaling.ts +90 -0
- package/src/cli/convoy/engine.test.ts +175 -18
- package/src/cli/convoy/engine.ts +301 -7
- package/src/cli/convoy/event-schemas.ts +55 -0
- package/src/cli/convoy/isolation.test.ts +137 -0
- package/src/cli/convoy/isolation.ts +165 -0
- package/src/cli/convoy/review-stages.test.ts +235 -0
- package/src/cli/convoy/review-stages.ts +166 -0
- package/src/cli/convoy/skill-refinement.test.ts +277 -0
- package/src/cli/convoy/skill-refinement.ts +306 -0
- package/src/cli/convoy/spec-builder.test.ts +61 -0
- package/src/cli/convoy/spec-builder.ts +9 -0
- package/src/cli/convoy/store.test.ts +15 -15
- package/src/cli/convoy/store.ts +26 -4
- package/src/cli/convoy/tdd-gate.test.ts +281 -0
- package/src/cli/convoy/tdd-gate.ts +154 -0
- package/src/cli/convoy/types.ts +51 -0
- package/src/cli/insights.ts +99 -0
- package/src/cli/lesson.ts +8 -0
- package/src/cli/log.ts +8 -0
- package/src/cli/package-config.ts +48 -0
- package/src/cli/package.test.ts +276 -0
- package/src/cli/package.ts +329 -0
- package/src/cli/pipeline.ts +21 -2
- package/src/cli/run/schema.test.ts +58 -0
- package/src/cli/run/schema.ts +33 -0
- package/src/cli/skills.ts +121 -0
- package/src/cli/types.ts +4 -1
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/prompts/assess-complexity.prompt.md +13 -0
- 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
|
+
}
|