@miphamai/cli 0.33.0 → 0.33.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -23,12 +23,23 @@ const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
23
23
  * Each message file is named: <ISO-timestamp>-<msg-id>.json
24
24
  */
25
25
  export class FileInboxTransport implements CrossSessionTransport {
26
+ private initialized = false
27
+
26
28
  private ensureDir(dir: string): void {
27
29
  mkdirSync(dir, { recursive: true })
28
30
  }
29
31
 
32
+ /** Ensure the inbox root directory exists (call on first use). */
33
+ private ensureInboxRoot(): void {
34
+ if (!this.initialized) {
35
+ this.ensureDir(INBOX_DIR)
36
+ this.initialized = true
37
+ }
38
+ }
39
+
30
40
  async send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean> {
31
41
  try {
42
+ this.ensureInboxRoot()
32
43
  const inboxDir = join(INBOX_DIR, toSessionId)
33
44
  this.ensureDir(inboxDir)
34
45
 
@@ -36,13 +47,17 @@ export class FileInboxTransport implements CrossSessionTransport {
36
47
  const filename = `${timestamp}-${msg.id}.json`
37
48
  const filePath = join(inboxDir, filename)
38
49
 
50
+ // Serialize with structured sender fields
51
+ const envelope = {
52
+ ...msg,
53
+ senderName: from.id,
54
+ senderMachine: from.machine,
55
+ senderSession: from.id,
56
+ }
57
+
39
58
  // Atomic write: temp file then rename
40
59
  const tmpPath = filePath + '.tmp'
41
- writeFileSync(
42
- tmpPath,
43
- JSON.stringify({ ...msg, _fromSession: from.id, _fromMachine: from.machine }, null, 2),
44
- 'utf-8',
45
- )
60
+ writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), 'utf-8')
46
61
  renameSync(tmpPath, filePath)
47
62
 
48
63
  return true
@@ -52,6 +67,8 @@ export class FileInboxTransport implements CrossSessionTransport {
52
67
  }
53
68
 
54
69
  async poll(sessionId: string): Promise<AgentMessage[]> {
70
+ // Fix 6: Ensure inbox root exists before checking subdirectory
71
+ this.ensureInboxRoot()
55
72
  const inboxDir = join(INBOX_DIR, sessionId)
56
73
  if (!existsSync(inboxDir)) return []
57
74
 
@@ -65,21 +82,22 @@ export class FileInboxTransport implements CrossSessionTransport {
65
82
  try {
66
83
  const raw = readFileSync(join(inboxDir, file), 'utf-8')
67
84
  const parsed = JSON.parse(raw) as AgentMessage & {
68
- _fromSession?: string
69
- _fromMachine?: string
85
+ senderName?: string
86
+ senderMachine?: string
87
+ senderSession?: string
70
88
  }
71
89
 
72
- // Build the message body with cross-session origin prefix
73
- const originPrefix = parsed._fromSession
74
- ? `[From: ${parsed._fromSession}@${parsed._fromMachine || 'unknown'}]\n\n`
75
- : ''
90
+ // Fix 5: Structured sender display — separate From: line before body
91
+ const senderName = parsed.senderName || parsed.from || 'unknown'
92
+ const senderMachine = parsed.senderMachine || ''
93
+ const originLine = `[From: ${senderName}${senderMachine ? ` @ ${senderMachine}` : ''}]`
76
94
 
77
95
  messages.push({
78
96
  id: parsed.id,
79
97
  from: parsed.from,
80
98
  to: parsed.to,
81
99
  summary: parsed.summary,
82
- message: `${originPrefix}${parsed.message}`,
100
+ message: `${originLine}\n${parsed.message}`,
83
101
  timestamp: new Date(parsed.timestamp),
84
102
  read: parsed.read,
85
103
  type: parsed.type,
@@ -0,0 +1,205 @@
1
+ /**
2
+ * P1.1: Compaction Progress Tracker
3
+ *
4
+ * Provides user-visible progress feedback during context compaction:
5
+ * - Phase indicator (which compaction layer is active)
6
+ * - Message count (how many messages being processed)
7
+ * - Retry countdown for API-based summarization
8
+ * - Stuck detection hints
9
+ *
10
+ * Designed as an event emitter that the UI layer subscribes to.
11
+ * Falls back to stderr output when no UI subscriber is registered.
12
+ */
13
+
14
+ // ── Types ──
15
+
16
+ export type CompactionPhase =
17
+ 'microcompact' | 'snip' | 'reactive-compact' | 'emergency-drain' | 'complete'
18
+
19
+ export interface CompactionStatus {
20
+ phase: CompactionPhase
21
+ /** Human-readable description of current action */
22
+ message: string
23
+ /** 0-100 percentage, or -1 if indeterminate */
24
+ percent: number
25
+ /** Total messages before compaction */
26
+ messagesBefore: number
27
+ /** Messages remaining after compaction */
28
+ messagesAfter: number
29
+ /** Tokens before compaction */
30
+ tokensBefore: number
31
+ /** Tokens after compaction */
32
+ tokensAfter: number
33
+ /** Elapsed ms since compaction started */
34
+ elapsedMs: number
35
+ /** Retry count for summarization API calls */
36
+ retryCount: number
37
+ /** Whether a retry is in progress */
38
+ retrying: boolean
39
+ /** Whether the compaction appears stuck (no progress for >30s) */
40
+ stuck: boolean
41
+ }
42
+
43
+ export type ProgressCallback = (status: CompactionStatus) => void
44
+
45
+ // ── Constants ──
46
+
47
+ /** Timeout in ms after which we consider compaction "stuck" */
48
+ const STUCK_TIMEOUT_MS = 30_000
49
+
50
+ /** Max retries for summarizer API calls */
51
+ const MAX_SUMMARIZER_RETRIES = 3
52
+
53
+ /** Retry delay in ms between summarizer attempts */
54
+ const RETRY_DELAY_MS = 2_000
55
+
56
+ // ── Tracker ──
57
+
58
+ export class CompactionProgressTracker {
59
+ private callback: ProgressCallback | null = null
60
+ private status: CompactionStatus
61
+ private startTime = 0
62
+ private lastProgressTime = 0
63
+
64
+ constructor(messagesBefore: number, tokensBefore: number) {
65
+ this.status = {
66
+ phase: 'microcompact',
67
+ message: 'Starting compaction...',
68
+ percent: 0,
69
+ messagesBefore,
70
+ messagesAfter: messagesBefore,
71
+ tokensBefore,
72
+ tokensAfter: tokensBefore,
73
+ elapsedMs: 0,
74
+ retryCount: 0,
75
+ retrying: false,
76
+ stuck: false,
77
+ }
78
+ this.startTime = Date.now()
79
+ this.lastProgressTime = Date.now()
80
+ }
81
+
82
+ /** Register a UI callback for progress updates. */
83
+ onProgress(cb: ProgressCallback): void {
84
+ this.callback = cb
85
+ }
86
+
87
+ /** Update phase with new values. */
88
+ update(partial: {
89
+ phase?: CompactionPhase
90
+ message?: string
91
+ percent?: number
92
+ messagesAfter?: number
93
+ tokensAfter?: number
94
+ retrying?: boolean
95
+ }): void {
96
+ if (partial.phase !== undefined) this.status.phase = partial.phase
97
+ if (partial.message !== undefined) this.status.message = partial.message
98
+ if (partial.percent !== undefined) this.status.percent = partial.percent
99
+ if (partial.messagesAfter !== undefined) this.status.messagesAfter = partial.messagesAfter
100
+ if (partial.tokensAfter !== undefined) this.status.tokensAfter = partial.tokensAfter
101
+ if (partial.retrying !== undefined) this.status.retrying = partial.retrying
102
+
103
+ this.status.elapsedMs = Date.now() - this.startTime
104
+
105
+ // Check for stuck detection
106
+ if (this.status.phase !== 'complete') {
107
+ const timeSinceProgress = Date.now() - this.lastProgressTime
108
+ this.status.stuck = timeSinceProgress > STUCK_TIMEOUT_MS
109
+ }
110
+
111
+ this.emit()
112
+ }
113
+
114
+ /** Mark current retry attempt. */
115
+ onRetry(attempt: number): void {
116
+ this.status.retryCount = attempt
117
+ this.status.retrying = true
118
+ this.emit()
119
+ }
120
+
121
+ /** Mark retry as complete (success or giving up). */
122
+ onRetryComplete(): void {
123
+ this.status.retrying = false
124
+ this.lastProgressTime = Date.now()
125
+ this.status.stuck = false
126
+ this.emit()
127
+ }
128
+
129
+ /** Mark compaction as complete. */
130
+ complete(messagesAfter: number, tokensAfter: number): void {
131
+ this.status.phase = 'complete'
132
+ this.status.percent = 100
133
+ this.status.messagesAfter = messagesAfter
134
+ this.status.tokensAfter = tokensAfter
135
+ this.status.message = `Compaction complete: ${this.status.messagesBefore} → ${messagesAfter} messages`
136
+ this.status.elapsedMs = Date.now() - this.startTime
137
+ this.emit()
138
+ }
139
+
140
+ /** Get current status. */
141
+ getStatus(): CompactionStatus {
142
+ return { ...this.status }
143
+ }
144
+
145
+ /** Generate a compact summary string for CLI display. */
146
+ getSummary(): string {
147
+ const s = this.status
148
+ const elapsed = (s.elapsedMs / 1000).toFixed(1)
149
+ const stuckHint = s.stuck ? ' (stalled — press Esc to cancel)' : ''
150
+ const retryHint = s.retrying ? ` [retry ${s.retryCount}/${MAX_SUMMARIZER_RETRIES}...]` : ''
151
+ const pct = s.percent >= 0 ? ` ${s.percent}%` : ''
152
+ return `🔄 [${s.phase}]${pct} ${s.message}${retryHint}${stuckHint} (${elapsed}s)`
153
+ }
154
+
155
+ private emit(): void {
156
+ if (this.callback) {
157
+ try {
158
+ this.callback(this.getStatus())
159
+ } catch {
160
+ // UI callback failure shouldn't block compaction
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── Static helpers ──
166
+
167
+ /** Retry a summarizer call with backoff and progress tracking. */
168
+ static async retrySummarizer(
169
+ summarizer: () => Promise<string>,
170
+ tracker: CompactionProgressTracker,
171
+ messagesToSummarize: number,
172
+ ): Promise<string> {
173
+ let lastError: Error | undefined
174
+
175
+ for (let attempt = 1; attempt <= MAX_SUMMARIZER_RETRIES; attempt++) {
176
+ try {
177
+ tracker.update({
178
+ message: `Summarizing ${messagesToSummarize} messages${attempt > 1 ? ` (attempt ${attempt}/${MAX_SUMMARIZER_RETRIES})` : ''}...`,
179
+ percent: 30 + attempt * 20,
180
+ retrying: attempt > 1,
181
+ })
182
+
183
+ const result = await summarizer()
184
+ tracker.onRetryComplete()
185
+ return result
186
+ } catch (err) {
187
+ lastError = err instanceof Error ? err : new Error(String(err))
188
+
189
+ if (attempt < MAX_SUMMARIZER_RETRIES) {
190
+ tracker.onRetry(attempt)
191
+ // Wait before retry
192
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
193
+ tracker.onRetryComplete()
194
+ }
195
+ }
196
+ }
197
+
198
+ tracker.update({
199
+ message: `Summarization failed after ${MAX_SUMMARIZER_RETRIES} attempts — falling back to truncation`,
200
+ percent: 60,
201
+ })
202
+
203
+ throw lastError ?? new Error('Summarization failed')
204
+ }
205
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ContextManager, Summarizer } from './context'
2
2
  import { snipMessages } from './context-snip'
3
+ import { CompactionProgressTracker } from './compaction-progress'
3
4
 
4
5
  /**
5
6
  * Layer 3: Reactive Compact — API-based summarization.
@@ -20,33 +21,85 @@ function getKeepRecent(contextWindow: number): number {
20
21
  return Math.max(20, Math.floor(contextWindow / 12500))
21
22
  }
22
23
 
24
+ export interface CompactResult {
25
+ /** Messages before compaction */
26
+ beforeCount: number
27
+ /** Messages after compaction */
28
+ afterCount: number
29
+ /** Whether compaction was actually performed */
30
+ compacted: boolean
31
+ /** Progress summary for display */
32
+ summary: string
33
+ }
34
+
23
35
  export async function reactiveCompact(
24
36
  context: ContextManager,
25
37
  summarizer: Summarizer,
26
38
  heading: string,
27
39
  keepRecent?: number,
28
- ): Promise<void> {
40
+ ): Promise<CompactResult> {
29
41
  // Use explicit override, or adaptive default based on context window
30
42
  const effectiveKeepRecent = keepRecent ?? getKeepRecent(context.getMaxTokens())
31
43
  const messages = context.getMessages()
44
+ const tokensBefore = context.getEstimatedTokens()
32
45
 
33
- if (messages.length <= effectiveKeepRecent + 4) return
46
+ if (messages.length <= effectiveKeepRecent + 4) {
47
+ return {
48
+ beforeCount: messages.length,
49
+ afterCount: messages.length,
50
+ compacted: false,
51
+ summary: `Context has ${messages.length} messages — no compaction needed (threshold: ${effectiveKeepRecent + 4}).`,
52
+ }
53
+ }
54
+
55
+ // ── Progress tracker ──
56
+ const tracker = new CompactionProgressTracker(messages.length, tokensBefore)
57
+ tracker.update({ phase: 'snip', message: 'Snipping empty pairs...', percent: 5 })
34
58
 
35
59
  // First, run snip to remove empty pairs
36
60
  const { messages: snipped } = snipMessages(messages)
61
+ tracker.update({
62
+ phase: 'snip',
63
+ message: `Snipped ${messages.length - snipped.length} empty pairs`,
64
+ percent: 15,
65
+ messagesAfter: snipped.length,
66
+ })
37
67
 
38
- if (snipped.length <= effectiveKeepRecent + 4) return
68
+ if (snipped.length <= effectiveKeepRecent + 4) {
69
+ const afterCount = snipped.length
70
+ tracker.complete(afterCount, tokensBefore)
71
+ return {
72
+ beforeCount: messages.length,
73
+ afterCount,
74
+ compacted: true,
75
+ summary: `Snip-only: ${messages.length} → ${afterCount} messages (removed ${messages.length - afterCount} empty pairs).`,
76
+ }
77
+ }
39
78
 
40
79
  // Split: old messages to summarize, recent messages to keep
41
80
  const toSummarize = snipped.slice(0, -effectiveKeepRecent)
42
81
  const toKeep = snipped.slice(-effectiveKeepRecent)
43
82
 
44
- // Generate summary
83
+ tracker.update({
84
+ phase: 'reactive-compact',
85
+ message: `Summarizing ${toSummarize.length} messages...`,
86
+ percent: 20,
87
+ })
88
+
89
+ // Generate summary with retry logic
45
90
  let summary: string
46
91
  try {
47
- summary = await summarizer(toSummarize, heading)
92
+ summary = await CompactionProgressTracker.retrySummarizer(
93
+ () => summarizer(toSummarize, heading),
94
+ tracker,
95
+ toSummarize.length,
96
+ )
48
97
  } catch {
49
98
  // On summarizer failure, do a simple truncation
99
+ tracker.update({
100
+ message: `Summarizer unavailable — truncating to last ${effectiveKeepRecent} messages.`,
101
+ percent: 80,
102
+ })
50
103
  summary = `Earlier conversation (${toSummarize.length} messages) omitted due to context limits.`
51
104
  }
52
105
 
@@ -56,5 +109,16 @@ export async function reactiveCompact(
56
109
  content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 8000)}`,
57
110
  }
58
111
 
59
- context.replaceMessages([summaryMsg, ...toKeep])
112
+ const newMessages = [summaryMsg, ...toKeep]
113
+ context.replaceMessages(newMessages)
114
+
115
+ const tokensAfter = context.getEstimatedTokens()
116
+ tracker.complete(newMessages.length, tokensAfter)
117
+
118
+ return {
119
+ beforeCount: messages.length,
120
+ afterCount: newMessages.length,
121
+ compacted: true,
122
+ summary: tracker.getSummary(),
123
+ }
60
124
  }
@@ -1,4 +1,5 @@
1
1
  import type { ContextManager } from './context'
2
+ import type { CompactionProgressTracker } from './compaction-progress'
2
3
 
3
4
  const MINIMAL_KEEP = 5
4
5
  let drainLevel = 0
@@ -12,22 +13,62 @@ let drainLevel = 0
12
13
  * Level 1: Drop earliest 50% of messages
13
14
  * Level 2+: Keep only system prompt + last 5 messages
14
15
  *
15
- * Returns true if recovery was possible, false if context is already minimal.
16
+ * Returns drain result with progress summary.
16
17
  */
17
- export async function emergencyDrain(context: ContextManager): Promise<boolean> {
18
+ export interface DrainResult {
19
+ /** Whether recovery was possible */
20
+ recovered: boolean
21
+ /** Messages before drain */
22
+ beforeCount: number
23
+ /** Messages after drain */
24
+ afterCount: number
25
+ /** Current drain level */
26
+ level: number
27
+ /** Human-readable summary */
28
+ summary: string
29
+ }
30
+
31
+ export async function emergencyDrain(
32
+ context: ContextManager,
33
+ progress?: CompactionProgressTracker,
34
+ ): Promise<DrainResult> {
18
35
  const messages = context.getMessages()
19
36
 
20
37
  if (messages.length <= MINIMAL_KEEP) {
21
- return false // Already minimal, cannot drain further
38
+ return {
39
+ recovered: false,
40
+ beforeCount: messages.length,
41
+ afterCount: messages.length,
42
+ level: drainLevel,
43
+ summary: `Cannot drain further: already at minimal ${messages.length} messages.`,
44
+ }
22
45
  }
23
46
 
47
+ progress?.update({
48
+ phase: 'emergency-drain',
49
+ message: `Emergency drain level ${drainLevel + 1}: recovering from 413 error...`,
50
+ percent: 50,
51
+ })
52
+
24
53
  if (drainLevel === 0) {
25
54
  // First attempt: drop earliest 50%
26
55
  const keepCount = Math.max(MINIMAL_KEEP, Math.floor(messages.length / 2))
27
56
  const kept = messages.slice(-keepCount)
28
57
  context.replaceMessages(kept)
29
58
  drainLevel++
30
- return true
59
+ progress?.update({
60
+ phase: 'emergency-drain',
61
+ message: `Drain level 1: dropped ${messages.length - keepCount} messages (${messages.length} → ${keepCount})`,
62
+ percent: 80,
63
+ messagesAfter: keepCount,
64
+ })
65
+ return {
66
+ recovered: true,
67
+ beforeCount: messages.length,
68
+ afterCount: keepCount,
69
+ level: drainLevel,
70
+ summary: `Emergency drain level 1: ${messages.length} → ${keepCount} messages (dropped earliest 50%).`,
71
+ }
31
72
  }
32
73
 
33
74
  // Subsequent attempts: keep only system + last MINIMAL_KEEP messages
@@ -37,7 +78,7 @@ export async function emergencyDrain(context: ContextManager): Promise<boolean>
37
78
  if (messages.length > MINIMAL_KEEP * 2) {
38
79
  const summaryMsg = {
39
80
  role: 'user' as const,
40
- content: `[Emergency context drain: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
81
+ content: `[Emergency context drain level ${drainLevel + 1}: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
41
82
  }
42
83
  context.replaceMessages([summaryMsg, ...kept])
43
84
  } else {
@@ -45,10 +86,28 @@ export async function emergencyDrain(context: ContextManager): Promise<boolean>
45
86
  }
46
87
 
47
88
  drainLevel++
48
- return true
89
+ progress?.update({
90
+ phase: 'emergency-drain',
91
+ message: `Drain level ${drainLevel}: ${messages.length} → ${kept.length} messages`,
92
+ percent: 90,
93
+ messagesAfter: kept.length,
94
+ })
95
+
96
+ return {
97
+ recovered: true,
98
+ beforeCount: messages.length,
99
+ afterCount: kept.length,
100
+ level: drainLevel,
101
+ summary: `Emergency drain level ${drainLevel}: ${messages.length} → ${kept.length} messages.`,
102
+ }
49
103
  }
50
104
 
51
105
  /** Reset drain level (call at session start). */
52
106
  export function resetDrainLevel(): void {
53
107
  drainLevel = 0
54
108
  }
109
+
110
+ /** Get current drain level for display. */
111
+ export function getDrainLevel(): number {
112
+ return drainLevel
113
+ }
@@ -124,8 +124,13 @@ export class ContextManager {
124
124
  return this.estimatedTokens > this.config.maxTokens * this.config.compactionThreshold
125
125
  }
126
126
 
127
- async compact(heading: string): Promise<void> {
128
- if (this.messages.length <= 30) return
127
+ async compact(heading: string): Promise<{ before: number; after: number }> {
128
+ const beforeMsgs = this.messages.length
129
+ const beforeTokens = this.estimatedTokens
130
+
131
+ if (this.messages.length <= 30) {
132
+ return { before: beforeTokens, after: beforeTokens }
133
+ }
129
134
 
130
135
  const keep = 20
131
136
  const toDrop = this.messages.slice(0, -keep)
@@ -155,6 +160,8 @@ export class ContextManager {
155
160
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
156
161
  )
157
162
  }
163
+
164
+ return { before: beforeTokens, after: this.estimatedTokens }
158
165
  }
159
166
 
160
167
  getEstimatedTokens(): number {
@@ -266,11 +273,11 @@ export class ContextManager {
266
273
  */
267
274
  async on413Error(): Promise<boolean> {
268
275
  const result = await emergencyDrain(this)
269
- if (result) {
276
+ if (result.recovered) {
270
277
  this.compactionStats.drainCount++
271
278
  this.compactionStats.lastCompaction = new Date()
272
279
  }
273
- return result
280
+ return result.recovered
274
281
  }
275
282
 
276
283
  // ── Forced compaction (e.g. /compact command) ──
@@ -55,6 +55,8 @@ export class QueryEngine {
55
55
  private _preflightChecker?: PreFlightChecker
56
56
  private _autoCorrector?: AutoCorrector
57
57
  private _metaRuleEngine?: MetaRuleEngine
58
+ /** Files read this session — tracks what the Read tool has loaded */
59
+ private readFiles = new Set<string>()
58
60
  private goal?: string
59
61
  private maxGoalLoops = 20
60
62
  private lastAssistantContent?: string
@@ -158,6 +160,11 @@ export class QueryEngine {
158
160
  this.hookEngine = hooks
159
161
  }
160
162
 
163
+ /** Get the registered hook engine (for health inspection). */
164
+ getHookEngine(): HookEngine | undefined {
165
+ return this.hookEngine
166
+ }
167
+
161
168
  /** Register the artifact server for tool context. */
162
169
  setArtifactServer(server: ArtifactServer): void {
163
170
  this.artifactServer = server
@@ -943,6 +950,7 @@ export class QueryEngine {
943
950
  backgroundAgentRegistry: getBackgroundAgentRegistry(),
944
951
  permissionSystem: this.permission,
945
952
  ruleEngine: this.ruleEngine,
953
+ readFiles: this.readFiles,
946
954
  })
947
955
 
948
956
  // Track touched files for rules matching