@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.
Files changed (60) hide show
  1. package/bin/mipham +51 -24
  2. package/bin/mipham.ts +186 -0
  3. package/package.json +1 -1
  4. package/src/agent/agent-context.ts +56 -0
  5. package/src/agent/agent-registry.ts +134 -0
  6. package/src/agent/sub-agent.ts +73 -88
  7. package/src/agent/types.ts +39 -0
  8. package/src/agent-view/agent-view-manager.ts +217 -0
  9. package/src/agent-view/dashboard.tsx +218 -0
  10. package/src/agent-view/session-peek.tsx +72 -0
  11. package/src/agent-view/session-row.tsx +70 -0
  12. package/src/artifacts/server.ts +31 -0
  13. package/src/artifacts/versioning.ts +127 -0
  14. package/src/config/defaults.ts +2 -1
  15. package/src/core/context-compact.ts +50 -0
  16. package/src/core/context-drain.ts +54 -0
  17. package/src/core/context-microcompact.ts +132 -0
  18. package/src/core/context-snip.ts +74 -0
  19. package/src/core/context-token.ts +108 -0
  20. package/src/core/context.ts +169 -0
  21. package/src/core/engine.ts +144 -3
  22. package/src/core/hooks-config.ts +52 -0
  23. package/src/core/hooks-executor.ts +113 -0
  24. package/src/core/hooks.ts +90 -30
  25. package/src/core/instructions.ts +11 -0
  26. package/src/core/memory/memory-loader.ts +23 -0
  27. package/src/core/memory/memory-manager.ts +192 -0
  28. package/src/core/memory/memory-writer.ts +72 -0
  29. package/src/core/permission-config.ts +34 -0
  30. package/src/core/permission-rules.ts +66 -0
  31. package/src/core/permission.ts +221 -36
  32. package/src/index.tsx +20 -0
  33. package/src/plugin/plugin-loader.ts +36 -0
  34. package/src/plugin/plugin-manager.ts +125 -0
  35. package/src/plugin/plugin-validator.ts +41 -0
  36. package/src/shared/types.ts +61 -1
  37. package/src/skills/fork-executor.ts +40 -0
  38. package/src/skills/loader.ts +46 -0
  39. package/src/tools/agent/agent.ts +20 -11
  40. package/src/tools/agent/skill.ts +32 -2
  41. package/src/tools/computer/app-launcher.ts +39 -0
  42. package/src/tools/computer/browser.ts +48 -0
  43. package/src/tools/computer/computer-use.ts +88 -0
  44. package/src/tools/computer/playwright.d.ts +7 -0
  45. package/src/tools/computer/screenshot.ts +57 -0
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/network/web-fetch.ts +1 -1
  48. package/src/ui/app.tsx +14 -3
  49. package/src/ui/commands.ts +101 -32
  50. package/src/ui/input.tsx +123 -8
  51. package/src/ui/vim-motions.ts +96 -0
  52. package/src/workflow/budget.ts +33 -0
  53. package/src/workflow/journal.ts +105 -0
  54. package/src/workflow/primitives/agent.ts +51 -0
  55. package/src/workflow/primitives/parallel.ts +8 -0
  56. package/src/workflow/primitives/phase.ts +19 -0
  57. package/src/workflow/primitives/pipeline.ts +24 -0
  58. package/src/workflow/runtime.ts +87 -0
  59. package/src/workflow/sandbox.ts +79 -0
  60. package/dist/mipham +0 -0
@@ -0,0 +1,127 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const DEFAULT_VERSIONS_DIR = join(homedir(), '.mipham', 'artifacts')
6
+
7
+ export interface ArtifactVersion {
8
+ name: string
9
+ version: number
10
+ path: string
11
+ createdAt: string
12
+ size: number
13
+ }
14
+
15
+ /**
16
+ * Manages artifact version snapshots on disk.
17
+ *
18
+ * Directory layout:
19
+ * <dir>/<name>/versions/v1.html
20
+ * <dir>/<name>/versions/v2.html
21
+ * <dir>/<name>/current.html (latest snapshot)
22
+ * <dir>/<name>/manifest.json ({ name, currentVersion, versionCount })
23
+ */
24
+ export class ArtifactVersioning {
25
+ private dir: string
26
+
27
+ constructor(dir?: string) {
28
+ this.dir = dir ?? DEFAULT_VERSIONS_DIR
29
+ mkdirSync(this.dir, { recursive: true })
30
+ }
31
+
32
+ /** Save a new version of an artifact. Returns the assigned version number. */
33
+ saveVersion(name: string, content: string): number {
34
+ const artifactDir = join(this.dir, name)
35
+ const versionsDir = join(artifactDir, 'versions')
36
+ mkdirSync(versionsDir, { recursive: true })
37
+
38
+ // Determine next version number
39
+ const existing = this.listVersions(name)
40
+ const nextVersion = (existing.length > 0 ? Math.max(...existing.map((v) => v.version)) : 0) + 1
41
+
42
+ // Save versioned file
43
+ const versionPath = join(versionsDir, `v${nextVersion}.html`)
44
+ writeFileSync(versionPath, content, 'utf-8')
45
+
46
+ // Update current.html
47
+ writeFileSync(join(artifactDir, 'current.html'), content, 'utf-8')
48
+
49
+ // Update manifest
50
+ writeFileSync(
51
+ join(artifactDir, 'manifest.json'),
52
+ JSON.stringify(
53
+ {
54
+ name,
55
+ currentVersion: nextVersion,
56
+ versionCount: nextVersion,
57
+ },
58
+ null,
59
+ 2,
60
+ ),
61
+ )
62
+
63
+ return nextVersion
64
+ }
65
+
66
+ /** List all saved versions for an artifact, newest first. */
67
+ listVersions(name: string): ArtifactVersion[] {
68
+ const versionsDir = join(this.dir, name, 'versions')
69
+ if (!existsSync(versionsDir)) return []
70
+
71
+ try {
72
+ return readdirSync(versionsDir)
73
+ .filter((f) => f.startsWith('v') && f.endsWith('.html'))
74
+ .map((f) => {
75
+ const vNum = parseInt(f.replace('v', '').replace('.html', ''), 10)
76
+ const path = join(versionsDir, f)
77
+ const stat = statSync(path)
78
+ return {
79
+ name,
80
+ version: vNum,
81
+ path,
82
+ createdAt: stat.birthtime.toISOString(),
83
+ size: stat.size,
84
+ }
85
+ })
86
+ .sort((a, b) => b.version - a.version)
87
+ } catch {
88
+ return []
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Get artifact content.
94
+ * - If a version number is given, returns that specific version.
95
+ * - Otherwise returns the latest (current.html).
96
+ * Returns null if the requested version does not exist.
97
+ */
98
+ getVersion(name: string, version?: number): string | null {
99
+ const artifactDir = join(this.dir, name)
100
+
101
+ if (version !== undefined) {
102
+ const vPath = join(artifactDir, 'versions', `v${version}.html`)
103
+ return existsSync(vPath) ? readFileSync(vPath, 'utf-8') : null
104
+ }
105
+
106
+ const currentPath = join(artifactDir, 'current.html')
107
+ return existsSync(currentPath) ? readFileSync(currentPath, 'utf-8') : null
108
+ }
109
+
110
+ /** Produce a simple line-by-line text diff between two versions. */
111
+ diff(name: string, v1: number, v2: number): string {
112
+ const content1 = this.getVersion(name, v1) || ''
113
+ const content2 = this.getVersion(name, v2) || ''
114
+ const lines1 = content1.split('\n')
115
+ const lines2 = content2.split('\n')
116
+
117
+ const diffLines: string[] = []
118
+ const maxLen = Math.max(lines1.length, lines2.length)
119
+ for (let i = 0; i < maxLen; i++) {
120
+ if (lines1[i] !== lines2[i]) {
121
+ if (lines1[i] !== undefined) diffLines.push(`- ${lines1[i]}`)
122
+ if (lines2[i] !== undefined) diffLines.push(`+ ${lines2[i]}`)
123
+ }
124
+ }
125
+ return diffLines.length > 0 ? diffLines.join('\n') : '(no changes)'
126
+ }
127
+ }
@@ -1,8 +1,9 @@
1
1
  import type { MiphamConfig } from '../shared/index.ts'
2
2
  import { DEFAULT_PROVIDERS } from '../shared/index.ts'
3
+ import { PACKAGE_VERSION } from '@mipham/shared'
3
4
 
4
5
  export const DEFAULT_CONFIG: MiphamConfig = {
5
- version: '0.1.0',
6
+ version: PACKAGE_VERSION,
6
7
  defaultProvider: 'anthropic',
7
8
  defaultModel: 'claude-sonnet-4-6',
8
9
  permission: 'auto',
@@ -0,0 +1,50 @@
1
+ import type { ContextManager, Summarizer } from './context'
2
+ import { snipMessages } from './context-snip'
3
+
4
+ /**
5
+ * Layer 3: Reactive Compact — API-based summarization.
6
+ *
7
+ * When the context exceeds the compaction threshold, uses an LLM summarizer
8
+ * to condense the conversation history, keeping recent messages intact.
9
+ *
10
+ * After compaction, the context contains:
11
+ * 1. A summary message of the truncated history
12
+ * 2. The most recent N messages (kept intact)
13
+ * 3. The system prompt (unchanged)
14
+ */
15
+ export async function reactiveCompact(
16
+ context: ContextManager,
17
+ summarizer: Summarizer,
18
+ heading: string,
19
+ keepRecent: number = 20,
20
+ ): Promise<void> {
21
+ const messages = context.getMessages()
22
+
23
+ if (messages.length <= keepRecent + 4) return
24
+
25
+ // First, run snip to remove empty pairs
26
+ const { messages: snipped } = snipMessages(messages)
27
+
28
+ if (snipped.length <= keepRecent + 4) return
29
+
30
+ // Split: old messages to summarize, recent messages to keep
31
+ const toSummarize = snipped.slice(0, -keepRecent)
32
+ const toKeep = snipped.slice(-keepRecent)
33
+
34
+ // Generate summary
35
+ let summary: string
36
+ try {
37
+ summary = await summarizer(toSummarize, heading)
38
+ } catch {
39
+ // On summarizer failure, do a simple truncation
40
+ summary = `Earlier conversation (${toSummarize.length} messages) omitted due to context limits.`
41
+ }
42
+
43
+ // Rebuild messages: summary + recent
44
+ const summaryMsg = {
45
+ role: 'user' as const,
46
+ content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 2000)}`,
47
+ }
48
+
49
+ context.replaceMessages([summaryMsg, ...toKeep])
50
+ }
@@ -0,0 +1,54 @@
1
+ import type { ContextManager } from './context'
2
+
3
+ const MINIMAL_KEEP = 5
4
+ let drainLevel = 0
5
+
6
+ /**
7
+ * Layer 4: Emergency Drain — 413 error recovery.
8
+ *
9
+ * Called when a 413 (context too large) error is received.
10
+ * Progressively strips messages until the context fits:
11
+ *
12
+ * Level 1: Drop earliest 50% of messages
13
+ * Level 2+: Keep only system prompt + last 5 messages
14
+ *
15
+ * Returns true if recovery was possible, false if context is already minimal.
16
+ */
17
+ export async function emergencyDrain(context: ContextManager): Promise<boolean> {
18
+ const messages = context.getMessages()
19
+
20
+ if (messages.length <= MINIMAL_KEEP) {
21
+ return false // Already minimal, cannot drain further
22
+ }
23
+
24
+ if (drainLevel === 0) {
25
+ // First attempt: drop earliest 50%
26
+ const keepCount = Math.max(MINIMAL_KEEP, Math.floor(messages.length / 2))
27
+ const kept = messages.slice(-keepCount)
28
+ context.replaceMessages(kept)
29
+ drainLevel++
30
+ return true
31
+ }
32
+
33
+ // Subsequent attempts: keep only system + last MINIMAL_KEEP messages
34
+ const kept = messages.slice(-MINIMAL_KEEP)
35
+
36
+ // Add a summary placeholder if we're dropping a lot
37
+ if (messages.length > MINIMAL_KEEP * 2) {
38
+ const summaryMsg = {
39
+ role: 'user' as const,
40
+ content: `[Emergency context drain: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
41
+ }
42
+ context.replaceMessages([summaryMsg, ...kept])
43
+ } else {
44
+ context.replaceMessages(kept)
45
+ }
46
+
47
+ drainLevel++
48
+ return true
49
+ }
50
+
51
+ /** Reset drain level (call at session start). */
52
+ export function resetDrainLevel(): void {
53
+ drainLevel = 0
54
+ }
@@ -0,0 +1,132 @@
1
+ import type { Message, ToolResultContent } from '@mipham/shared'
2
+ import type { CacheTracker } from './context-token'
3
+ import { estimateMessageTokens } from './context-token'
4
+
5
+ const PLACEHOLDER = '[earlier result omitted]'
6
+
7
+ interface MicrocompactOptions {
8
+ keepRecent: number // number of recent tool-call pairs to keep intact
9
+ }
10
+
11
+ interface MicrocompactResult {
12
+ messages: Message[]
13
+ tokensSaved: number
14
+ }
15
+
16
+ /**
17
+ * Layer 2: Cache-aware micro-compression.
18
+ *
19
+ * Replaces old tool_result content with a placeholder, preserving message
20
+ * structure. Only compresses messages NOT in the prompt cache to avoid
21
+ * cache invalidation costs.
22
+ */
23
+ export function microcompact(
24
+ messages: Message[],
25
+ cacheTracker: CacheTracker,
26
+ options: MicrocompactOptions = { keepRecent: 3 },
27
+ ): MicrocompactResult {
28
+ const result: Message[] = []
29
+ let tokensSaved = 0
30
+
31
+ // Identify tool-call pairs (user query -> assistant tool_use -> user tool_result)
32
+ // Count from the end to keep the most recent ones intact
33
+ const pairCount = countToolPairs(messages)
34
+ const compressBeforePair = Math.max(0, pairCount - options.keepRecent)
35
+
36
+ let pairIndex = 0
37
+ let i = 0
38
+
39
+ while (i < messages.length) {
40
+ const msg = messages[i]!
41
+
42
+ // Check if this starts a tool-call pair
43
+ if (isToolResultUserMessage(msg) && pairIndex < compressBeforePair) {
44
+ // Don't compress if it's in the prompt cache
45
+ if (!cacheTracker.isInCache(msg)) {
46
+ const compressed = compressToolResult(msg)
47
+ result.push(compressed)
48
+ tokensSaved += estimateMessageTokens(msg) - estimateMessageTokens(compressed)
49
+ i++
50
+ pairIndex++
51
+ continue
52
+ }
53
+ }
54
+
55
+ // Count pairs as we go
56
+ if (isToolResultUserMessage(msg)) {
57
+ pairIndex++
58
+ }
59
+
60
+ result.push(msg)
61
+ i++
62
+ }
63
+
64
+ return { messages: result, tokensSaved }
65
+ }
66
+
67
+ function countToolPairs(messages: Message[]): number {
68
+ let count = 0
69
+ for (const msg of messages) {
70
+ if (isToolResultUserMessage(msg)) count++
71
+ }
72
+ return count
73
+ }
74
+
75
+ function isToolResultUserMessage(msg: Message): boolean {
76
+ if (msg.role !== 'user') return false
77
+ if (!Array.isArray(msg.content)) return false
78
+ return msg.content.some(
79
+ (block: unknown) =>
80
+ block !== null &&
81
+ typeof block === 'object' &&
82
+ (block as unknown as Record<string, unknown>).type === 'tool_result',
83
+ )
84
+ }
85
+
86
+ function compressToolResult(msg: Message): Message {
87
+ if (!Array.isArray(msg.content)) return msg
88
+
89
+ const compressed = msg.content.map((block: unknown) => {
90
+ if (
91
+ block !== null &&
92
+ typeof block === 'object' &&
93
+ (block as unknown as Record<string, unknown>).type === 'tool_result'
94
+ ) {
95
+ return {
96
+ ...(block as object),
97
+ content: PLACEHOLDER,
98
+ } as ToolResultContent
99
+ }
100
+ return block as ToolResultContent
101
+ })
102
+
103
+ return { ...msg, content: compressed }
104
+ }
105
+
106
+ /**
107
+ * Decide whether microcompaction is worth it.
108
+ *
109
+ * Only compress when tokens saved > tokens lost from cache invalidation.
110
+ * The multiplier of 1.5 provides a safety margin.
111
+ */
112
+ export function shouldMicrocompact(
113
+ messages: Message[],
114
+ cacheTracker: CacheTracker,
115
+ _threshold: number,
116
+ ): boolean {
117
+ let savingsTokens = 0
118
+ let cacheLossTokens = 0
119
+
120
+ for (const msg of messages) {
121
+ if (isToolResultUserMessage(msg)) {
122
+ const tokens = estimateMessageTokens(msg)
123
+ if (cacheTracker.isInCache(msg)) {
124
+ cacheLossTokens += tokens
125
+ } else {
126
+ savingsTokens += tokens * 0.5 // we save ~50% by replacing content
127
+ }
128
+ }
129
+ }
130
+
131
+ return savingsTokens > cacheLossTokens * 1.5
132
+ }
@@ -0,0 +1,74 @@
1
+ import type { Message } from '../shared/index.js'
2
+
3
+ interface SnipResult {
4
+ messages: Message[]
5
+ removed: number
6
+ }
7
+
8
+ /**
9
+ * Layer 1: Zero-cost pruning.
10
+ *
11
+ * Removes tool_use + empty tool_result message pairs that carry no
12
+ * information value. This is a pure data transformation — no API calls.
13
+ *
14
+ * A pair is eligible for removal when:
15
+ * - The assistant message contains only a tool_use block
16
+ * - The immediately following user message contains only a tool_result
17
+ * with empty or whitespace-only content
18
+ */
19
+ export function snipMessages(messages: Message[]): SnipResult {
20
+ if (messages.length < 2) return { messages: [...messages], removed: 0 }
21
+
22
+ const result: Message[] = []
23
+ let removed = 0
24
+ let i = 0
25
+
26
+ while (i < messages.length) {
27
+ // Look for a tool_use assistant message followed by empty tool_result
28
+ if (
29
+ i + 1 < messages.length &&
30
+ messages[i]!.role === 'assistant' &&
31
+ isOnlyToolUse(messages[i]!) &&
32
+ messages[i + 1]!.role === 'user' &&
33
+ isEmptyToolResult(messages[i + 1]!)
34
+ ) {
35
+ removed += 2
36
+ i += 2
37
+ continue
38
+ }
39
+
40
+ result.push(messages[i]!)
41
+ i++
42
+ }
43
+
44
+ return { messages: result, removed }
45
+ }
46
+
47
+ function isOnlyToolUse(msg: Message): boolean {
48
+ if (!Array.isArray(msg.content)) return false
49
+ const nonToolUse = msg.content.filter(
50
+ (block: unknown) =>
51
+ !(
52
+ block &&
53
+ typeof block === 'object' &&
54
+ (block as Record<string, unknown>).type === 'tool_use'
55
+ ),
56
+ )
57
+ // Must have at least one tool_use and nothing else substantial
58
+ const hasToolUse = msg.content.some(
59
+ (block: unknown) =>
60
+ block && typeof block === 'object' && (block as Record<string, unknown>).type === 'tool_use',
61
+ )
62
+ return hasToolUse && nonToolUse.length === 0
63
+ }
64
+
65
+ function isEmptyToolResult(msg: Message): boolean {
66
+ if (!Array.isArray(msg.content)) return false
67
+ return msg.content.every((block: unknown) => {
68
+ if (!block || typeof block !== 'object') return false
69
+ const b = block as Record<string, unknown>
70
+ if (b.type !== 'tool_result') return true // non-tool_result blocks don't count
71
+ const content = String(b.content || '')
72
+ return content.trim().length === 0
73
+ })
74
+ }
@@ -0,0 +1,108 @@
1
+ import type { Message } from '@mipham/shared'
2
+
3
+ export interface CacheStatus {
4
+ totalMessages: number
5
+ cachedMessages: number
6
+ cachedTokens: number
7
+ uncachedTokens: number
8
+ }
9
+
10
+ export interface CacheTracker {
11
+ /** Returns true if the message is currently held in the provider's prompt cache. */
12
+ isInCache(msg: Message): boolean
13
+ /** Returns a snapshot of the cache state for metrics / logging. */
14
+ getStatus(): CacheStatus
15
+ /** Clear the tracker state (does NOT evict from provider cache). */
16
+ invalidate(): void
17
+ }
18
+
19
+ /**
20
+ * A no-op cache tracker for use when prompt caching is unavailable or disabled.
21
+ * Every message reports as uncached, so microcompaction always considers savings.
22
+ */
23
+ export class NoopCacheTracker implements CacheTracker {
24
+ private messageCount = 0
25
+
26
+ isInCache(_msg: Message): boolean {
27
+ return false
28
+ }
29
+
30
+ getStatus(): CacheStatus {
31
+ return {
32
+ totalMessages: this.messageCount,
33
+ cachedMessages: 0,
34
+ cachedTokens: 0,
35
+ uncachedTokens: 0,
36
+ }
37
+ }
38
+
39
+ /** Register a batch of messages for status tracking. */
40
+ track(messages: Message[]): void {
41
+ this.messageCount = messages.length
42
+ }
43
+
44
+ invalidate(): void {
45
+ this.messageCount = 0
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Estimate the token count for a single message.
51
+ *
52
+ * Uses the same character-class-aware heuristic as ContextManager.estimateTokens():
53
+ * - CJK / emoji: ~1.5 chars per token
54
+ * - Other scripts: ~4 chars per token
55
+ */
56
+ export function estimateMessageTokens(msg: Message): number {
57
+ const text = extractMessageText(msg)
58
+ return estimateStringTokens(text)
59
+ }
60
+
61
+ function extractMessageText(msg: Message): string {
62
+ if (typeof msg.content === 'string') {
63
+ return msg.content
64
+ }
65
+ if (Array.isArray(msg.content)) {
66
+ return msg.content
67
+ .map((block) => {
68
+ if (block && typeof block === 'object') {
69
+ const b = block as unknown as Record<string, unknown>
70
+ if (b.type === 'text') return String(b.text ?? '')
71
+ if (b.type === 'tool_use') return JSON.stringify(b.input ?? {})
72
+ if (b.type === 'tool_result') return String(b.content ?? '')
73
+ if (b.type === 'image_url') return '[image]'
74
+ }
75
+ return ''
76
+ })
77
+ .join(' ')
78
+ }
79
+ return ''
80
+ }
81
+
82
+ function estimateStringTokens(text: string): number {
83
+ if (!text) return 0
84
+
85
+ let cjk = 0
86
+ let latin = 0
87
+
88
+ for (const ch of text) {
89
+ const cp = ch.codePointAt(0)!
90
+ // CJK Unified Ideographs, Hangul, Kana, CJK Extensions, fullwidth forms
91
+ if (
92
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
93
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext-A
94
+ (cp >= 0x20000 && cp <= 0x2a6df) || // CJK Ext-B
95
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul
96
+ (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
97
+ (cp >= 0xff01 && cp <= 0xff60) || // Fullwidth
98
+ (cp >= 0x1f300 && cp <= 0x1f9ff) // Emoji / pictographs
99
+ ) {
100
+ cjk++
101
+ } else {
102
+ latin++
103
+ }
104
+ }
105
+
106
+ // CJK: ~1.5 chars/token, Latin/other: ~4 chars/token
107
+ return Math.max(1, Math.ceil(cjk / 1.5 + latin / 4))
108
+ }