@miphamai/cli 0.2.4 → 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 +10 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +58 -9
- package/skills/mipham/om-security.mipham-skill.md +85 -10
- package/skills/standard/doc-generator.SKILL.md +72 -11
- package/skills/standard/github-ops.SKILL.md +94 -13
- package/skills/standard/memory.SKILL.md +72 -11
- package/skills/standard/self-review.SKILL.md +50 -10
- package/skills/standard/superpower.SKILL.md +46 -8
- package/skills/standard/tdd.SKILL.md +85 -12
- package/skills/standard/web-search.SKILL.md +65 -13
- 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/context.ts +73 -13
- package/src/core/engine.ts +194 -71
- package/src/core/instructions.ts +1 -1
- package/src/core/permission.ts +35 -4
- package/src/core/session-store.ts +5 -1
- package/src/index.tsx +35 -2
- package/src/mcp/transport.ts +42 -5
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/fetch-utils.ts +101 -0
- package/src/providers/openai-compat.ts +53 -15
- package/src/providers/registry.ts +1 -0
- 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 +122 -15
- package/src/ui/chat.tsx +29 -7
- package/src/ui/commands.ts +117 -19
- package/src/ui/input.tsx +117 -35
- package/src/ui/picker.tsx +2 -5
package/src/core/engine.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
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
|
+
import type { HookEngine } from './hooks'
|
|
6
|
+
import type { ArtifactServer } from '../artifacts/server'
|
|
5
7
|
|
|
6
8
|
export class QueryEngine {
|
|
9
|
+
private hookEngine?: HookEngine
|
|
10
|
+
private artifactServer?: ArtifactServer
|
|
11
|
+
|
|
7
12
|
constructor(
|
|
8
13
|
private registry: ProviderRegistry,
|
|
9
14
|
private context: ContextManager,
|
|
@@ -11,11 +16,60 @@ export class QueryEngine {
|
|
|
11
16
|
private permission: PermissionSystem = new PermissionSystem('bypass'),
|
|
12
17
|
) {}
|
|
13
18
|
|
|
19
|
+
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
20
|
+
setHookEngine(hooks: HookEngine): void {
|
|
21
|
+
this.hookEngine = hooks
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Register the artifact server for tool context. */
|
|
25
|
+
setArtifactServer(server: ArtifactServer): void {
|
|
26
|
+
this.artifactServer = server
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Wire LLM-based conversation summarization into the context manager. */
|
|
30
|
+
setupContextSummarizer(): void {
|
|
31
|
+
this.context.setSummarizer(async (messages, heading) => {
|
|
32
|
+
const text = messages
|
|
33
|
+
.map((m) => {
|
|
34
|
+
const role = m.role
|
|
35
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
|
|
36
|
+
return `[${role}]: ${content.slice(0, 500)}`
|
|
37
|
+
})
|
|
38
|
+
.join('\n')
|
|
39
|
+
|
|
40
|
+
// Build a minimal system prompt for summarization
|
|
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}`
|
|
42
|
+
|
|
43
|
+
// Collect full summary text from streaming response
|
|
44
|
+
let summary = ''
|
|
45
|
+
try {
|
|
46
|
+
for await (const chunk of this.registry.chat({
|
|
47
|
+
model: this.registry.getActiveModel(),
|
|
48
|
+
messages: [
|
|
49
|
+
{ role: 'system', content: summaryPrompt },
|
|
50
|
+
{ role: 'user', content: text },
|
|
51
|
+
],
|
|
52
|
+
maxTokens: 300,
|
|
53
|
+
})) {
|
|
54
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
55
|
+
summary += chunk.content
|
|
56
|
+
}
|
|
57
|
+
if (chunk.type === 'stop') break
|
|
58
|
+
if (chunk.type === 'error') break
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Return a minimal summary on failure
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return summary.slice(0, 2000) || 'Prior conversation context omitted.'
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
14
68
|
getPermission(): PermissionSystem {
|
|
15
69
|
return this.permission
|
|
16
70
|
}
|
|
17
71
|
|
|
18
|
-
async *process(userInput: string): AsyncGenerator<StreamChunk> {
|
|
72
|
+
async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
19
73
|
// Add user message to context
|
|
20
74
|
this.context.addMessage({ role: 'user', content: userInput })
|
|
21
75
|
|
|
@@ -32,37 +86,51 @@ export class QueryEngine {
|
|
|
32
86
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
33
87
|
|
|
34
88
|
// Stream model response
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
45
|
-
return
|
|
46
|
-
}
|
|
89
|
+
try {
|
|
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
|
|
47
98
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
if (chunk.type === 'error') {
|
|
100
|
+
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
51
103
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
name: chunk.toolUse.name,
|
|
56
|
-
input: chunk.toolUse.input,
|
|
57
|
-
})
|
|
58
|
-
}
|
|
104
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
105
|
+
assistantContent += chunk.content
|
|
106
|
+
}
|
|
59
107
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (isAbortError(err)) {
|
|
125
|
+
// User interrupted — keep partial content, stop gracefully
|
|
62
126
|
if (assistantContent) {
|
|
63
127
|
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
64
128
|
}
|
|
129
|
+
yield { type: 'stop' }
|
|
130
|
+
return
|
|
65
131
|
}
|
|
132
|
+
yield { type: 'error', error: String(err) }
|
|
133
|
+
return
|
|
66
134
|
}
|
|
67
135
|
|
|
68
136
|
// Execute any tools that were requested
|
|
@@ -71,7 +139,7 @@ export class QueryEngine {
|
|
|
71
139
|
yield {
|
|
72
140
|
type: 'tool_result',
|
|
73
141
|
tool_use_id: toolUse.id,
|
|
74
|
-
content: result.content,
|
|
142
|
+
content: result.success ? result.content : result.error || result.content,
|
|
75
143
|
}
|
|
76
144
|
|
|
77
145
|
// Add tool use + result to context
|
|
@@ -87,60 +155,80 @@ export class QueryEngine {
|
|
|
87
155
|
|
|
88
156
|
// If tools were executed, recursively continue the conversation
|
|
89
157
|
if (toolUses.length > 0) {
|
|
90
|
-
yield* this.continueWithTools()
|
|
158
|
+
yield* this.continueWithTools(signal)
|
|
91
159
|
}
|
|
92
160
|
}
|
|
93
161
|
|
|
94
|
-
private async *continueWithTools(): AsyncGenerator<StreamChunk> {
|
|
95
|
-
const
|
|
96
|
-
const messages = this.context.getMessages()
|
|
162
|
+
private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
163
|
+
const MAX_TURNS = 10
|
|
97
164
|
const toolDefs = this.getToolDefinitions()
|
|
98
165
|
|
|
99
|
-
let
|
|
100
|
-
|
|
166
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
167
|
+
const systemPrompt = this.context.getSystemPrompt()
|
|
168
|
+
const messages = this.context.getMessages()
|
|
101
169
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
messages,
|
|
105
|
-
systemPrompt,
|
|
106
|
-
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
107
|
-
})) {
|
|
108
|
-
yield chunk
|
|
109
|
-
|
|
110
|
-
if (chunk.type === 'error') return
|
|
111
|
-
if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
|
|
112
|
-
|
|
113
|
-
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
114
|
-
toolUses.push({
|
|
115
|
-
id: chunk.toolUse.id,
|
|
116
|
-
name: chunk.toolUse.name,
|
|
117
|
-
input: chunk.toolUse.input,
|
|
118
|
-
})
|
|
119
|
-
}
|
|
120
|
-
}
|
|
170
|
+
let assistantContent = ''
|
|
171
|
+
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
121
172
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
try {
|
|
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
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
if (chunk.type === 'error') return
|
|
184
|
+
if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
|
|
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
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (isAbortError(err)) {
|
|
196
|
+
if (assistantContent) {
|
|
197
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
198
|
+
}
|
|
199
|
+
yield { type: 'stop' }
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
return
|
|
133
203
|
}
|
|
134
204
|
|
|
135
|
-
|
|
136
|
-
role: 'assistant',
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
if (assistantContent) {
|
|
206
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// No more tool calls — conversation complete
|
|
210
|
+
if (toolUses.length === 0) return
|
|
211
|
+
|
|
212
|
+
// Execute tools and feed results back to the model for the next turn
|
|
213
|
+
for (const toolUse of toolUses) {
|
|
214
|
+
const result = await this.executeTool(toolUse.name, toolUse.input)
|
|
215
|
+
yield {
|
|
216
|
+
type: 'tool_result',
|
|
217
|
+
tool_use_id: toolUse.id,
|
|
218
|
+
content: result.content,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.context.addMessage({
|
|
222
|
+
role: 'assistant',
|
|
223
|
+
content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
|
|
224
|
+
})
|
|
225
|
+
this.context.addMessage({
|
|
226
|
+
role: 'user',
|
|
227
|
+
content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
|
|
228
|
+
})
|
|
229
|
+
}
|
|
143
230
|
}
|
|
231
|
+
// Max turns reached — safety limit, stop gracefully
|
|
144
232
|
}
|
|
145
233
|
|
|
146
234
|
private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -158,13 +246,37 @@ export class QueryEngine {
|
|
|
158
246
|
}
|
|
159
247
|
}
|
|
160
248
|
|
|
249
|
+
// Run PreToolUse hooks
|
|
250
|
+
let effectiveParams = params
|
|
251
|
+
if (this.hookEngine) {
|
|
252
|
+
const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
|
|
253
|
+
if (!preResult.allowed) {
|
|
254
|
+
return {
|
|
255
|
+
success: false,
|
|
256
|
+
content: '',
|
|
257
|
+
error: preResult.reason || `Tool "${name}" blocked by hook`,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (preResult.modifiedInput) {
|
|
261
|
+
effectiveParams = { ...params, ...preResult.modifiedInput }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
161
265
|
try {
|
|
162
|
-
|
|
266
|
+
const result = await tool.execute(effectiveParams, {
|
|
163
267
|
cwd: process.cwd(),
|
|
164
268
|
sessionId: 'session-1',
|
|
165
269
|
provider: this.registry.getActive().config.id,
|
|
166
270
|
model: this.registry.getActiveModel(),
|
|
271
|
+
artifactServer: this.artifactServer,
|
|
167
272
|
})
|
|
273
|
+
|
|
274
|
+
// Run PostToolUse hooks
|
|
275
|
+
if (this.hookEngine) {
|
|
276
|
+
await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return result
|
|
168
280
|
} catch (err) {
|
|
169
281
|
return { success: false, content: '', error: String(err) }
|
|
170
282
|
}
|
|
@@ -191,3 +303,14 @@ export class QueryEngine {
|
|
|
191
303
|
this.registry.switchProvider(providerId, modelId)
|
|
192
304
|
}
|
|
193
305
|
}
|
|
306
|
+
|
|
307
|
+
function isAbortError(err: unknown): boolean {
|
|
308
|
+
if (err instanceof Error && err.name === 'AbortError') return true
|
|
309
|
+
if (
|
|
310
|
+
typeof DOMException !== 'undefined' &&
|
|
311
|
+
err instanceof DOMException &&
|
|
312
|
+
err.name === 'AbortError'
|
|
313
|
+
)
|
|
314
|
+
return true
|
|
315
|
+
return false
|
|
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,7 +1,13 @@
|
|
|
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>()
|
|
10
|
+
private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
|
|
5
11
|
|
|
6
12
|
constructor(private defaultLevel: ToolPermission = 'auto') {}
|
|
7
13
|
|
|
@@ -13,18 +19,43 @@ export class PermissionSystem {
|
|
|
13
19
|
return this.defaultLevel
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
setRule(
|
|
17
|
-
|
|
22
|
+
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
23
|
+
if (typeof toolNameOrRule === 'string') {
|
|
24
|
+
if (level) this.rules.set(toolNameOrRule, level)
|
|
25
|
+
} else {
|
|
26
|
+
const rule = toolNameOrRule
|
|
27
|
+
if (rule.pattern) {
|
|
28
|
+
this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
|
|
29
|
+
} else {
|
|
30
|
+
this.rules.set(rule.toolName, rule.level)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
removeRule(toolName: string): void {
|
|
21
36
|
this.rules.delete(toolName)
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve permission level for a tool call.
|
|
41
|
+
*
|
|
42
|
+
* Fallback chain: exact rule → pattern rule → tool permission → system default
|
|
43
|
+
*/
|
|
24
44
|
check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
|
|
45
|
+
// 1. Exact-name rule wins
|
|
25
46
|
const ruleLevel = this.rules.get(tool.name)
|
|
26
47
|
if (ruleLevel) return ruleLevel
|
|
27
|
-
|
|
48
|
+
|
|
49
|
+
// 2. Pattern-based rules (e.g. "file_*" → bypass)
|
|
50
|
+
for (const { pattern, level } of this.patternRules) {
|
|
51
|
+
if (pattern.test(tool.name)) return level
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Tool's own permission level
|
|
55
|
+
if (tool.permission) return tool.permission
|
|
56
|
+
|
|
57
|
+
// 4. System default
|
|
58
|
+
return this.defaultLevel
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
writeFileSync,
|
|
6
6
|
unlinkSync,
|
|
7
|
+
renameSync,
|
|
7
8
|
existsSync,
|
|
8
9
|
} from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
@@ -60,7 +61,10 @@ export class SessionStore {
|
|
|
60
61
|
messages,
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Atomic write: write to temp file, then rename (same-fs rename is atomic)
|
|
65
|
+
const tmp = path + '.tmp'
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
|
|
67
|
+
renameSync(tmp, path)
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/**
|
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'
|
|
@@ -8,6 +9,10 @@ import { QueryEngine } from './core/engine'
|
|
|
8
9
|
import { SessionStore } from './core/session-store'
|
|
9
10
|
import { SkillsLoader } from './skills/loader'
|
|
10
11
|
import { createToolRegistry } from './tools'
|
|
12
|
+
import { McpClient } from './mcp/client'
|
|
13
|
+
import { HookEngine } from './core/hooks'
|
|
14
|
+
import { ArtifactServer } from './artifacts/server'
|
|
15
|
+
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
11
16
|
|
|
12
17
|
interface RunOptions {
|
|
13
18
|
model?: string
|
|
@@ -24,6 +29,17 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
24
29
|
// Load configuration
|
|
25
30
|
const config = loadConfig()
|
|
26
31
|
|
|
32
|
+
// Connect MCP servers (fire-and-forget — do not block startup)
|
|
33
|
+
const mcpServers = config.skills?.mcpServers ?? []
|
|
34
|
+
if (mcpServers.length > 0) {
|
|
35
|
+
const mcp = McpClient.getInstance()
|
|
36
|
+
for (const server of mcpServers) {
|
|
37
|
+
mcp.connect(server).catch((err) => {
|
|
38
|
+
process.stderr.write(`[mcp] Failed to connect "${server.name}": ${String(err)}\n`)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
// Bootstrap providers
|
|
28
44
|
const defaultProvider = options.provider || config.defaultProvider
|
|
29
45
|
const defaultModel = options.model || config.defaultModel
|
|
@@ -60,14 +76,31 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
60
76
|
// Create tool registry with all 16 tools
|
|
61
77
|
const tools = createToolRegistry()
|
|
62
78
|
|
|
79
|
+
// Initialize hook engine — register skill-defined hooks
|
|
80
|
+
const hookEngine = new HookEngine()
|
|
81
|
+
for (const skill of skillsLoader.list()) {
|
|
82
|
+
if (skill.hooks) {
|
|
83
|
+
for (const hook of skill.hooks) {
|
|
84
|
+
hookEngine.register(hook)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
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
|
+
|
|
63
93
|
// Create query engine
|
|
64
94
|
const engine = new QueryEngine(registry, context, tools)
|
|
95
|
+
engine.setHookEngine(hookEngine)
|
|
96
|
+
engine.setArtifactServer(artifactServer)
|
|
97
|
+
engine.setupContextSummarizer()
|
|
65
98
|
|
|
66
99
|
// Auto-save session on exit
|
|
67
|
-
let autoSaveName: string | undefined
|
|
68
100
|
const saveAndExit = () => {
|
|
101
|
+
artifactServer.stop()
|
|
69
102
|
if (context.getMessageCount() > 0) {
|
|
70
|
-
|
|
103
|
+
SessionStore.autoSave(context.getMessages(), {
|
|
71
104
|
provider: defaultProvider,
|
|
72
105
|
model: defaultModel,
|
|
73
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, {
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ContentBlock,
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
9
|
+
import { fetchWithRetry } from './fetch-utils'
|
|
9
10
|
|
|
10
11
|
interface AnthropicContentBlock {
|
|
11
12
|
type: string
|
|
@@ -72,7 +73,7 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
72
73
|
}))
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const response = await
|
|
76
|
+
const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
|
|
76
77
|
method: 'POST',
|
|
77
78
|
headers: {
|
|
78
79
|
'Content-Type': 'application/json',
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fetch utilities — retry, timeout, backoff.
|
|
3
|
+
*
|
|
4
|
+
* Both AnthropicProvider and OpenAICompatProvider use these for
|
|
5
|
+
* resilient API communication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FetchWithRetryOptions {
|
|
9
|
+
/** Request timeout in ms (applied via AbortController). */
|
|
10
|
+
timeout?: number
|
|
11
|
+
/** Maximum retry attempts (default 0 = no retry). */
|
|
12
|
+
maxRetries?: number
|
|
13
|
+
/** Base delay for exponential backoff in ms (default 1000). */
|
|
14
|
+
baseDelay?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
18
|
+
|
|
19
|
+
function isRetryableError(err: unknown): boolean {
|
|
20
|
+
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch with optional timeout and retry with exponential backoff.
|
|
26
|
+
*
|
|
27
|
+
* Retries on: network errors, 5xx, 429.
|
|
28
|
+
* Does NOT retry on: timeout (AbortError), 4xx (except 429).
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchWithRetry(
|
|
31
|
+
url: string,
|
|
32
|
+
init: RequestInit,
|
|
33
|
+
options: FetchWithRetryOptions = {},
|
|
34
|
+
): Promise<Response> {
|
|
35
|
+
const { timeout = 60_000, maxRetries = 2, baseDelay = 1000 } = options
|
|
36
|
+
|
|
37
|
+
let lastErr: unknown
|
|
38
|
+
|
|
39
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
42
|
+
const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(url, { ...init, signal })
|
|
46
|
+
|
|
47
|
+
// 429 / 5xx → retry
|
|
48
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
49
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
50
|
+
const delay = retryAfter
|
|
51
|
+
? parseInt(retryAfter, 10) * 1000
|
|
52
|
+
: baseDelay * Math.pow(2, attempt)
|
|
53
|
+
await sleep(delay)
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return response
|
|
58
|
+
} catch (err) {
|
|
59
|
+
lastErr = err
|
|
60
|
+
if (!isRetryableError(err) || attempt >= maxRetries) throw err
|
|
61
|
+
await sleep(baseDelay * Math.pow(2, attempt))
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
// Clean up combined signal if we created one
|
|
65
|
+
if (init.signal) {
|
|
66
|
+
try {
|
|
67
|
+
controller.abort()
|
|
68
|
+
} catch {
|
|
69
|
+
/* best effort */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw lastErr
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Simple promise-based sleep. */
|
|
79
|
+
function sleep(ms: number): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Combine multiple AbortSignals into one — any signal aborting
|
|
85
|
+
* triggers the combined signal.
|
|
86
|
+
*/
|
|
87
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
88
|
+
const controller = new AbortController()
|
|
89
|
+
const onAbort = () => {
|
|
90
|
+
controller.abort()
|
|
91
|
+
for (const s of signals) s.removeEventListener('abort', onAbort)
|
|
92
|
+
}
|
|
93
|
+
for (const s of signals) {
|
|
94
|
+
if (s.aborted) {
|
|
95
|
+
controller.abort()
|
|
96
|
+
return controller.signal
|
|
97
|
+
}
|
|
98
|
+
s.addEventListener('abort', onAbort)
|
|
99
|
+
}
|
|
100
|
+
return controller.signal
|
|
101
|
+
}
|