@miphamai/cli 0.66.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/bin/mipham.ts CHANGED
@@ -1070,7 +1070,14 @@ async function runInitCLI(): Promise<boolean> {
1070
1070
  const targetDir = rawTarget ? resolve(rawTarget.replace(/^~/, homedir())) : process.cwd()
1071
1071
 
1072
1072
  const gitInit = !args.includes('--no-git')
1073
- const result = scaffoldProject(targetDir, { gitInit })
1073
+ const full = args.includes('--full')
1074
+ const licenseArg = args.find((a) => a.startsWith('--license='))
1075
+ const licenseVal = licenseArg?.split('=')[1]
1076
+ const license =
1077
+ licenseVal === 'mit' || licenseVal === 'apache' || licenseVal === 'proprietary'
1078
+ ? licenseVal
1079
+ : undefined
1080
+ const result = scaffoldProject(targetDir, { gitInit, full, license })
1074
1081
 
1075
1082
  const lines: string[] = ['', `✅ Mipham Code 项目已初始化:${targetDir}`, '']
1076
1083
  if (result.created.length > 0) {
@@ -1160,6 +1167,9 @@ async function main() {
1160
1167
  Usage:
1161
1168
  mipham Launch interactive CLI
1162
1169
  mipham init [dir] Scaffold a project (CLAUDE.md/MIPHAM.md/README.md)
1170
+ --full Full scaffold: 8-section CLAUDE.md + 7 .md + .github templates
1171
+ --license=<type> mit | apache | proprietary (default: proprietary)
1172
+ --no-git Skip git init
1163
1173
  mipham update Update to the latest version
1164
1174
  mipham upgrade Same as 'mipham update'
1165
1175
  mipham daemon <cmd> Daemon lifecycle (start, stop, status, restart)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.66.0",
3
+ "version": "0.69.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,7 +1,22 @@
1
+ import { realpathSync } from 'node:fs'
1
2
  import type { CredentialMaskingConfig } from '../../shared/index.ts'
2
3
  import { matchCredentialFile } from './matcher'
3
4
  import { CREDENTIAL_SENTINEL } from './types'
4
5
 
6
+ /**
7
+ * Resolve a path through symlinks before deny-rule matching, so a symlink whose
8
+ * name is innocuous but whose target is a sensitive file is still masked —
9
+ * mirroring Read, which matches on the canonical path. Falls back to the raw
10
+ * path when the target doesn't exist (e.g. synthetic paths in tests).
11
+ */
12
+ function resolveForMatch(p: string): string {
13
+ try {
14
+ return realpathSync(p)
15
+ } catch {
16
+ return p
17
+ }
18
+ }
19
+
5
20
  /**
6
21
  * Mask grep/search output: any match line that belongs to a credential file
7
22
  * (e.g. `.env`, `.aws/credentials`) has its content replaced with the
@@ -31,7 +46,9 @@ export function maskSearchOutput(
31
46
  const m = line.match(/^(.+?):(\d+):(.*)$/)
32
47
  if (m) {
33
48
  out.push(
34
- matchCredentialFile(m[1]!, config) ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}` : line,
49
+ matchCredentialFile(resolveForMatch(m[1]!), config)
50
+ ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}`
51
+ : line,
35
52
  )
36
53
  } else {
37
54
  out.push(line)
@@ -49,7 +66,7 @@ export function maskSearchOutput(
49
66
  out.push(line) // blank separator — pass through, keep current file
50
67
  continue
51
68
  }
52
- currentMasked = !!matchCredentialFile(line, config)
69
+ currentMasked = !!matchCredentialFile(resolveForMatch(line), config)
53
70
  out.push(line)
54
71
  }
55
72
 
@@ -68,6 +85,6 @@ export function maskGlobOutput(paths: string, config?: CredentialMaskingConfig):
68
85
  if (!config?.enabled) return paths
69
86
  return paths
70
87
  .split('\n')
71
- .map((p) => (matchCredentialFile(p, config) ? CREDENTIAL_SENTINEL : p))
88
+ .map((p) => (matchCredentialFile(resolveForMatch(p), config) ? CREDENTIAL_SENTINEL : p))
72
89
  .join('\n')
73
90
  }
@@ -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
 
@@ -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
@@ -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 { produceRuleProposal, MANAGED_RULES_FILE } from './crsi-producer'
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
- // Dedup: same name = update, don't create duplicate
80
- const formattedBody = this.formatMemoryBody(metadata, content)
115
+ // 同名 update(replace 语义)
81
116
  const existing = this.memories.get(name)
82
117
  if (existing) {
83
- existing.content = formattedBody
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
- recall(context: string, limit: number = 10): MemoryEntry[] {
116
- const contextLower = context.toLowerCase()
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
- context,
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
- return scored.slice(0, limit).map((s) => s.entry)
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
- buildSystemReminder(context: string, maxTokens?: number): string {
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