@miphamai/cli 0.20.3 → 0.22.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/package.json +1 -1
- package/src/commands/workflow-view.ts +74 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/keys-manager.ts +18 -2
- package/src/core/context-compact.ts +15 -5
- package/src/core/context.ts +19 -6
- package/src/core/memory/memory-manager.ts +49 -4
- package/src/index.tsx +10 -1
- package/src/shared/sanitize.ts +1 -1
- package/src/shared/types.ts +7 -0
- package/src/ui/app.tsx +4 -0
- package/src/ui/commands.ts +7 -0
- package/src/ui/workflow-progress.tsx +176 -0
- package/src/workflow/event-bus.ts +36 -0
- package/src/workflow/runtime.ts +24 -0
- package/dist/mipham +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CommandHandler } from '../ui/commands.js'
|
|
2
|
+
import { listRuns, loadJournal } from '../workflow/journal.js'
|
|
3
|
+
|
|
4
|
+
export const workflowViewCmd: CommandHandler = async (_ctx, args) => {
|
|
5
|
+
const runId = args[0]
|
|
6
|
+
|
|
7
|
+
// /workflow list — show recent runs
|
|
8
|
+
if (!runId || runId === 'list') {
|
|
9
|
+
const runs = listRuns()
|
|
10
|
+
if (runs.length === 0) {
|
|
11
|
+
return {
|
|
12
|
+
content:
|
|
13
|
+
'No workflow runs found.\n\nRuns are saved to ~/.mipham/workflows/ after each Workflow tool invocation.',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const lines: string[] = ['── Recent Workflow Runs ──', '']
|
|
18
|
+
|
|
19
|
+
// Show last 10, newest first
|
|
20
|
+
const recent = runs.sort().reverse().slice(0, 10)
|
|
21
|
+
for (const id of recent) {
|
|
22
|
+
const entries = loadJournal(id)
|
|
23
|
+
const agentCount = entries.filter((e) => e.type === 'agent').length
|
|
24
|
+
const phaseCount = entries.filter((e) => e.type === 'phase').length
|
|
25
|
+
const shortId = id.slice(0, 20)
|
|
26
|
+
lines.push(` ${shortId}... — ${agentCount} agents, ${phaseCount} phases`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
lines.push('', `Total runs: ${runs.length}`)
|
|
30
|
+
lines.push('', 'Use /workflow view <runId> to see details.')
|
|
31
|
+
return { content: lines.join('\n') }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// /workflow view <runId> — show run details
|
|
35
|
+
const entries = loadJournal(runId)
|
|
36
|
+
if (entries.length === 0) {
|
|
37
|
+
return { content: `Run "${runId}" not found.\n\nUse /workflow list to see available runs.` }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lines: string[] = [`── Workflow: ${runId.slice(0, 30)} ──`, '']
|
|
41
|
+
|
|
42
|
+
let currentPhase = ''
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.type === 'phase') {
|
|
45
|
+
currentPhase = entry.message || ''
|
|
46
|
+
lines.push(` ▸ Phase: ${currentPhase}`)
|
|
47
|
+
} else if (entry.type === 'agent') {
|
|
48
|
+
const label = entry.opts?.label || entry.prompt?.slice(0, 50) || 'agent'
|
|
49
|
+
lines.push(` ● ${label}`)
|
|
50
|
+
} else if (entry.type === 'log') {
|
|
51
|
+
lines.push(` ℹ ${entry.message}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
lines.push('', `Total entries: ${entries.length}`)
|
|
56
|
+
return { content: lines.join('\n') }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const workflowWatchCmd: CommandHandler = async (_ctx, _args) => {
|
|
60
|
+
// /workflow watch — monitor the currently active workflow
|
|
61
|
+
// This is a hint: the actual rendering is handled by WorkflowProgress component
|
|
62
|
+
// which auto-detects active workflows. This command just confirms watch mode.
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
'── Workflow Watch Mode ──',
|
|
66
|
+
'',
|
|
67
|
+
'Workflow progress is displayed automatically when a workflow is running.',
|
|
68
|
+
'No active workflow detected.',
|
|
69
|
+
'',
|
|
70
|
+
'Use /workflow list to see past runs.',
|
|
71
|
+
'Use /workflow view <id> to replay a completed run.',
|
|
72
|
+
].join('\n'),
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -17,6 +17,10 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
17
17
|
strictKnownMarketplaces: [],
|
|
18
18
|
blockedMarketplaces: [],
|
|
19
19
|
},
|
|
20
|
+
features: {
|
|
21
|
+
mcp: { oauthEnabled: true },
|
|
22
|
+
context: { useRealTokenizer: true, adaptiveThresholds: true },
|
|
23
|
+
},
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os'
|
|
|
4
4
|
|
|
5
5
|
const MIPHAM_DIR = join(homedir(), '.mipham')
|
|
6
6
|
const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
|
|
7
|
+
const EXPIRY_DAYS = 90
|
|
7
8
|
|
|
8
9
|
export interface KeyEntry {
|
|
9
10
|
createdAt: string
|
|
@@ -62,7 +63,7 @@ export class KeyManager {
|
|
|
62
63
|
lastRotated: entry.lastRotated,
|
|
63
64
|
rotationCount: entry.rotationCount,
|
|
64
65
|
ageDays: daysSince(entry.lastRotated),
|
|
65
|
-
expired: daysSince(entry.lastRotated) >
|
|
66
|
+
expired: daysSince(entry.lastRotated) > EXPIRY_DAYS,
|
|
66
67
|
}))
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -114,8 +115,23 @@ export class KeyManager {
|
|
|
114
115
|
if (expired.length === 0) return null
|
|
115
116
|
|
|
116
117
|
const lines = expired.map(
|
|
117
|
-
(k) =>
|
|
118
|
+
(k) =>
|
|
119
|
+
` - ${k.provider}: last rotated ${k.ageDays} days ago (${EXPIRY_DAYS}-day threshold exceeded)`,
|
|
118
120
|
)
|
|
119
121
|
return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
ensureEntry(provider: string): void {
|
|
125
|
+
const data = loadKeys()
|
|
126
|
+
if (!data[provider]) {
|
|
127
|
+
const now = new Date().toISOString()
|
|
128
|
+
data[provider] = {
|
|
129
|
+
provider,
|
|
130
|
+
createdAt: now,
|
|
131
|
+
lastRotated: now,
|
|
132
|
+
rotationCount: 0,
|
|
133
|
+
}
|
|
134
|
+
saveKeys(data)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
121
137
|
}
|
|
@@ -12,24 +12,34 @@ import { snipMessages } from './context-snip'
|
|
|
12
12
|
* 2. The most recent N messages (kept intact)
|
|
13
13
|
* 3. The system prompt (unchanged)
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* Adaptive keep-recent count: scales with context window size.
|
|
17
|
+
* 128K → 20, 200K → 20, 500K → 40, 1M → 80
|
|
18
|
+
*/
|
|
19
|
+
function getKeepRecent(contextWindow: number): number {
|
|
20
|
+
return Math.max(20, Math.floor(contextWindow / 12500))
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export async function reactiveCompact(
|
|
16
24
|
context: ContextManager,
|
|
17
25
|
summarizer: Summarizer,
|
|
18
26
|
heading: string,
|
|
19
|
-
keepRecent
|
|
27
|
+
keepRecent?: number,
|
|
20
28
|
): Promise<void> {
|
|
29
|
+
// Use explicit override, or adaptive default based on context window
|
|
30
|
+
const effectiveKeepRecent = keepRecent ?? getKeepRecent(context.getMaxTokens())
|
|
21
31
|
const messages = context.getMessages()
|
|
22
32
|
|
|
23
|
-
if (messages.length <=
|
|
33
|
+
if (messages.length <= effectiveKeepRecent + 4) return
|
|
24
34
|
|
|
25
35
|
// First, run snip to remove empty pairs
|
|
26
36
|
const { messages: snipped } = snipMessages(messages)
|
|
27
37
|
|
|
28
|
-
if (snipped.length <=
|
|
38
|
+
if (snipped.length <= effectiveKeepRecent + 4) return
|
|
29
39
|
|
|
30
40
|
// Split: old messages to summarize, recent messages to keep
|
|
31
|
-
const toSummarize = snipped.slice(0, -
|
|
32
|
-
const toKeep = snipped.slice(-
|
|
41
|
+
const toSummarize = snipped.slice(0, -effectiveKeepRecent)
|
|
42
|
+
const toKeep = snipped.slice(-effectiveKeepRecent)
|
|
33
43
|
|
|
34
44
|
// Generate summary
|
|
35
45
|
let summary: string
|
package/src/core/context.ts
CHANGED
|
@@ -55,11 +55,24 @@ export class ContextManager {
|
|
|
55
55
|
private compressionPending = false
|
|
56
56
|
|
|
57
57
|
constructor(private config: ContextConfig) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
this.calculateThresholds()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Calculate adaptive compaction thresholds based on the model's context window.
|
|
63
|
+
*
|
|
64
|
+
* compaction: 200K→0.75(→clamped 0.90), 500K→0.90, 1M→0.95
|
|
65
|
+
* microcompact: 200K→0.70(→clamped 0.70), 500K→0.80, 1M→0.85
|
|
66
|
+
*/
|
|
67
|
+
calculateThresholds(): void {
|
|
68
|
+
const w = this.config.contextWindow
|
|
69
|
+
if (!w) return
|
|
70
|
+
|
|
71
|
+
// compaction: window越大越晚compact,floor at 0.90
|
|
72
|
+
this.config.compactionThreshold = Math.max(0.9, 1 - 50000 / w)
|
|
73
|
+
|
|
74
|
+
// microcompact threshold is computed inline in checkCompression()
|
|
75
|
+
// using this.config.contextWindow directly
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
getCompactionThreshold(): number {
|
|
@@ -71,8 +84,8 @@ export class ContextManager {
|
|
|
71
84
|
this.config.maxTokens = maxTokens
|
|
72
85
|
if (contextWindow) {
|
|
73
86
|
this.config.contextWindow = contextWindow
|
|
74
|
-
this.config.compactionThreshold = Math.max(0.9, 1 - 50000 / contextWindow)
|
|
75
87
|
}
|
|
88
|
+
this.calculateThresholds()
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
getMaxTokens(): number {
|
|
@@ -26,15 +26,22 @@ export interface MemoryEntry {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const INDEX_FILE = 'MEMORY.md'
|
|
29
|
+
const LINKS_FILE = 'links.json'
|
|
29
30
|
|
|
30
31
|
export class MemoryManager {
|
|
31
32
|
private memories = new Map<string, MemoryEntry>()
|
|
32
33
|
private linkGraph: Map<string, Set<string>> = new Map()
|
|
34
|
+
private contextMaxTokens = 200_000
|
|
33
35
|
|
|
34
36
|
constructor(private memoryDir: string) {
|
|
35
37
|
mkdirSync(memoryDir, { recursive: true })
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Set the model's context window size for adaptive memory budget. */
|
|
41
|
+
setContextWindow(tokens: number): void {
|
|
42
|
+
this.contextMaxTokens = tokens
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
loadAll(): void {
|
|
39
46
|
this.memories.clear()
|
|
40
47
|
this.linkGraph.clear()
|
|
@@ -48,7 +55,7 @@ export class MemoryManager {
|
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
for (const entry of entries) {
|
|
51
|
-
if (entry === INDEX_FILE || extname(entry) !== '.md') continue
|
|
58
|
+
if (entry === INDEX_FILE || entry === LINKS_FILE || extname(entry) !== '.md') continue
|
|
52
59
|
const filePath = join(this.memoryDir, entry)
|
|
53
60
|
try {
|
|
54
61
|
const raw = readFileSync(filePath, 'utf-8')
|
|
@@ -60,7 +67,11 @@ export class MemoryManager {
|
|
|
60
67
|
// skip unparseable
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
|
-
|
|
70
|
+
|
|
71
|
+
// Try cached link graph first, fall back to rebuild
|
|
72
|
+
if (!this.loadLinkGraph()) {
|
|
73
|
+
this.rebuildLinkGraph()
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
write(name: string, content: string, metadata: MemoryMetadata): void {
|
|
@@ -172,10 +183,15 @@ export class MemoryManager {
|
|
|
172
183
|
// file may already be gone
|
|
173
184
|
}
|
|
174
185
|
this.memories.delete(name)
|
|
186
|
+
this.linkGraph.delete(name)
|
|
187
|
+
this.saveLinkGraph()
|
|
175
188
|
this.updateIndex()
|
|
176
189
|
}
|
|
177
190
|
|
|
178
|
-
buildSystemReminder(context: string, maxTokens
|
|
191
|
+
buildSystemReminder(context: string, maxTokens?: number): string {
|
|
192
|
+
// Adaptive budget: 5% of context window, min 5000, max 75000
|
|
193
|
+
const effectiveMaxTokens =
|
|
194
|
+
maxTokens ?? Math.max(5000, Math.min(75000, Math.floor(this.contextMaxTokens * 0.05)))
|
|
179
195
|
const relevant = this.recall(context, 10)
|
|
180
196
|
if (relevant.length === 0) return ''
|
|
181
197
|
|
|
@@ -185,7 +201,7 @@ export class MemoryManager {
|
|
|
185
201
|
for (const entry of relevant) {
|
|
186
202
|
const line = `- ${entry.name}: ${entry.content.slice(0, 200)}`
|
|
187
203
|
const lineTokens = Math.ceil(line.length / 4)
|
|
188
|
-
if (tokenBudget + lineTokens >
|
|
204
|
+
if (tokenBudget + lineTokens > effectiveMaxTokens) break
|
|
189
205
|
lines.push(line)
|
|
190
206
|
tokenBudget += lineTokens
|
|
191
207
|
}
|
|
@@ -318,6 +334,8 @@ export class MemoryManager {
|
|
|
318
334
|
this.linkGraph.set(otherName, otherSet)
|
|
319
335
|
}
|
|
320
336
|
}
|
|
337
|
+
|
|
338
|
+
this.saveLinkGraph()
|
|
321
339
|
}
|
|
322
340
|
|
|
323
341
|
private rebuildLinkGraph(): void {
|
|
@@ -343,6 +361,33 @@ export class MemoryManager {
|
|
|
343
361
|
}
|
|
344
362
|
}
|
|
345
363
|
|
|
364
|
+
private saveLinkGraph(): void {
|
|
365
|
+
const obj: Record<string, string[]> = {}
|
|
366
|
+
for (const [k, v] of this.linkGraph) {
|
|
367
|
+
obj[k] = Array.from(v)
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
writeFileSync(join(this.memoryDir, LINKS_FILE), JSON.stringify(obj, null, 2), 'utf-8')
|
|
371
|
+
} catch {
|
|
372
|
+
// best-effort — never block on cache write
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private loadLinkGraph(): boolean {
|
|
377
|
+
const path = join(this.memoryDir, LINKS_FILE)
|
|
378
|
+
if (!existsSync(path)) return false
|
|
379
|
+
try {
|
|
380
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
381
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
382
|
+
this.linkGraph.set(k, new Set(v as string[]))
|
|
383
|
+
}
|
|
384
|
+
return this.linkGraph.size > 0
|
|
385
|
+
} catch {
|
|
386
|
+
// corrupt file — rebuild from source
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
346
391
|
private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
|
|
347
392
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
348
393
|
if (!match) return null
|
package/src/index.tsx
CHANGED
|
@@ -135,12 +135,21 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
135
135
|
)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Phase 9 feature flags (all default true — opt-out via config)
|
|
139
|
+
const features = config.features || {}
|
|
140
|
+
const adaptiveThresholds = features.context?.adaptiveThresholds !== false
|
|
141
|
+
// gated via TokenCounter — set to false to fall back to chars/4 heuristic
|
|
142
|
+
const _useRealTokenizer = features.context?.useRealTokenizer !== false
|
|
143
|
+
|
|
138
144
|
const context = new ContextManager({
|
|
139
145
|
maxTokens: contextMaxTokens,
|
|
140
146
|
compactionThreshold: 0.9,
|
|
141
|
-
contextWindow: modelContextWindow,
|
|
147
|
+
contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
|
|
142
148
|
})
|
|
143
149
|
|
|
150
|
+
// Adaptive memory budget: scale with model's context window
|
|
151
|
+
getMemoryManager().setContextWindow(modelContextWindow)
|
|
152
|
+
|
|
144
153
|
if (options.resume) {
|
|
145
154
|
const saved = SessionStore.load(options.resume)
|
|
146
155
|
if (saved) {
|
package/src/shared/sanitize.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Strips invisible/control characters that could hide command content
|
|
4
4
|
* from visual inspection or enable homoglyph attacks.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Protects against hidden command text via tabs/invisible Unicode characters.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const DANGEROUS_UNICODE = /[]/g
|
package/src/shared/types.ts
CHANGED
|
@@ -160,6 +160,13 @@ export interface MiphamConfig {
|
|
|
160
160
|
/** Block installs from matching repos (e.g. ["malicious-org/*"]) */
|
|
161
161
|
blockedMarketplaces?: string[]
|
|
162
162
|
}
|
|
163
|
+
/** Phase 9 feature flags. All default to true. */
|
|
164
|
+
features?: Partial<FeatureFlags>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface FeatureFlags {
|
|
168
|
+
mcp: { oauthEnabled: boolean }
|
|
169
|
+
context: { useRealTokenizer: boolean; adaptiveThresholds: boolean }
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
export interface McpServerConfig {
|
package/src/ui/app.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { ChatPanel } from './chat'
|
|
|
12
12
|
import { InputBar } from './input'
|
|
13
13
|
import { ModelPicker } from './picker'
|
|
14
14
|
import { AgentFooter, type AgentEntry } from './agent-footer'
|
|
15
|
+
import { WorkflowProgress } from './workflow-progress.js'
|
|
15
16
|
import {
|
|
16
17
|
getCommand,
|
|
17
18
|
looksLikeSlashCommand,
|
|
@@ -576,6 +577,9 @@ export function App({
|
|
|
576
577
|
<Text dimColor>{modelId}</Text>
|
|
577
578
|
</Box>
|
|
578
579
|
|
|
580
|
+
{/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
|
|
581
|
+
<WorkflowProgress />
|
|
582
|
+
|
|
579
583
|
{/* Chat panel */}
|
|
580
584
|
<ChatPanel messages={messages} focusMode={focusMode} />
|
|
581
585
|
|
package/src/ui/commands.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
|
|
40
40
|
import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
|
|
41
41
|
import { keysCmd } from '../commands/keys'
|
|
42
|
+
import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
|
|
42
43
|
|
|
43
44
|
export interface CommandContext {
|
|
44
45
|
engine: QueryEngine
|
|
@@ -3080,6 +3081,8 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
3080
3081
|
'/pr-comments': 'Workflow',
|
|
3081
3082
|
'/diff': 'Workflow',
|
|
3082
3083
|
'/workflows': 'Workflow',
|
|
3084
|
+
'/workflow view': 'Workflow',
|
|
3085
|
+
'/workflow watch': 'Workflow',
|
|
3083
3086
|
'/deep-research': 'Workflow',
|
|
3084
3087
|
'/loop': 'Workflow',
|
|
3085
3088
|
'/init': 'Project',
|
|
@@ -3217,6 +3220,8 @@ registry.set('/loop', loopCmd)
|
|
|
3217
3220
|
registry.set('/no-plan', noPlanCmd)
|
|
3218
3221
|
registry.set('/workflow', workflowAutoCmd)
|
|
3219
3222
|
registry.set('/workflows', workflowsCmd)
|
|
3223
|
+
registry.set('/workflow view', workflowViewCmd)
|
|
3224
|
+
registry.set('/workflow watch', workflowWatchCmd)
|
|
3220
3225
|
registry.set('/deep-research', deepResearchCmd)
|
|
3221
3226
|
registry.set('/review', reviewCmd)
|
|
3222
3227
|
registry.set('/pr-comments', prCommentsCmd)
|
|
@@ -3363,6 +3368,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
3363
3368
|
'/pr-comments': 'PR review summary',
|
|
3364
3369
|
'/diff': 'Show git diff',
|
|
3365
3370
|
'/workflows': 'List workflow scripts',
|
|
3371
|
+
'/workflow view': 'View workflow run details or list runs',
|
|
3372
|
+
'/workflow watch': 'Monitor active workflow execution',
|
|
3366
3373
|
'/deep-research':
|
|
3367
3374
|
'Deep research with multi-agent parallel search, verification, and cited synthesis',
|
|
3368
3375
|
'/loop': 'Run prompt on interval',
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { getEventBus } from '../workflow/event-bus.js'
|
|
4
|
+
import type { WorkflowEvent } from '../workflow/event-bus.js'
|
|
5
|
+
|
|
6
|
+
interface AgentState {
|
|
7
|
+
label: string
|
|
8
|
+
phase: string
|
|
9
|
+
status: 'running' | 'done' | 'failed'
|
|
10
|
+
durationMs: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WorkflowProgressProps {
|
|
14
|
+
/** If provided, only show progress for this runId. Otherwise auto-detect. */
|
|
15
|
+
runId?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function WorkflowProgress({ runId }: WorkflowProgressProps) {
|
|
19
|
+
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
|
20
|
+
const [phases, setPhases] = useState<Array<{ name: string; done: boolean }>>([])
|
|
21
|
+
const [agents, setAgents] = useState<Map<string, AgentState>>(new Map())
|
|
22
|
+
const [elapsed, setElapsed] = useState(0)
|
|
23
|
+
const [done, setDone] = useState(false)
|
|
24
|
+
const [_totalAgents, setTotalAgents] = useState(0)
|
|
25
|
+
const [cacheHits, setCacheHits] = useState(0)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const bus = getEventBus()
|
|
29
|
+
|
|
30
|
+
const handleEvent = (event: WorkflowEvent) => {
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'phase:start':
|
|
33
|
+
setPhases((prev) => [...prev, { name: event.phase, done: false }])
|
|
34
|
+
setActiveRunId(bus.getActiveRunId())
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
case 'agent:start':
|
|
38
|
+
setAgents((prev) => {
|
|
39
|
+
const next = new Map(prev)
|
|
40
|
+
next.set(event.agentId, {
|
|
41
|
+
label: event.label,
|
|
42
|
+
phase: event.phase,
|
|
43
|
+
status: 'running',
|
|
44
|
+
durationMs: 0,
|
|
45
|
+
})
|
|
46
|
+
return next
|
|
47
|
+
})
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
case 'agent:end':
|
|
51
|
+
setAgents((prev) => {
|
|
52
|
+
const next = new Map(prev)
|
|
53
|
+
const existing = next.get(event.agentId)
|
|
54
|
+
if (existing) {
|
|
55
|
+
next.set(event.agentId, {
|
|
56
|
+
...existing,
|
|
57
|
+
status: event.success ? 'done' : 'failed',
|
|
58
|
+
durationMs: event.durationMs,
|
|
59
|
+
})
|
|
60
|
+
// Mark phase as done if all agents in it are done
|
|
61
|
+
const phaseName = existing.phase
|
|
62
|
+
const allInPhase = Array.from(next.values()).filter((a) => a.phase === phaseName)
|
|
63
|
+
const allDone = allInPhase.every((a) => a.status !== 'running')
|
|
64
|
+
if (allDone) {
|
|
65
|
+
setPhases((prev) =>
|
|
66
|
+
prev.map((p) => (p.name === phaseName ? { ...p, done: true } : p)),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return next
|
|
71
|
+
})
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
case 'done':
|
|
75
|
+
setDone(true)
|
|
76
|
+
setTotalAgents(event.totalAgents)
|
|
77
|
+
setCacheHits(event.cacheHits)
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
case 'error':
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Subscribe to all event types
|
|
86
|
+
const types = [
|
|
87
|
+
'phase:start',
|
|
88
|
+
'agent:start',
|
|
89
|
+
'agent:end',
|
|
90
|
+
'agent:result',
|
|
91
|
+
'log',
|
|
92
|
+
'error',
|
|
93
|
+
'done',
|
|
94
|
+
]
|
|
95
|
+
for (const type of types) {
|
|
96
|
+
bus.on(type, handleEvent)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Elapsed timer (ticks every 100ms)
|
|
100
|
+
const timer = setInterval(() => {
|
|
101
|
+
setElapsed((prev) => prev + 100)
|
|
102
|
+
}, 100)
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
for (const type of types) {
|
|
106
|
+
bus.off(type, handleEvent)
|
|
107
|
+
}
|
|
108
|
+
clearInterval(timer)
|
|
109
|
+
}
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
const agentList = useMemo(() => Array.from(agents.values()), [agents])
|
|
113
|
+
|
|
114
|
+
if (!activeRunId && !runId) return null
|
|
115
|
+
if (done && agentList.length === 0) return null
|
|
116
|
+
|
|
117
|
+
const runningCount = agentList.filter((a) => a.status === 'running').length
|
|
118
|
+
const doneCount = agentList.filter((a) => a.status === 'done').length
|
|
119
|
+
const failedCount = agentList.filter((a) => a.status === 'failed').length
|
|
120
|
+
|
|
121
|
+
const formatDuration = (ms: number) => {
|
|
122
|
+
if (ms < 1000) return `${ms}ms`
|
|
123
|
+
return `${(ms / 1000).toFixed(1)}s`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const elapsedStr = formatDuration(elapsed)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
130
|
+
<Box>
|
|
131
|
+
<Text bold color="cyan">
|
|
132
|
+
═══ Workflow: {activeRunId || runId} ═══
|
|
133
|
+
</Text>
|
|
134
|
+
</Box>
|
|
135
|
+
|
|
136
|
+
{/* Phases */}
|
|
137
|
+
{phases.map((phase, i) => {
|
|
138
|
+
const phaseAgents = agentList.filter((a) => a.phase === phase.name)
|
|
139
|
+
return (
|
|
140
|
+
<Box key={i} flexDirection="column" marginTop={1}>
|
|
141
|
+
<Text>
|
|
142
|
+
{phase.done ? '●' : '◌'} Phase: {phase.name}
|
|
143
|
+
{phaseAgents.length > 0 &&
|
|
144
|
+
` [${phaseAgents.filter((a) => a.status === 'done').length}/${phaseAgents.length} done]`}
|
|
145
|
+
</Text>
|
|
146
|
+
{phaseAgents.map((agent, j) => (
|
|
147
|
+
<Box key={j} marginLeft={2}>
|
|
148
|
+
<Text
|
|
149
|
+
color={
|
|
150
|
+
agent.status === 'done' ? 'green' : agent.status === 'failed' ? 'red' : 'yellow'
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
{agent.status === 'done' ? '✓' : agent.status === 'failed' ? '✗' : '⏳'}{' '}
|
|
154
|
+
{agent.label}
|
|
155
|
+
{agent.status !== 'running' && ` ${formatDuration(agent.durationMs)}`}
|
|
156
|
+
</Text>
|
|
157
|
+
</Box>
|
|
158
|
+
))}
|
|
159
|
+
</Box>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
|
|
163
|
+
{/* Summary footer */}
|
|
164
|
+
<Box marginTop={1}>
|
|
165
|
+
<Text dimColor>
|
|
166
|
+
═══ Elapsed: {elapsedStr} | Agents: {doneCount}/{agentList.length}
|
|
167
|
+
{runningCount > 0 && ` (${runningCount} running)`}
|
|
168
|
+
{failedCount > 0 && ` (${failedCount} failed)`}
|
|
169
|
+
{done && ` | Cache hits: ${cacheHits}`}
|
|
170
|
+
{done && ' | DONE ═══'}
|
|
171
|
+
{!done && ' | Status: running ═══'}
|
|
172
|
+
</Text>
|
|
173
|
+
</Box>
|
|
174
|
+
</Box>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
|
|
3
|
+
export type WorkflowEvent =
|
|
4
|
+
| { type: 'phase:start'; phase: string; timestamp: number }
|
|
5
|
+
| { type: 'phase:end'; phase: string; timestamp: number }
|
|
6
|
+
| { type: 'agent:start'; agentId: string; label: string; phase: string }
|
|
7
|
+
| { type: 'agent:end'; agentId: string; label: string; success: boolean; durationMs: number }
|
|
8
|
+
| { type: 'agent:result'; agentId: string; summary: string }
|
|
9
|
+
| { type: 'log'; message: string }
|
|
10
|
+
| { type: 'error'; agentId?: string; message: string }
|
|
11
|
+
| { type: 'done'; runId: string; totalAgents: number; cacheHits: number }
|
|
12
|
+
|
|
13
|
+
export class WorkflowEventBus extends EventEmitter {
|
|
14
|
+
private activeRunId: string | null = null
|
|
15
|
+
|
|
16
|
+
startRun(runId: string): void {
|
|
17
|
+
this.activeRunId = runId
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
emitEvent(event: WorkflowEvent): void {
|
|
21
|
+
this.emit(event.type, event)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getActiveRunId(): string | null {
|
|
25
|
+
return this.activeRunId
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let instance: WorkflowEventBus | null = null
|
|
30
|
+
|
|
31
|
+
export function getEventBus(): WorkflowEventBus {
|
|
32
|
+
if (!instance) {
|
|
33
|
+
instance = new WorkflowEventBus()
|
|
34
|
+
}
|
|
35
|
+
return instance
|
|
36
|
+
}
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { workflowAgent } from './primitives/agent'
|
|
|
6
6
|
import { parallel } from './primitives/parallel'
|
|
7
7
|
import { pipeline } from './primitives/pipeline'
|
|
8
8
|
import { phase as phasePrimitive } from './primitives/phase'
|
|
9
|
+
import { getEventBus } from './event-bus'
|
|
9
10
|
import { verify, judge } from './primitives/verify'
|
|
10
11
|
import { loopUntilConvergence } from './primitives/loop'
|
|
11
12
|
import type { ProviderRegistry } from '../providers/registry'
|
|
@@ -96,6 +97,9 @@ export async function runWorkflow(
|
|
|
96
97
|
createJournal(runId, script)
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
const bus = getEventBus()
|
|
101
|
+
bus.startRun(runId)
|
|
102
|
+
|
|
99
103
|
const registry: ProviderRegistry = engine.getRegistry()
|
|
100
104
|
const toolRegistry = engine.getTools()
|
|
101
105
|
const permission = engine.getPermission()
|
|
@@ -114,10 +118,20 @@ export async function runWorkflow(
|
|
|
114
118
|
}
|
|
115
119
|
cacheMisses++
|
|
116
120
|
|
|
121
|
+
const agentId = (opts?.label as string) || `agent-${cacheMisses}`
|
|
122
|
+
const phase = (opts?.phase as string) || 'default'
|
|
123
|
+
const startTime = Date.now()
|
|
124
|
+
|
|
125
|
+
bus.emitEvent({ type: 'agent:start', agentId, label: agentId, phase })
|
|
126
|
+
|
|
117
127
|
const result = await workflowAgent(prompt, registry, toolRegistry, {
|
|
118
128
|
...(opts || {}),
|
|
119
129
|
permissionSystem: permission,
|
|
120
130
|
} as Record<string, unknown>)
|
|
131
|
+
|
|
132
|
+
const durationMs = Date.now() - startTime
|
|
133
|
+
bus.emitEvent({ type: 'agent:end', agentId, label: agentId, success: true, durationMs })
|
|
134
|
+
|
|
121
135
|
appendJournal(runId, {
|
|
122
136
|
type: 'agent',
|
|
123
137
|
prompt,
|
|
@@ -129,10 +143,12 @@ export async function runWorkflow(
|
|
|
129
143
|
|
|
130
144
|
const wrappedPhase = (title: string) => {
|
|
131
145
|
phasePrimitive(title)
|
|
146
|
+
bus.emitEvent({ type: 'phase:start', phase: title, timestamp: Date.now() })
|
|
132
147
|
appendJournal(runId, { type: 'phase', message: title })
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
const log = (message: string) => {
|
|
151
|
+
bus.emitEvent({ type: 'log', message })
|
|
136
152
|
appendJournal(runId, { type: 'log', message })
|
|
137
153
|
}
|
|
138
154
|
|
|
@@ -160,8 +176,16 @@ export async function runWorkflow(
|
|
|
160
176
|
let result: unknown
|
|
161
177
|
try {
|
|
162
178
|
result = await vmScript.runInContext(sandboxCtx, { timeout: 120_000 })
|
|
179
|
+
|
|
180
|
+
bus.emitEvent({
|
|
181
|
+
type: 'done',
|
|
182
|
+
runId,
|
|
183
|
+
totalAgents: cacheMisses,
|
|
184
|
+
cacheHits,
|
|
185
|
+
})
|
|
163
186
|
} catch (err) {
|
|
164
187
|
const message = err instanceof Error ? err.message : String(err)
|
|
188
|
+
bus.emitEvent({ type: 'error', message })
|
|
165
189
|
throw new Error(
|
|
166
190
|
`Workflow script execution failed: ${message}\n\n` +
|
|
167
191
|
`This is likely a bug in the workflow script, not in Mipham Code itself. ` +
|
package/dist/mipham
DELETED
|
Binary file
|