@miphamai/cli 0.85.5 → 0.85.6
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/core/autocomplete.ts +30 -2
- package/src/core/context.ts +5 -1
- 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/daemon/launch.ts +69 -2
- package/src/i18n-core/locales/en-US.json +81 -141
- package/src/i18n-core/locales/zh-CN.json +81 -141
- 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/ui/commands.ts +19 -5
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
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.6",
|
|
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
|
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
|
@@ -137,7 +137,11 @@ export class ContextManager {
|
|
|
137
137
|
|
|
138
138
|
setSystemPrompt(prompt: string): void {
|
|
139
139
|
this.systemPrompt = prompt
|
|
140
|
-
|
|
140
|
+
// 估值必须**含 messages** —— 走 `reEstimateTokens()` 这一条唯一推导,别在这儿手写
|
|
141
|
+
// 第二份。`--resume` 路径(`index.tsx:584-585`)先 `restoreLog()` 算出含消息的完整
|
|
142
|
+
// 估值,紧接着调这里;只按系统提示重算会把它**覆盖成偏低值** ⇒ `needsCompaction()`
|
|
143
|
+
// 长期偏 false ⇒ 压缩迟触发(上下文越滚越大才动手)。
|
|
144
|
+
this.reEstimateTokens()
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
/**
|
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
|
|
package/src/core/rule-engine.ts
CHANGED
|
@@ -18,6 +18,23 @@ export interface ToolRule {
|
|
|
18
18
|
enabled: boolean
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 一条**能真正生效**的规则必须自带 `match`/`fix` —— 而这两个是函数,落不了盘。
|
|
23
|
+
* 判据放在这里而不是内联,是为了让「载回来的规则必须是能用的规则」只有一处定义。
|
|
24
|
+
*/
|
|
25
|
+
function isUsableRule(candidate: unknown): candidate is ToolRule {
|
|
26
|
+
const r = candidate as Partial<ToolRule> | null
|
|
27
|
+
return (
|
|
28
|
+
!!r &&
|
|
29
|
+
typeof r === 'object' &&
|
|
30
|
+
typeof r.id === 'string' &&
|
|
31
|
+
r.id.length > 0 &&
|
|
32
|
+
typeof r.toolName === 'string' &&
|
|
33
|
+
typeof r.match === 'function' &&
|
|
34
|
+
typeof r.fix === 'function'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
const BUILTIN_RULES: ToolRule[] = [
|
|
22
39
|
{
|
|
23
40
|
id: 'rule-timeout-bash-heavy',
|
|
@@ -168,9 +185,17 @@ export class ExperienceRuleEngine {
|
|
|
168
185
|
load(): void {
|
|
169
186
|
if (!existsSync(this.storePath)) return
|
|
170
187
|
try {
|
|
171
|
-
const
|
|
188
|
+
const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'))
|
|
189
|
+
// 形状门 —— 这一格的门比别处严,因为**这个 store 载不动一条可用的规则**:
|
|
190
|
+
// `ToolRule.match`/`fix` 是函数,`JSON.stringify` 必然丢,所以 `persist()` 写出去的
|
|
191
|
+
// 形状里不可能带回来它们。旧读法把这种条目照收,`getActiveRules()` 再把它报成
|
|
192
|
+
// active(`/rules` 面板跟着说它是活的),而 `intercept()` 里 `rule.match` 不存在
|
|
193
|
+
// → 抛 → 被那个 `try/catch` 吞成**静默惰性**。列出来是活的、实际永不触发 ——
|
|
194
|
+
// 正是本仓库反复收的那种账。收不进就是收不进:不校验形状 = 报一份假的活跃清单。
|
|
195
|
+
if (!Array.isArray(parsed)) return
|
|
172
196
|
const reservedIds = new Set([...BUILTIN_RULES, ...MANAGED_RULES].map((r) => r.id))
|
|
173
|
-
for (const rule of
|
|
197
|
+
for (const rule of parsed) {
|
|
198
|
+
if (!isUsableRule(rule)) continue
|
|
174
199
|
// Reject if a builtin/managed rule with the same ID exists (source rules always win)
|
|
175
200
|
if (reservedIds.has(rule.id)) continue
|
|
176
201
|
this.rules.push(rule)
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Inspired by Anthropic's RLAIF (Reinforcement Learning from AI Feedback):
|
|
5
5
|
* instead of relying on human feedback loops, the AI critiques its own
|
|
6
|
-
* tool calls before execution. A fast model
|
|
7
|
-
*
|
|
6
|
+
* tool calls before execution. A fast model performs a lightweight safety &
|
|
7
|
+
* correctness check — but "lightweight" is about the *prompt*, not the clock:
|
|
8
|
+
* it is a full model round-trip, measured at a median ≈4s on the configured
|
|
9
|
+
* provider (see `DEFAULT_SELF_CRITIQUE_CONFIG.timeoutMs`), not the sub-200ms
|
|
10
|
+
* this comment used to claim.
|
|
8
11
|
*
|
|
9
12
|
* Architecture:
|
|
10
13
|
* Model generates tool call
|
|
@@ -58,7 +61,16 @@ export const DEFAULT_SELF_CRITIQUE_CONFIG: SelfCritiqueConfig = {
|
|
|
58
61
|
enabled: false, // Opt-in by default — user enables via /crsi critique on
|
|
59
62
|
threshold: 0.6,
|
|
60
63
|
targetTools: ['Bash', 'Write', 'Edit', 'Agent'],
|
|
61
|
-
|
|
64
|
+
// Measured, not chosen: 30 real critiques against the configured provider
|
|
65
|
+
// (`findFastestModel` → `deepseek-v4-flash`; default model is the slower
|
|
66
|
+
// `-pro`) took min 1.85s / median 3.95s / max 17.9s, and **28 of 30 exceeded
|
|
67
|
+
// 2s** — the value this replaced. At 2s the budget would have skipped 93% of
|
|
68
|
+
// critiques, and since `critique()` fails *open* (null ⇒ the tool runs
|
|
69
|
+
// unreviewed) that failure is silent: `/crsi critique` would look enabled and
|
|
70
|
+
// do nothing. 15s keeps 90% (27/30) while still bounding a gated tool call to
|
|
71
|
+
// well under the provider's 90s stream-idle backstop. One model, one day —
|
|
72
|
+
// re-measure if the critique model changes.
|
|
73
|
+
timeoutMs: 15_000,
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
// ── Prompt Templates ──
|
package/src/daemon/launch.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawn, type SpawnOptions } from 'node:child_process'
|
|
15
|
-
import { closeSync, mkdirSync, openSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
16
16
|
import { dirname, resolve } from 'node:path'
|
|
17
17
|
import { miphamHome } from '../core/paths.ts'
|
|
18
18
|
|
|
@@ -141,6 +141,13 @@ const READY_TIMEOUT_MS = 10_000
|
|
|
141
141
|
const POLL_INTERVAL_MS = 100
|
|
142
142
|
/** How long `restart` waits for the *old* daemon to go before refusing. */
|
|
143
143
|
const OLD_DAEMON_EXIT_TIMEOUT_MS = 10_000
|
|
144
|
+
/**
|
|
145
|
+
* Bound on the daemon log, enforced by `rotateLogIfLarge`. Two files at this size
|
|
146
|
+
* are invisible on any disk it will sit on, while the reader only ever wants a tail
|
|
147
|
+
* (`tailLog` takes the last 800 bytes) — so what the bound has to preserve is not
|
|
148
|
+
* volume but *the previous generation* of a start that keeps failing.
|
|
149
|
+
*/
|
|
150
|
+
export const MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
144
151
|
|
|
145
152
|
async function defaultGetStatus(): Promise<DaemonStatusLike | null> {
|
|
146
153
|
const { getDaemonStatus } = await import('./index')
|
|
@@ -157,6 +164,45 @@ function tailLog(logPath: string, maxBytes = 800): string {
|
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Bound the log the daemon is about to append to.
|
|
169
|
+
*
|
|
170
|
+
* Called before the child is spawned, which is the only moment nothing holds it:
|
|
171
|
+
* `startDetachedDaemon` returns early when a daemon is already up, so by here its
|
|
172
|
+
* pid file was gone. (A daemon that died without unlinking the pid file could still
|
|
173
|
+
* hold the old inode — its lines then land in the renamed file, which loses nothing
|
|
174
|
+
* and corrupts nothing.)
|
|
175
|
+
*
|
|
176
|
+
* **Rename, not truncate.** Either bounds the file, and the reader only wants a tail
|
|
177
|
+
* — but the case this exists for is a start that keeps failing, and there the
|
|
178
|
+
* previous generation *is* the evidence. Keeping exactly one bounds the sink at two
|
|
179
|
+
* files; `renameSync` overwrites the target, so that is also the oldest generation's
|
|
180
|
+
* cleanup. There is never a `.2`.
|
|
181
|
+
*
|
|
182
|
+
* Failing to bound is never fatal: a daemon that refuses to start because its log
|
|
183
|
+
* could not be rotated trades a slow hazard for an immediate one. And an unreadable
|
|
184
|
+
* size means the append below is about to fail loudly anyway, so `return` is not a
|
|
185
|
+
* silent degradation of anything that was working.
|
|
186
|
+
*/
|
|
187
|
+
export function rotateLogIfLarge(logPath: string, maxBytes: number = MAX_LOG_BYTES): void {
|
|
188
|
+
let size: number
|
|
189
|
+
try {
|
|
190
|
+
size = statSync(logPath).size
|
|
191
|
+
} catch {
|
|
192
|
+
return // no log yet (every first start)
|
|
193
|
+
}
|
|
194
|
+
if (size < maxBytes) return
|
|
195
|
+
try {
|
|
196
|
+
renameSync(logPath, `${logPath}.1`)
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// Printed rather than thrown, but never swallowed: a bound that switched itself
|
|
199
|
+
// off in silence is the defect class this whole file is about.
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`⚠️ daemon 日志轮转失败,本次仍按无上限追加: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
160
206
|
/**
|
|
161
207
|
* Start the daemon detached and *wait until it is actually up*.
|
|
162
208
|
*
|
|
@@ -177,6 +223,7 @@ export async function startDetachedDaemon(
|
|
|
177
223
|
|
|
178
224
|
const plan = planDaemonSpawn()
|
|
179
225
|
mkdirSync(dirname(plan.logPath), { recursive: true, mode: 0o700 })
|
|
226
|
+
rotateLogIfLarge(plan.logPath)
|
|
180
227
|
|
|
181
228
|
let spawnError: Error | null = null
|
|
182
229
|
let exitCode: number | null = null
|
|
@@ -217,9 +264,29 @@ export async function startDetachedDaemon(
|
|
|
217
264
|
}
|
|
218
265
|
}
|
|
219
266
|
}
|
|
267
|
+
// The deadline is a prediction, not a fact. Probe once more before acting on
|
|
268
|
+
// it: a child that became ready inside the last poll interval is up, and
|
|
269
|
+
// reporting failure for it — then killing it — would be this module's own
|
|
270
|
+
// "success with no daemon behind it" defect wearing the opposite sign.
|
|
271
|
+
const late = await getStatus()
|
|
272
|
+
if (late) return { ok: true, pid: late.pid, port: late.port }
|
|
273
|
+
|
|
274
|
+
// Reclaim the child. Leaving it running makes a failed start a half-truth the
|
|
275
|
+
// caller cannot act on: `getStatus()` is a pid file plus `kill(pid, 0)`, so the
|
|
276
|
+
// next `daemon start` finds the abandoned process and reports *success* with
|
|
277
|
+
// its pid. SIGKILL, not SIGTERM: this process is by definition not ready, so
|
|
278
|
+
// there is no session to drain — and a child that has already ignored the
|
|
279
|
+
// deadline is exactly the one that may ignore a polite signal too.
|
|
280
|
+
const reclaimed = child.kill('SIGKILL')
|
|
220
281
|
return {
|
|
221
282
|
ok: false,
|
|
222
|
-
reason:
|
|
283
|
+
reason:
|
|
284
|
+
`daemon did not become ready within ${opts.timeoutMs ?? READY_TIMEOUT_MS}ms` +
|
|
285
|
+
// Claim the kill only when it happened: `kill()` also returns false for a
|
|
286
|
+
// child that exited on its own between the probe and here, and that is not
|
|
287
|
+
// something to report as "reclaimed".
|
|
288
|
+
(reclaimed ? '; the child it spawned was killed so no daemon is left running' : '') +
|
|
289
|
+
` (log: ${plan.logPath})`,
|
|
223
290
|
}
|
|
224
291
|
}
|
|
225
292
|
|