@miphamai/cli 0.4.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 +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- 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/server.ts +31 -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 +144 -3
- 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 +11 -0
- 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 +221 -36
- package/src/index.tsx +20 -0
- 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/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- 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 +3 -0
- package/src/ui/app.tsx +10 -1
- package/src/ui/commands.ts +99 -30
- package/src/ui/input.tsx +123 -8
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|