@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 +1 -1
- package/src/agent/cross-session/file-inbox.ts +30 -12
- package/src/core/compaction-progress.ts +205 -0
- package/src/core/context-compact.ts +70 -6
- package/src/core/context-drain.ts +65 -6
- package/src/core/context.ts +11 -4
- package/src/core/engine.ts +8 -0
- package/src/core/hooks.ts +114 -9
- package/src/shared/types.ts +2 -0
- package/src/skills/fork-executor.ts +6 -2
- package/src/skills/loader.ts +37 -4
- package/src/skills/sanitizer.ts +214 -0
- package/src/tools/agent/skill.ts +6 -1
- package/src/tools/file/edit.ts +4 -0
- package/src/tools/file/read.ts +3 -0
- package/src/tools/file/write.ts +20 -3
- package/src/ui/app.tsx +142 -129
- package/src/ui/commands.ts +71 -4
- package/src/ui/error-boundary.tsx +85 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
85
|
+
senderName?: string
|
|
86
|
+
senderMachine?: string
|
|
87
|
+
senderSession?: string
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
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: `${
|
|
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<
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
16
|
+
* Returns drain result with progress summary.
|
|
16
17
|
*/
|
|
17
|
-
export
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/context.ts
CHANGED
|
@@ -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<
|
|
128
|
-
|
|
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) ──
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|