@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 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
- if (result.rolledBack) {
297
- console.log(
298
- ` Your previous install (v${currentVersion}) has been restored — mipham still works.`,
299
- )
300
- } else {
301
- console.log(' ⚠ The previous install could not be restored.')
302
- console.log(` Reinstall with: npm install -g ${PACKAGE}@${currentVersion}`)
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.5",
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 raw = JSON.parse(readFileSync(this.storePath, 'utf-8'))
185
- this.data = new Map(Object.entries(raw))
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()
@@ -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
- const resolvedDef: AgentDefinition = agentDef || {
343
- name: agentType,
344
- description: '',
345
- systemPrompt,
346
- model: options.modelOverride || 'inherit',
347
- permissionMode: 'inherit',
348
- background: false,
349
- source: 'builtin',
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
- const { context, allowedTools } = createAgentContext(
353
- resolvedDef,
354
- this.toolRegistry,
355
- contextWindow,
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
- ? `${systemPrompt}\n\n---\n\n${permissionBlock}`
409
- : systemPrompt
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')
@@ -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
- /** 拼续写请求:systemPrompt + 最近 N 条 + 当前输入作为待续写消息。 */
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
- messages: [...recent.slice(-AUTOCOMPLETE_MAX_CONTEXT), { role: 'user', content: input }],
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
@@ -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
- private estimatedTokens = 0
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.reEstimateTokens()
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
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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.estimatedTokens += this.estimateTokens(
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.estimatedTokens += this.estimateTokens(text)
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.estimatedTokens += this.estimateTokens(JSON.stringify(msg.content))
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.reEstimateTokens()
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.estimatedTokens > this.config.maxTokens * this.config.compactionThreshold
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.estimatedTokens
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 tokens
322
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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.estimatedTokens }
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.estimatedTokens
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.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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 tokens
356
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
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
- this.estimatedTokens = target.estimatedTokens
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.estimatedTokens / this.config.maxTokens
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.reEstimateTokens()
525
+ this.recountMessageTokens()
497
526
  }
498
527
 
499
- /** Re-estimate tokens from system prompt + current messages. */
500
- private reEstimateTokens(): void {
501
- this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
528
+ /** 从当前消息**重算消息那半**的估值(提示那半不在这里 —— 它是读时派生的)。 */
529
+ private recountMessageTokens(): void {
530
+ this.messageTokens = 0
502
531
  for (const msg of this.messages) {
503
- this.estimatedTokens += this.estimateTokens(
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
  *
@@ -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
- log = JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
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
- return JSON.parse(readFileSync(this.dreamLog, 'utf-8'))
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 arr: ErrorSignature[] = JSON.parse(raw)
91
- for (const sig of arr) {
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
- this.linkGraph.set(k, new Set(v as string[]))
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
- const rec = v as { recallCount?: number; lastRecalledAt?: string }
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 ?? 0,
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. Same bound as
61
- * `self-critique.ts:52`, which is the only measured precedent in this repo.
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
- * nobody has measured where the real latency distribution sits. Making it
68
- * configurable is the right fix when someone does.
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