@miphamai/cli 0.3.0 → 0.5.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/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
import { createReadStream, existsSync, statSync } from 'node:fs'
|
|
3
|
+
import { join, normalize, extname } from 'node:path'
|
|
4
|
+
import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
|
|
5
|
+
import { readManifest } from './manifest'
|
|
6
|
+
import { ArtifactVersioning } from './versioning'
|
|
7
|
+
import type { ArtifactEntry } from '../shared/types'
|
|
8
|
+
|
|
9
|
+
// ── SSE client tracking ──
|
|
10
|
+
interface SseClient {
|
|
11
|
+
id: number
|
|
12
|
+
res: any
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Embedded HTTP server for serving artifact files (.html, .svg).
|
|
17
|
+
*
|
|
18
|
+
* Phase 2 additions:
|
|
19
|
+
* - Gallery page at / with dark-themed card layout
|
|
20
|
+
* - SSE /events endpoint for live reload
|
|
21
|
+
* - Versioned file serving (name.v2.html)
|
|
22
|
+
* - Port auto-increment on conflict
|
|
23
|
+
*/
|
|
24
|
+
export class ArtifactServer {
|
|
25
|
+
private server: Server | null = null
|
|
26
|
+
private port: number
|
|
27
|
+
private artifactsDir: string
|
|
28
|
+
private started = false
|
|
29
|
+
private sseClients: SseClient[] = []
|
|
30
|
+
private sseIdCounter = 0
|
|
31
|
+
private versioning: ArtifactVersioning
|
|
32
|
+
|
|
33
|
+
constructor(artifactsDir: string, preferredPort: number) {
|
|
34
|
+
this.artifactsDir = artifactsDir
|
|
35
|
+
this.port = preferredPort
|
|
36
|
+
this.versioning = new ArtifactVersioning(artifactsDir)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Notify all connected SSE clients to reload. Called after artifact changes. */
|
|
40
|
+
notifyReload(): void {
|
|
41
|
+
for (const client of this.sseClients) {
|
|
42
|
+
try {
|
|
43
|
+
client.res.write('event: reload\ndata: {}\n\n')
|
|
44
|
+
} catch {
|
|
45
|
+
// Client disconnected — will be cleaned up on next request
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start(maxTries = 10): Promise<number> {
|
|
51
|
+
if (this.started) return this.port
|
|
52
|
+
|
|
53
|
+
for (let attempt = 0; attempt < maxTries; attempt++) {
|
|
54
|
+
const port = this.port + attempt
|
|
55
|
+
try {
|
|
56
|
+
await this.listenOn(port)
|
|
57
|
+
this.port = port
|
|
58
|
+
this.started = true
|
|
59
|
+
return port
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw err
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Artifact server could not find an available port after ${maxTries} attempts.`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop(): void {
|
|
69
|
+
// Close all SSE connections
|
|
70
|
+
for (const client of this.sseClients) {
|
|
71
|
+
try {
|
|
72
|
+
client.res.end()
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.sseClients = []
|
|
78
|
+
|
|
79
|
+
if (this.server) {
|
|
80
|
+
this.server.close()
|
|
81
|
+
this.server = null
|
|
82
|
+
this.started = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getPort(): number {
|
|
87
|
+
return this.port
|
|
88
|
+
}
|
|
89
|
+
isRunning(): boolean {
|
|
90
|
+
return this.started
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
resolveFile(urlPath: string): { filePath: string; error?: string; status?: number } {
|
|
94
|
+
const cleaned = urlPath.replace(/^\/+/, '')
|
|
95
|
+
const normalized = normalize(cleaned)
|
|
96
|
+
if (normalized.startsWith('..') || normalized.includes('/../')) {
|
|
97
|
+
return { filePath: '', error: 'Path traversal rejected', status: 403 }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fullPath = join(this.artifactsDir, normalized)
|
|
101
|
+
const ext = extname(fullPath).toLowerCase()
|
|
102
|
+
if (!ARTIFACT_ALLOWED_EXTENSIONS.includes(ext)) {
|
|
103
|
+
return { filePath: '', error: `File type "${ext}" not served`, status: 403 }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { filePath: fullPath }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Private ──
|
|
110
|
+
|
|
111
|
+
private listenOn(port: number): Promise<void> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const srv = createServer((req, res) => {
|
|
114
|
+
this.handleRequest(req, res)
|
|
115
|
+
})
|
|
116
|
+
srv.on('error', reject)
|
|
117
|
+
srv.listen(port, () => {
|
|
118
|
+
this.server = srv
|
|
119
|
+
resolve()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private handleRequest(req: { method?: string; url?: string }, res: any): void {
|
|
125
|
+
if (req.method !== 'GET') {
|
|
126
|
+
res.writeHead(405, { Allow: 'GET' })
|
|
127
|
+
res.end('Method Not Allowed')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const urlPath = req.url || '/'
|
|
132
|
+
|
|
133
|
+
// SSE endpoint
|
|
134
|
+
if (urlPath === '/events') {
|
|
135
|
+
this.handleSse(res)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Name-based SSE endpoint: GET /:name/sse
|
|
140
|
+
if (urlPath.endsWith('/sse') && urlPath.length > 4) {
|
|
141
|
+
const name = urlPath.slice(1, -4) // strip leading '/' and trailing '/sse'
|
|
142
|
+
if (name.length > 0 && !name.includes('/')) {
|
|
143
|
+
this.handleNameSse(name, res)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Gallery page
|
|
149
|
+
if (urlPath === '/' || urlPath === '/index.html') {
|
|
150
|
+
this.serveGallery(res)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Artifact file
|
|
155
|
+
this.serveArtifact(urlPath, res)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── SSE ──
|
|
159
|
+
|
|
160
|
+
private handleSse(res: any): void {
|
|
161
|
+
res.writeHead(200, {
|
|
162
|
+
'Content-Type': 'text/event-stream',
|
|
163
|
+
'Cache-Control': 'no-cache',
|
|
164
|
+
Connection: 'keep-alive',
|
|
165
|
+
'Access-Control-Allow-Origin': '*',
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const client: SseClient = { id: ++this.sseIdCounter, res }
|
|
169
|
+
this.sseClients.push(client)
|
|
170
|
+
|
|
171
|
+
// Send initial heartbeat
|
|
172
|
+
res.write(':ok\n\n')
|
|
173
|
+
|
|
174
|
+
// Clean up on close
|
|
175
|
+
res.on('close', () => {
|
|
176
|
+
this.sseClients = this.sseClients.filter((c) => c.id !== client.id)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Per-artifact SSE stream: pushes content updates to connected browsers every 500ms. */
|
|
181
|
+
private handleNameSse(name: string, res: any): void {
|
|
182
|
+
res.writeHead(200, {
|
|
183
|
+
'Content-Type': 'text/event-stream',
|
|
184
|
+
'Cache-Control': 'no-cache',
|
|
185
|
+
Connection: 'keep-alive',
|
|
186
|
+
'Access-Control-Allow-Origin': '*',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const interval = setInterval(() => {
|
|
190
|
+
const content = this.versioning.getVersion(name)
|
|
191
|
+
if (content) {
|
|
192
|
+
res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
|
|
193
|
+
}
|
|
194
|
+
}, 500)
|
|
195
|
+
|
|
196
|
+
res.on('close', () => clearInterval(interval))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Gallery Page ──
|
|
200
|
+
|
|
201
|
+
private serveGallery(res: any): void {
|
|
202
|
+
const manifest = readManifest(this.artifactsDir)
|
|
203
|
+
const artifacts = manifest.artifacts
|
|
204
|
+
|
|
205
|
+
const cards =
|
|
206
|
+
artifacts.length === 0
|
|
207
|
+
? `<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: #666;">
|
|
208
|
+
<p style="font-size: 48px; margin: 0 0 16px 0;">🎨</p>
|
|
209
|
+
<p style="font-size: 16px;">No artifacts yet.</p>
|
|
210
|
+
<p style="font-size: 13px; color: #555;">Ask the AI to create one with the Artifact tool.</p>
|
|
211
|
+
</div>`
|
|
212
|
+
: artifacts.map((a) => this.artifactCard(a)).join('\n')
|
|
213
|
+
|
|
214
|
+
const html = `<!DOCTYPE html>
|
|
215
|
+
<html lang="en">
|
|
216
|
+
<head>
|
|
217
|
+
<meta charset="UTF-8">
|
|
218
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
219
|
+
<title>Mipham Code — Artifacts</title>
|
|
220
|
+
<style>
|
|
221
|
+
:root {
|
|
222
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
223
|
+
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
|
|
224
|
+
--green: #3fb950; --orange: #d2991d; --purple: #a371f7;
|
|
225
|
+
}
|
|
226
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
227
|
+
body {
|
|
228
|
+
background: var(--bg); color: var(--text);
|
|
229
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
230
|
+
min-height: 100vh;
|
|
231
|
+
}
|
|
232
|
+
header {
|
|
233
|
+
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
234
|
+
padding: 20px 32px; display: flex; align-items: center; justify-content: space-between;
|
|
235
|
+
}
|
|
236
|
+
header h1 { font-size: 20px; font-weight: 600; }
|
|
237
|
+
header .meta { font-size: 13px; color: var(--muted); }
|
|
238
|
+
.grid {
|
|
239
|
+
display: grid;
|
|
240
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
241
|
+
gap: 16px; padding: 24px 32px;
|
|
242
|
+
}
|
|
243
|
+
.card {
|
|
244
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
245
|
+
border-radius: 8px; padding: 20px; transition: border-color .15s;
|
|
246
|
+
text-decoration: none; color: inherit; display: block;
|
|
247
|
+
}
|
|
248
|
+
.card:hover { border-color: var(--accent); }
|
|
249
|
+
.card .icon { font-size: 32px; margin-bottom: 12px; }
|
|
250
|
+
.card .name { font-size: 15px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
|
|
251
|
+
.card .row { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 12px; }
|
|
252
|
+
.card .badge {
|
|
253
|
+
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
|
254
|
+
font-size: 11px; font-weight: 600;
|
|
255
|
+
}
|
|
256
|
+
.badge-html { background: #1f3a5f; color: var(--accent); }
|
|
257
|
+
.badge-svg { background: #2d1f4e; color: var(--purple); }
|
|
258
|
+
.badge-version { background: #2d2d1f; color: var(--orange); }
|
|
259
|
+
footer {
|
|
260
|
+
padding: 16px 32px; border-top: 1px solid var(--border);
|
|
261
|
+
font-size: 12px; color: var(--muted); display: flex; gap: 16px;
|
|
262
|
+
}
|
|
263
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); display: inline-block; margin-right: 4px; }
|
|
264
|
+
</style>
|
|
265
|
+
</head>
|
|
266
|
+
<body>
|
|
267
|
+
<header>
|
|
268
|
+
<div>
|
|
269
|
+
<h1>🎨 Mipham Code Artifacts</h1>
|
|
270
|
+
<p style="font-size:13px;color:var(--muted);margin-top:4px;">
|
|
271
|
+
Interactive visual output from your AI sessions
|
|
272
|
+
</p>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="meta">
|
|
275
|
+
<div><span class="dot"></span> Server running</div>
|
|
276
|
+
<div>Port ${this.port}</div>
|
|
277
|
+
</div>
|
|
278
|
+
</header>
|
|
279
|
+
<div class="grid">${cards}</div>
|
|
280
|
+
<footer>
|
|
281
|
+
<span>${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}</span>
|
|
282
|
+
<span>·</span>
|
|
283
|
+
<span>Open in terminal: <code>/artifact open <name></code></span>
|
|
284
|
+
<span>·</span>
|
|
285
|
+
<span>Auto-refresh active</span>
|
|
286
|
+
</footer>
|
|
287
|
+
<script>
|
|
288
|
+
// SSE auto-refresh
|
|
289
|
+
const es = new EventSource('/events')
|
|
290
|
+
es.addEventListener('reload', () => location.reload())
|
|
291
|
+
</script>
|
|
292
|
+
</body>
|
|
293
|
+
</html>`
|
|
294
|
+
|
|
295
|
+
res.writeHead(200, {
|
|
296
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
297
|
+
'Cache-Control': 'no-cache',
|
|
298
|
+
})
|
|
299
|
+
res.end(html)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private artifactCard(a: ArtifactEntry): string {
|
|
303
|
+
const icon = a.type === 'svg' ? '🖼' : '📊'
|
|
304
|
+
const sizeStr =
|
|
305
|
+
a.size < 1024
|
|
306
|
+
? `${a.size}B`
|
|
307
|
+
: a.size < 1_048_576
|
|
308
|
+
? `${(a.size / 1024).toFixed(1)}KB`
|
|
309
|
+
: `${(a.size / 1_048_576).toFixed(1)}MB`
|
|
310
|
+
const date = a.createdAt.slice(0, 16).replace('T', ' ')
|
|
311
|
+
const versionInfo =
|
|
312
|
+
a.versions && a.versions.length > 1
|
|
313
|
+
? `<span class="badge badge-version">${a.versions.length} versions</span>`
|
|
314
|
+
: ''
|
|
315
|
+
|
|
316
|
+
return `<a class="card" href="/${a.sessionId}/${a.name}.${a.type === 'svg' ? 'svg' : 'html'}">
|
|
317
|
+
<div class="icon">${icon}</div>
|
|
318
|
+
<div class="name">${this.escapeHtml(a.name)}</div>
|
|
319
|
+
<div class="row">
|
|
320
|
+
<span class="badge badge-${a.type}">${a.type.toUpperCase()}</span>
|
|
321
|
+
<span>${sizeStr}</span>
|
|
322
|
+
<span>${date}</span>
|
|
323
|
+
${versionInfo}
|
|
324
|
+
</div>
|
|
325
|
+
<div class="row">
|
|
326
|
+
<span>session: ${a.sessionId}</span>
|
|
327
|
+
</div>
|
|
328
|
+
</a>`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Artifact Serving ──
|
|
332
|
+
|
|
333
|
+
private serveArtifact(urlPath: string, res: any): void {
|
|
334
|
+
const { filePath, error, status } = this.resolveFile(urlPath)
|
|
335
|
+
|
|
336
|
+
if (error) {
|
|
337
|
+
res.writeHead(status || 403, { 'Content-Type': 'text/plain' })
|
|
338
|
+
res.end(error)
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!existsSync(filePath)) {
|
|
343
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
344
|
+
res.end('Artifact not found')
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const ext = extname(filePath).toLowerCase()
|
|
349
|
+
const mimeType = ext === '.svg' ? 'image/svg+xml' : 'text/html; charset=utf-8'
|
|
350
|
+
const size = statSync(filePath).size
|
|
351
|
+
|
|
352
|
+
res.writeHead(200, {
|
|
353
|
+
'Content-Type': mimeType,
|
|
354
|
+
'Content-Length': size,
|
|
355
|
+
'Content-Security-Policy':
|
|
356
|
+
"default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; img-src data: 'self';",
|
|
357
|
+
'X-Content-Type-Options': 'nosniff',
|
|
358
|
+
'Cache-Control': 'no-cache',
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const stream = createReadStream(filePath)
|
|
362
|
+
stream.pipe(res)
|
|
363
|
+
stream.on('error', () => {
|
|
364
|
+
if (!res.headersSent) {
|
|
365
|
+
res.writeHead(500)
|
|
366
|
+
res.end('Stream error')
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private escapeHtml(s: string): string {
|
|
372
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VERSIONS_DIR = join(homedir(), '.mipham', 'artifacts')
|
|
6
|
+
|
|
7
|
+
export interface ArtifactVersion {
|
|
8
|
+
name: string
|
|
9
|
+
version: number
|
|
10
|
+
path: string
|
|
11
|
+
createdAt: string
|
|
12
|
+
size: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages artifact version snapshots on disk.
|
|
17
|
+
*
|
|
18
|
+
* Directory layout:
|
|
19
|
+
* <dir>/<name>/versions/v1.html
|
|
20
|
+
* <dir>/<name>/versions/v2.html
|
|
21
|
+
* <dir>/<name>/current.html (latest snapshot)
|
|
22
|
+
* <dir>/<name>/manifest.json ({ name, currentVersion, versionCount })
|
|
23
|
+
*/
|
|
24
|
+
export class ArtifactVersioning {
|
|
25
|
+
private dir: string
|
|
26
|
+
|
|
27
|
+
constructor(dir?: string) {
|
|
28
|
+
this.dir = dir ?? DEFAULT_VERSIONS_DIR
|
|
29
|
+
mkdirSync(this.dir, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Save a new version of an artifact. Returns the assigned version number. */
|
|
33
|
+
saveVersion(name: string, content: string): number {
|
|
34
|
+
const artifactDir = join(this.dir, name)
|
|
35
|
+
const versionsDir = join(artifactDir, 'versions')
|
|
36
|
+
mkdirSync(versionsDir, { recursive: true })
|
|
37
|
+
|
|
38
|
+
// Determine next version number
|
|
39
|
+
const existing = this.listVersions(name)
|
|
40
|
+
const nextVersion = (existing.length > 0 ? Math.max(...existing.map((v) => v.version)) : 0) + 1
|
|
41
|
+
|
|
42
|
+
// Save versioned file
|
|
43
|
+
const versionPath = join(versionsDir, `v${nextVersion}.html`)
|
|
44
|
+
writeFileSync(versionPath, content, 'utf-8')
|
|
45
|
+
|
|
46
|
+
// Update current.html
|
|
47
|
+
writeFileSync(join(artifactDir, 'current.html'), content, 'utf-8')
|
|
48
|
+
|
|
49
|
+
// Update manifest
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(artifactDir, 'manifest.json'),
|
|
52
|
+
JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
name,
|
|
55
|
+
currentVersion: nextVersion,
|
|
56
|
+
versionCount: nextVersion,
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return nextVersion
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** List all saved versions for an artifact, newest first. */
|
|
67
|
+
listVersions(name: string): ArtifactVersion[] {
|
|
68
|
+
const versionsDir = join(this.dir, name, 'versions')
|
|
69
|
+
if (!existsSync(versionsDir)) return []
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return readdirSync(versionsDir)
|
|
73
|
+
.filter((f) => f.startsWith('v') && f.endsWith('.html'))
|
|
74
|
+
.map((f) => {
|
|
75
|
+
const vNum = parseInt(f.replace('v', '').replace('.html', ''), 10)
|
|
76
|
+
const path = join(versionsDir, f)
|
|
77
|
+
const stat = statSync(path)
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
version: vNum,
|
|
81
|
+
path,
|
|
82
|
+
createdAt: stat.birthtime.toISOString(),
|
|
83
|
+
size: stat.size,
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => b.version - a.version)
|
|
87
|
+
} catch {
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get artifact content.
|
|
94
|
+
* - If a version number is given, returns that specific version.
|
|
95
|
+
* - Otherwise returns the latest (current.html).
|
|
96
|
+
* Returns null if the requested version does not exist.
|
|
97
|
+
*/
|
|
98
|
+
getVersion(name: string, version?: number): string | null {
|
|
99
|
+
const artifactDir = join(this.dir, name)
|
|
100
|
+
|
|
101
|
+
if (version !== undefined) {
|
|
102
|
+
const vPath = join(artifactDir, 'versions', `v${version}.html`)
|
|
103
|
+
return existsSync(vPath) ? readFileSync(vPath, 'utf-8') : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const currentPath = join(artifactDir, 'current.html')
|
|
107
|
+
return existsSync(currentPath) ? readFileSync(currentPath, 'utf-8') : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Produce a simple line-by-line text diff between two versions. */
|
|
111
|
+
diff(name: string, v1: number, v2: number): string {
|
|
112
|
+
const content1 = this.getVersion(name, v1) || ''
|
|
113
|
+
const content2 = this.getVersion(name, v2) || ''
|
|
114
|
+
const lines1 = content1.split('\n')
|
|
115
|
+
const lines2 = content2.split('\n')
|
|
116
|
+
|
|
117
|
+
const diffLines: string[] = []
|
|
118
|
+
const maxLen = Math.max(lines1.length, lines2.length)
|
|
119
|
+
for (let i = 0; i < maxLen; i++) {
|
|
120
|
+
if (lines1[i] !== lines2[i]) {
|
|
121
|
+
if (lines1[i] !== undefined) diffLines.push(`- ${lines1[i]}`)
|
|
122
|
+
if (lines2[i] !== undefined) diffLines.push(`+ ${lines2[i]}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return diffLines.length > 0 ? diffLines.join('\n') : '(no changes)'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ContextManager, Summarizer } from './context'
|
|
2
|
+
import { snipMessages } from './context-snip'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Layer 3: Reactive Compact — API-based summarization.
|
|
6
|
+
*
|
|
7
|
+
* When the context exceeds the compaction threshold, uses an LLM summarizer
|
|
8
|
+
* to condense the conversation history, keeping recent messages intact.
|
|
9
|
+
*
|
|
10
|
+
* After compaction, the context contains:
|
|
11
|
+
* 1. A summary message of the truncated history
|
|
12
|
+
* 2. The most recent N messages (kept intact)
|
|
13
|
+
* 3. The system prompt (unchanged)
|
|
14
|
+
*/
|
|
15
|
+
export async function reactiveCompact(
|
|
16
|
+
context: ContextManager,
|
|
17
|
+
summarizer: Summarizer,
|
|
18
|
+
heading: string,
|
|
19
|
+
keepRecent: number = 20,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const messages = context.getMessages()
|
|
22
|
+
|
|
23
|
+
if (messages.length <= keepRecent + 4) return
|
|
24
|
+
|
|
25
|
+
// First, run snip to remove empty pairs
|
|
26
|
+
const { messages: snipped } = snipMessages(messages)
|
|
27
|
+
|
|
28
|
+
if (snipped.length <= keepRecent + 4) return
|
|
29
|
+
|
|
30
|
+
// Split: old messages to summarize, recent messages to keep
|
|
31
|
+
const toSummarize = snipped.slice(0, -keepRecent)
|
|
32
|
+
const toKeep = snipped.slice(-keepRecent)
|
|
33
|
+
|
|
34
|
+
// Generate summary
|
|
35
|
+
let summary: string
|
|
36
|
+
try {
|
|
37
|
+
summary = await summarizer(toSummarize, heading)
|
|
38
|
+
} catch {
|
|
39
|
+
// On summarizer failure, do a simple truncation
|
|
40
|
+
summary = `Earlier conversation (${toSummarize.length} messages) omitted due to context limits.`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Rebuild messages: summary + recent
|
|
44
|
+
const summaryMsg = {
|
|
45
|
+
role: 'user' as const,
|
|
46
|
+
content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 2000)}`,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context.replaceMessages([summaryMsg, ...toKeep])
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ContextManager } from './context'
|
|
2
|
+
|
|
3
|
+
const MINIMAL_KEEP = 5
|
|
4
|
+
let drainLevel = 0
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Layer 4: Emergency Drain — 413 error recovery.
|
|
8
|
+
*
|
|
9
|
+
* Called when a 413 (context too large) error is received.
|
|
10
|
+
* Progressively strips messages until the context fits:
|
|
11
|
+
*
|
|
12
|
+
* Level 1: Drop earliest 50% of messages
|
|
13
|
+
* Level 2+: Keep only system prompt + last 5 messages
|
|
14
|
+
*
|
|
15
|
+
* Returns true if recovery was possible, false if context is already minimal.
|
|
16
|
+
*/
|
|
17
|
+
export async function emergencyDrain(context: ContextManager): Promise<boolean> {
|
|
18
|
+
const messages = context.getMessages()
|
|
19
|
+
|
|
20
|
+
if (messages.length <= MINIMAL_KEEP) {
|
|
21
|
+
return false // Already minimal, cannot drain further
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (drainLevel === 0) {
|
|
25
|
+
// First attempt: drop earliest 50%
|
|
26
|
+
const keepCount = Math.max(MINIMAL_KEEP, Math.floor(messages.length / 2))
|
|
27
|
+
const kept = messages.slice(-keepCount)
|
|
28
|
+
context.replaceMessages(kept)
|
|
29
|
+
drainLevel++
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Subsequent attempts: keep only system + last MINIMAL_KEEP messages
|
|
34
|
+
const kept = messages.slice(-MINIMAL_KEEP)
|
|
35
|
+
|
|
36
|
+
// Add a summary placeholder if we're dropping a lot
|
|
37
|
+
if (messages.length > MINIMAL_KEEP * 2) {
|
|
38
|
+
const summaryMsg = {
|
|
39
|
+
role: 'user' as const,
|
|
40
|
+
content: `[Emergency context drain: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
|
|
41
|
+
}
|
|
42
|
+
context.replaceMessages([summaryMsg, ...kept])
|
|
43
|
+
} else {
|
|
44
|
+
context.replaceMessages(kept)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
drainLevel++
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reset drain level (call at session start). */
|
|
52
|
+
export function resetDrainLevel(): void {
|
|
53
|
+
drainLevel = 0
|
|
54
|
+
}
|