@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.
- package/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- 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
|
+
}
|
package/src/core/context.ts
CHANGED
|
@@ -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
|
*
|