@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
package/src/ui/input.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from 'react'
|
|
2
|
-
import { Box, Text } from 'ink'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import TextInput from 'ink-text-input'
|
|
4
|
+
import { VimMotionEngine, type VimMode } from './vim-motions.js'
|
|
4
5
|
|
|
5
6
|
interface InputBarProps {
|
|
6
7
|
onSubmit: (input: string) => void
|
|
@@ -10,34 +11,95 @@ interface InputBarProps {
|
|
|
10
11
|
// ── Status verbs — English (Claude Code-aligned) + Chinese (Mipham originals) ──
|
|
11
12
|
|
|
12
13
|
const STATUS_EN = [
|
|
13
|
-
'Doodling',
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
14
|
+
'Doodling',
|
|
15
|
+
'Forging',
|
|
16
|
+
'Germinating',
|
|
17
|
+
'Mosmosing',
|
|
18
|
+
'Cerebrating',
|
|
19
|
+
'Boogieing',
|
|
20
|
+
'Finagling',
|
|
21
|
+
'Misting',
|
|
22
|
+
'Recombobulating',
|
|
23
|
+
'Slithering',
|
|
24
|
+
'Effecting',
|
|
25
|
+
'Roosting',
|
|
26
|
+
'Crystallizing',
|
|
27
|
+
'Canoodling',
|
|
28
|
+
'Billowing',
|
|
29
|
+
'Warping',
|
|
30
|
+
'Improvising',
|
|
31
|
+
'Metamorphing',
|
|
32
|
+
'Gusting',
|
|
33
|
+
'Whiring',
|
|
34
|
+
'Topsy-turvying',
|
|
35
|
+
'Flibbertigibetting',
|
|
36
|
+
'Wibbling',
|
|
37
|
+
'Thundering',
|
|
38
|
+
'Moseying',
|
|
39
|
+
'Julienning',
|
|
40
|
+
'Evaporating',
|
|
41
|
+
'Ruminating',
|
|
42
|
+
'Whisking',
|
|
43
|
+
'Scampering',
|
|
44
|
+
'Meandering',
|
|
45
|
+
'Concoting',
|
|
46
|
+
'Gesticulating',
|
|
47
|
+
'Flamebeing',
|
|
48
|
+
'Quantumizing',
|
|
49
|
+
'Waddling',
|
|
50
|
+
'Fluttering',
|
|
51
|
+
'Sprouting',
|
|
52
|
+
'Elucidating',
|
|
53
|
+
'Embellishing',
|
|
54
|
+
'Razzmatizing',
|
|
55
|
+
'Pondering',
|
|
56
|
+
'Cogitating',
|
|
57
|
+
'Garnishing',
|
|
58
|
+
'Actualizing',
|
|
59
|
+
'Zigzagging',
|
|
60
|
+
'Mustering',
|
|
61
|
+
'Frolicking',
|
|
62
|
+
'Hullaballooing',
|
|
63
|
+
'Doing',
|
|
64
|
+
'Fermenting',
|
|
65
|
+
'Considering',
|
|
66
|
+
'Skedaddling',
|
|
67
|
+
'Actioning',
|
|
68
|
+
'Orbiting',
|
|
69
|
+
'Perambulating',
|
|
70
|
+
'Drizzling',
|
|
71
|
+
'Schlepping',
|
|
72
|
+
'Ionizing',
|
|
25
73
|
'Scurrying',
|
|
26
74
|
]
|
|
27
75
|
|
|
28
76
|
const STATUS_CN = [
|
|
29
|
-
'思忖中',
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
77
|
+
'思忖中',
|
|
78
|
+
'推演中',
|
|
79
|
+
'凝炼中',
|
|
80
|
+
'熬煮中',
|
|
81
|
+
'锻造中',
|
|
82
|
+
'研磨中',
|
|
83
|
+
'解构中',
|
|
84
|
+
'冥思中',
|
|
85
|
+
'编织中',
|
|
86
|
+
'萃取中',
|
|
87
|
+
'烹制中',
|
|
88
|
+
'淬火中',
|
|
89
|
+
'雕琢中',
|
|
90
|
+
'融汇中',
|
|
91
|
+
'觉照中',
|
|
92
|
+
'运转中',
|
|
93
|
+
'参详中',
|
|
94
|
+
'化合中',
|
|
95
|
+
'浸润中',
|
|
96
|
+
'焙烤中',
|
|
33
97
|
]
|
|
34
98
|
|
|
35
99
|
// Merge both — bilingual status verbs
|
|
36
100
|
const STATUS_GERUNDS = [...STATUS_EN, ...STATUS_CN]
|
|
37
101
|
|
|
38
|
-
const STATUS_PAST = [
|
|
39
|
-
'Brewed', 'Churned', 'Cooked', 'Sautéed', 'Cogitated', 'Crunched',
|
|
40
|
-
]
|
|
102
|
+
const STATUS_PAST = ['Brewed', 'Churned', 'Cooked', 'Sautéed', 'Cogitated', 'Crunched']
|
|
41
103
|
|
|
42
104
|
function pick<T>(arr: T[]): T {
|
|
43
105
|
return arr[Math.floor(Math.random() * arr.length)]!
|
|
@@ -76,6 +138,110 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
|
|
|
76
138
|
prevLoading.current = isLoading
|
|
77
139
|
}, [isLoading])
|
|
78
140
|
|
|
141
|
+
const [vimMode, setVimMode] = useState<VimMode>('insert')
|
|
142
|
+
// NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
|
|
143
|
+
// placeholder); engine mode (vimEngine.current.mode) drives logic inside useInput so
|
|
144
|
+
// the handler always reads the authoritative mode without stale-closure risk.
|
|
145
|
+
const vimEngine = useRef(new VimMotionEngine())
|
|
146
|
+
const vimPending = useRef<string | null>(null)
|
|
147
|
+
const [searchMode, setSearchMode] = useState(false)
|
|
148
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
149
|
+
|
|
150
|
+
// ── Vim motions: intercept keys in normal mode ──
|
|
151
|
+
|
|
152
|
+
useInput((input, key) => {
|
|
153
|
+
// Escape toggles between insert and normal mode
|
|
154
|
+
if (key.escape) {
|
|
155
|
+
// Cancel search mode if active
|
|
156
|
+
if (searchMode) {
|
|
157
|
+
setSearchMode(false)
|
|
158
|
+
setSearchQuery('')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// Clear any pending multi-key sequence
|
|
162
|
+
if (vimPending.current) {
|
|
163
|
+
vimPending.current = null
|
|
164
|
+
}
|
|
165
|
+
setVimMode((prev) => (prev === 'insert' ? 'normal' : 'insert'))
|
|
166
|
+
vimEngine.current.mode = vimEngine.current.mode === 'insert' ? 'normal' : 'insert'
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (vimEngine.current.mode !== 'normal') return
|
|
171
|
+
|
|
172
|
+
// Handle search mode — collect query characters
|
|
173
|
+
if (searchMode) {
|
|
174
|
+
if (key.return) {
|
|
175
|
+
const action = vimEngine.current.handleSearch(value, searchQuery)
|
|
176
|
+
if (action.text !== undefined) setValue(action.text)
|
|
177
|
+
// NOTE: action.cursor is not settable on ink-text-input — user repositions manually
|
|
178
|
+
setSearchMode(false)
|
|
179
|
+
setSearchQuery('')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (key.backspace || key.delete) {
|
|
183
|
+
setSearchQuery((q) => q.slice(0, -1))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
// Accumulate printable characters
|
|
187
|
+
if (input && input.length === 1 && !key.escape && !key.return) {
|
|
188
|
+
setSearchQuery((q) => q + input)
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Handle pending two-key sequences (dd, yy)
|
|
194
|
+
if (vimPending.current !== null) {
|
|
195
|
+
if (vimPending.current === 'd' && input === 'd') {
|
|
196
|
+
const action = vimEngine.current.handleDD(value)
|
|
197
|
+
setValue(action.text ?? value)
|
|
198
|
+
} else if (vimPending.current === 'y' && input === 'y') {
|
|
199
|
+
vimEngine.current.handleYY(value)
|
|
200
|
+
}
|
|
201
|
+
// Always clear pending — even when second key doesn't match
|
|
202
|
+
vimPending.current = null
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle p (paste) — pastes clipboard at cursor position
|
|
207
|
+
if (input === 'p') {
|
|
208
|
+
const action = vimEngine.current.handlePaste(value, value.length)
|
|
209
|
+
if (action.text !== undefined) setValue(action.text)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Handle u (undo)
|
|
214
|
+
if (input === 'u') {
|
|
215
|
+
const action = vimEngine.current.handleUndo(value)
|
|
216
|
+
if (action.text !== undefined) setValue(action.text)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle / (enter search mode)
|
|
221
|
+
if (input === '/') {
|
|
222
|
+
setSearchMode(true)
|
|
223
|
+
setSearchQuery('')
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Handle single-key motions (h, j, k, l, w, b, 0, $, d, y)
|
|
228
|
+
const action = vimEngine.current.handleNormal(input, value, value.length)
|
|
229
|
+
if (!action) return
|
|
230
|
+
|
|
231
|
+
if (action.pending) {
|
|
232
|
+
vimPending.current = action.pending
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (action.text !== undefined) {
|
|
237
|
+
setValue(action.text)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// NOTE: action.cursor is returned by motions (h/j/k/l/w/b/0/$) but
|
|
241
|
+
// ink-text-input does not expose a programmatic cursor-position API.
|
|
242
|
+
// The cursor hint is informational only; the user repositions manually.
|
|
243
|
+
})
|
|
244
|
+
|
|
79
245
|
const handleSubmit = (val: string) => {
|
|
80
246
|
if (!val.trim() || isLoading) return
|
|
81
247
|
onSubmit(val)
|
|
@@ -85,18 +251,28 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
|
|
|
85
251
|
return (
|
|
86
252
|
<Box marginTop={1}>
|
|
87
253
|
<Box marginRight={1}>
|
|
88
|
-
<Text color={isLoading ? 'yellow' : 'cyan'}>
|
|
254
|
+
<Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
|
|
255
|
+
{vimMode === 'normal' ? ':' : '>'}
|
|
256
|
+
</Text>
|
|
89
257
|
</Box>
|
|
90
258
|
<TextInput
|
|
91
259
|
value={value}
|
|
92
|
-
onChange={
|
|
260
|
+
onChange={(val) => {
|
|
261
|
+
// Block text changes during search mode — keys go to search query
|
|
262
|
+
if (searchMode) return
|
|
263
|
+
setValue(val)
|
|
264
|
+
}}
|
|
93
265
|
onSubmit={handleSubmit}
|
|
94
266
|
placeholder={
|
|
95
|
-
|
|
96
|
-
?
|
|
97
|
-
:
|
|
98
|
-
?
|
|
99
|
-
:
|
|
267
|
+
searchMode
|
|
268
|
+
? `/${searchQuery}`
|
|
269
|
+
: vimMode === 'normal'
|
|
270
|
+
? '[NORMAL] h/j/k/l w/b 0/$ dd yy p u / (Esc to insert)'
|
|
271
|
+
: isLoading
|
|
272
|
+
? `${verb}...`
|
|
273
|
+
: completionVerb
|
|
274
|
+
? completionVerb
|
|
275
|
+
: 'Type a message (Esc to cancel)...'
|
|
100
276
|
}
|
|
101
277
|
/>
|
|
102
278
|
</Box>
|
package/src/ui/picker.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React, { useState, useCallback
|
|
1
|
+
import React, { useState, useCallback } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
|
-
import type { MiphamConfig, ProviderConfig
|
|
3
|
+
import type { MiphamConfig, ProviderConfig } from '../shared/index.ts'
|
|
4
4
|
|
|
5
5
|
interface PickerProps {
|
|
6
6
|
config: MiphamConfig
|
|
@@ -113,9 +113,6 @@ export function ModelPicker({
|
|
|
113
113
|
// Provider labels
|
|
114
114
|
const providerColor = (p: ProviderConfig) => (p.id === currentProvider ? 'green' : 'white')
|
|
115
115
|
|
|
116
|
-
const modelColor = (m: ModelInfo) =>
|
|
117
|
-
selectedProvider?.id === currentProvider && m.id === currentModel ? 'green' : 'white'
|
|
118
|
-
|
|
119
116
|
return (
|
|
120
117
|
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
|
|
121
118
|
{/* Title */}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type VimMode = 'insert' | 'normal'
|
|
2
|
+
|
|
3
|
+
export class VimMotionEngine {
|
|
4
|
+
mode: VimMode = 'insert'
|
|
5
|
+
private clipboard = ''
|
|
6
|
+
private undoStack: string[] = []
|
|
7
|
+
|
|
8
|
+
/** Handle a keypress in normal mode. Returns new cursor position delta and any text mutation. */
|
|
9
|
+
handleNormal(key: string, text: string, cursor: number): VimAction | null {
|
|
10
|
+
switch (key) {
|
|
11
|
+
case 'h':
|
|
12
|
+
return { cursor: cursor - 1 }
|
|
13
|
+
case 'j':
|
|
14
|
+
return { cursor: text.length } // end of line (like $)
|
|
15
|
+
case 'k':
|
|
16
|
+
return { cursor: 0 } // start of line (like 0)
|
|
17
|
+
case 'l':
|
|
18
|
+
return { cursor: cursor + 1 }
|
|
19
|
+
case '0':
|
|
20
|
+
return { cursor: 0 }
|
|
21
|
+
case '$':
|
|
22
|
+
return { cursor: text.length }
|
|
23
|
+
case 'w':
|
|
24
|
+
return { cursor: this.nextWord(text, cursor) }
|
|
25
|
+
case 'b':
|
|
26
|
+
return { cursor: this.prevWord(text, cursor) }
|
|
27
|
+
case 'd':
|
|
28
|
+
return { pending: 'd' }
|
|
29
|
+
case 'y':
|
|
30
|
+
return { pending: 'y' }
|
|
31
|
+
default:
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Handle dd — delete entire line */
|
|
37
|
+
handleDD(text: string): VimAction {
|
|
38
|
+
this.clipboard = text
|
|
39
|
+
this.pushUndo(text)
|
|
40
|
+
return { text: '', cursor: 0 }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Handle yy — yank entire line */
|
|
44
|
+
handleYY(text: string): VimAction {
|
|
45
|
+
this.clipboard = text
|
|
46
|
+
return { cursor: 0 } // no change
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Handle p — paste after cursor */
|
|
50
|
+
handlePaste(text: string, cursor: number): VimAction {
|
|
51
|
+
const newText = text.slice(0, cursor) + this.clipboard + text.slice(cursor)
|
|
52
|
+
this.pushUndo(text)
|
|
53
|
+
return { text: newText, cursor: cursor + this.clipboard.length }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Handle u — undo */
|
|
57
|
+
handleUndo(text: string): VimAction {
|
|
58
|
+
const prev = this.undoStack.pop()
|
|
59
|
+
if (prev !== undefined) {
|
|
60
|
+
return { text: prev, cursor: prev.length }
|
|
61
|
+
}
|
|
62
|
+
return { cursor: text.length }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Handle /search */
|
|
66
|
+
handleSearch(text: string, query: string): VimAction {
|
|
67
|
+
const idx = text.indexOf(query)
|
|
68
|
+
return idx >= 0 ? { cursor: idx } : { cursor: 0 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private nextWord(text: string, cursor: number): number {
|
|
72
|
+
// Skip word characters, then skip whitespace
|
|
73
|
+
let i = cursor
|
|
74
|
+
while (i < text.length && text[i] !== ' ') i++
|
|
75
|
+
while (i < text.length && text[i] === ' ') i++
|
|
76
|
+
return i
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private prevWord(text: string, cursor: number): number {
|
|
80
|
+
let i = cursor - 1
|
|
81
|
+
while (i > 0 && text[i] === ' ') i--
|
|
82
|
+
while (i > 0 && text[i] !== ' ') i--
|
|
83
|
+
return i > 0 ? i + 1 : 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private pushUndo(text: string): void {
|
|
87
|
+
this.undoStack.push(text)
|
|
88
|
+
if (this.undoStack.length > 50) this.undoStack.shift()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface VimAction {
|
|
93
|
+
text?: string
|
|
94
|
+
cursor?: number
|
|
95
|
+
pending?: string
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface Budget {
|
|
2
|
+
total: number | null
|
|
3
|
+
spent(): number
|
|
4
|
+
remaining(): number
|
|
5
|
+
consume(tokens: number): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a token budget tracker.
|
|
10
|
+
* If totalTokens is null, the budget is unlimited.
|
|
11
|
+
*/
|
|
12
|
+
export function createBudget(totalTokens: number | null): Budget {
|
|
13
|
+
let spent = 0
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
total: totalTokens,
|
|
17
|
+
|
|
18
|
+
spent(): number {
|
|
19
|
+
return spent
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
remaining(): number {
|
|
23
|
+
return totalTokens === null ? Infinity : Math.max(0, totalTokens - spent)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
consume(tokens: number): void {
|
|
27
|
+
spent += tokens
|
|
28
|
+
if (totalTokens !== null && spent >= totalTokens) {
|
|
29
|
+
throw new Error('Token budget exceeded')
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
appendFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
|
|
12
|
+
const WORKFLOW_DIR = join(homedir(), '.mipham', 'workflows')
|
|
13
|
+
|
|
14
|
+
export interface JournalEntry {
|
|
15
|
+
seq: number
|
|
16
|
+
type: 'agent' | 'phase' | 'log'
|
|
17
|
+
prompt?: string
|
|
18
|
+
opts?: Record<string, unknown>
|
|
19
|
+
result?: unknown
|
|
20
|
+
message?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JournalState {
|
|
24
|
+
seq: number
|
|
25
|
+
phases: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a journal for a workflow run.
|
|
30
|
+
* Returns the path to the run directory.
|
|
31
|
+
*/
|
|
32
|
+
export function createJournal(runId: string, script: string): string {
|
|
33
|
+
const dir = join(WORKFLOW_DIR, runId)
|
|
34
|
+
mkdirSync(dir, { recursive: true })
|
|
35
|
+
|
|
36
|
+
writeFileSync(join(dir, 'script.js'), script, 'utf-8')
|
|
37
|
+
writeFileSync(join(dir, 'journal.jsonl'), '', 'utf-8')
|
|
38
|
+
writeFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }), 'utf-8')
|
|
39
|
+
|
|
40
|
+
return dir
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append an agent call to the journal. Returns the new sequence number.
|
|
45
|
+
*/
|
|
46
|
+
export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>): number {
|
|
47
|
+
const dir = join(WORKFLOW_DIR, runId)
|
|
48
|
+
const state = readState(runId)
|
|
49
|
+
|
|
50
|
+
const seq = state.seq + 1
|
|
51
|
+
const fullEntry: JournalEntry = { seq, ...entry }
|
|
52
|
+
|
|
53
|
+
appendFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n', 'utf-8')
|
|
54
|
+
|
|
55
|
+
state.seq = seq
|
|
56
|
+
writeFileSync(join(dir, 'state.json'), JSON.stringify(state), 'utf-8')
|
|
57
|
+
|
|
58
|
+
return seq
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load all journal entries for a run. Returns empty array if run not found.
|
|
63
|
+
*/
|
|
64
|
+
export function loadJournal(runId: string): JournalEntry[] {
|
|
65
|
+
const journalPath = join(WORKFLOW_DIR, runId, 'journal.jsonl')
|
|
66
|
+
if (!existsSync(journalPath)) return []
|
|
67
|
+
|
|
68
|
+
const raw = readFileSync(journalPath, 'utf-8')
|
|
69
|
+
return raw
|
|
70
|
+
.split('\n')
|
|
71
|
+
.filter((line) => line.trim())
|
|
72
|
+
.map((line) => JSON.parse(line) as JournalEntry)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readState(runId: string): JournalState {
|
|
76
|
+
const statePath = join(WORKFLOW_DIR, runId, 'state.json')
|
|
77
|
+
if (!existsSync(statePath)) return { seq: 0, phases: [] }
|
|
78
|
+
return JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load the saved script for a run. Returns empty string if not found.
|
|
83
|
+
*/
|
|
84
|
+
export function loadScript(runId: string): string {
|
|
85
|
+
const scriptPath = join(WORKFLOW_DIR, runId, 'script.js')
|
|
86
|
+
if (!existsSync(scriptPath)) return ''
|
|
87
|
+
return readFileSync(scriptPath, 'utf-8')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* List all workflow run IDs.
|
|
92
|
+
*/
|
|
93
|
+
export function listRuns(): string[] {
|
|
94
|
+
if (!existsSync(WORKFLOW_DIR)) return []
|
|
95
|
+
return readdirSync(WORKFLOW_DIR, { withFileTypes: true })
|
|
96
|
+
.filter((d) => d.isDirectory())
|
|
97
|
+
.map((d) => d.name)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the journal directory path for a run.
|
|
102
|
+
*/
|
|
103
|
+
export function getRunDir(runId: string): string {
|
|
104
|
+
return join(WORKFLOW_DIR, runId)
|
|
105
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SubAgent } from '../../agent/sub-agent'
|
|
2
|
+
import type { ProviderRegistry } from '../../providers/registry'
|
|
3
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
export interface WorkflowAgentOpts {
|
|
6
|
+
label?: string
|
|
7
|
+
phase?: string
|
|
8
|
+
schema?: object
|
|
9
|
+
model?: string
|
|
10
|
+
provider?: string
|
|
11
|
+
effort?: 'low' | 'medium' | 'high' | 'max'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Workflow agent() primitive — creates a SubAgent with optional
|
|
16
|
+
* provider/model override and structured output schema.
|
|
17
|
+
*/
|
|
18
|
+
export async function workflowAgent(
|
|
19
|
+
prompt: string,
|
|
20
|
+
registry: ProviderRegistry,
|
|
21
|
+
toolRegistry: Map<string, ToolDefinition>,
|
|
22
|
+
opts: WorkflowAgentOpts = {},
|
|
23
|
+
): Promise<unknown> {
|
|
24
|
+
// If provider override, switch temporarily
|
|
25
|
+
if (opts.provider) {
|
|
26
|
+
registry.switchProvider(opts.provider, opts.model)
|
|
27
|
+
} else if (opts.model) {
|
|
28
|
+
registry.switchProvider(registry.getActive().config.id, opts.model)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
32
|
+
const result = await sub.execute(prompt, opts.label || 'workflow-agent', {
|
|
33
|
+
type: 'general',
|
|
34
|
+
modelOverride: opts.model,
|
|
35
|
+
allowedTools: undefined, // use all tools by default
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// If schema is provided, attempt to parse the result as JSON
|
|
39
|
+
if (opts.schema) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(result)
|
|
42
|
+
// Basic validation: return parsed object
|
|
43
|
+
return parsed
|
|
44
|
+
} catch {
|
|
45
|
+
// Return raw text if JSON parse fails
|
|
46
|
+
return { raw: result }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parallel() — barrier: executes all thunks concurrently, waits for all.
|
|
3
|
+
* Failed thunks resolve to null. Never throws.
|
|
4
|
+
*/
|
|
5
|
+
export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<(T | null)[]> {
|
|
6
|
+
const results = await Promise.allSettled(thunks.map((t) => t()))
|
|
7
|
+
return results.map((r) => (r.status === 'fulfilled' ? r.value : null))
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let currentPhase: string = ''
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Set the current workflow phase title.
|
|
5
|
+
* In a real implementation, this emits to a progress tracker.
|
|
6
|
+
*/
|
|
7
|
+
export function phase(title: string): void {
|
|
8
|
+
currentPhase = title
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Get the current workflow phase title. */
|
|
12
|
+
export function getCurrentPhase(): string {
|
|
13
|
+
return currentPhase
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reset the current phase state (useful for testing). */
|
|
17
|
+
export function resetPhase(): void {
|
|
18
|
+
currentPhase = ''
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline() — no barrier: each item flows through all stages independently.
|
|
3
|
+
* Item A can be in stage 3 while item B is still in stage 1.
|
|
4
|
+
* Failed items become null and skip remaining stages.
|
|
5
|
+
*/
|
|
6
|
+
export async function pipeline<T, R>(
|
|
7
|
+
items: T[],
|
|
8
|
+
...stages: Array<(item: T, index: number, original: T) => Promise<R>>
|
|
9
|
+
): Promise<(R | null)[]> {
|
|
10
|
+
// Process each item through all stages concurrently
|
|
11
|
+
return Promise.all(
|
|
12
|
+
items.map(async (item, index) => {
|
|
13
|
+
let current: unknown = item
|
|
14
|
+
for (const stage of stages) {
|
|
15
|
+
try {
|
|
16
|
+
current = await stage(current as T, index, item)
|
|
17
|
+
} catch {
|
|
18
|
+
return null // item failed, skip remaining stages
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return current as R
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
}
|