@miphamai/cli 0.85.5 → 0.85.7
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 +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/dashboard.tsx +7 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +75 -35
- package/src/core/dream-engine.ts +17 -2
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +9 -0
- package/src/daemon/launch.ts +69 -2
- package/src/i18n-core/locales/en-US.json +81 -142
- package/src/i18n-core/locales/zh-CN.json +81 -142
- package/src/providers/anthropic.ts +147 -133
- package/src/providers/fetch-utils.ts +12 -29
- package/src/providers/openai-compat.ts +121 -106
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/update.ts +290 -146
- package/src/telemetry/consent.ts +50 -4
- package/src/telemetry/index.ts +27 -0
- package/src/tools/exec/task.ts +73 -61
- package/src/ui/commands.ts +40 -17
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +19 -15
- package/src/workflow/primitives/agent.ts +23 -7
package/bin/mipham.ts
CHANGED
|
@@ -293,13 +293,26 @@ async function runUpdate(): Promise<boolean> {
|
|
|
293
293
|
if (!result.ok) {
|
|
294
294
|
console.log()
|
|
295
295
|
console.log(`✗ Update failed: ${result.reason ?? 'unknown error'}`)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
296
|
+
// 这句必须只说**我们知道的**:staging 之后最常见的失败(装在旁边那步挂了)压根没碰过
|
|
297
|
+
// 旧树,此时沿用「未能恢复」就是在讲一件没发生的事。
|
|
298
|
+
switch (result.installState) {
|
|
299
|
+
case 'untouched':
|
|
300
|
+
console.log(
|
|
301
|
+
` Your previous install (v${currentVersion}) was not touched — mipham still works.`,
|
|
302
|
+
)
|
|
303
|
+
break
|
|
304
|
+
case 'restored':
|
|
305
|
+
console.log(
|
|
306
|
+
` Your previous install (v${currentVersion}) has been restored — mipham still works.`,
|
|
307
|
+
)
|
|
308
|
+
break
|
|
309
|
+
case 'unknown':
|
|
310
|
+
console.log(' ⚠ Could not locate the global install path — unable to tell.')
|
|
311
|
+
console.log(` Check with: mipham --version`)
|
|
312
|
+
break
|
|
313
|
+
default:
|
|
314
|
+
console.log(' ⚠ The previous install could not be restored.')
|
|
315
|
+
console.log(` Reinstall with: npm install -g ${PACKAGE}@${currentVersion}`)
|
|
303
316
|
}
|
|
304
317
|
if (backupPath && existsSync(backupPath)) {
|
|
305
318
|
console.log(` Your config backup is at: ${backupPath}`)
|
|
@@ -1249,6 +1262,8 @@ Flags:
|
|
|
1249
1262
|
--resume <name> Open a saved session (see /resume for names)
|
|
1250
1263
|
--permission <mode> Start in this mode: ${ALL_MODES.join('|')}
|
|
1251
1264
|
(also accepted by 'mipham attach'; the daemon may clamp it)
|
|
1265
|
+
--provider <id> Start on this provider (overrides config.yml)
|
|
1266
|
+
--model <id> Start on this model (overrides config.yml)
|
|
1252
1267
|
--version, -v, -V Print version
|
|
1253
1268
|
|
|
1254
1269
|
Docs: https://mipham.ai/code
|
|
@@ -1384,12 +1399,38 @@ npm: https://www.npmjs.com/package/@miphamai/cli`)
|
|
|
1384
1399
|
process.exit(1)
|
|
1385
1400
|
}
|
|
1386
1401
|
|
|
1402
|
+
// Parse --provider <id> / --model <id>: the provider and model the session starts
|
|
1403
|
+
// on. Same shape as `--resume`/`--permission` — `RunOptions` declared both fields all
|
|
1404
|
+
// along, `index.tsx` already reads them *ahead of* the merged config, and **nothing
|
|
1405
|
+
// ever passed them**. That third instance cost more than the first two: both shipped
|
|
1406
|
+
// IDE integrations build this exact command from their settings
|
|
1407
|
+
// (`infrastructure/vscode/extension.js` `buildFlags()`, `MiphamAction.kt`
|
|
1408
|
+
// `buildCommand()`), so a user who set a provider there got
|
|
1409
|
+
// `Unknown command: mipham deepseek` and no CLI at all. Their values are open — a
|
|
1410
|
+
// provider may be user-defined — so the value is forwarded as typed and an unknown id
|
|
1411
|
+
// is refused by the registry (`ProviderRegistry.getActive()` throws with the id named),
|
|
1412
|
+
// exactly as the same value already was when it came from `config.yml`.
|
|
1413
|
+
const flagValue = (name: string): string | undefined => {
|
|
1414
|
+
const at = process.argv.indexOf(name)
|
|
1415
|
+
if (at === -1) return undefined
|
|
1416
|
+
const value = process.argv[at + 1]
|
|
1417
|
+
if (!value || value.startsWith('-')) {
|
|
1418
|
+
console.error(`Usage: mipham ${name} <id>`)
|
|
1419
|
+
process.exit(1)
|
|
1420
|
+
}
|
|
1421
|
+
return value
|
|
1422
|
+
}
|
|
1423
|
+
const providerFlag = flagValue('--provider')
|
|
1424
|
+
const modelFlag = flagValue('--model')
|
|
1425
|
+
|
|
1387
1426
|
try {
|
|
1388
1427
|
const { runApp } = await import('../src/index')
|
|
1389
1428
|
await runApp({
|
|
1390
1429
|
version: APP_VERSION,
|
|
1391
1430
|
resume: resumeName,
|
|
1392
1431
|
permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
|
|
1432
|
+
provider: providerFlag,
|
|
1433
|
+
model: modelFlag,
|
|
1393
1434
|
})
|
|
1394
1435
|
} catch (err: unknown) {
|
|
1395
1436
|
const msg = err instanceof Error ? err.message : String(err)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "0.85.
|
|
3
|
+
"version": "0.85.7",
|
|
4
4
|
"description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"knip": "knip --production --no-progress --no-exit-code",
|
|
43
|
-
"coverage": "vitest run --coverage",
|
|
43
|
+
"coverage": "vitest run --coverage --reporter=default --reporter=json --outputFile.json=coverage/vitest-report.json",
|
|
44
44
|
"mutate": "stryker run"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
@@ -13,6 +13,13 @@ import { MIPHAM_DIR } from '../shared/constants.ts'
|
|
|
13
13
|
export interface AgentContextResult {
|
|
14
14
|
context: ContextManager
|
|
15
15
|
allowedTools: ToolDefinition[]
|
|
16
|
+
/**
|
|
17
|
+
* 组装好的系统提示(**含** agent memory)。
|
|
18
|
+
*
|
|
19
|
+
* 必须由这里交出去、而不是让调用方自己再拼一份:memory 的拼接规则只此一处,调用方
|
|
20
|
+
* 拿不到它就等于重新推导一遍(漏掉的那一遍正是缺陷本身 —— 请求里从来没有记忆)。
|
|
21
|
+
*/
|
|
22
|
+
systemPrompt: string
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/**
|
|
@@ -145,5 +152,5 @@ export function createAgentContext(
|
|
|
145
152
|
allowedTools = allowedTools.filter((t) => !denySet.has(t.name))
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
return { context, allowedTools }
|
|
155
|
+
return { context, allowedTools, systemPrompt }
|
|
149
156
|
}
|
|
@@ -181,8 +181,22 @@ export class EffectivenessTracker {
|
|
|
181
181
|
load(): void {
|
|
182
182
|
if (!existsSync(this.storePath)) return
|
|
183
183
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
184
|
+
const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'))
|
|
185
|
+
// 形状门:要的是「**以规则 id 为键的对象**」。`Object.entries` 对数组会拿**下标**
|
|
186
|
+
// 当键(`{"0":"x"}` ⇒ 一条 ruleId 为 undefined 的记录),而 `new Map(...)` 照收,
|
|
187
|
+
// 于是 `allRules` 把非规则对象当规则报出去。
|
|
188
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
189
|
+
this.data = new Map()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const data = new Map<string, RuleEffectiveness>()
|
|
193
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
194
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
195
|
+
const rec = v as { ruleId?: unknown }
|
|
196
|
+
if (typeof rec.ruleId !== 'string') continue
|
|
197
|
+
data.set(k, v as RuleEffectiveness)
|
|
198
|
+
}
|
|
199
|
+
this.data = data
|
|
186
200
|
} catch {
|
|
187
201
|
// Corrupt file — start fresh
|
|
188
202
|
this.data = new Map()
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -339,23 +339,31 @@ export class SubAgent {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
// Create isolated context with tool scoping, sized to the resolved model.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
342
|
+
// `systemPrompt` 是运行时**解析出来的那一份**(定义 > 调用方传入 > 内建类型),
|
|
343
|
+
// 定义自己没写时就落到解析结果上 —— 否则 `createAgentContext()` 拿到的是空串,
|
|
344
|
+
// 记忆会拼在一个空的基座上。
|
|
345
|
+
const resolvedDef: AgentDefinition = agentDef
|
|
346
|
+
? { ...agentDef, systemPrompt: agentDef.systemPrompt || systemPrompt }
|
|
347
|
+
: {
|
|
348
|
+
name: agentType,
|
|
349
|
+
description: '',
|
|
350
|
+
systemPrompt,
|
|
351
|
+
model: options.modelOverride || 'inherit',
|
|
352
|
+
permissionMode: 'inherit',
|
|
353
|
+
background: false,
|
|
354
|
+
source: 'builtin',
|
|
355
|
+
}
|
|
351
356
|
const contextWindow = this.registry.findModel(finalModel)?.contextWindow
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
+
// 这一份**已含 agent memory**,是发出去的那个提示的唯一来源。
|
|
358
|
+
const {
|
|
359
|
+
context,
|
|
360
|
+
allowedTools,
|
|
361
|
+
systemPrompt: composedSystemPrompt,
|
|
362
|
+
} = createAgentContext(resolvedDef, this.toolRegistry, contextWindow)
|
|
357
363
|
|
|
358
|
-
context.setSystemPrompt(systemPrompt)
|
|
364
|
+
// 别再 `context.setSystemPrompt(systemPrompt)`:`createAgentContext` 已经拿**含记忆**的
|
|
365
|
+
// 那一份设过上下文,这里用裸提示再设一遍只会把记忆从上下文里抹掉,而请求读的又不是
|
|
366
|
+
// 上下文 —— 两处各设一次的结果是「上下文里没有、请求里也没有」。
|
|
359
367
|
|
|
360
368
|
// Seed inherited parent conversation (fork inheritance) as a byte-identical
|
|
361
369
|
// prefix so the provider prompt cache is reused.
|
|
@@ -405,8 +413,8 @@ export class SubAgent {
|
|
|
405
413
|
// 权限段随**唯一**带提示的那一轮走,而不是每轮派生。
|
|
406
414
|
const permissionBlock = buildPermissionBlock(gate.getMode())
|
|
407
415
|
let currentSystemPrompt = permissionBlock
|
|
408
|
-
? `${
|
|
409
|
-
:
|
|
416
|
+
? `${composedSystemPrompt}\n\n---\n\n${permissionBlock}`
|
|
417
|
+
: composedSystemPrompt
|
|
410
418
|
let totalTokens = 0
|
|
411
419
|
|
|
412
420
|
// Context for the sub-agent's own tool calls. Built once per run: the caller's
|
|
@@ -31,6 +31,13 @@ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
|
|
34
|
+
// 这里**刻意**不迁 `useKeyState`(picker / config-wizard / command-picker 都迁了)。
|
|
35
|
+
// 那些地方的病是「一次刷进来的按键作用在上一拍的行上」,而 Ink 只在 chunk **以转义
|
|
36
|
+
// 序列打头**时才把它拆成多个事件 —— 所以能撞上的是方向键(`\x1b[B` + `\r` 拆成两拍)。
|
|
37
|
+
// 本面板的键全是普通字符(j / k / space / Enter / Ctrl+X / Ctrl+R / Ctrl+T),一起到达
|
|
38
|
+
// 时会**合并成一个**事件(实测 `'j\r'` → 单个 `input="j\r"`、`key.return` 为 false),
|
|
39
|
+
// 两个分支都不匹配 ⇒ 症状是「这一拍什么也没发生」,不是「作用在上一行」。
|
|
40
|
+
// 换句话说这一格没有可复现的故障,迁过去只会让测试**改前改后都绿**。
|
|
34
41
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
35
42
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
36
43
|
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
package/src/core/autocomplete.ts
CHANGED
|
@@ -7,16 +7,39 @@ export const AUTOCOMPLETE_SYSTEM_PROMPT =
|
|
|
7
7
|
/** 带上最近几条对话(含待续写输入),供续写贴合上下文。 */
|
|
8
8
|
export const AUTOCOMPLETE_MAX_CONTEXT = 6
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* 每条上下文消息最多带这么多**字符**(保留尾部)。
|
|
12
|
+
*
|
|
13
|
+
* 上面那个常数限的是**条数**,而一条 `content` 可以任意长 —— 贴进来一个文件、
|
|
14
|
+
* 或一条长回复,6 条就是上万 token,而用户每次 >400ms 的停顿都要买一次。
|
|
15
|
+
* 续写要看的是「刚说到哪儿」,所以砍头留尾;加 `…` 是免得把片段读成消息开头。
|
|
16
|
+
* 每条封顶 + 条数封顶,总量就是封死的,不需要再维护第二个预算常数。
|
|
17
|
+
*/
|
|
18
|
+
export const AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE = 2000
|
|
19
|
+
|
|
10
20
|
export interface RecentMessage {
|
|
11
21
|
role: 'user' | 'assistant'
|
|
12
22
|
content: string
|
|
13
23
|
}
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
function tailOf(content: string): string {
|
|
26
|
+
return content.length <= AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE
|
|
27
|
+
? content
|
|
28
|
+
: '…' + content.slice(-AUTOCOMPLETE_MAX_CHARS_PER_MESSAGE)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 拼续写请求:systemPrompt + 最近 N 条(每条限长)+ 当前输入作为待续写消息。 */
|
|
16
32
|
export function buildAutocompleteRequest(recent: RecentMessage[], input: string): ChatRequest {
|
|
17
33
|
return {
|
|
18
34
|
model: '', // falsy → registry 回退 active model
|
|
19
|
-
|
|
35
|
+
// 待续写的当前输入**不截断**:它是被续写的那条本身,且 extractCompletion 的
|
|
36
|
+
// 判据依赖它的完整值。上限落在历史消息上。
|
|
37
|
+
messages: [
|
|
38
|
+
...recent
|
|
39
|
+
.slice(-AUTOCOMPLETE_MAX_CONTEXT)
|
|
40
|
+
.map((m) => ({ role: m.role, content: tailOf(m.content) })),
|
|
41
|
+
{ role: 'user', content: input },
|
|
42
|
+
],
|
|
20
43
|
systemPrompt: AUTOCOMPLETE_SYSTEM_PROMPT,
|
|
21
44
|
temperature: 0,
|
|
22
45
|
maxTokens: 64,
|
|
@@ -57,6 +80,11 @@ export async function requestSuggestion(
|
|
|
57
80
|
const req = buildAutocompleteRequest(recent, input)
|
|
58
81
|
let text = ''
|
|
59
82
|
for await (const chunk of llm.chat(req)) {
|
|
83
|
+
// 用户又敲了一下 ⇒ 这条请求已经过期,当场走人。`break` 不只是「不再读」:
|
|
84
|
+
// 它触发生成器的 `.return()` ⇒ provider 的 `finally` ⇒ `reader.cancel()`,
|
|
85
|
+
// 连接当场释放。若把这一判挪到循环外,就等于**先把整条流读完**再丢掉结果 ——
|
|
86
|
+
// 那正是「取消不掉」:每次 >400ms 的停顿都买一个完整 completion。
|
|
87
|
+
if (isStale()) break
|
|
60
88
|
if (chunk.type === 'text' && chunk.content) text += chunk.content
|
|
61
89
|
}
|
|
62
90
|
if (isStale()) return null
|
package/src/core/context.ts
CHANGED
|
@@ -30,7 +30,6 @@ export interface CompactionStats {
|
|
|
30
30
|
interface Checkpoint {
|
|
31
31
|
id: number
|
|
32
32
|
messages: Message[]
|
|
33
|
-
estimatedTokens: number
|
|
34
33
|
timestamp: Date
|
|
35
34
|
label: string
|
|
36
35
|
}
|
|
@@ -52,7 +51,22 @@ export class ContextManager {
|
|
|
52
51
|
* 组装时烘进去的话,本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。
|
|
53
52
|
*/
|
|
54
53
|
private mcpInstructionsSource: (() => string) | null = null
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* **消息那部分**的估值,增量累加(提示那部分见 `promptTokens()`,读时派生)。
|
|
56
|
+
*
|
|
57
|
+
* 两份分开是因为它们的变化条件不同:消息只在本类里变(每次 push 顺手加一笔就够了),
|
|
58
|
+
* 而提示里的两段是**读时闭包**、变点不在这条类里(见上面两个 source 的注释)。
|
|
59
|
+
* 把两者混在一个累加器里,闭包那半就必然滞后 —— 这正是本类此前的缺陷。
|
|
60
|
+
*/
|
|
61
|
+
private messageTokens = 0
|
|
62
|
+
/**
|
|
63
|
+
* `promptTokens()` 的记忆化。键**就是**那份拼好的提示本身,所以不存在失效问题:
|
|
64
|
+
* 「键没变而值该变」需要的恰恰是「输入没变而输出该变」,不可能发生。
|
|
65
|
+
*
|
|
66
|
+
* 记忆化只是别在每次 `addMessage` 里把同一份四万字符的系统提示重扫一遍
|
|
67
|
+
* (`checkCompression()` 会读估值,它在每条消息上都被调一次)。
|
|
68
|
+
*/
|
|
69
|
+
private promptTokensCache: { text: string; tokens: number } | null = null
|
|
56
70
|
private checkpoints: Checkpoint[] = []
|
|
57
71
|
private checkpointCounter = 0
|
|
58
72
|
private summarizer?: Summarizer
|
|
@@ -93,7 +107,7 @@ export class ContextManager {
|
|
|
93
107
|
// 否则「模型看得见的必须已记录」这条不变量当场破),见 `closeInterruptedToolCalls`。
|
|
94
108
|
closeInterruptedToolCalls(log)
|
|
95
109
|
this.messages = deriveMessages(log.events())
|
|
96
|
-
this.
|
|
110
|
+
this.recountMessageTokens()
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/**
|
|
@@ -137,7 +151,13 @@ export class ContextManager {
|
|
|
137
151
|
|
|
138
152
|
setSystemPrompt(prompt: string): void {
|
|
139
153
|
this.systemPrompt = prompt
|
|
140
|
-
|
|
154
|
+
// 这里**故意什么都不算**。提示那部分由 `promptTokens()` 读时派生,消息那部分由
|
|
155
|
+
// `messageTokens` 自己带着 —— 所以「设提示」这件事对估值**没有可出错的空间**。
|
|
156
|
+
//
|
|
157
|
+
// 从前这里要重算一次,且必须记得「重算要含消息」:`--resume` 路径
|
|
158
|
+
// (`index.tsx:584-585`)先 `restoreLog()` 得出含消息的估值、紧接着设提示,只按
|
|
159
|
+
// 提示重算就会把它覆盖成偏低值。那是个**要靠注释守住的契约**;现在它不可能被违反
|
|
160
|
+
// —— 没有任何一条路径能在这里把消息那半丢掉。
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
/**
|
|
@@ -181,7 +201,7 @@ export class ContextManager {
|
|
|
181
201
|
|
|
182
202
|
addMessage(msg: Message): void {
|
|
183
203
|
this.messages.push(msg)
|
|
184
|
-
this.
|
|
204
|
+
this.messageTokens += this.estimateTokens(
|
|
185
205
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
186
206
|
)
|
|
187
207
|
|
|
@@ -208,7 +228,7 @@ export class ContextManager {
|
|
|
208
228
|
*/
|
|
209
229
|
injectContext(source: string, text: string): void {
|
|
210
230
|
this.messages.push({ role: 'user', content: text })
|
|
211
|
-
this.
|
|
231
|
+
this.messageTokens += this.estimateTokens(text)
|
|
212
232
|
|
|
213
233
|
if (this.log) {
|
|
214
234
|
this.log.append({ type: 'context/inject', at: Date.now(), source, text })
|
|
@@ -237,7 +257,7 @@ export class ContextManager {
|
|
|
237
257
|
],
|
|
238
258
|
}
|
|
239
259
|
this.messages.push(msg)
|
|
240
|
-
this.
|
|
260
|
+
this.messageTokens += this.estimateTokens(JSON.stringify(msg.content))
|
|
241
261
|
if (this.log) this.log.append({ type: 'tool/result', at: Date.now(), id: toolUseId, result })
|
|
242
262
|
this.checkCompression()
|
|
243
263
|
}
|
|
@@ -258,7 +278,7 @@ export class ContextManager {
|
|
|
258
278
|
if (this.log) {
|
|
259
279
|
for (const m of messages) for (const e of messageToEvents(m, Date.now())) this.log.append(e)
|
|
260
280
|
}
|
|
261
|
-
this.
|
|
281
|
+
this.recountMessageTokens()
|
|
262
282
|
|
|
263
283
|
if (this.log && isAssertModelVisibleDebug()) {
|
|
264
284
|
assertModelVisible(this.log.events(), this.messages)
|
|
@@ -270,11 +290,11 @@ export class ContextManager {
|
|
|
270
290
|
}
|
|
271
291
|
|
|
272
292
|
needsCompaction(): boolean {
|
|
273
|
-
return this.
|
|
293
|
+
return this.getEstimatedTokens() > this.config.maxTokens * this.config.compactionThreshold
|
|
274
294
|
}
|
|
275
295
|
|
|
276
296
|
async compact(heading: string): Promise<{ before: number; after: number }> {
|
|
277
|
-
const beforeTokens = this.
|
|
297
|
+
const beforeTokens = this.getEstimatedTokens()
|
|
278
298
|
|
|
279
299
|
if (this.messages.length <= 30) {
|
|
280
300
|
return { before: beforeTokens, after: beforeTokens }
|
|
@@ -318,26 +338,27 @@ export class ContextManager {
|
|
|
318
338
|
}
|
|
319
339
|
}
|
|
320
340
|
|
|
321
|
-
// Re-estimate
|
|
322
|
-
this.
|
|
323
|
-
for (const msg of this.messages) {
|
|
324
|
-
this.estimatedTokens += this.estimateTokens(
|
|
325
|
-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
326
|
-
)
|
|
327
|
-
}
|
|
341
|
+
// Re-estimate the message half (the prompt half is derived on read).
|
|
342
|
+
this.recountMessageTokens()
|
|
328
343
|
|
|
329
|
-
return { before: beforeTokens, after: this.
|
|
344
|
+
return { before: beforeTokens, after: this.getEstimatedTokens() }
|
|
330
345
|
}
|
|
331
346
|
|
|
347
|
+
/**
|
|
348
|
+
* 当前会话的估算 token 数 = **消息那半(累加)+ 提示那半(读时派生)**。
|
|
349
|
+
*
|
|
350
|
+
* 提示那半必须在读数这一刻才拼:`systemPrompt`、权限段、MCP instructions 段三者任一
|
|
351
|
+
* 变了都该反映出来,而其中两段的变点在调用方(见字段注释)—— 派生就没有变点要枚举。
|
|
352
|
+
*/
|
|
332
353
|
getEstimatedTokens(): number {
|
|
333
|
-
return this.
|
|
354
|
+
return this.messageTokens + this.promptTokens()
|
|
334
355
|
}
|
|
335
356
|
|
|
336
357
|
clear(): void {
|
|
337
358
|
this.messages = []
|
|
338
359
|
this.checkpoints = []
|
|
339
360
|
this.checkpointCounter = 0
|
|
340
|
-
this.
|
|
361
|
+
this.messageTokens = 0
|
|
341
362
|
}
|
|
342
363
|
|
|
343
364
|
getMessageCount(): number {
|
|
@@ -352,13 +373,8 @@ export class ContextManager {
|
|
|
352
373
|
*/
|
|
353
374
|
replaceMessages(messages: Message[]): void {
|
|
354
375
|
this.messages = messages
|
|
355
|
-
// Re-estimate
|
|
356
|
-
this.
|
|
357
|
-
for (const msg of messages) {
|
|
358
|
-
this.estimatedTokens += this.estimateTokens(
|
|
359
|
-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
360
|
-
)
|
|
361
|
-
}
|
|
376
|
+
// Re-estimate the message half (the prompt half is derived on read).
|
|
377
|
+
this.recountMessageTokens()
|
|
362
378
|
}
|
|
363
379
|
|
|
364
380
|
// ── Checkpoint / Rewind ──
|
|
@@ -368,7 +384,6 @@ export class ContextManager {
|
|
|
368
384
|
const checkpoint: Checkpoint = {
|
|
369
385
|
id: this.checkpointCounter,
|
|
370
386
|
messages: structuredClone(this.messages),
|
|
371
|
-
estimatedTokens: this.estimatedTokens,
|
|
372
387
|
timestamp: new Date(),
|
|
373
388
|
label,
|
|
374
389
|
}
|
|
@@ -395,7 +410,21 @@ export class ContextManager {
|
|
|
395
410
|
}
|
|
396
411
|
|
|
397
412
|
this.messages = structuredClone(target.messages)
|
|
398
|
-
|
|
413
|
+
// 估值从**刚恢复出来的这份消息**重算,而不是从快照里存的一个数还原:存下来的数
|
|
414
|
+
// 是「同一件事的第二份拷贝」,它会与消息各自漂移,而消息本身就是唯一真源。
|
|
415
|
+
this.recountMessageTokens()
|
|
416
|
+
// 回退改写的是**投影的整份内容**,所以它必须落成事件:日志是 `--resume` / `/resume`
|
|
417
|
+
// 重建历史的唯一来源,不记这一次改写,被回退掉的那一轮会在下次恢复时原样回来。
|
|
418
|
+
// 走与 `addMessage` 同一条写通路径(先入日志、再断言)—— 断言因此也从「前缀匹配」
|
|
419
|
+
// 变成「逐条相等」,回退不再是断言的一个盲区。
|
|
420
|
+
// 传快照副本:`append` 只按引用入 buf,序列化推迟到 `save()`,共用同一个数组会让
|
|
421
|
+
// 之后对 `this.messages` 的原地修改回写进已入队的事件里。
|
|
422
|
+
if (this.log) {
|
|
423
|
+
this.log.append({ type: 'rewind', at: Date.now(), messages: structuredClone(this.messages) })
|
|
424
|
+
if (isAssertModelVisibleDebug()) {
|
|
425
|
+
assertModelVisible(this.log.events(), this.messages)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
399
428
|
return { restored: true, messageCount: this.messages.length, label: target.label }
|
|
400
429
|
}
|
|
401
430
|
|
|
@@ -447,7 +476,7 @@ export class ContextManager {
|
|
|
447
476
|
private checkCompression(): void {
|
|
448
477
|
if (this.compressionPending) return
|
|
449
478
|
|
|
450
|
-
const usage = this.
|
|
479
|
+
const usage = this.getEstimatedTokens() / this.config.maxTokens
|
|
451
480
|
|
|
452
481
|
// Adaptive microcompact threshold: 200K→0.70, 500K→0.80, 1M→0.85
|
|
453
482
|
const microThreshold = this.config.contextWindow
|
|
@@ -493,19 +522,30 @@ export class ContextManager {
|
|
|
493
522
|
} else {
|
|
494
523
|
this.messages = compacted
|
|
495
524
|
}
|
|
496
|
-
this.
|
|
525
|
+
this.recountMessageTokens()
|
|
497
526
|
}
|
|
498
527
|
|
|
499
|
-
/**
|
|
500
|
-
private
|
|
501
|
-
this.
|
|
528
|
+
/** 从当前消息**重算消息那半**的估值(提示那半不在这里 —— 它是读时派生的)。 */
|
|
529
|
+
private recountMessageTokens(): void {
|
|
530
|
+
this.messageTokens = 0
|
|
502
531
|
for (const msg of this.messages) {
|
|
503
|
-
this.
|
|
532
|
+
this.messageTokens += this.estimateTokens(
|
|
504
533
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
505
534
|
)
|
|
506
535
|
}
|
|
507
536
|
}
|
|
508
537
|
|
|
538
|
+
/** 提示那半的估值 —— 每次读数现拼现算,见 `getEstimatedTokens()`。 */
|
|
539
|
+
private promptTokens(): number {
|
|
540
|
+
const text = this.composedSystemPrompt()
|
|
541
|
+
const cached = this.promptTokensCache
|
|
542
|
+
if (cached && cached.text === text) return cached.tokens
|
|
543
|
+
|
|
544
|
+
const tokens = this.estimateTokens(text)
|
|
545
|
+
this.promptTokensCache = { text, tokens }
|
|
546
|
+
return tokens
|
|
547
|
+
}
|
|
548
|
+
|
|
509
549
|
/**
|
|
510
550
|
* Estimate token count for a text string.
|
|
511
551
|
*
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -521,7 +521,11 @@ export class DreamEngine {
|
|
|
521
521
|
|
|
522
522
|
let log: Array<{ timestamp: string; actions: DreamAction[] }> = []
|
|
523
523
|
if (existsSync(this.dreamLog)) {
|
|
524
|
-
|
|
524
|
+
// 形状门:`{"a":1}` 是**合法 JSON**,不抛 —— 而 `log.unshift` 会抛,被下面的
|
|
525
|
+
// `catch` 吞掉 ⇒ 这一轮梦白做、且用户看不到任何提示。退成空表继续,
|
|
526
|
+
// 让这一轮活下来,别让它给一个坏文件陪葬。
|
|
527
|
+
const parsed: unknown = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
|
|
528
|
+
if (Array.isArray(parsed)) log = parsed
|
|
525
529
|
}
|
|
526
530
|
log.unshift({ timestamp, actions })
|
|
527
531
|
// Keep last 10 dream cycles
|
|
@@ -536,7 +540,18 @@ export class DreamEngine {
|
|
|
536
540
|
getDreamHistory(): Array<{ timestamp: string; actions: DreamAction[] }> {
|
|
537
541
|
try {
|
|
538
542
|
if (!existsSync(this.dreamLog)) return []
|
|
539
|
-
|
|
543
|
+
const parsed: unknown = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
|
|
544
|
+
// 声明返回数组,就必须真的是数组:`{"a":1}` 合法且不抛,而调用方
|
|
545
|
+
// (`ui/commands.ts` 的 `/dream --status`)紧接着 `.length` / `.slice` /
|
|
546
|
+
// `entry.actions.length` ⇒ `try` 护不住调用点,TypeError 直接冒到 UI。
|
|
547
|
+
if (!Array.isArray(parsed)) return []
|
|
548
|
+
return parsed.filter(
|
|
549
|
+
(e): e is { timestamp: string; actions: DreamAction[] } =>
|
|
550
|
+
!!e &&
|
|
551
|
+
typeof e === 'object' &&
|
|
552
|
+
typeof e.timestamp === 'string' &&
|
|
553
|
+
Array.isArray(e.actions),
|
|
554
|
+
)
|
|
540
555
|
} catch {
|
|
541
556
|
return []
|
|
542
557
|
}
|
|
@@ -87,8 +87,13 @@ export class ErrorSignatureDB {
|
|
|
87
87
|
try {
|
|
88
88
|
if (!existsSync(this.storePath)) return
|
|
89
89
|
const raw = readFileSync(this.storePath, 'utf-8')
|
|
90
|
-
const
|
|
91
|
-
for
|
|
90
|
+
const parsed: unknown = JSON.parse(raw)
|
|
91
|
+
// 形状门:`["x"]` 是合法 JSON、`for…of` 也照收 —— `sig.id` 是 `undefined`,
|
|
92
|
+
// 于是库里躺着一个**没有 id 的成员**(后面 `get(id)` 永远找不到它,
|
|
93
|
+
// 而 `getStats()` 的分母把它算进去)。形状不验 = 垃圾静默入库。
|
|
94
|
+
if (!Array.isArray(parsed)) return
|
|
95
|
+
for (const sig of parsed) {
|
|
96
|
+
if (!sig || typeof sig !== 'object' || typeof sig.id !== 'string') continue
|
|
92
97
|
this.signatures.set(sig.id, sig)
|
|
93
98
|
}
|
|
94
99
|
} catch {
|
|
@@ -546,9 +546,13 @@ export class MemoryManager {
|
|
|
546
546
|
const path = join(this.memoryDir, LINKS_FILE)
|
|
547
547
|
if (!existsSync(path)) return false
|
|
548
548
|
try {
|
|
549
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
549
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
|
550
|
+
// 形状门:值必须是**字符串数组**。`new Set("bc")` 会**按字符**迭代 ⇒ 一条链接被
|
|
551
|
+
// 拆成 'b'、'c' 两条(而 `as string[]` 让 TS 一声不吭)。对象/数组本身也过了门才用。
|
|
552
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
|
|
550
553
|
for (const [k, v] of Object.entries(raw)) {
|
|
551
|
-
|
|
554
|
+
if (!Array.isArray(v)) continue
|
|
555
|
+
this.linkGraph.set(k, new Set(v.filter((x): x is string => typeof x === 'string')))
|
|
552
556
|
}
|
|
553
557
|
return this.linkGraph.size > 0
|
|
554
558
|
} catch {
|
|
@@ -587,12 +591,16 @@ export class MemoryManager {
|
|
|
587
591
|
const path = join(this.memoryDir, RECALL_STATS_FILE)
|
|
588
592
|
if (!existsSync(path)) return
|
|
589
593
|
try {
|
|
590
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
594
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
|
595
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return
|
|
591
596
|
for (const [k, v] of Object.entries(raw)) {
|
|
592
|
-
|
|
597
|
+
// 逐条门控(不是整表退回):`catch` 在循环外,一个坏条目会把**后面所有**条目
|
|
598
|
+
// 一起带走 —— 一条脏记录赔上整份召回统计。
|
|
599
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
600
|
+
const rec = v as { recallCount?: unknown; lastRecalledAt?: unknown }
|
|
593
601
|
this.recallStats.set(k, {
|
|
594
|
-
recallCount: rec.recallCount
|
|
595
|
-
lastRecalledAt: rec.lastRecalledAt
|
|
602
|
+
recallCount: typeof rec.recallCount === 'number' ? rec.recallCount : 0,
|
|
603
|
+
lastRecalledAt: typeof rec.lastRecalledAt === 'string' ? rec.lastRecalledAt : '',
|
|
596
604
|
})
|
|
597
605
|
}
|
|
598
606
|
} catch {
|
|
@@ -57,15 +57,27 @@ import type { PermissionMode } from '../shared/index.ts'
|
|
|
57
57
|
export const PROMPT_VERSION = 'mipham-auto-classifier/1'
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Milliseconds before a ruling is abandoned.
|
|
61
|
-
*
|
|
60
|
+
* Milliseconds before a ruling is abandoned.
|
|
61
|
+
*
|
|
62
|
+
* This used to be declared as "same bound as `self-critique.ts`" — that pairing
|
|
63
|
+
* is gone, and deliberately not restored in either direction. The two are both
|
|
64
|
+
* secondary model calls, but they fail in *opposite* directions: `self-critique`
|
|
65
|
+
* fails **open** (a timeout ⇒ `null` ⇒ the tool runs), so its budget is bounded
|
|
66
|
+
* by "how often do we want the critique to actually happen"; this one fails
|
|
67
|
+
* **closed**, so its budget is bounded by "how long may a legitimate call be
|
|
68
|
+
* refused for". A budget derived from the fail-open side would be a budget
|
|
69
|
+
* derived from the wrong question.
|
|
62
70
|
*
|
|
63
71
|
* A tighter bound was considered (it is on the gated path, so every ruled call
|
|
64
72
|
* costs the user the full wait) and rejected: with a fail-closed default, a
|
|
65
73
|
* timeout is indistinguishable from a denial to the user, so shrinking this
|
|
66
|
-
* trades "slow" for "auto mode intermittently refuses legitimate work" — and
|
|
67
|
-
*
|
|
68
|
-
*
|
|
74
|
+
* trades "slow" for "auto mode intermittently refuses legitimate work" — and the
|
|
75
|
+
* classifier's own latency distribution still has not been measured. (A sibling
|
|
76
|
+
* measurement does now exist — `self-critique`'s, median 3.95s over 30 real
|
|
77
|
+
* calls — and it is a reason to *distrust* this 2s, not a reading that may be
|
|
78
|
+
* substituted for one.) Making it configurable, or re-basing it, needs that
|
|
79
|
+
* measurement first; the prompts, the target model and the output shape all
|
|
80
|
+
* differ from the sibling.
|
|
69
81
|
*/
|
|
70
82
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 2000
|
|
71
83
|
|