@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.
Files changed (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. 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 &lt;name&gt;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ }