@miphamai/cli 0.68.0 → 0.69.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/package.json +1 -1
- package/src/core/crsi-modify.ts +10 -1
- package/src/core/crsi-producer.ts +13 -0
- package/src/core/engine.ts +31 -0
- package/src/core/eval-harness.ts +94 -2
- package/src/core/memory/memory-manager.ts +205 -19
- package/src/core/post-flight-checker.ts +102 -0
- package/src/core/reward-fn.ts +3 -1
- package/src/core/session-log.ts +3 -1
- package/src/core/working-memory.ts +140 -0
- package/src/i18n-core/locales/en-US.json +9 -1
- package/src/i18n-core/locales/zh-CN.json +9 -1
- package/src/shared/package-info.ts +1 -1
- package/src/tools/agent/memory.ts +5 -1
- package/src/tools/exec/task.ts +21 -2
- package/src/ui/commands.ts +43 -1
package/package.json
CHANGED
package/src/core/crsi-modify.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
16
|
import { CrsiSandbox, validateBlastRadius } from './crsi-sandbox'
|
|
17
17
|
import type { CrsiModificationResult } from './crsi-sandbox'
|
|
18
|
-
import { appendEvalScore, getLastEvalScore } from './eval-harness'
|
|
18
|
+
import { appendEvalScore, getLastEvalScore, regressedAnchors } from './eval-harness'
|
|
19
19
|
import { mechanismSentinel, type RewardFn } from './reward-fn'
|
|
20
20
|
|
|
21
21
|
export interface CrsiProposal {
|
|
@@ -102,6 +102,15 @@ export async function runCrsiModification(
|
|
|
102
102
|
// 默认机制哨兵;可插拔——调用方传 opts.rewardFn 换用其他奖励源(如任务表现)。
|
|
103
103
|
const rewardFn = opts?.rewardFn ?? mechanismSentinel()
|
|
104
104
|
const report = await rewardFn.evaluate()
|
|
105
|
+
// 细粒度防回退:anchor 契约(安全/机制不变量)任一翻转 PASS→FAIL 即拒,
|
|
106
|
+
// 即使总分因新增契约而上升也被拦(比「总分不退化」更严格)。
|
|
107
|
+
const anchors = regressedAnchors(report.results ?? [])
|
|
108
|
+
if (anchors.length > 0) {
|
|
109
|
+
sandbox.rollback()
|
|
110
|
+
applied.phase = 'failed'
|
|
111
|
+
applied.error = `Anchor regression: ${anchors.join(', ')}`
|
|
112
|
+
return applied
|
|
113
|
+
}
|
|
105
114
|
const last = getLastEvalScore(rewardFn.name)
|
|
106
115
|
if (last !== null && report.score < last) {
|
|
107
116
|
sandbox.rollback()
|
|
@@ -20,6 +20,9 @@ import { homedir } from 'node:os'
|
|
|
20
20
|
/** 教训文件(相对仓库根)。预建,沙箱只能改已存在文件。 */
|
|
21
21
|
export const LESSONS_FILE = 'apps/cli/crsi-lessons.md'
|
|
22
22
|
|
|
23
|
+
/** 组件归因(修复决策,非因果断言):失败最可能被哪个记忆组件的局部干预修复。 */
|
|
24
|
+
export type MemoryComponent = 'experiential' | 'working' | 'invocation' | 'checker'
|
|
25
|
+
|
|
23
26
|
/** 归一化的教训信号(insight 与 meta-rule 的公共面)。 */
|
|
24
27
|
export interface CrsiSignal {
|
|
25
28
|
category: string
|
|
@@ -27,6 +30,12 @@ export interface CrsiSignal {
|
|
|
27
30
|
severity?: string
|
|
28
31
|
suggestion: string
|
|
29
32
|
evidence: string[]
|
|
33
|
+
/**
|
|
34
|
+
* 组件归因:该失败最可能被哪个记忆组件的局部干预修复。
|
|
35
|
+
* 缺省 experiential(现状 = 教训/技能/受管理规则,全属 E)。
|
|
36
|
+
* working/invocation/checker 待 ②③ 落地后由对应信号源产出。
|
|
37
|
+
*/
|
|
38
|
+
component?: MemoryComponent
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
const SEVERITY_RANK: Record<string, number> = { critical: 0, warning: 1, info: 2 }
|
|
@@ -76,6 +85,7 @@ export function buildLessonContent(
|
|
|
76
85
|
`## ${signal.category}: ${signal.title}`,
|
|
77
86
|
'',
|
|
78
87
|
`- 建议: ${signal.suggestion}`,
|
|
88
|
+
`- 组件: ${signal.component ?? 'experiential'}`,
|
|
79
89
|
]
|
|
80
90
|
if (signal.severity) lines.push(`- 严重度: ${signal.severity}`)
|
|
81
91
|
lines.push(`- 生成时间: ${timestamp}`, `- 来源: ${source}`, '', '### 证据')
|
|
@@ -188,6 +198,9 @@ export function managedRuleId(signal: CrsiSignal): string {
|
|
|
188
198
|
* 只支持 timeout / tool-params 两类确定性 category,其余返回 null。
|
|
189
199
|
*/
|
|
190
200
|
export function renderManagedRuleSource(signal: CrsiSignal): string | null {
|
|
201
|
+
// managed-rule 是「行为固化」,只处理 experiential 组件(timeout/tool-params 本质是 E)。
|
|
202
|
+
// working/invocation/checker 的修复走各自组件路径,不进这里。
|
|
203
|
+
if ((signal.component ?? 'experiential') !== 'experiential') return null
|
|
191
204
|
const id = managedRuleId(signal)
|
|
192
205
|
const warning = signal.suggestion || `CRSI 自动固化: ${signal.title}`
|
|
193
206
|
|
package/src/core/engine.ts
CHANGED
|
@@ -29,6 +29,8 @@ import { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
|
|
|
29
29
|
import { McpClient } from '../mcp/client.js'
|
|
30
30
|
import { ErrorSignatureDB } from './error-signature-db.js'
|
|
31
31
|
import { PreFlightChecker } from './preflight-checker.js'
|
|
32
|
+
import { PostFlightChecker, createDefaultPostFlightChecker } from './post-flight-checker'
|
|
33
|
+
import { recordToolEvidence } from './working-memory'
|
|
32
34
|
import { AutoCorrector } from './auto-corrector.js'
|
|
33
35
|
import { MetaRuleEngine } from './meta-rule-engine.js'
|
|
34
36
|
import { DreamEngine } from './dream-engine.js'
|
|
@@ -83,6 +85,7 @@ export class QueryEngine {
|
|
|
83
85
|
private _autoMemory?: AutoMemoryEngine
|
|
84
86
|
private _errorSignatureDB?: ErrorSignatureDB
|
|
85
87
|
private _preflightChecker?: PreFlightChecker
|
|
88
|
+
private _postFlightChecker?: PostFlightChecker
|
|
86
89
|
private _autoCorrector?: AutoCorrector
|
|
87
90
|
private _metaRuleEngine?: MetaRuleEngine
|
|
88
91
|
private _dreamEngine?: DreamEngine
|
|
@@ -1243,6 +1246,23 @@ export class QueryEngine {
|
|
|
1243
1246
|
// Accumulate graft token savings from "[graft] tokens saved ≈ N" footers
|
|
1244
1247
|
accumulateGraftSavings(result.content)
|
|
1245
1248
|
|
|
1249
|
+
// ── PostFlightChecker — 事后验证「观察是否支撑变更」(Recuris C 组件)──
|
|
1250
|
+
// 默认 no-checker 静默、rejected 只记录不阻塞(第一阶段只产证据,不强制拦截)。
|
|
1251
|
+
const decision = this.getPostFlightChecker().check(name, {
|
|
1252
|
+
params: effectiveParams,
|
|
1253
|
+
result,
|
|
1254
|
+
})
|
|
1255
|
+
// 证据账本:supported/rejected 回灌工作记忆(供 Task 完成门读取)
|
|
1256
|
+
recordToolEvidence(name, decision)
|
|
1257
|
+
if (decision.verdict !== 'no-checker') {
|
|
1258
|
+
this.context.getLog()?.append({
|
|
1259
|
+
type: 'checker/decision',
|
|
1260
|
+
at: Date.now(),
|
|
1261
|
+
toolName: name,
|
|
1262
|
+
decision,
|
|
1263
|
+
})
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1246
1266
|
return result
|
|
1247
1267
|
} catch (err) {
|
|
1248
1268
|
// P1-3: Trigger PostToolUseFailure hook on tool execution errors
|
|
@@ -1488,6 +1508,17 @@ export class QueryEngine {
|
|
|
1488
1508
|
return this._preflightChecker
|
|
1489
1509
|
}
|
|
1490
1510
|
|
|
1511
|
+
/**
|
|
1512
|
+
* Recuris C 组件:Lazily-initialized PostFlightChecker singleton。
|
|
1513
|
+
* 事后验证「工具/env 观察是否支撑变更」,与 PreFlightChecker(事前拦截)互补。
|
|
1514
|
+
*/
|
|
1515
|
+
getPostFlightChecker(): PostFlightChecker {
|
|
1516
|
+
if (!this._postFlightChecker) {
|
|
1517
|
+
this._postFlightChecker = createDefaultPostFlightChecker()
|
|
1518
|
+
}
|
|
1519
|
+
return this._postFlightChecker
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1491
1522
|
/**
|
|
1492
1523
|
* SIS Phase 2: Lazily-initialized AutoCorrector singleton.
|
|
1493
1524
|
* Analyzes failed tool calls and suggests corrections based on
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -18,19 +18,31 @@ import { ExperienceRuleEngine } from './rule-engine'
|
|
|
18
18
|
import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
|
|
19
19
|
import { ErrorSignatureDB } from './error-signature-db'
|
|
20
20
|
import { PreFlightChecker } from './preflight-checker'
|
|
21
|
+
import { createDefaultPostFlightChecker } from './post-flight-checker'
|
|
22
|
+
import { WorkingMemory } from './working-memory'
|
|
21
23
|
import { RedTeam } from './red-team'
|
|
22
24
|
import { isProtectedPath, validateBlastRadius, PROTECTED_CRITICAL_FILES } from './crsi-sandbox'
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
produceRuleProposal,
|
|
27
|
+
MANAGED_RULES_FILE,
|
|
28
|
+
buildLessonContent,
|
|
29
|
+
renderManagedRuleSource,
|
|
30
|
+
} from './crsi-producer'
|
|
24
31
|
import type { CrsiSignal } from './crsi-producer'
|
|
25
32
|
import { loadBehaviorTasks, judgeBehaviorTask } from './behavior-tasks'
|
|
26
33
|
|
|
27
34
|
// ── Types ──
|
|
28
35
|
|
|
36
|
+
/** 契约角色:anchor = 安全/机制不变量(门强制不许回退);target = 缺口/覆盖(应被补)。 */
|
|
37
|
+
export type ContractRole = 'anchor' | 'target' | 'neutral'
|
|
38
|
+
|
|
29
39
|
export interface EvalResult {
|
|
30
40
|
id: string
|
|
31
41
|
description: string
|
|
32
42
|
passed: boolean
|
|
33
43
|
detail?: string
|
|
44
|
+
/** 契约角色。缺省 neutral。 */
|
|
45
|
+
role?: ContractRole
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
export interface EvalReport {
|
|
@@ -42,6 +54,29 @@ export interface EvalReport {
|
|
|
42
54
|
failures: string[]
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/** anchor 契约 id 集合:安全/机制不变量,绝不许回退。门(crsi-modify)强制此集合零回退。 */
|
|
58
|
+
export const ANCHOR_CONTRACT_IDS: ReadonlySet<string> = new Set([
|
|
59
|
+
'rule-timeout',
|
|
60
|
+
'rule-git-force',
|
|
61
|
+
'rule-disabled-skip',
|
|
62
|
+
'constitution-8-principles',
|
|
63
|
+
'constitution-facets',
|
|
64
|
+
'constitution-preamble',
|
|
65
|
+
'sandbox-protected-constitution',
|
|
66
|
+
'sandbox-protected-tests',
|
|
67
|
+
'sandbox-protected-machinery',
|
|
68
|
+
'protection-completeness',
|
|
69
|
+
'blast-radius-gate',
|
|
70
|
+
'red-team-zero-gaps',
|
|
71
|
+
'producer-rule-shape',
|
|
72
|
+
'producer-rule-idempotent',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** 细粒度防回退:返回 role==='anchor' 且已 FAIL 的契约 id。空 = 无 anchor 回退。 */
|
|
76
|
+
export function regressedAnchors(results: EvalResult[]): string[] {
|
|
77
|
+
return results.filter((r) => r.role === 'anchor' && !r.passed).map((r) => r.id)
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
// ── Rewards log (path A Phase 1: 奖励信号持久化) ──
|
|
46
81
|
|
|
47
82
|
const SCORES_FILE = join(homedir(), '.mipham', 'crsi', 'eval-scores.jsonl')
|
|
@@ -228,6 +263,48 @@ export function runEval(): EvalReport {
|
|
|
228
263
|
ruleProposal !== null && produceRuleProposal(frozenSignal, ruleProposal.newContent) === null,
|
|
229
264
|
})
|
|
230
265
|
|
|
266
|
+
// ── 组件归因(ground truth:缺省 experiential、显式组件透传、非 experiential 不进 managed-rule) ──
|
|
267
|
+
results.push({
|
|
268
|
+
id: 'producer-component-tag',
|
|
269
|
+
description: '组件归因:缺省 experiential、显式组件透传、非 experiential 不进 managed-rule',
|
|
270
|
+
passed:
|
|
271
|
+
buildLessonContent(frozenSignal, 't', 'src').includes('- 组件: experiential') &&
|
|
272
|
+
buildLessonContent({ ...frozenSignal, component: 'checker' }, 't', 'src').includes(
|
|
273
|
+
'- 组件: checker',
|
|
274
|
+
) &&
|
|
275
|
+
renderManagedRuleSource({ ...frozenSignal, component: 'working' }) === null &&
|
|
276
|
+
renderManagedRuleSource(frozenSignal) !== null,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// ── 事后检查器(ground truth:exit 0 判 supported、exit 非 0 判 rejected) ──
|
|
280
|
+
const postFlight = createDefaultPostFlightChecker()
|
|
281
|
+
results.push({
|
|
282
|
+
id: 'postflight-bash-exit',
|
|
283
|
+
description: '事后检查器:bash exit 0 判 supported、exit 非 0 判 rejected',
|
|
284
|
+
passed:
|
|
285
|
+
postFlight.check('Bash', { params: {}, result: { success: true, content: '' } }).verdict ===
|
|
286
|
+
'supported' &&
|
|
287
|
+
postFlight.check('Bash', { params: {}, result: { success: false, content: '', error: 'x' } })
|
|
288
|
+
.verdict === 'rejected',
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// ── 工作记忆证据接地(ground truth:done 只能由 supported 推进,模型自称不算) ──
|
|
292
|
+
const wm = new WorkingMemory()
|
|
293
|
+
wm.setGoal('install-deps', 'install dependencies')
|
|
294
|
+
wm.observe('install-deps', { verdict: 'no-checker' })
|
|
295
|
+
const pendingAfterNoChecker = wm.getGoal('install-deps')!.status === 'pending'
|
|
296
|
+
wm.observe('install-deps', { verdict: 'supported', checkerId: 'bash-exit' })
|
|
297
|
+
const doneAfterSupported = wm.getGoal('install-deps')!.status === 'done'
|
|
298
|
+
wm.setGoal('edit-file', 'edit the file')
|
|
299
|
+
wm.observe('edit-file', { verdict: 'rejected', checkerId: 'edit-applied', reason: 'x' })
|
|
300
|
+
const blockedAfterRejected = wm.getGoal('edit-file')!.status === 'blocked'
|
|
301
|
+
results.push({
|
|
302
|
+
id: 'working-memory-evidence-gated',
|
|
303
|
+
description:
|
|
304
|
+
'工作记忆:done 只能由 checker supported 推进,rejected 置 blocked,模型自称(no-checker)不算',
|
|
305
|
+
passed: pendingAfterNoChecker && doneAfterSupported && blockedAfterRejected,
|
|
306
|
+
})
|
|
307
|
+
|
|
231
308
|
// ── 行为缺口(ground truth:当前无规则覆盖的确定性拦截,如实判 FAIL) ──
|
|
232
309
|
// producer 固化 tool-params 规则后,这些缺口翻转 PASS → 分数上升 =「证明更好」。
|
|
233
310
|
const behaviorGaps: Array<{ id: string; command: string }> = [
|
|
@@ -246,15 +323,30 @@ export function runEval(): EvalReport {
|
|
|
246
323
|
id: gap.id,
|
|
247
324
|
description: `行为缺口未覆盖: ${gap.command}`,
|
|
248
325
|
passed: r.warnings.length > 0,
|
|
326
|
+
role: 'target',
|
|
249
327
|
})
|
|
250
328
|
}
|
|
251
329
|
|
|
252
330
|
// ── 行为任务集(ground truth:约束行为效果,确定性无 LLM) ──
|
|
253
331
|
const behaviorTasks = loadBehaviorTasks()
|
|
254
332
|
for (const task of behaviorTasks) {
|
|
255
|
-
results.push(judgeBehaviorTask(task, ruleEngine))
|
|
333
|
+
results.push({ ...judgeBehaviorTask(task, ruleEngine), role: 'target' })
|
|
256
334
|
}
|
|
257
335
|
|
|
336
|
+
// 角色标注:anchor 走集中清单(门保护面单一真源),target 已在上方循环内联。
|
|
337
|
+
for (const r of results) {
|
|
338
|
+
if (ANCHOR_CONTRACT_IDS.has(r.id)) r.role = 'anchor'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// anchor 自检(ground truth:所有 anchor 契约必须全绿,否则门拒)。
|
|
342
|
+
const anchorFailures = results.filter((r) => r.role === 'anchor' && !r.passed).map((r) => r.id)
|
|
343
|
+
results.push({
|
|
344
|
+
id: 'anchor-gate',
|
|
345
|
+
description: '所有 anchor 契约必须全绿(细粒度防回退闸)',
|
|
346
|
+
passed: anchorFailures.length === 0,
|
|
347
|
+
...(anchorFailures.length > 0 ? { detail: `回退的 anchor: ${anchorFailures.join(', ')}` } : {}),
|
|
348
|
+
})
|
|
349
|
+
|
|
258
350
|
const passed = results.filter((r) => r.passed).length
|
|
259
351
|
return {
|
|
260
352
|
total: results.length,
|
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
writeFileSync,
|
|
6
6
|
unlinkSync,
|
|
7
|
+
renameSync,
|
|
7
8
|
existsSync,
|
|
8
9
|
statSync,
|
|
9
10
|
} from 'node:fs'
|
|
10
|
-
import { join, extname } from 'node:path'
|
|
11
|
+
import { join, extname, basename } from 'node:path'
|
|
11
12
|
import { similarities } from './tfidf'
|
|
12
13
|
|
|
13
14
|
export interface MemoryMetadata {
|
|
@@ -28,10 +29,43 @@ export interface MemoryEntry {
|
|
|
28
29
|
|
|
29
30
|
const INDEX_FILE = 'MEMORY.md'
|
|
30
31
|
const LINKS_FILE = 'links.json'
|
|
32
|
+
const RECALL_STATS_FILE = 'recall-stats.json'
|
|
33
|
+
const AUTO_PREFIX = 'auto-'
|
|
34
|
+
/** 「从没被召回 + 过期」的 auto-* 记忆归档阈值(60 天)。 */
|
|
35
|
+
const GC_STALE_MS = 60 * 24 * 60 * 60 * 1000
|
|
36
|
+
/** 会话记忆合并的 TF-IDF 余弦阈值(> 此值聚成一簇)。 */
|
|
37
|
+
const CONSOLIDATE_THRESHOLD = 0.5
|
|
38
|
+
/** 写时去重的 TF-IDF 余弦阈值(> 此值视为近重复,合并而非新增)。高于合并阈值,只拦近重复。 */
|
|
39
|
+
const DEDUP_THRESHOLD = 0.65
|
|
40
|
+
|
|
41
|
+
/** 确定性 hash(无 Date.now / Math.random,同输入同输出 → 幂等 lesson 名)。 */
|
|
42
|
+
function stableHash(s: string): string {
|
|
43
|
+
let h = 0
|
|
44
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
|
|
45
|
+
return h.toString(36)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 写时去重:找同 type 且内容近重复(余弦 > 阈值)的现有记忆。纯函数便于测。 */
|
|
49
|
+
export function findNearDuplicate(
|
|
50
|
+
candidates: ReadonlyArray<MemoryEntry>,
|
|
51
|
+
content: string,
|
|
52
|
+
type: string,
|
|
53
|
+
): MemoryEntry | null {
|
|
54
|
+
for (const entry of candidates) {
|
|
55
|
+
if (entry.metadata.type !== type) continue
|
|
56
|
+
// auto-*(会话记忆,归合并管)与 lesson-*(合并产物)不做写时去重,
|
|
57
|
+
// 否则合并阶段 write 的 lesson 会被尚存的 auto-* 吸收掉。
|
|
58
|
+
if (entry.name.startsWith(AUTO_PREFIX) || entry.name.startsWith('lesson-')) continue
|
|
59
|
+
const sim = similarities(content, [entry.content])[0] ?? 0
|
|
60
|
+
if (sim > DEDUP_THRESHOLD) return entry
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
31
64
|
|
|
32
65
|
export class MemoryManager {
|
|
33
66
|
private memories = new Map<string, MemoryEntry>()
|
|
34
67
|
private linkGraph: Map<string, Set<string>> = new Map()
|
|
68
|
+
private recallStats = new Map<string, { recallCount: number; lastRecalledAt: string }>()
|
|
35
69
|
private contextMaxTokens = 200_000
|
|
36
70
|
|
|
37
71
|
constructor(private memoryDir: string) {
|
|
@@ -46,6 +80,7 @@ export class MemoryManager {
|
|
|
46
80
|
loadAll(): void {
|
|
47
81
|
this.memories.clear()
|
|
48
82
|
this.linkGraph.clear()
|
|
83
|
+
this.recallStats.clear()
|
|
49
84
|
if (!existsSync(this.memoryDir)) return
|
|
50
85
|
|
|
51
86
|
let entries: string[] = []
|
|
@@ -73,28 +108,32 @@ export class MemoryManager {
|
|
|
73
108
|
if (!this.loadLinkGraph()) {
|
|
74
109
|
this.rebuildLinkGraph()
|
|
75
110
|
}
|
|
111
|
+
this.loadRecallStats()
|
|
76
112
|
}
|
|
77
113
|
|
|
78
114
|
write(name: string, content: string, metadata: MemoryMetadata): void {
|
|
79
|
-
//
|
|
80
|
-
const formattedBody = this.formatMemoryBody(metadata, content)
|
|
115
|
+
// 同名 update(replace 语义)
|
|
81
116
|
const existing = this.memories.get(name)
|
|
82
117
|
if (existing) {
|
|
83
|
-
existing
|
|
84
|
-
existing.metadata = metadata
|
|
85
|
-
existing.description = metadata.relevance.join(', ')
|
|
86
|
-
existing.updatedAt = new Date()
|
|
87
|
-
this.memories.set(name, existing)
|
|
88
|
-
const body = this.formatMemoryFile(name, metadata, content)
|
|
89
|
-
writeFileSync(existing.filePath, body, 'utf-8')
|
|
90
|
-
this.updateWikilinks(name, content)
|
|
91
|
-
this.updateIndex()
|
|
118
|
+
this.updateEntry(existing, content, metadata)
|
|
92
119
|
return
|
|
93
120
|
}
|
|
94
121
|
|
|
122
|
+
// 写时去重:不同名但同 type + 内容近重复 → 合并进现有(union relevance),不新增(治「越存越乱」)。
|
|
123
|
+
const nearDup = findNearDuplicate([...this.memories.values()], content, metadata.type)
|
|
124
|
+
if (nearDup) {
|
|
125
|
+
const mergedMetadata: MemoryMetadata = {
|
|
126
|
+
...metadata,
|
|
127
|
+
relevance: [...new Set([...nearDup.metadata.relevance, ...metadata.relevance])],
|
|
128
|
+
}
|
|
129
|
+
this.updateEntry(nearDup, content, mergedMetadata)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 新建
|
|
95
134
|
const fileName = `${name}.md`
|
|
96
135
|
const filePath = join(this.memoryDir, fileName)
|
|
97
|
-
|
|
136
|
+
const formattedBody = this.formatMemoryBody(metadata, content)
|
|
98
137
|
const body = this.formatMemoryFile(name, metadata, content)
|
|
99
138
|
writeFileSync(filePath, body, 'utf-8')
|
|
100
139
|
|
|
@@ -112,12 +151,27 @@ export class MemoryManager {
|
|
|
112
151
|
this.updateIndex()
|
|
113
152
|
}
|
|
114
153
|
|
|
115
|
-
|
|
116
|
-
|
|
154
|
+
/** 更新一条现有记忆(内容/元数据/文件/索引)。同名 update 与近重复合并共用。 */
|
|
155
|
+
private updateEntry(entry: MemoryEntry, content: string, metadata: MemoryMetadata): void {
|
|
156
|
+
entry.content = this.formatMemoryBody(metadata, content)
|
|
157
|
+
entry.metadata = metadata
|
|
158
|
+
entry.description = metadata.relevance.join(', ')
|
|
159
|
+
entry.updatedAt = new Date()
|
|
160
|
+
const body = this.formatMemoryFile(entry.name, metadata, content)
|
|
161
|
+
writeFileSync(entry.filePath, body, 'utf-8')
|
|
162
|
+
this.updateWikilinks(entry.name, content)
|
|
163
|
+
this.updateIndex()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
recall(context: string, limit: number = 10, grounding?: string): MemoryEntry[] {
|
|
167
|
+
// 状态接地(治「前存后忘」):把「当前还剩什么没做」拼进 query,让召回绑定到
|
|
168
|
+
// 已验证的当前状态,而不是只绑定到「刚说了什么」。历史越长,旧但相关的记忆越不被埋。
|
|
169
|
+
const query = grounding ? `${grounding}\n${context}` : context
|
|
170
|
+
const contextLower = query.toLowerCase()
|
|
117
171
|
const entries = [...this.memories.values()]
|
|
118
172
|
// TF-IDF cosine similarity (CJK-bigram aware) replaces the old word-overlap.
|
|
119
173
|
const sims = similarities(
|
|
120
|
-
|
|
174
|
+
query,
|
|
121
175
|
entries.map((e) => e.content),
|
|
122
176
|
)
|
|
123
177
|
const scored: Array<{ entry: MemoryEntry; score: number }> = []
|
|
@@ -161,7 +215,9 @@ export class MemoryManager {
|
|
|
161
215
|
}
|
|
162
216
|
|
|
163
217
|
scored.sort((a, b) => b.score - a.score)
|
|
164
|
-
|
|
218
|
+
const top = scored.slice(0, limit).map((s) => s.entry)
|
|
219
|
+
this.recordRecall(top.map((e) => e.name))
|
|
220
|
+
return top
|
|
165
221
|
}
|
|
166
222
|
|
|
167
223
|
getLinkedMemories(name: string): MemoryEntry[] {
|
|
@@ -190,11 +246,98 @@ export class MemoryManager {
|
|
|
190
246
|
this.updateIndex()
|
|
191
247
|
}
|
|
192
248
|
|
|
193
|
-
|
|
249
|
+
/**
|
|
250
|
+
* 淘汰:归档「从没被召回 + 过期」的 auto-* 记忆;手写记忆只报告不自动动。
|
|
251
|
+
* auto-* 是 `distillFromSession` 的产物(每次会话各写一条),是可安全淘汰的膨胀源;
|
|
252
|
+
* 用户手写进 candidates 待人工确认。
|
|
253
|
+
*/
|
|
254
|
+
gc(): { archived: string[]; candidates: string[] } {
|
|
255
|
+
const archived: string[] = []
|
|
256
|
+
const candidates: string[] = []
|
|
257
|
+
const now = Date.now()
|
|
258
|
+
|
|
259
|
+
for (const [name, entry] of this.memories) {
|
|
260
|
+
const recallCount = this.recallStats.get(name)?.recallCount ?? 0
|
|
261
|
+
const age = now - entry.updatedAt.getTime()
|
|
262
|
+
if (recallCount > 0 || age <= GC_STALE_MS) continue
|
|
263
|
+
|
|
264
|
+
if (name.startsWith(AUTO_PREFIX)) {
|
|
265
|
+
this.archive(name, entry)
|
|
266
|
+
archived.push(name)
|
|
267
|
+
} else {
|
|
268
|
+
candidates.push(name)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { archived, candidates }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 把一条记忆移到 archive/ 子目录并从内存/索引移除。loadAll 不递归,故天然不再加载。 */
|
|
276
|
+
private archive(name: string, entry: MemoryEntry): void {
|
|
277
|
+
try {
|
|
278
|
+
mkdirSync(join(this.memoryDir, 'archive'), { recursive: true })
|
|
279
|
+
renameSync(entry.filePath, join(this.memoryDir, 'archive', basename(entry.filePath)))
|
|
280
|
+
} catch {
|
|
281
|
+
// best-effort:rename 失败(文件已不在等)也不阻塞淘汰
|
|
282
|
+
}
|
|
283
|
+
this.memories.delete(name)
|
|
284
|
+
this.linkGraph.delete(name)
|
|
285
|
+
this.recallStats.delete(name)
|
|
286
|
+
this.saveLinkGraph()
|
|
287
|
+
this.saveRecallStats()
|
|
288
|
+
this.updateIndex()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 会话记忆合并:把 auto-* 聚簇成持久化 lesson-*(重叠的合并、去重,删原 auto-*)。
|
|
293
|
+
* 手动触发(/memory consolidate),不后台自动跑。返回创建的 lesson 数与删除的 auto-* 数。
|
|
294
|
+
*/
|
|
295
|
+
consolidateAutoMemories(): { merged: number; removed: number } {
|
|
296
|
+
const autoEntries = [...this.memories.values()].filter((e) => e.name.startsWith(AUTO_PREFIX))
|
|
297
|
+
if (autoEntries.length === 0) return { merged: 0, removed: 0 }
|
|
298
|
+
|
|
299
|
+
// 贪心聚簇:与簇代表(首条)余弦 > 阈值则归入,否则新开一簇。
|
|
300
|
+
const clusters: MemoryEntry[][] = []
|
|
301
|
+
for (const entry of autoEntries) {
|
|
302
|
+
let placed = false
|
|
303
|
+
for (const cluster of clusters) {
|
|
304
|
+
const sim = similarities(cluster[0]!.content, [entry.content])[0] ?? 0
|
|
305
|
+
if (sim > CONSOLIDATE_THRESHOLD) {
|
|
306
|
+
cluster.push(entry)
|
|
307
|
+
placed = true
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!placed) clusters.push([entry])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let merged = 0
|
|
315
|
+
let removed = 0
|
|
316
|
+
for (const cluster of clusters) {
|
|
317
|
+
const name = `lesson-${stableHash(
|
|
318
|
+
cluster
|
|
319
|
+
.map((e) => e.name)
|
|
320
|
+
.sort()
|
|
321
|
+
.join('|'),
|
|
322
|
+
)}`
|
|
323
|
+
const content = cluster.map((e) => e.content).join('\n\n')
|
|
324
|
+
const relevance = [...new Set(cluster.flatMap((e) => e.metadata.relevance))].slice(0, 10)
|
|
325
|
+
this.write(name, content, { type: 'feedback', relevance })
|
|
326
|
+
merged++
|
|
327
|
+
for (const member of cluster) {
|
|
328
|
+
this.delete(member.name)
|
|
329
|
+
removed++
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { merged, removed }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
buildSystemReminder(context: string, maxTokens?: number, grounding?: string): string {
|
|
194
337
|
// Adaptive budget: 5% of context window, min 5000, max 75000
|
|
195
338
|
const effectiveMaxTokens =
|
|
196
339
|
maxTokens ?? Math.max(5000, Math.min(75000, Math.floor(this.contextMaxTokens * 0.05)))
|
|
197
|
-
const relevant = this.recall(context, 10)
|
|
340
|
+
const relevant = this.recall(context, 10, grounding)
|
|
198
341
|
if (relevant.length === 0) return ''
|
|
199
342
|
|
|
200
343
|
const lines: string[] = ['<system-reminder>', 'Relevant memories from previous sessions:']
|
|
@@ -390,6 +533,49 @@ export class MemoryManager {
|
|
|
390
533
|
}
|
|
391
534
|
}
|
|
392
535
|
|
|
536
|
+
/** 记录召回(质量信号):被召回的条目 recallCount+1。写入 sidecar,不改记忆文件本身。 */
|
|
537
|
+
private recordRecall(names: string[]): void {
|
|
538
|
+
if (names.length === 0) return
|
|
539
|
+
const now = new Date().toISOString()
|
|
540
|
+
for (const name of names) {
|
|
541
|
+
const stats = this.recallStats.get(name)
|
|
542
|
+
if (stats) {
|
|
543
|
+
stats.recallCount++
|
|
544
|
+
stats.lastRecalledAt = now
|
|
545
|
+
} else {
|
|
546
|
+
this.recallStats.set(name, { recallCount: 1, lastRecalledAt: now })
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
this.saveRecallStats()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private saveRecallStats(): void {
|
|
553
|
+
const obj: Record<string, { recallCount: number; lastRecalledAt: string }> = {}
|
|
554
|
+
for (const [k, v] of this.recallStats) obj[k] = v
|
|
555
|
+
try {
|
|
556
|
+
writeFileSync(join(this.memoryDir, RECALL_STATS_FILE), JSON.stringify(obj, null, 2), 'utf-8')
|
|
557
|
+
} catch {
|
|
558
|
+
// best-effort — never block on stats write
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private loadRecallStats(): void {
|
|
563
|
+
const path = join(this.memoryDir, RECALL_STATS_FILE)
|
|
564
|
+
if (!existsSync(path)) return
|
|
565
|
+
try {
|
|
566
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
567
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
568
|
+
const rec = v as { recallCount?: number; lastRecalledAt?: string }
|
|
569
|
+
this.recallStats.set(k, {
|
|
570
|
+
recallCount: rec.recallCount ?? 0,
|
|
571
|
+
lastRecalledAt: rec.lastRecalledAt ?? '',
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
// corrupt file — ignore
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
393
579
|
private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
|
|
394
580
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
395
581
|
if (!match) return null
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostFlightChecker — 事后检查器(Recuris C 组件)。
|
|
3
|
+
*
|
|
4
|
+
* Recuris M=(E,W,ρ,C) 的 C:检查器。核心原则「调用技能/尝试工具 ≠ 完成证据;
|
|
5
|
+
* 只有工具/env 的观察结果支撑才算」。`PreFlightChecker` 是事前拦截(ErrorSignatureDB
|
|
6
|
+
* + RuleEngine),本模块是事后验证:把「完成谓词 (observation) => boolean」抽象出来,
|
|
7
|
+
* 对工具结果核对,决策写进会话轨迹(供 ① 组件归因 + eval 打分)。
|
|
8
|
+
*
|
|
9
|
+
* 设计约束:
|
|
10
|
+
* - 默认 no-checker 静默、rejected 只记录不阻塞(第一阶段只产证据,不强制拦截)。
|
|
11
|
+
* - 确定性、无 LLM、可单测。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import type { ToolResult } from '../shared/types'
|
|
16
|
+
|
|
17
|
+
/** 工具事后观察:工具实际用的参数 + 工具返回结果。 */
|
|
18
|
+
export interface ToolObservation {
|
|
19
|
+
params: Record<string, unknown>
|
|
20
|
+
result: ToolResult
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type CheckerDecision =
|
|
24
|
+
| { verdict: 'supported'; checkerId: string }
|
|
25
|
+
| { verdict: 'rejected'; checkerId: string; reason: string }
|
|
26
|
+
| { verdict: 'no-checker' }
|
|
27
|
+
|
|
28
|
+
/** 完成谓词:工具/env 观察是否支撑「该变更已生效」。绝不信任模型自称成功。 */
|
|
29
|
+
export type Checker = (observation: ToolObservation) => boolean
|
|
30
|
+
|
|
31
|
+
export class PostFlightChecker {
|
|
32
|
+
private checkers = new Map<string, { id: string; predicate: Checker }>()
|
|
33
|
+
|
|
34
|
+
register(checkerId: string, toolName: string, predicate: Checker): void {
|
|
35
|
+
this.checkers.set(toolName, { id: checkerId, predicate })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 事后核对一次工具结果。无匹配 checker 时返回 no-checker(不阻塞)。 */
|
|
39
|
+
check(toolName: string, observation: ToolObservation): CheckerDecision {
|
|
40
|
+
const entry = this.checkers.get(toolName)
|
|
41
|
+
if (!entry) return { verdict: 'no-checker' }
|
|
42
|
+
try {
|
|
43
|
+
return entry.predicate(observation)
|
|
44
|
+
? { verdict: 'supported', checkerId: entry.id }
|
|
45
|
+
: {
|
|
46
|
+
verdict: 'rejected',
|
|
47
|
+
checkerId: entry.id,
|
|
48
|
+
reason: `post-condition failed (${entry.id})`,
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return { verdict: 'rejected', checkerId: entry.id, reason: `checker threw (${entry.id})` }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 首批 checker(最小、确定性、可测) ──
|
|
57
|
+
|
|
58
|
+
/** Bash → exit code 0(对齐 detectViolations 语义,不重复实现)。 */
|
|
59
|
+
function bashExit(o: ToolObservation): boolean {
|
|
60
|
+
return o.result.success === true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Write → 文件存在且长度一致。 */
|
|
64
|
+
function writeExists(o: ToolObservation): boolean {
|
|
65
|
+
if (!o.result.success) return false
|
|
66
|
+
const filePath = String(o.params.file_path ?? '')
|
|
67
|
+
if (!filePath) return false
|
|
68
|
+
try {
|
|
69
|
+
if (!existsSync(filePath)) return false
|
|
70
|
+
const expected = Buffer.byteLength(String(o.params.content ?? ''), 'utf-8')
|
|
71
|
+
return statSync(filePath).size === expected
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Edit → 工具返回 applied 且 new_string 已生效(退化为「old_string 无残留」)。 */
|
|
78
|
+
function editApplied(o: ToolObservation): boolean {
|
|
79
|
+
if (!o.result.success) return false
|
|
80
|
+
const filePath = String(o.params.file_path ?? '')
|
|
81
|
+
const newString = String(o.params.new_string ?? '')
|
|
82
|
+
const oldString = String(o.params.old_string ?? '')
|
|
83
|
+
if (!filePath) return false
|
|
84
|
+
try {
|
|
85
|
+
if (!existsSync(filePath)) return false
|
|
86
|
+
const text = readFileSync(filePath, 'utf-8')
|
|
87
|
+
if (newString) return text.includes(newString)
|
|
88
|
+
if (oldString) return !text.includes(oldString)
|
|
89
|
+
return true
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 默认 checker:Bash/Write/Edit 三件套(工具作用域后置条件)。 */
|
|
96
|
+
export function createDefaultPostFlightChecker(): PostFlightChecker {
|
|
97
|
+
const c = new PostFlightChecker()
|
|
98
|
+
c.register('bash-exit', 'Bash', bashExit)
|
|
99
|
+
c.register('write-exists', 'Write', writeExists)
|
|
100
|
+
c.register('edit-applied', 'Edit', editApplied)
|
|
101
|
+
return c
|
|
102
|
+
}
|
package/src/core/reward-fn.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 统一「给一个 policy 打分」:机制哨兵(runEval)与任务表现(runTaskPerformance)
|
|
3
3
|
// 都 conform 成 RewardFn,自改进环的 verify 阶段可对任意奖励源比分数、判退化。
|
|
4
4
|
import type { Llm } from '../providers/llm'
|
|
5
|
-
import { runEval } from './eval-harness'
|
|
5
|
+
import { runEval, type EvalResult } from './eval-harness'
|
|
6
6
|
import { runTaskPerformance } from './task-performance'
|
|
7
7
|
|
|
8
8
|
/** 奖励函数统一输出的「分数」形状——所有 RewardFn 的 evaluate 都产出它。 */
|
|
@@ -11,6 +11,8 @@ export interface ScoreReport {
|
|
|
11
11
|
passed: number
|
|
12
12
|
score: number // 0-100
|
|
13
13
|
failures: string[]
|
|
14
|
+
/** 逐契约结果(机制哨兵 runEval 天然携带;任务表现等 reward 可缺省)。 */
|
|
15
|
+
results?: EvalResult[]
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** 奖励函数(reward function = policy→feedback):一个具名、可替换的打分器。 */
|
package/src/core/session-log.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import type { Message, ToolUseContent, ToolResultContent, ToolResult } from '../shared/types'
|
|
5
|
+
import type { CheckerDecision } from './post-flight-checker'
|
|
5
6
|
|
|
6
7
|
export type SessionEvent =
|
|
7
8
|
| {
|
|
@@ -20,6 +21,7 @@ export type SessionEvent =
|
|
|
20
21
|
| { type: 'context/inject'; at: number; source: string; text: string }
|
|
21
22
|
| { type: 'compaction/summary'; at: number; summary: string; replacedCount: number }
|
|
22
23
|
| { type: 'compaction/rewrite'; at: number; messages: Message[] }
|
|
24
|
+
| { type: 'checker/decision'; at: number; toolName: string; decision: CheckerDecision }
|
|
23
25
|
|
|
24
26
|
export function messageToEvents(msg: Message, at = 0): SessionEvent[] {
|
|
25
27
|
if (msg.role === 'user') {
|
|
@@ -94,7 +96,7 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
94
96
|
// 快照替换:整个投影重建(微压缩/截断等结构性编辑的字节级复现)
|
|
95
97
|
out = structuredClone(e.messages)
|
|
96
98
|
}
|
|
97
|
-
// 'session/start' →
|
|
99
|
+
// 'session/start' / 'checker/decision' → 无消息(决策仅记录证据,不进投影,保字节级可逆)
|
|
98
100
|
}
|
|
99
101
|
return out
|
|
100
102
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working Memory — 用「已验证的当前任务状态」接地记忆召回(借鉴 Recuris 的 W,仅借概念)。
|
|
3
|
+
*
|
|
4
|
+
* 问题(治「前存后忘」):MemoryManager.recall 用 TF-IDF 去和「刚说的 query」比,
|
|
5
|
+
* 历史越长,旧但相关的记忆越容易被埋掉。Recuris 的解法是把召回绑定到「当前还剩
|
|
6
|
+
* 什么没做」(pending/in_progress),而不是只绑定到「刚说了什么」。
|
|
7
|
+
*
|
|
8
|
+
* 本文件分两层:
|
|
9
|
+
* - Phase 1(纯只读):`renderWorkingState(tasks)` 把「现有任务追踪」的未完成任务
|
|
10
|
+
* 渲染成紧凑接地串,拼进 recall 的 query(状态接地召回)。
|
|
11
|
+
* - Phase 2(证据接地状态机):`WorkingMemory` 类复现 Recuris「w_t → ρ → E_t → (a,o)
|
|
12
|
+
* → C → w_{t+1}」环——`done` 只能由 PostFlightChecker 的 `supported` 决策推进,
|
|
13
|
+
* `blocked` 由 `rejected` 置位;**没有「模型自称 done」的入口**(不信任模型叙事,
|
|
14
|
+
* 只信任工具/env 观察)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CheckerDecision } from './post-flight-checker'
|
|
18
|
+
|
|
19
|
+
export interface WorkingGoal {
|
|
20
|
+
subject: string
|
|
21
|
+
status: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 渲染「当前仍未完成」的任务主题为紧凑接地串;无未完成任务返回空串。 */
|
|
25
|
+
export function renderWorkingState(tasks: ReadonlyArray<WorkingGoal>): string {
|
|
26
|
+
const open = tasks.filter((t) => t.status === 'pending' || t.status === 'in_progress')
|
|
27
|
+
if (open.length === 0) return ''
|
|
28
|
+
return open
|
|
29
|
+
.map((t) => t.subject.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join('\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Phase 2:证据接地状态机 ──
|
|
35
|
+
|
|
36
|
+
export type GoalStatus = 'pending' | 'done' | 'blocked'
|
|
37
|
+
|
|
38
|
+
export interface GoalState {
|
|
39
|
+
id: string
|
|
40
|
+
content: string
|
|
41
|
+
status: GoalStatus
|
|
42
|
+
/** 推进/置位时固化的证据(checkerId + reason),供审计「谁证明了这个状态」。 */
|
|
43
|
+
evidence: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 从现有任务追踪(Task 工具的 Task 列表)同步的最小结构,避免 import Task 造成循环依赖。 */
|
|
47
|
+
export interface TaskLike {
|
|
48
|
+
id: string
|
|
49
|
+
subject: string
|
|
50
|
+
status: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class WorkingMemory {
|
|
54
|
+
private goals = new Map<string, GoalState>()
|
|
55
|
+
|
|
56
|
+
/** 注册一个待办目标(新目标默认 pending)。重复 id 只更新 content,不回退已验证的状态。 */
|
|
57
|
+
setGoal(id: string, content: string): void {
|
|
58
|
+
const existing = this.goals.get(id)
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.content = content
|
|
61
|
+
} else {
|
|
62
|
+
this.goals.set(id, { id, content, status: 'pending', evidence: [] })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 证据接地推进:`done` 只能由 checker `supported` 决策推进;`rejected` 置 `blocked`;
|
|
68
|
+
* `no-checker` 不改状态(模型自称不算)。已 `done` 的目标不再回退。
|
|
69
|
+
*/
|
|
70
|
+
observe(goalId: string, decision: CheckerDecision): void {
|
|
71
|
+
const g = this.goals.get(goalId)
|
|
72
|
+
if (!g || g.status === 'done') return
|
|
73
|
+
if (decision.verdict === 'supported') {
|
|
74
|
+
g.status = 'done'
|
|
75
|
+
g.evidence.push(`supported:${decision.checkerId}`)
|
|
76
|
+
} else if (decision.verdict === 'rejected') {
|
|
77
|
+
g.status = 'blocked'
|
|
78
|
+
g.evidence.push(`rejected:${decision.checkerId}:${decision.reason}`)
|
|
79
|
+
}
|
|
80
|
+
// no-checker:证据不足,状态不变
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getGoal(id: string): GoalState | undefined {
|
|
84
|
+
return this.goals.get(id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 从现有任务追踪(Task 工具的 Task 列表)重建目标状态——TaskList 是唯一真源。
|
|
88
|
+
* pending/in_progress → pending;completed → done;failed → blocked;deleted 跳过。 */
|
|
89
|
+
syncFromTasks(tasks: ReadonlyArray<TaskLike>): void {
|
|
90
|
+
this.goals.clear()
|
|
91
|
+
for (const t of tasks) {
|
|
92
|
+
if (t.status === 'deleted') continue
|
|
93
|
+
const status: GoalStatus =
|
|
94
|
+
t.status === 'completed' ? 'done' : t.status === 'failed' ? 'blocked' : 'pending'
|
|
95
|
+
this.goals.set(t.id, { id: t.id, content: t.subject, status, evidence: [] })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 紧凑三态块(检索/系统提示用);无任何目标时返回空串。 */
|
|
100
|
+
renderWorkingState(): string {
|
|
101
|
+
if (this.goals.size === 0) return ''
|
|
102
|
+
const byStatus: Record<GoalStatus, string[]> = { pending: [], done: [], blocked: [] }
|
|
103
|
+
for (const g of this.goals.values()) {
|
|
104
|
+
byStatus[g.status].push(g.content.trim())
|
|
105
|
+
}
|
|
106
|
+
return (['pending', 'done', 'blocked'] as const)
|
|
107
|
+
.map((s) => {
|
|
108
|
+
const items = byStatus[s].filter(Boolean)
|
|
109
|
+
return `[WORKING] ${s}: ${items.length > 0 ? items.join(', ') : '(none)'}`
|
|
110
|
+
})
|
|
111
|
+
.join('\n')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 证据账本(engine post-flight 后写入;Task 完成门读取) ──
|
|
116
|
+
|
|
117
|
+
export interface ToolEvidence {
|
|
118
|
+
toolName: string
|
|
119
|
+
decision: CheckerDecision
|
|
120
|
+
at: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 会话级证据账本(对齐 Task 工具的 module-level `tasks` Map,session 生命周期)。 */
|
|
124
|
+
const evidenceLog: ToolEvidence[] = []
|
|
125
|
+
|
|
126
|
+
/** 记录一次工具事后决策。no-checker 不产证据(忽略)。engine post-flight 后调用。 */
|
|
127
|
+
export function recordToolEvidence(toolName: string, decision: CheckerDecision): void {
|
|
128
|
+
if (decision.verdict === 'no-checker') return
|
|
129
|
+
evidenceLog.push({ toolName, decision, at: Date.now() })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 某时间点之后是否有 supported 决策(用于任务完成证据门)。 */
|
|
133
|
+
export function hasSupportedEvidenceSince(sinceAt: number): boolean {
|
|
134
|
+
return evidenceLog.some((e) => e.decision.verdict === 'supported' && e.at >= sinceAt)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 测试用:清空证据账本(避免跨测试污染)。 */
|
|
138
|
+
export function clearEvidenceLog(): void {
|
|
139
|
+
evidenceLog.length = 0
|
|
140
|
+
}
|
|
@@ -407,7 +407,15 @@
|
|
|
407
407
|
"error": "─ Memory ─\n\nCould not read memory directory.",
|
|
408
408
|
"location": "Location: {path}",
|
|
409
409
|
"total": "Total: {count} memor{plural}",
|
|
410
|
-
"footer": "Memories are used by the AI to provide personalized context across sessions.\nEach file in ~/.mipham/memory/ represents one remembered fact or preference."
|
|
410
|
+
"footer": "Memories are used by the AI to provide personalized context across sessions.\nEach file in ~/.mipham/memory/ represents one remembered fact or preference.",
|
|
411
|
+
"gc_title": "─ Memory GC ─",
|
|
412
|
+
"gc_none": "Nothing to archive. All auto-* memories are recent or have been recalled.",
|
|
413
|
+
"gc_archived": "Archived ({count}):",
|
|
414
|
+
"gc_candidates": "Manual review candidates ({count}):",
|
|
415
|
+
"gc_done": "Done. Archived files moved to ~/.mipham/memory/archive/",
|
|
416
|
+
"consolidate_title": "─ Memory Consolidate ─",
|
|
417
|
+
"consolidate_none": "No auto-* memories to consolidate.",
|
|
418
|
+
"consolidate_result": "Consolidated: {removed} auto-* removed, {merged} lessons written."
|
|
411
419
|
},
|
|
412
420
|
"upgrade": {
|
|
413
421
|
"uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
|
|
@@ -407,7 +407,15 @@
|
|
|
407
407
|
"error": "─ 记忆 ─\n\n无法读取记忆目录。",
|
|
408
408
|
"location": "位置: {path}",
|
|
409
409
|
"total": "总计: {count} 条记忆",
|
|
410
|
-
"footer": "AI 使用记忆提供跨会话的个性化上下文。\n~/.mipham/memory/ 中每个文件代表一条已记住的事实或偏好。"
|
|
410
|
+
"footer": "AI 使用记忆提供跨会话的个性化上下文。\n~/.mipham/memory/ 中每个文件代表一条已记住的事实或偏好。",
|
|
411
|
+
"gc_title": "─ 记忆 GC ─",
|
|
412
|
+
"gc_none": "无需归档。所有 auto-* 记忆要么较新、要么已被召回。",
|
|
413
|
+
"gc_archived": "已归档({count} 条):",
|
|
414
|
+
"gc_candidates": "待人工确认({count} 条):",
|
|
415
|
+
"gc_done": "完成。归档文件已移到 ~/.mipham/memory/archive/",
|
|
416
|
+
"consolidate_title": "─ 记忆合并 ─",
|
|
417
|
+
"consolidate_none": "没有需要合并的 auto-* 记忆。",
|
|
418
|
+
"consolidate_result": "合并完成:删除 {removed} 条 auto-*,写入 {merged} 条 lesson-*。"
|
|
411
419
|
},
|
|
412
420
|
"upgrade": {
|
|
413
421
|
"uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.69.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -3,6 +3,8 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
5
5
|
import { MemoryManager } from '../../core/memory/memory-manager'
|
|
6
|
+
import { renderWorkingState } from '../../core/working-memory'
|
|
7
|
+
import { getTasks } from '../exec/task'
|
|
6
8
|
|
|
7
9
|
const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
|
|
8
10
|
|
|
@@ -58,7 +60,9 @@ export const memoryTool: ToolDefinition = {
|
|
|
58
60
|
if (!query) return { success: false, content: '', error: 'query is required' }
|
|
59
61
|
const mm = new MemoryManager(MEMORY_DIR)
|
|
60
62
|
mm.loadAll()
|
|
61
|
-
|
|
63
|
+
// 状态接地(治「前存后忘」):除了精确匹配 query,还带上「当前未完成任务」相关的记忆。
|
|
64
|
+
const grounding = renderWorkingState(getTasks())
|
|
65
|
+
const results = mm.recall(query, 10, grounding)
|
|
62
66
|
if (results.length === 0) return { success: true, content: '(no matching memories)' }
|
|
63
67
|
const lines = results.map((entry) => {
|
|
64
68
|
const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { hasSupportedEvidenceSince } from '../../core/working-memory'
|
|
2
3
|
|
|
3
4
|
export interface Task {
|
|
4
5
|
id: string
|
|
@@ -11,6 +12,8 @@ export interface Task {
|
|
|
11
12
|
blockedBy: string[]
|
|
12
13
|
owner?: string
|
|
13
14
|
createdAt: string
|
|
15
|
+
/** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
|
|
16
|
+
completionEvidence?: 'supported' | 'unverified'
|
|
14
17
|
/** Background task output content (set when a background agent completes). */
|
|
15
18
|
output?: string
|
|
16
19
|
/** Background task error message (set when a background agent fails). */
|
|
@@ -218,6 +221,9 @@ export const taskTool: ToolDefinition = {
|
|
|
218
221
|
`Status: ${task.status}`,
|
|
219
222
|
`Description: ${task.description || '(none)'}`,
|
|
220
223
|
]
|
|
224
|
+
if (task.completionEvidence) {
|
|
225
|
+
lines.push(`Completion evidence: ${task.completionEvidence}`)
|
|
226
|
+
}
|
|
221
227
|
if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
|
|
222
228
|
if (task.owner) lines.push(`Owner: ${task.owner}`)
|
|
223
229
|
if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
|
|
@@ -255,7 +261,16 @@ export const taskTool: ToolDefinition = {
|
|
|
255
261
|
if (params.subject !== undefined) task.subject = params.subject as string
|
|
256
262
|
if (params.description !== undefined) task.description = params.description as string
|
|
257
263
|
if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
|
|
258
|
-
if (params.status !== undefined)
|
|
264
|
+
if (params.status !== undefined) {
|
|
265
|
+
task.status = params.status as Task['status']
|
|
266
|
+
// 完成证据门(软门):completed 时按「自任务创建以来是否有 supported 决策」标记。
|
|
267
|
+
// unverified 只标记不阻断(对齐 CRSI「最小干预」)。
|
|
268
|
+
if (params.status === 'completed') {
|
|
269
|
+
task.completionEvidence = hasSupportedEvidenceSince(new Date(task.createdAt).getTime())
|
|
270
|
+
? 'supported'
|
|
271
|
+
: 'unverified'
|
|
272
|
+
}
|
|
273
|
+
}
|
|
259
274
|
if (params.owner !== undefined) task.owner = params.owner as string
|
|
260
275
|
|
|
261
276
|
// Merge dependency arrays
|
|
@@ -281,10 +296,14 @@ export const taskTool: ToolDefinition = {
|
|
|
281
296
|
const blockedNote = isBlocked(task)
|
|
282
297
|
? ` (blocked — waiting on: ${getBlockingIds(task).join(', ')})`
|
|
283
298
|
: ''
|
|
299
|
+
const unverifiedNote =
|
|
300
|
+
task.status === 'completed' && task.completionEvidence === 'unverified'
|
|
301
|
+
? '\n⚠ completed without verified evidence (no supported tool decision since task start)'
|
|
302
|
+
: ''
|
|
284
303
|
|
|
285
304
|
return {
|
|
286
305
|
success: true,
|
|
287
|
-
content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}`,
|
|
306
|
+
content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}${unverifiedNote}`,
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
309
|
|
package/src/ui/commands.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
|
|
47
47
|
import { getPreference } from '../config/preferences'
|
|
48
48
|
import { loadCrossSessionConfig } from '../config/loader'
|
|
49
|
+
import { getMemoryManager } from '../core/memory/memory-loader'
|
|
49
50
|
import { stripIndent } from './strip-indent.js'
|
|
50
51
|
import { createT } from '../i18n-core/t'
|
|
51
52
|
import type { TranslationMap } from '../i18n-core/types'
|
|
@@ -3766,7 +3767,7 @@ const saveCmd: CommandHandler = (ctx, args) => {
|
|
|
3766
3767
|
// Memory Management
|
|
3767
3768
|
// ═══════════════════════════════════════════════════════════════
|
|
3768
3769
|
|
|
3769
|
-
const memoryCmd: CommandHandler = async (ctx) => {
|
|
3770
|
+
const memoryCmd: CommandHandler = async (ctx, args) => {
|
|
3770
3771
|
const t = resolveT(ctx)
|
|
3771
3772
|
const { existsSync, readdirSync, readFileSync, statSync } = await import('node:fs')
|
|
3772
3773
|
const { join } = await import('node:path')
|
|
@@ -3774,6 +3775,47 @@ const memoryCmd: CommandHandler = async (ctx) => {
|
|
|
3774
3775
|
const home = process.env.HOME || '~'
|
|
3775
3776
|
const memoryDir = join(home, '.mipham', 'memory')
|
|
3776
3777
|
|
|
3778
|
+
// /memory gc — 记忆卫生:归档「0 召回 + 过期」的 auto-* 记忆(手写只报告)
|
|
3779
|
+
if (args[0]?.toLowerCase() === 'gc') {
|
|
3780
|
+
const mm = getMemoryManager()
|
|
3781
|
+
const { archived, candidates } = mm.gc()
|
|
3782
|
+
const lines: string[] = [t('commands.memories.gc_title'), '']
|
|
3783
|
+
if (archived.length === 0 && candidates.length === 0) {
|
|
3784
|
+
lines.push(t('commands.memories.gc_none'))
|
|
3785
|
+
} else {
|
|
3786
|
+
if (archived.length > 0) {
|
|
3787
|
+
lines.push(t('commands.memories.gc_archived', { count: String(archived.length) }))
|
|
3788
|
+
lines.push(...archived.map((n) => ` - ${n}`))
|
|
3789
|
+
lines.push('')
|
|
3790
|
+
}
|
|
3791
|
+
if (candidates.length > 0) {
|
|
3792
|
+
lines.push(t('commands.memories.gc_candidates', { count: String(candidates.length) }))
|
|
3793
|
+
lines.push(...candidates.map((n) => ` - ${n}`))
|
|
3794
|
+
lines.push('')
|
|
3795
|
+
}
|
|
3796
|
+
lines.push(t('commands.memories.gc_done'))
|
|
3797
|
+
}
|
|
3798
|
+
return { content: lines.join('\n') }
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
// /memory consolidate — 会话记忆合并:把 auto-* 聚簇成持久化 lesson-*
|
|
3802
|
+
if (args[0]?.toLowerCase() === 'consolidate') {
|
|
3803
|
+
const mm = getMemoryManager()
|
|
3804
|
+
const { merged, removed } = mm.consolidateAutoMemories()
|
|
3805
|
+
const lines: string[] = [t('commands.memories.consolidate_title'), '']
|
|
3806
|
+
if (removed === 0) {
|
|
3807
|
+
lines.push(t('commands.memories.consolidate_none'))
|
|
3808
|
+
} else {
|
|
3809
|
+
lines.push(
|
|
3810
|
+
t('commands.memories.consolidate_result', {
|
|
3811
|
+
removed: String(removed),
|
|
3812
|
+
merged: String(merged),
|
|
3813
|
+
}),
|
|
3814
|
+
)
|
|
3815
|
+
}
|
|
3816
|
+
return { content: lines.join('\n') }
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3777
3819
|
if (!existsSync(memoryDir)) {
|
|
3778
3820
|
return { content: t('commands.memories.no_memories') }
|
|
3779
3821
|
}
|