@miphamai/cli 0.8.0 → 0.10.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/src/ui/app.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  handleSwitch,
16
16
  type CommandContext,
17
17
  } from './commands'
18
+ import type { PermissionMode } from '../shared/index.ts'
18
19
 
19
20
  interface AppProps {
20
21
  engine: QueryEngine
@@ -49,13 +50,29 @@ interface AgentProgress {
49
50
  // Version is read fresh from package.json at startup via runApp prop
50
51
  // (bypasses Bun module caching after npm update)
51
52
 
52
- type PermissionMode = 'auto' | 'ask' | 'bypass'
53
-
54
- const PERMISSION_MODES: PermissionMode[] = ['auto', 'ask', 'bypass']
53
+ const PERMISSION_MODES: PermissionMode[] = [
54
+ 'default',
55
+ 'acceptEdits',
56
+ 'plan',
57
+ 'auto',
58
+ 'dontAsk',
59
+ 'bypassPermissions',
60
+ ]
55
61
  const PERMISSION_LABELS: Record<PermissionMode, string> = {
56
- auto: 'auto mode on',
57
- ask: 'ask mode (confirm each action)',
58
- bypass: 'bypass mode (skip all checks)',
62
+ default: 'default · reads free',
63
+ acceptEdits: 'accept edits · reads + edits free',
64
+ plan: 'plan mode · read-only',
65
+ auto: 'auto mode · hooks gate',
66
+ dontAsk: "don't ask · pre-approved only",
67
+ bypassPermissions: 'bypass · skip all checks',
68
+ }
69
+ const PERMISSION_COLORS: Record<PermissionMode, string> = {
70
+ default: 'white',
71
+ acceptEdits: 'blue',
72
+ plan: 'yellow',
73
+ auto: 'green',
74
+ dontAsk: 'cyan',
75
+ bypassPermissions: 'red',
59
76
  }
60
77
 
61
78
  export function App({
@@ -78,7 +95,7 @@ export function App({
78
95
  const [effort, setEffort] = useState('high')
79
96
  const [focusMode, setFocusMode] = useState(false)
80
97
  const [goalText, setGoalText] = useState('')
81
- const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
98
+ const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
82
99
  const abortRef = useRef<AbortController | null>(null)
83
100
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
84
101
  const [agentElapsed, setAgentElapsed] = useState(0)
@@ -304,6 +321,24 @@ export function App({
304
321
  { role: 'system', content: `❌ Error: ${chunk.error}` },
305
322
  ])
306
323
  }
324
+
325
+ if (chunk.type === 'task_notification' && chunk.taskNotification) {
326
+ const tn = chunk.taskNotification
327
+ const emoji = tn.status === 'completed' ? '✅' : '❌'
328
+ const label = tn.status === 'completed' ? 'completed' : 'failed'
329
+ const preview = tn.content
330
+ ? tn.content.slice(0, 120) + (tn.content.length > 120 ? '...' : '')
331
+ : tn.error
332
+ ? `Error: ${tn.error.slice(0, 120)}`
333
+ : '(no output)'
334
+ setMessages((prev) => [
335
+ ...prev,
336
+ {
337
+ role: 'system',
338
+ content: `${emoji} Background Agent ${label}: ${tn.description}\n ID: ${tn.taskId}\n ${preview}`,
339
+ },
340
+ ])
341
+ }
307
342
  }
308
343
  } catch (err) {
309
344
  setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
@@ -365,13 +400,13 @@ export function App({
365
400
  })
366
401
  return
367
402
  }
368
- // Shift+Tab → cycle permission mode (auto → ask → bypass → auto)
403
+ // Shift+Tab → cycle through all 6 permission modes
369
404
  if (key.shift && key.tab) {
370
405
  setPermissionMode((prev) => {
371
406
  const idx = PERMISSION_MODES.indexOf(prev)
372
407
  const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
373
- // Sync to engine
374
- engine.getPermission().setDefaultLevel(next)
408
+ // Sync to engine — use setMode for proper mode semantics
409
+ engine.getPermission().setMode(next)
375
410
  return next
376
411
  })
377
412
  return
@@ -441,17 +476,12 @@ export function App({
441
476
  </Box>
442
477
  )}
443
478
  <Box flexDirection="row">
444
- <Text
445
- color={
446
- permissionMode === 'auto' ? 'green' : permissionMode === 'ask' ? 'yellow' : 'red'
447
- }
448
- >
479
+ <Text color={PERMISSION_COLORS[permissionMode]}>
449
480
  ● {PERMISSION_LABELS[permissionMode]}
450
481
  </Text>
451
- <Text dimColor> · ⏵⏵ accept edits on</Text>
452
- <Text dimColor> (Shift+Tab to cycle)</Text>
453
- <Text dimColor> · Ctrl+P pick · /help /commands · Esc cancel</Text>
454
- <Text dimColor> · ← agents</Text>
482
+ <Text dimColor> · Shift+Tab mode</Text>
483
+ <Text dimColor> · Ctrl+P model · Ctrl+O expand</Text>
484
+ <Text dimColor> · Esc cancel · /help · ← agents</Text>
455
485
  </Box>
456
486
  </Box>
457
487
  </Box>
@@ -392,18 +392,16 @@ const skillsCmd: CommandHandler = (ctx) => {
392
392
  // Workflow
393
393
  // ═══════════════════════════════════════════════════════════════
394
394
 
395
- const planCmd: CommandHandler = (_ctx) => ({
396
- content: stripIndent`
397
- ── Plan Mode ──
398
- Plan mode activated — read-only analysis and design.
399
- No code will be modified. Use /no-plan to exit.
400
-
401
- In plan mode you can:
402
- • Explore codebase and analyze architecture
403
- • Design implementation approaches
404
- • Create structured plans before coding
405
- `,
406
- })
395
+ const planCmd: CommandHandler = (_ctx, args) => {
396
+ const description = args.join(' ') || undefined
397
+ return {
398
+ content:
399
+ '── Plan Mode ──\n\nEntering plan mode — read-only analysis and design.\nUse EnterPlanMode to start, ExitPlanMode to submit for approval.',
400
+ forwardToAI: description
401
+ ? `Use EnterPlanMode with description: "${description}". Then explore, design, and use ExitPlanMode when ready for approval.`
402
+ : 'Use EnterPlanMode to enter plan mode. Explore the codebase, design an approach, then use ExitPlanMode to submit for approval.',
403
+ }
404
+ }
407
405
 
408
406
  const tddCmd: CommandHandler = (_ctx, args) => {
409
407
  const target = args.join(' ') || 'the current task'
@@ -1,6 +1,7 @@
1
1
  import { SubAgent } from '../../agent/sub-agent'
2
2
  import type { ProviderRegistry } from '../../providers/registry'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
+ import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
4
5
 
5
6
  export interface WorkflowAgentOpts {
6
7
  label?: string
@@ -9,11 +10,19 @@ export interface WorkflowAgentOpts {
9
10
  model?: string
10
11
  provider?: string
11
12
  effort?: 'low' | 'medium' | 'high' | 'max'
13
+ /** Maximum retries on schema validation failure (default: 2). */
14
+ maxRetries?: number
12
15
  }
13
16
 
14
17
  /**
15
18
  * Workflow agent() primitive — creates a SubAgent with optional
16
- * provider/model override and structured output schema.
19
+ * provider/model override and structured output schema validation.
20
+ *
21
+ * When `schema` is provided:
22
+ * 1. The sub-agent is prompted to return valid JSON matching the schema.
23
+ * 2. The result is JSON.parsed and validated against the schema.
24
+ * 3. On validation failure, the sub-agent is retried with error feedback.
25
+ * 4. Returns the validated object, or { raw, validationErrors } on final failure.
17
26
  */
18
27
  export async function workflowAgent(
19
28
  prompt: string,
@@ -28,24 +37,77 @@ export async function workflowAgent(
28
37
  registry.switchProvider(registry.getActive().config.id, opts.model)
29
38
  }
30
39
 
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
- })
40
+ const maxRetries = opts.maxRetries ?? 2
37
41
 
38
- // If schema is provided, attempt to parse the result as JSON
42
+ // Build the prompt — if schema is requested, instruct the model to return JSON
43
+ let effectivePrompt = prompt
39
44
  if (opts.schema) {
45
+ const schemaDesc = JSON.stringify(opts.schema, null, 2)
46
+ effectivePrompt =
47
+ `${prompt}\n\n` +
48
+ `IMPORTANT: Your response MUST be valid JSON matching this schema:\n` +
49
+ `\`\`\`json\n${schemaDesc}\n\`\`\`\n` +
50
+ `Return ONLY the JSON object, no other text. Do not wrap in markdown code fences.`
51
+ }
52
+
53
+ const sub = new SubAgent(registry, toolRegistry)
54
+
55
+ // ── Attempt execution with optional schema validation + retry ──
56
+ let lastResult = ''
57
+ let lastErrors: string[] = []
58
+
59
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
60
+ const retryPrompt =
61
+ attempt === 0
62
+ ? effectivePrompt
63
+ : `${effectivePrompt}\n\n` +
64
+ `[RETRY #${attempt}] Your previous response did NOT match the required schema.\n` +
65
+ `Validation errors:\n${lastErrors.map((e) => ` • ${e}`).join('\n')}\n\n` +
66
+ `Please fix the errors and return a valid JSON object matching the schema exactly.`
67
+
68
+ const result = await sub.execute(retryPrompt, opts.label || 'workflow-agent', {
69
+ type: 'general',
70
+ modelOverride: opts.model,
71
+ allowedTools: undefined, // use all tools by default
72
+ })
73
+
74
+ lastResult = result
75
+
76
+ // No schema — return raw result
77
+ if (!opts.schema) {
78
+ return result
79
+ }
80
+
81
+ // Try to parse and validate
40
82
  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 }
83
+ // Extract JSON from potential markdown fences
84
+ let jsonStr = result.trim()
85
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
86
+ if (fenceMatch) {
87
+ jsonStr = fenceMatch[1]!.trim()
88
+ }
89
+
90
+ const parsed = JSON.parse(jsonStr)
91
+ const errors = validateJSONSchema(parsed, opts.schema as Record<string, unknown>)
92
+
93
+ if (errors.length === 0) {
94
+ // Valid! Return the parsed object
95
+ return parsed
96
+ }
97
+
98
+ // Not valid — collect errors for retry
99
+ lastErrors = [formatValidationErrors(errors)]
100
+ } catch (err) {
101
+ // JSON parse error
102
+ lastErrors = [`JSON parse error: ${String(err)}. Ensure your response is valid JSON only.`]
47
103
  }
48
104
  }
49
105
 
50
- return result
106
+ // All retries exhausted — return raw result with validation errors
107
+ try {
108
+ const parsed = JSON.parse(lastResult)
109
+ return { raw: lastResult, parsed, validationErrors: lastErrors }
110
+ } catch {
111
+ return { raw: lastResult, validationErrors: lastErrors }
112
+ }
51
113
  }
@@ -1,5 +1,5 @@
1
1
  import { createSandbox } from './sandbox'
2
- import { createJournal, appendJournal } from './journal'
2
+ import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
3
3
  import { createBudget } from './budget'
4
4
  import { workflowAgent } from './primitives/agent'
5
5
  import { parallel } from './primitives/parallel'
@@ -12,30 +12,103 @@ export interface WorkflowRunResult {
12
12
  runId: string
13
13
  result: unknown
14
14
  journalEntries: number
15
+ /** Number of agent() calls served from cache (only set when resumeFromRunId is used). */
16
+ cacheHits: number
17
+ /** Number of agent() calls executed live (only set when resumeFromRunId is used). */
18
+ cacheMisses: number
19
+ }
20
+
21
+ /**
22
+ * Generate a cache key for an agent() call.
23
+ * Uses prompt + deterministic subset of opts.
24
+ */
25
+ function agentCacheKey(prompt: string, opts: Record<string, unknown> = {}): string {
26
+ const relevant = {
27
+ prompt,
28
+ label: opts.label,
29
+ phase: opts.phase,
30
+ model: opts.model,
31
+ provider: opts.provider,
32
+ }
33
+ return JSON.stringify(relevant)
15
34
  }
16
35
 
17
36
  /**
18
37
  * Execute a workflow script string.
19
38
  *
20
- * The script is evaluated in a sandboxed context with the workflow
21
- * primitives (agent, parallel, pipeline, phase, args, budget) injected.
39
+ * When `resumeFromRunId` is provided:
40
+ * - Loads the journal from the prior run
41
+ * - Compares the current script against the saved script
42
+ * - Builds a cache from prior agent() results (keyed by prompt+opts)
43
+ * - New agent() calls that match the cache return the cached result
44
+ * - New/changed agent() calls execute live and append to the journal
45
+ * - Returns cacheHits and cacheMisses counts
46
+ *
47
+ * The sandbox blocks non-deterministic APIs (Date.now, Math.random) to make
48
+ * this deterministic replay possible.
22
49
  */
23
50
  export async function runWorkflow(
24
51
  script: string,
25
52
  engine: QueryEngine,
26
53
  args: unknown = {},
27
54
  budgetTotal: number | null = null,
55
+ resumeFromRunId?: string,
28
56
  ): Promise<WorkflowRunResult> {
29
- const runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
30
- createJournal(runId, script)
57
+ let runId: string
58
+ let cacheHits = 0
59
+ let cacheMisses = 0
60
+
61
+ // ── Resume mode: load prior journal, build cache ──
62
+ let resultCache: Map<string, unknown> | null = null
63
+ if (resumeFromRunId) {
64
+ const savedScript = loadScript(resumeFromRunId)
65
+ if (savedScript && savedScript !== script) {
66
+ // Script changed — warn but proceed (cache may have stale entries)
67
+ process.stderr.write(
68
+ `[workflow] ⚠ Script differs from saved run "${resumeFromRunId}". ` +
69
+ `Cache may contain stale entries for changed agent calls.\n`,
70
+ )
71
+ }
72
+
73
+ const priorEntries = loadJournal(resumeFromRunId)
74
+ if (priorEntries.length === 0) {
75
+ process.stderr.write(
76
+ `[workflow] ⚠ No journal entries found for run "${resumeFromRunId}". Proceeding without cache.\n`,
77
+ )
78
+ } else {
79
+ resultCache = new Map()
80
+ for (const entry of priorEntries) {
81
+ if (entry.type === 'agent' && entry.prompt && entry.result !== undefined) {
82
+ const key = agentCacheKey(entry.prompt, entry.opts || {})
83
+ resultCache.set(key, entry.result)
84
+ }
85
+ }
86
+ }
87
+
88
+ // Re-use the existing run directory for appending
89
+ runId = resumeFromRunId
90
+ } else {
91
+ runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
92
+ createJournal(runId, script)
93
+ }
31
94
 
32
95
  const registry: ProviderRegistry = engine.getRegistry()
33
96
  const toolRegistry = engine.getTools()
34
97
 
35
98
  const budget = createBudget(budgetTotal)
36
99
 
37
- // Wrap primitives with journal recording
100
+ // Wrap primitives with journal recording + cache support
38
101
  const agent = async (prompt: string, opts?: Record<string, unknown>) => {
102
+ // Check cache first
103
+ if (resultCache) {
104
+ const key = agentCacheKey(prompt, opts || {})
105
+ if (resultCache.has(key)) {
106
+ cacheHits++
107
+ return resultCache.get(key)!
108
+ }
109
+ }
110
+ cacheMisses++
111
+
39
112
  const result = await workflowAgent(
40
113
  prompt,
41
114
  registry,
@@ -83,5 +156,9 @@ export async function runWorkflow(
83
156
 
84
157
  const result = await scriptFn(agent, parallel, pipeline, wrappedPhase, log, args, budget)
85
158
 
86
- return { runId, result, journalEntries: 0 }
159
+ // Count journal entries from state
160
+ const priorEntries = loadJournal(runId)
161
+ const journalEntries = priorEntries.length
162
+
163
+ return { runId, result, journalEntries, cacheHits, cacheMisses }
87
164
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Lightweight JSON Schema validator for workflow agent() schema parameter.
3
+ *
4
+ * Supports:
5
+ * - type: string | number | integer | boolean | object | array
6
+ * - properties (objects), items (arrays), required, enum
7
+ * - Nested objects and arrays (recursive validation)
8
+ *
9
+ * Zero external dependencies — pure TypeScript implementation.
10
+ */
11
+
12
+ export interface ValidationError {
13
+ path: string // JSON path to the error, e.g. "$.name" or "$.items[0].value"
14
+ message: string
15
+ }
16
+
17
+ /**
18
+ * Validate `data` against a JSON Schema definition.
19
+ * Returns an array of ValidationError — empty means valid.
20
+ */
21
+ export function validateJSONSchema(
22
+ data: unknown,
23
+ schema: Record<string, unknown>,
24
+ ): ValidationError[] {
25
+ const errors: ValidationError[] = []
26
+ _validate(data, schema, '$', errors)
27
+ return errors
28
+ }
29
+
30
+ /**
31
+ * Format validation errors into a human-readable message for LLM feedback.
32
+ */
33
+ export function formatValidationErrors(errors: ValidationError[]): string {
34
+ if (errors.length === 0) return ''
35
+ return errors.map((e) => ` • ${e.path}: ${e.message}`).join('\n')
36
+ }
37
+
38
+ // ── Internal recursive validator ──
39
+
40
+ function _validate(
41
+ data: unknown,
42
+ schema: Record<string, unknown>,
43
+ path: string,
44
+ errors: ValidationError[],
45
+ ): void {
46
+ const type = schema.type as string | undefined
47
+
48
+ // ── Null check ──
49
+ if (data === null || data === undefined) {
50
+ // If required, the parent object handles this. Here we just check type mismatch.
51
+ if (type && type !== 'null') {
52
+ errors.push({ path, message: `expected ${type}, got null` })
53
+ }
54
+ return
55
+ }
56
+
57
+ // ── Type checking ──
58
+ if (type) {
59
+ const actualType = Array.isArray(data) ? 'array' : typeof data
60
+ const expectedType = type === 'integer' ? 'number' : type
61
+
62
+ if (expectedType === 'number' && actualType === 'number') {
63
+ // OK — integer is a subset of number
64
+ } else if (expectedType !== actualType) {
65
+ errors.push({ path, message: `expected type "${type}", got "${actualType}"` })
66
+ return // Can't validate further if type is wrong
67
+ }
68
+
69
+ // Integer check
70
+ if (type === 'integer' && !Number.isInteger(data)) {
71
+ errors.push({ path, message: `expected integer, got ${data}` })
72
+ }
73
+ }
74
+
75
+ // ── Enum validation ──
76
+ if (schema.enum && Array.isArray(schema.enum)) {
77
+ if (!schema.enum.includes(data)) {
78
+ errors.push({
79
+ path,
80
+ message: `value must be one of: ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}`,
81
+ })
82
+ }
83
+ }
84
+
85
+ // ── Object validation ──
86
+ if (type === 'object' || (!type && typeof data === 'object' && !Array.isArray(data))) {
87
+ const obj = data as Record<string, unknown>
88
+ const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
89
+ const required = schema.required as string[] | undefined
90
+
91
+ // Required fields
92
+ if (required) {
93
+ for (const field of required) {
94
+ if (obj[field] === undefined || obj[field] === null) {
95
+ errors.push({ path: `${path}.${field}`, message: `required field is missing` })
96
+ }
97
+ }
98
+ }
99
+
100
+ // Property validation
101
+ if (properties) {
102
+ for (const [key, propSchema] of Object.entries(properties)) {
103
+ const value = obj[key]
104
+ if (value !== undefined && value !== null) {
105
+ _validate(value, propSchema, `${path}.${key}`, errors)
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Array validation ──
112
+ if (type === 'array' || (!type && Array.isArray(data))) {
113
+ const arr = data as unknown[]
114
+ const items = schema.items as Record<string, unknown> | undefined
115
+
116
+ if (items) {
117
+ for (let i = 0; i < arr.length; i++) {
118
+ _validate(arr[i], items, `${path}[${i}]`, errors)
119
+ }
120
+ }
121
+
122
+ // minItems / maxItems
123
+ if (schema.minItems !== undefined && arr.length < (schema.minItems as number)) {
124
+ errors.push({
125
+ path,
126
+ message: `array has ${arr.length} items, minimum is ${schema.minItems}`,
127
+ })
128
+ }
129
+ if (schema.maxItems !== undefined && arr.length > (schema.maxItems as number)) {
130
+ errors.push({
131
+ path,
132
+ message: `array has ${arr.length} items, maximum is ${schema.maxItems}`,
133
+ })
134
+ }
135
+ }
136
+
137
+ // ── String validation ──
138
+ if ((type === 'string' || typeof data === 'string') && typeof data === 'string') {
139
+ if (schema.minLength !== undefined && data.length < (schema.minLength as number)) {
140
+ errors.push({
141
+ path,
142
+ message: `string length ${data.length} is less than minimum ${schema.minLength}`,
143
+ })
144
+ }
145
+ if (schema.maxLength !== undefined && data.length > (schema.maxLength as number)) {
146
+ errors.push({
147
+ path,
148
+ message: `string length ${data.length} exceeds maximum ${schema.maxLength}`,
149
+ })
150
+ }
151
+ }
152
+
153
+ // ── Number validation ──
154
+ if ((type === 'number' || type === 'integer') && typeof data === 'number') {
155
+ if (schema.minimum !== undefined && data < (schema.minimum as number)) {
156
+ errors.push({ path, message: `value ${data} is less than minimum ${schema.minimum}` })
157
+ }
158
+ if (schema.maximum !== undefined && data > (schema.maximum as number)) {
159
+ errors.push({ path, message: `value ${data} exceeds maximum ${schema.maximum}` })
160
+ }
161
+ }
162
+ }