@miphamai/cli 0.4.0 → 0.5.1
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 +51 -24
- package/bin/mipham.ts +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- 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/server.ts +31 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/config/defaults.ts +2 -1
- 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 +144 -3
- 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 +11 -0
- 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 +221 -36
- package/src/index.tsx +20 -0
- 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/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- 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 +3 -0
- package/src/tools/network/web-fetch.ts +1 -1
- package/src/ui/app.tsx +14 -3
- package/src/ui/commands.ts +101 -32
- package/src/ui/input.tsx +123 -8
- 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 +79 -0
- package/dist/mipham +0 -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
|
|
@@ -137,6 +138,110 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
|
|
|
137
138
|
prevLoading.current = isLoading
|
|
138
139
|
}, [isLoading])
|
|
139
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
|
+
|
|
140
245
|
const handleSubmit = (val: string) => {
|
|
141
246
|
if (!val.trim() || isLoading) return
|
|
142
247
|
onSubmit(val)
|
|
@@ -146,18 +251,28 @@ export function InputBar({ onSubmit, isLoading }: InputBarProps) {
|
|
|
146
251
|
return (
|
|
147
252
|
<Box marginTop={1}>
|
|
148
253
|
<Box marginRight={1}>
|
|
149
|
-
<Text color={isLoading ? 'yellow' : 'cyan'}>
|
|
254
|
+
<Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
|
|
255
|
+
{vimMode === 'normal' ? ':' : '>'}
|
|
256
|
+
</Text>
|
|
150
257
|
</Box>
|
|
151
258
|
<TextInput
|
|
152
259
|
value={value}
|
|
153
|
-
onChange={
|
|
260
|
+
onChange={(val) => {
|
|
261
|
+
// Block text changes during search mode — keys go to search query
|
|
262
|
+
if (searchMode) return
|
|
263
|
+
setValue(val)
|
|
264
|
+
}}
|
|
154
265
|
onSubmit={handleSubmit}
|
|
155
266
|
placeholder={
|
|
156
|
-
|
|
157
|
-
?
|
|
158
|
-
:
|
|
159
|
-
?
|
|
160
|
-
:
|
|
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)...'
|
|
161
276
|
}
|
|
162
277
|
/>
|
|
163
278
|
</Box>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createSandbox } from './sandbox'
|
|
2
|
+
import { createJournal, appendJournal } from './journal'
|
|
3
|
+
import { createBudget } from './budget'
|
|
4
|
+
import { workflowAgent } from './primitives/agent'
|
|
5
|
+
import { parallel } from './primitives/parallel'
|
|
6
|
+
import { pipeline } from './primitives/pipeline'
|
|
7
|
+
import { phase as phasePrimitive } from './primitives/phase'
|
|
8
|
+
import type { ProviderRegistry } from '../providers/registry'
|
|
9
|
+
import type { QueryEngine } from '../core/engine'
|
|
10
|
+
|
|
11
|
+
export interface WorkflowRunResult {
|
|
12
|
+
runId: string
|
|
13
|
+
result: unknown
|
|
14
|
+
journalEntries: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a workflow script string.
|
|
19
|
+
*
|
|
20
|
+
* The script is evaluated in a sandboxed context with the workflow
|
|
21
|
+
* primitives (agent, parallel, pipeline, phase, args, budget) injected.
|
|
22
|
+
*/
|
|
23
|
+
export async function runWorkflow(
|
|
24
|
+
script: string,
|
|
25
|
+
engine: QueryEngine,
|
|
26
|
+
args: unknown = {},
|
|
27
|
+
budgetTotal: number | null = null,
|
|
28
|
+
): Promise<WorkflowRunResult> {
|
|
29
|
+
const runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
30
|
+
createJournal(runId, script)
|
|
31
|
+
|
|
32
|
+
const registry: ProviderRegistry = engine.getRegistry()
|
|
33
|
+
const toolRegistry = engine.getTools()
|
|
34
|
+
|
|
35
|
+
const budget = createBudget(budgetTotal)
|
|
36
|
+
|
|
37
|
+
// Wrap primitives with journal recording
|
|
38
|
+
const agent = async (prompt: string, opts?: Record<string, unknown>) => {
|
|
39
|
+
const result = await workflowAgent(
|
|
40
|
+
prompt,
|
|
41
|
+
registry,
|
|
42
|
+
toolRegistry,
|
|
43
|
+
opts as Record<string, unknown>,
|
|
44
|
+
)
|
|
45
|
+
appendJournal(runId, {
|
|
46
|
+
type: 'agent',
|
|
47
|
+
prompt,
|
|
48
|
+
opts: opts as Record<string, unknown> | undefined,
|
|
49
|
+
result,
|
|
50
|
+
})
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const wrappedPhase = (title: string) => {
|
|
55
|
+
phasePrimitive(title)
|
|
56
|
+
appendJournal(runId, { type: 'phase', message: title })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const log = (message: string) => {
|
|
60
|
+
appendJournal(runId, { type: 'log', message })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sandbox = createSandbox(args, budget)
|
|
64
|
+
|
|
65
|
+
// Build the script wrapper
|
|
66
|
+
const wrappedScript = `
|
|
67
|
+
return (async () => {
|
|
68
|
+
${script}
|
|
69
|
+
})()
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
// Execute in sandboxed context
|
|
73
|
+
const scriptFn = new Function(
|
|
74
|
+
'agent',
|
|
75
|
+
'parallel',
|
|
76
|
+
'pipeline',
|
|
77
|
+
'phase',
|
|
78
|
+
'log',
|
|
79
|
+
'args',
|
|
80
|
+
'budget',
|
|
81
|
+
wrappedScript,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const result = await scriptFn(agent, parallel, pipeline, wrappedPhase, log, args, budget)
|
|
85
|
+
|
|
86
|
+
return { runId, result, journalEntries: 0 }
|
|
87
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** APIs disabled in workflow scripts to ensure deterministic replay. */
|
|
2
|
+
const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a sandboxed global scope for workflow script execution.
|
|
6
|
+
* Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID().
|
|
7
|
+
*/
|
|
8
|
+
export function createSandbox(
|
|
9
|
+
args: unknown,
|
|
10
|
+
budget: { total: number | null; spent(): number; remaining(): number },
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
const sandbox: Record<string, unknown> = {
|
|
13
|
+
args,
|
|
14
|
+
budget,
|
|
15
|
+
console: {
|
|
16
|
+
log: (..._a: unknown[]) => {}, // no-op in sandbox
|
|
17
|
+
error: (..._a: unknown[]) => {},
|
|
18
|
+
},
|
|
19
|
+
// Primitives are injected by the runtime, not the sandbox
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Override Date to block now() and argless constructor
|
|
23
|
+
const OriginalDate = Date
|
|
24
|
+
sandbox.Date = new Proxy(OriginalDate, {
|
|
25
|
+
construct(_target, constructorArgs) {
|
|
26
|
+
if (constructorArgs.length === 0) {
|
|
27
|
+
throw new Error('new Date() is disabled in workflow sandbox. Pass timestamps via args.')
|
|
28
|
+
}
|
|
29
|
+
return new (OriginalDate as unknown as new (...a: unknown[]) => Date)(
|
|
30
|
+
...(constructorArgs as [number]),
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
get(_target, prop) {
|
|
34
|
+
if (prop === 'now') {
|
|
35
|
+
throw new Error('Date.now() is disabled in workflow sandbox. Pass timestamps via args.')
|
|
36
|
+
}
|
|
37
|
+
const val = (OriginalDate as unknown as Record<string, unknown>)[prop as string]
|
|
38
|
+
return typeof val === 'function'
|
|
39
|
+
? (val as (...args: unknown[]) => unknown).bind(OriginalDate)
|
|
40
|
+
: val
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Override Math.random
|
|
45
|
+
sandbox.Math = new Proxy(Math, {
|
|
46
|
+
get(_target, prop) {
|
|
47
|
+
if (prop === 'random') {
|
|
48
|
+
throw new Error('Math.random() is disabled in workflow sandbox. Use a seed from args.')
|
|
49
|
+
}
|
|
50
|
+
const val = (Math as unknown as Record<string, unknown>)[prop as string]
|
|
51
|
+
return typeof val === 'function' ? (val as (...args: unknown[]) => unknown).bind(Math) : val
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Block crypto.randomUUID
|
|
56
|
+
const globalCrypto = (globalThis as Record<string, unknown>).crypto as
|
|
57
|
+
| { randomUUID?: unknown; [key: string]: unknown }
|
|
58
|
+
| undefined
|
|
59
|
+
if (globalCrypto) {
|
|
60
|
+
sandbox.crypto = new Proxy(globalCrypto, {
|
|
61
|
+
get(_target, prop) {
|
|
62
|
+
if (prop === 'randomUUID') {
|
|
63
|
+
throw new Error('crypto.randomUUID() is disabled in workflow sandbox.')
|
|
64
|
+
}
|
|
65
|
+
const val = (globalCrypto as Record<string, unknown>)[prop as string]
|
|
66
|
+
return typeof val === 'function'
|
|
67
|
+
? (val as (...args: unknown[]) => unknown).bind(globalCrypto)
|
|
68
|
+
: val
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return sandbox
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Check whether a given API identifier is in the forbidden set. */
|
|
77
|
+
export function isForbidden(id: string): boolean {
|
|
78
|
+
return FORBIDDEN.has(id)
|
|
79
|
+
}
|
package/dist/mipham
DELETED
|
Binary file
|