@miphamai/cli 0.3.0 → 0.4.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 +2 -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/sub-agent.ts +0 -1
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +343 -0
- package/src/core/engine.ts +63 -55
- package/src/core/instructions.ts +1 -1
- package/src/core/permission.ts +7 -2
- package/src/index.tsx +10 -2
- package/src/mcp/transport.ts +42 -5
- 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 +21 -1
- package/src/skills/loader.ts +2 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/index.ts +3 -0
- package/src/ui/app.tsx +10 -6
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +86 -10
- package/src/ui/input.tsx +80 -19
- package/src/ui/picker.tsx +2 -5
|
@@ -0,0 +1,343 @@
|
|
|
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 type { ArtifactEntry } from '../shared/types'
|
|
7
|
+
|
|
8
|
+
// ── SSE client tracking ──
|
|
9
|
+
interface SseClient {
|
|
10
|
+
id: number
|
|
11
|
+
res: any
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Embedded HTTP server for serving artifact files (.html, .svg).
|
|
16
|
+
*
|
|
17
|
+
* Phase 2 additions:
|
|
18
|
+
* - Gallery page at / with dark-themed card layout
|
|
19
|
+
* - SSE /events endpoint for live reload
|
|
20
|
+
* - Versioned file serving (name.v2.html)
|
|
21
|
+
* - Port auto-increment on conflict
|
|
22
|
+
*/
|
|
23
|
+
export class ArtifactServer {
|
|
24
|
+
private server: Server | null = null
|
|
25
|
+
private port: number
|
|
26
|
+
private artifactsDir: string
|
|
27
|
+
private started = false
|
|
28
|
+
private sseClients: SseClient[] = []
|
|
29
|
+
private sseIdCounter = 0
|
|
30
|
+
|
|
31
|
+
constructor(artifactsDir: string, preferredPort: number) {
|
|
32
|
+
this.artifactsDir = artifactsDir
|
|
33
|
+
this.port = preferredPort
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Notify all connected SSE clients to reload. Called after artifact changes. */
|
|
37
|
+
notifyReload(): void {
|
|
38
|
+
for (const client of this.sseClients) {
|
|
39
|
+
try {
|
|
40
|
+
client.res.write('event: reload\ndata: {}\n\n')
|
|
41
|
+
} catch {
|
|
42
|
+
// Client disconnected — will be cleaned up on next request
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async start(maxTries = 10): Promise<number> {
|
|
48
|
+
if (this.started) return this.port
|
|
49
|
+
|
|
50
|
+
for (let attempt = 0; attempt < maxTries; attempt++) {
|
|
51
|
+
const port = this.port + attempt
|
|
52
|
+
try {
|
|
53
|
+
await this.listenOn(port)
|
|
54
|
+
this.port = port
|
|
55
|
+
this.started = true
|
|
56
|
+
return port
|
|
57
|
+
} catch (err: unknown) {
|
|
58
|
+
if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw err
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error(`Artifact server could not find an available port after ${maxTries} attempts.`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stop(): void {
|
|
66
|
+
// Close all SSE connections
|
|
67
|
+
for (const client of this.sseClients) {
|
|
68
|
+
try {
|
|
69
|
+
client.res.end()
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.sseClients = []
|
|
75
|
+
|
|
76
|
+
if (this.server) {
|
|
77
|
+
this.server.close()
|
|
78
|
+
this.server = null
|
|
79
|
+
this.started = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getPort(): number {
|
|
84
|
+
return this.port
|
|
85
|
+
}
|
|
86
|
+
isRunning(): boolean {
|
|
87
|
+
return this.started
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolveFile(urlPath: string): { filePath: string; error?: string; status?: number } {
|
|
91
|
+
const cleaned = urlPath.replace(/^\/+/, '')
|
|
92
|
+
const normalized = normalize(cleaned)
|
|
93
|
+
if (normalized.startsWith('..') || normalized.includes('/../')) {
|
|
94
|
+
return { filePath: '', error: 'Path traversal rejected', status: 403 }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fullPath = join(this.artifactsDir, normalized)
|
|
98
|
+
const ext = extname(fullPath).toLowerCase()
|
|
99
|
+
if (!ARTIFACT_ALLOWED_EXTENSIONS.includes(ext)) {
|
|
100
|
+
return { filePath: '', error: `File type "${ext}" not served`, status: 403 }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { filePath: fullPath }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Private ──
|
|
107
|
+
|
|
108
|
+
private listenOn(port: number): Promise<void> {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const srv = createServer((req, res) => {
|
|
111
|
+
this.handleRequest(req, res)
|
|
112
|
+
})
|
|
113
|
+
srv.on('error', reject)
|
|
114
|
+
srv.listen(port, () => {
|
|
115
|
+
this.server = srv
|
|
116
|
+
resolve()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private handleRequest(req: { method?: string; url?: string }, res: any): void {
|
|
122
|
+
if (req.method !== 'GET') {
|
|
123
|
+
res.writeHead(405, { Allow: 'GET' })
|
|
124
|
+
res.end('Method Not Allowed')
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const urlPath = req.url || '/'
|
|
129
|
+
|
|
130
|
+
// SSE endpoint
|
|
131
|
+
if (urlPath === '/events') {
|
|
132
|
+
this.handleSse(res)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Gallery page
|
|
137
|
+
if (urlPath === '/' || urlPath === '/index.html') {
|
|
138
|
+
this.serveGallery(res)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Artifact file
|
|
143
|
+
this.serveArtifact(urlPath, res)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── SSE ──
|
|
147
|
+
|
|
148
|
+
private handleSse(res: any): void {
|
|
149
|
+
res.writeHead(200, {
|
|
150
|
+
'Content-Type': 'text/event-stream',
|
|
151
|
+
'Cache-Control': 'no-cache',
|
|
152
|
+
Connection: 'keep-alive',
|
|
153
|
+
'Access-Control-Allow-Origin': '*',
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const client: SseClient = { id: ++this.sseIdCounter, res }
|
|
157
|
+
this.sseClients.push(client)
|
|
158
|
+
|
|
159
|
+
// Send initial heartbeat
|
|
160
|
+
res.write(':ok\n\n')
|
|
161
|
+
|
|
162
|
+
// Clean up on close
|
|
163
|
+
res.on('close', () => {
|
|
164
|
+
this.sseClients = this.sseClients.filter((c) => c.id !== client.id)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Gallery Page ──
|
|
169
|
+
|
|
170
|
+
private serveGallery(res: any): void {
|
|
171
|
+
const manifest = readManifest(this.artifactsDir)
|
|
172
|
+
const artifacts = manifest.artifacts
|
|
173
|
+
|
|
174
|
+
const cards =
|
|
175
|
+
artifacts.length === 0
|
|
176
|
+
? `<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: #666;">
|
|
177
|
+
<p style="font-size: 48px; margin: 0 0 16px 0;">🎨</p>
|
|
178
|
+
<p style="font-size: 16px;">No artifacts yet.</p>
|
|
179
|
+
<p style="font-size: 13px; color: #555;">Ask the AI to create one with the Artifact tool.</p>
|
|
180
|
+
</div>`
|
|
181
|
+
: artifacts.map((a) => this.artifactCard(a)).join('\n')
|
|
182
|
+
|
|
183
|
+
const html = `<!DOCTYPE html>
|
|
184
|
+
<html lang="en">
|
|
185
|
+
<head>
|
|
186
|
+
<meta charset="UTF-8">
|
|
187
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
188
|
+
<title>Mipham Code — Artifacts</title>
|
|
189
|
+
<style>
|
|
190
|
+
:root {
|
|
191
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
192
|
+
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
|
|
193
|
+
--green: #3fb950; --orange: #d2991d; --purple: #a371f7;
|
|
194
|
+
}
|
|
195
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
196
|
+
body {
|
|
197
|
+
background: var(--bg); color: var(--text);
|
|
198
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
199
|
+
min-height: 100vh;
|
|
200
|
+
}
|
|
201
|
+
header {
|
|
202
|
+
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
203
|
+
padding: 20px 32px; display: flex; align-items: center; justify-content: space-between;
|
|
204
|
+
}
|
|
205
|
+
header h1 { font-size: 20px; font-weight: 600; }
|
|
206
|
+
header .meta { font-size: 13px; color: var(--muted); }
|
|
207
|
+
.grid {
|
|
208
|
+
display: grid;
|
|
209
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
210
|
+
gap: 16px; padding: 24px 32px;
|
|
211
|
+
}
|
|
212
|
+
.card {
|
|
213
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
214
|
+
border-radius: 8px; padding: 20px; transition: border-color .15s;
|
|
215
|
+
text-decoration: none; color: inherit; display: block;
|
|
216
|
+
}
|
|
217
|
+
.card:hover { border-color: var(--accent); }
|
|
218
|
+
.card .icon { font-size: 32px; margin-bottom: 12px; }
|
|
219
|
+
.card .name { font-size: 15px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
|
|
220
|
+
.card .row { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 12px; }
|
|
221
|
+
.card .badge {
|
|
222
|
+
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
|
223
|
+
font-size: 11px; font-weight: 600;
|
|
224
|
+
}
|
|
225
|
+
.badge-html { background: #1f3a5f; color: var(--accent); }
|
|
226
|
+
.badge-svg { background: #2d1f4e; color: var(--purple); }
|
|
227
|
+
.badge-version { background: #2d2d1f; color: var(--orange); }
|
|
228
|
+
footer {
|
|
229
|
+
padding: 16px 32px; border-top: 1px solid var(--border);
|
|
230
|
+
font-size: 12px; color: var(--muted); display: flex; gap: 16px;
|
|
231
|
+
}
|
|
232
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); display: inline-block; margin-right: 4px; }
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<header>
|
|
237
|
+
<div>
|
|
238
|
+
<h1>🎨 Mipham Code Artifacts</h1>
|
|
239
|
+
<p style="font-size:13px;color:var(--muted);margin-top:4px;">
|
|
240
|
+
Interactive visual output from your AI sessions
|
|
241
|
+
</p>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="meta">
|
|
244
|
+
<div><span class="dot"></span> Server running</div>
|
|
245
|
+
<div>Port ${this.port}</div>
|
|
246
|
+
</div>
|
|
247
|
+
</header>
|
|
248
|
+
<div class="grid">${cards}</div>
|
|
249
|
+
<footer>
|
|
250
|
+
<span>${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}</span>
|
|
251
|
+
<span>·</span>
|
|
252
|
+
<span>Open in terminal: <code>/artifact open <name></code></span>
|
|
253
|
+
<span>·</span>
|
|
254
|
+
<span>Auto-refresh active</span>
|
|
255
|
+
</footer>
|
|
256
|
+
<script>
|
|
257
|
+
// SSE auto-refresh
|
|
258
|
+
const es = new EventSource('/events')
|
|
259
|
+
es.addEventListener('reload', () => location.reload())
|
|
260
|
+
</script>
|
|
261
|
+
</body>
|
|
262
|
+
</html>`
|
|
263
|
+
|
|
264
|
+
res.writeHead(200, {
|
|
265
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
266
|
+
'Cache-Control': 'no-cache',
|
|
267
|
+
})
|
|
268
|
+
res.end(html)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private artifactCard(a: ArtifactEntry): string {
|
|
272
|
+
const icon = a.type === 'svg' ? '🖼' : '📊'
|
|
273
|
+
const sizeStr =
|
|
274
|
+
a.size < 1024
|
|
275
|
+
? `${a.size}B`
|
|
276
|
+
: a.size < 1_048_576
|
|
277
|
+
? `${(a.size / 1024).toFixed(1)}KB`
|
|
278
|
+
: `${(a.size / 1_048_576).toFixed(1)}MB`
|
|
279
|
+
const date = a.createdAt.slice(0, 16).replace('T', ' ')
|
|
280
|
+
const versionInfo =
|
|
281
|
+
a.versions && a.versions.length > 1
|
|
282
|
+
? `<span class="badge badge-version">${a.versions.length} versions</span>`
|
|
283
|
+
: ''
|
|
284
|
+
|
|
285
|
+
return `<a class="card" href="/${a.sessionId}/${a.name}.${a.type === 'svg' ? 'svg' : 'html'}">
|
|
286
|
+
<div class="icon">${icon}</div>
|
|
287
|
+
<div class="name">${this.escapeHtml(a.name)}</div>
|
|
288
|
+
<div class="row">
|
|
289
|
+
<span class="badge badge-${a.type}">${a.type.toUpperCase()}</span>
|
|
290
|
+
<span>${sizeStr}</span>
|
|
291
|
+
<span>${date}</span>
|
|
292
|
+
${versionInfo}
|
|
293
|
+
</div>
|
|
294
|
+
<div class="row">
|
|
295
|
+
<span>session: ${a.sessionId}</span>
|
|
296
|
+
</div>
|
|
297
|
+
</a>`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Artifact Serving ──
|
|
301
|
+
|
|
302
|
+
private serveArtifact(urlPath: string, res: any): void {
|
|
303
|
+
const { filePath, error, status } = this.resolveFile(urlPath)
|
|
304
|
+
|
|
305
|
+
if (error) {
|
|
306
|
+
res.writeHead(status || 403, { 'Content-Type': 'text/plain' })
|
|
307
|
+
res.end(error)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!existsSync(filePath)) {
|
|
312
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
313
|
+
res.end('Artifact not found')
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ext = extname(filePath).toLowerCase()
|
|
318
|
+
const mimeType = ext === '.svg' ? 'image/svg+xml' : 'text/html; charset=utf-8'
|
|
319
|
+
const size = statSync(filePath).size
|
|
320
|
+
|
|
321
|
+
res.writeHead(200, {
|
|
322
|
+
'Content-Type': mimeType,
|
|
323
|
+
'Content-Length': size,
|
|
324
|
+
'Content-Security-Policy':
|
|
325
|
+
"default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; img-src data: 'self';",
|
|
326
|
+
'X-Content-Type-Options': 'nosniff',
|
|
327
|
+
'Cache-Control': 'no-cache',
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const stream = createReadStream(filePath)
|
|
331
|
+
stream.pipe(res)
|
|
332
|
+
stream.on('error', () => {
|
|
333
|
+
if (!res.headersSent) {
|
|
334
|
+
res.writeHead(500)
|
|
335
|
+
res.end('Stream error')
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private escapeHtml(s: string): string {
|
|
341
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
|
|
2
2
|
import { ProviderRegistry } from '../providers/registry'
|
|
3
3
|
import { ContextManager } from './context'
|
|
4
4
|
import { PermissionSystem } from './permission'
|
|
5
5
|
import type { HookEngine } from './hooks'
|
|
6
|
+
import type { ArtifactServer } from '../artifacts/server'
|
|
6
7
|
|
|
7
8
|
export class QueryEngine {
|
|
8
9
|
private hookEngine?: HookEngine
|
|
10
|
+
private artifactServer?: ArtifactServer
|
|
9
11
|
|
|
10
12
|
constructor(
|
|
11
13
|
private registry: ProviderRegistry,
|
|
@@ -19,6 +21,11 @@ export class QueryEngine {
|
|
|
19
21
|
this.hookEngine = hooks
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** Register the artifact server for tool context. */
|
|
25
|
+
setArtifactServer(server: ArtifactServer): void {
|
|
26
|
+
this.artifactServer = server
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/** Wire LLM-based conversation summarization into the context manager. */
|
|
23
30
|
setupContextSummarizer(): void {
|
|
24
31
|
this.context.setSummarizer(async (messages, heading) => {
|
|
@@ -31,8 +38,7 @@ export class QueryEngine {
|
|
|
31
38
|
.join('\n')
|
|
32
39
|
|
|
33
40
|
// Build a minimal system prompt for summarization
|
|
34
|
-
const summaryPrompt =
|
|
35
|
-
`You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
|
|
41
|
+
const summaryPrompt = `You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
|
|
36
42
|
|
|
37
43
|
// Collect full summary text from streaming response
|
|
38
44
|
let summary = ''
|
|
@@ -81,39 +87,39 @@ export class QueryEngine {
|
|
|
81
87
|
|
|
82
88
|
// Stream model response
|
|
83
89
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (chunk.type === 'error') {
|
|
94
|
-
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
95
|
-
return
|
|
96
|
-
}
|
|
90
|
+
for await (const chunk of this.registry.chat({
|
|
91
|
+
model: this.registry.getActiveModel(),
|
|
92
|
+
messages,
|
|
93
|
+
systemPrompt,
|
|
94
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
95
|
+
signal,
|
|
96
|
+
})) {
|
|
97
|
+
yield chunk
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
if (chunk.type === 'error') {
|
|
100
|
+
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
name: chunk.toolUse.name,
|
|
106
|
-
input: chunk.toolUse.input,
|
|
107
|
-
})
|
|
108
|
-
}
|
|
104
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
105
|
+
assistantContent += chunk.content
|
|
106
|
+
}
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
109
|
+
toolUses.push({
|
|
110
|
+
id: chunk.toolUse.id,
|
|
111
|
+
name: chunk.toolUse.name,
|
|
112
|
+
input: chunk.toolUse.input,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (chunk.type === 'stop') {
|
|
117
|
+
// Add assistant response to context
|
|
118
|
+
if (assistantContent) {
|
|
119
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
120
|
+
}
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
|
-
}
|
|
117
123
|
} catch (err) {
|
|
118
124
|
if (isAbortError(err)) {
|
|
119
125
|
// User interrupted — keep partial content, stop gracefully
|
|
@@ -133,7 +139,7 @@ export class QueryEngine {
|
|
|
133
139
|
yield {
|
|
134
140
|
type: 'tool_result',
|
|
135
141
|
tool_use_id: toolUse.id,
|
|
136
|
-
content: result.success ? result.content :
|
|
142
|
+
content: result.success ? result.content : result.error || result.content,
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
// Add tool use + result to context
|
|
@@ -165,26 +171,26 @@ export class QueryEngine {
|
|
|
165
171
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
166
172
|
|
|
167
173
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
for await (const chunk of this.registry.chat({
|
|
175
|
+
model: this.registry.getActiveModel(),
|
|
176
|
+
messages,
|
|
177
|
+
systemPrompt,
|
|
178
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
179
|
+
signal,
|
|
180
|
+
})) {
|
|
181
|
+
yield chunk
|
|
176
182
|
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
if (chunk.type === 'error') return
|
|
184
|
+
if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
|
|
179
185
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
187
|
+
toolUses.push({
|
|
188
|
+
id: chunk.toolUse.id,
|
|
189
|
+
name: chunk.toolUse.name,
|
|
190
|
+
input: chunk.toolUse.input,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
186
193
|
}
|
|
187
|
-
}
|
|
188
194
|
} catch (err) {
|
|
189
195
|
if (isAbortError(err)) {
|
|
190
196
|
if (assistantContent) {
|
|
@@ -243,11 +249,7 @@ export class QueryEngine {
|
|
|
243
249
|
// Run PreToolUse hooks
|
|
244
250
|
let effectiveParams = params
|
|
245
251
|
if (this.hookEngine) {
|
|
246
|
-
const preResult = await this.hookEngine.executePreToolUse(
|
|
247
|
-
name,
|
|
248
|
-
params,
|
|
249
|
-
'session-1',
|
|
250
|
-
)
|
|
252
|
+
const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
|
|
251
253
|
if (!preResult.allowed) {
|
|
252
254
|
return {
|
|
253
255
|
success: false,
|
|
@@ -266,6 +268,7 @@ export class QueryEngine {
|
|
|
266
268
|
sessionId: 'session-1',
|
|
267
269
|
provider: this.registry.getActive().config.id,
|
|
268
270
|
model: this.registry.getActiveModel(),
|
|
271
|
+
artifactServer: this.artifactServer,
|
|
269
272
|
})
|
|
270
273
|
|
|
271
274
|
// Run PostToolUse hooks
|
|
@@ -303,6 +306,11 @@ export class QueryEngine {
|
|
|
303
306
|
|
|
304
307
|
function isAbortError(err: unknown): boolean {
|
|
305
308
|
if (err instanceof Error && err.name === 'AbortError') return true
|
|
306
|
-
if (
|
|
309
|
+
if (
|
|
310
|
+
typeof DOMException !== 'undefined' &&
|
|
311
|
+
err instanceof DOMException &&
|
|
312
|
+
err.name === 'AbortError'
|
|
313
|
+
)
|
|
314
|
+
return true
|
|
307
315
|
return false
|
|
308
316
|
}
|
package/src/core/instructions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync, readdirSync
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { parse as parseYaml } from 'yaml'
|
|
4
4
|
import type { InstructionFile } from '../shared/index.ts'
|
package/src/core/permission.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ToolDefinition,
|
|
3
|
+
ToolPermission,
|
|
4
|
+
PermissionLevel,
|
|
5
|
+
PermissionRule,
|
|
6
|
+
} from '../shared/index.ts'
|
|
2
7
|
|
|
3
8
|
export class PermissionSystem {
|
|
4
9
|
private rules = new Map<string, PermissionLevel>()
|
|
@@ -36,7 +41,7 @@ export class PermissionSystem {
|
|
|
36
41
|
*
|
|
37
42
|
* Fallback chain: exact rule → pattern rule → tool permission → system default
|
|
38
43
|
*/
|
|
39
|
-
check(tool: ToolDefinition,
|
|
44
|
+
check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
|
|
40
45
|
// 1. Exact-name rule wins
|
|
41
46
|
const ruleLevel = this.rules.get(tool.name)
|
|
42
47
|
if (ruleLevel) return ruleLevel
|
package/src/index.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
1
2
|
import { render } from 'ink'
|
|
2
3
|
import { App } from './ui/app'
|
|
3
4
|
import { loadConfig } from './config/loader'
|
|
@@ -10,6 +11,8 @@ import { SkillsLoader } from './skills/loader'
|
|
|
10
11
|
import { createToolRegistry } from './tools'
|
|
11
12
|
import { McpClient } from './mcp/client'
|
|
12
13
|
import { HookEngine } from './core/hooks'
|
|
14
|
+
import { ArtifactServer } from './artifacts/server'
|
|
15
|
+
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
13
16
|
|
|
14
17
|
interface RunOptions {
|
|
15
18
|
model?: string
|
|
@@ -83,16 +86,21 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
// Start artifact server (lazy — first artifact creation triggers listening)
|
|
90
|
+
const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
|
|
91
|
+
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
92
|
+
|
|
86
93
|
// Create query engine
|
|
87
94
|
const engine = new QueryEngine(registry, context, tools)
|
|
88
95
|
engine.setHookEngine(hookEngine)
|
|
96
|
+
engine.setArtifactServer(artifactServer)
|
|
89
97
|
engine.setupContextSummarizer()
|
|
90
98
|
|
|
91
99
|
// Auto-save session on exit
|
|
92
|
-
let autoSaveName: string | undefined
|
|
93
100
|
const saveAndExit = () => {
|
|
101
|
+
artifactServer.stop()
|
|
94
102
|
if (context.getMessageCount() > 0) {
|
|
95
|
-
|
|
103
|
+
SessionStore.autoSave(context.getMessages(), {
|
|
96
104
|
provider: defaultProvider,
|
|
97
105
|
model: defaultModel,
|
|
98
106
|
})
|
package/src/mcp/transport.ts
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
2
|
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
|
|
3
3
|
|
|
4
|
-
type ResponseHandler = (response: JsonRpcResponse) => void
|
|
5
4
|
type NotificationHandler = (notification: JsonRpcNotification) => void
|
|
6
5
|
|
|
7
6
|
const REQUEST_TIMEOUT_MS = 30_000
|
|
8
7
|
|
|
8
|
+
// ── Environment variable security: block sensitive vars from MCP subprocess ──
|
|
9
|
+
//
|
|
10
|
+
// By default, MCP servers receive a sanitised copy of the parent environment.
|
|
11
|
+
// Sensitive values (API keys, tokens, secrets, cloud credentials) are stripped
|
|
12
|
+
// to prevent data exfiltration by third-party MCP plugins.
|
|
13
|
+
//
|
|
14
|
+
// The `env` parameter on `start()` allows explicit overrides — use it to
|
|
15
|
+
// intentionally pass specific values to a trusted MCP server.
|
|
16
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
17
|
+
/_API_KEY$/i, // ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
|
|
18
|
+
/_SECRET$/i, // STRIPE_SECRET, etc.
|
|
19
|
+
/_TOKEN$/i, // GITHUB_TOKEN, NPM_TOKEN, etc.
|
|
20
|
+
/_PASSWORD$/i, // DB_PASSWORD, etc.
|
|
21
|
+
/_CREDENTIALS?$/i, // GOOGLE_APPLICATION_CREDENTIALS, etc.
|
|
22
|
+
/^AWS_/, // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.
|
|
23
|
+
/^GCLOUD_/, // GCP credentials
|
|
24
|
+
/^AZURE_/, // Azure credentials
|
|
25
|
+
/^DOCKER_/, // DOCKER_TOKEN, DOCKER_PASSWORD
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a sanitised environment for MCP subprocesses.
|
|
30
|
+
* Strips sensitive vars; allows explicit overrides via `extra`.
|
|
31
|
+
*/
|
|
32
|
+
function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
|
|
33
|
+
const sanitized: Record<string, string> = {}
|
|
34
|
+
|
|
35
|
+
for (const [key, value] of Object.entries(process.env as Record<string, string>)) {
|
|
36
|
+
if (value === undefined) continue
|
|
37
|
+
if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key))) continue
|
|
38
|
+
sanitized[key] = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Explicit overrides take precedence (for intentionally shared secrets)
|
|
42
|
+
if (extra) {
|
|
43
|
+
Object.assign(sanitized, extra)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return sanitized
|
|
47
|
+
}
|
|
48
|
+
|
|
9
49
|
/**
|
|
10
50
|
* MCP stdio transport — spawns a subprocess and communicates via
|
|
11
51
|
* newline-delimited JSON-RPC 2.0 messages on stdin/stdout.
|
|
@@ -30,10 +70,7 @@ export class StdioTransport {
|
|
|
30
70
|
async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
|
|
31
71
|
this.closed = false
|
|
32
72
|
|
|
33
|
-
const procEnv
|
|
34
|
-
...(process.env as Record<string, string>),
|
|
35
|
-
...env,
|
|
36
|
-
}
|
|
73
|
+
const procEnv = buildProcEnv(env)
|
|
37
74
|
|
|
38
75
|
return new Promise((resolve, reject) => {
|
|
39
76
|
const child = spawn(command, args, {
|
|
@@ -39,9 +39,7 @@ export async function fetchWithRetry(
|
|
|
39
39
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
40
|
const controller = new AbortController()
|
|
41
41
|
const timer = setTimeout(() => controller.abort(), timeout)
|
|
42
|
-
const signal = init.signal
|
|
43
|
-
? anySignal([init.signal, controller.signal])
|
|
44
|
-
: controller.signal
|
|
42
|
+
const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
|
|
45
43
|
|
|
46
44
|
try {
|
|
47
45
|
const response = await fetch(url, { ...init, signal })
|