@miphamai/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. package/src/workflow/sandbox.ts +75 -0
@@ -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
+ }
@@ -1,4 +1,9 @@
1
1
  import type { Message } from '../shared/index.ts'
2
+ import { snipMessages } from './context-snip'
3
+ import { microcompact } from './context-microcompact'
4
+ import { reactiveCompact } from './context-compact'
5
+ import { emergencyDrain } from './context-drain'
6
+ import { NoopCacheTracker, type CacheTracker, type CacheStatus } from './context-token'
2
7
 
3
8
  export type Summarizer = (messages: Message[], heading: string) => Promise<string>
4
9
 
@@ -7,6 +12,17 @@ interface ContextConfig {
7
12
  compactionThreshold: number // e.g. 0.9 → compact at 90% usage
8
13
  }
9
14
 
15
+ export interface CompactionStats {
16
+ snipCount: number
17
+ snipMessagesRemoved: number
18
+ microcompactCount: number
19
+ microcompactTokensSaved: number
20
+ compactCount: number
21
+ compactTokensSaved: number
22
+ drainCount: number
23
+ lastCompaction: Date | null
24
+ }
25
+
10
26
  interface Checkpoint {
11
27
  id: number
12
28
  messages: Message[]
@@ -23,6 +39,20 @@ export class ContextManager {
23
39
  private checkpointCounter = 0
24
40
  private summarizer?: Summarizer
25
41
 
42
+ // ── Compression state ──
43
+ private cacheTracker: CacheTracker = new NoopCacheTracker()
44
+ private compactionStats: CompactionStats = {
45
+ snipCount: 0,
46
+ snipMessagesRemoved: 0,
47
+ microcompactCount: 0,
48
+ microcompactTokensSaved: 0,
49
+ compactCount: 0,
50
+ compactTokensSaved: 0,
51
+ drainCount: 0,
52
+ lastCompaction: null,
53
+ }
54
+ private compressionPending = false
55
+
26
56
  constructor(private config: ContextConfig) {}
27
57
 
28
58
  /** Set an optional LLM summarizer for intelligent compaction. */
@@ -44,6 +74,9 @@ export class ContextManager {
44
74
  this.estimatedTokens += this.estimateTokens(
45
75
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
46
76
  )
77
+
78
+ // Auto-trigger compression checks (fire-and-forget, don't await)
79
+ this.checkCompression()
47
80
  }
48
81
 
49
82
  getMessages(): Message[] {
@@ -102,6 +135,21 @@ export class ContextManager {
102
135
  return this.messages.length
103
136
  }
104
137
 
138
+ /**
139
+ * Replace all messages atomically (used by compaction layers).
140
+ * Preserves system prompt. Does NOT trigger compaction checks.
141
+ */
142
+ replaceMessages(messages: Message[]): void {
143
+ this.messages = messages
144
+ // Re-estimate tokens
145
+ this.estimatedTokens = this.estimateTokens(this.systemPrompt)
146
+ for (const msg of messages) {
147
+ this.estimatedTokens += this.estimateTokens(
148
+ typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
149
+ )
150
+ }
151
+ }
152
+
105
153
  // ── Checkpoint / Rewind ──
106
154
 
107
155
  saveCheckpoint(label = 'auto'): number {
@@ -153,6 +201,127 @@ export class ContextManager {
153
201
  return this.checkpoints.at(-1)?.id
154
202
  }
155
203
 
204
+ // ── Cache tracker integration ──
205
+
206
+ /** Register a cache tracker for cache-aware microcompaction decisions. */
207
+ setCacheTracker(tracker: CacheTracker): void {
208
+ this.cacheTracker = tracker
209
+ }
210
+
211
+ /** Get a snapshot of the provider prompt-cache state. */
212
+ getCacheStatus(): CacheStatus {
213
+ return this.cacheTracker.getStatus()
214
+ }
215
+
216
+ // ── Compression stats ──
217
+
218
+ /** Return a copy of the current compaction statistics. */
219
+ getCompactionStats(): CompactionStats {
220
+ return { ...this.compactionStats }
221
+ }
222
+
223
+ // ── 413 recovery ──
224
+
225
+ /**
226
+ * Emergency context drain for 413 (context too large) errors.
227
+ * Progressively strips messages until the context fits.
228
+ * Returns true if recovery was possible, false if already minimal.
229
+ */
230
+ async on413Error(): Promise<boolean> {
231
+ const result = await emergencyDrain(this)
232
+ if (result) {
233
+ this.compactionStats.drainCount++
234
+ this.compactionStats.lastCompaction = new Date()
235
+ }
236
+ return result
237
+ }
238
+
239
+ // ── Forced compaction (e.g. /compact command) ──
240
+
241
+ /**
242
+ * Force compaction of the conversation history.
243
+ *
244
+ * Uses the provided summarizer, the internal summarizer, or falls back
245
+ * to emergency drain if neither is available.
246
+ */
247
+ async forceCompact(heading: string, summarizer?: Summarizer): Promise<void> {
248
+ const beforeTokens = this.estimatedTokens
249
+
250
+ if (summarizer) {
251
+ await reactiveCompact(this, summarizer, heading)
252
+ } else if (this.summarizer) {
253
+ await reactiveCompact(this, this.summarizer, heading)
254
+ } else {
255
+ // Fallback: simple truncation via drain
256
+ await emergencyDrain(this)
257
+ }
258
+
259
+ const tokensSaved = beforeTokens - this.estimatedTokens
260
+ this.compactionStats.compactCount++
261
+ if (tokensSaved > 0) {
262
+ this.compactionStats.compactTokensSaved += tokensSaved
263
+ }
264
+ this.compactionStats.lastCompaction = new Date()
265
+ }
266
+
267
+ // ── Private compression helpers ──
268
+
269
+ /**
270
+ * Check estimated token usage and auto-trigger compression.
271
+ *
272
+ * - At 70%: run microcompact (fire-and-forget)
273
+ * - At 85%: the existing needsCompaction() serves as a compact hint
274
+ */
275
+ private checkCompression(): void {
276
+ if (this.compressionPending) return
277
+
278
+ const usage = this.estimatedTokens / this.config.maxTokens
279
+
280
+ if (usage > 0.7) {
281
+ this.compressionPending = true
282
+ // Schedule microcompact asynchronously (fire-and-forget)
283
+ Promise.resolve().then(() => {
284
+ this.runMicrocompact()
285
+ this.compressionPending = false
286
+ })
287
+ }
288
+ }
289
+
290
+ /** Run snip + microcompact inline and update stats. */
291
+ private runMicrocompact(): void {
292
+ const { messages: snipped, removed } = snipMessages(this.messages)
293
+
294
+ if (removed > 0) {
295
+ this.compactionStats.snipCount++
296
+ this.compactionStats.snipMessagesRemoved += removed
297
+ }
298
+
299
+ // Microcompact: compress tool_results not needed for recent context
300
+ const keepRecent = 3
301
+ const { messages: compacted, tokensSaved } = microcompact(snipped, this.cacheTracker, {
302
+ keepRecent,
303
+ })
304
+
305
+ if (tokensSaved > 0) {
306
+ this.compactionStats.microcompactCount++
307
+ this.compactionStats.microcompactTokensSaved += tokensSaved
308
+ this.compactionStats.lastCompaction = new Date()
309
+ }
310
+
311
+ this.messages = compacted
312
+ this.reEstimateTokens()
313
+ }
314
+
315
+ /** Re-estimate tokens from system prompt + current messages. */
316
+ private reEstimateTokens(): void {
317
+ this.estimatedTokens = this.estimateTokens(this.systemPrompt)
318
+ for (const msg of this.messages) {
319
+ this.estimatedTokens += this.estimateTokens(
320
+ typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
321
+ )
322
+ }
323
+ }
324
+
156
325
  /**
157
326
  * Estimate token count for a text string.
158
327
  *