@miphamai/cli 0.68.0 → 0.70.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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `/fix` — deterministic self-repair (no LLM). Orchestrates three repair targets:
3
+ * - doctor: write derivable CLAUDE.md sections into `prompt-exclude` frontmatter
4
+ * - config: restore a corrupted config.yml from backup, re-enable disabled hooks
5
+ * - cache: detect corrupt JSONL state lines under ~/.mipham (dry-run by default)
6
+ *
7
+ * The pure decision functions live here for testability; file I/O and engine calls
8
+ * are injected so each target is testable without touching the real filesystem.
9
+ */
10
+ import { findDerivableSections } from './claude-md-audit'
11
+ import { applyPromptExclude } from './claude-md-fix'
12
+
13
+ export interface DoctorFix {
14
+ content: string
15
+ added: string[]
16
+ }
17
+
18
+ /**
19
+ * Compute the repaired content for one CLAUDE.md document, or null when there is
20
+ * nothing to change (no derivable sections, or all of them already excluded).
21
+ */
22
+ export function computeDoctorFix(content: string): DoctorFix | null {
23
+ const sections = findDerivableSections(content)
24
+ if (sections.length === 0) return null
25
+ const { content: fixed, added } = applyPromptExclude(
26
+ content,
27
+ sections.map((s) => s.heading),
28
+ )
29
+ return added.length > 0 ? { content: fixed, added } : null
30
+ }
31
+
32
+ /**
33
+ * Return the 0-based line numbers of a JSONL document whose non-blank lines are not
34
+ * valid JSON. Blank/whitespace-only lines are ignored.
35
+ */
36
+ export function findCorruptJsonlLines(content: string): number[] {
37
+ const corrupt: number[] = []
38
+ content.split('\n').forEach((line, i) => {
39
+ const trimmed = line.trim()
40
+ if (trimmed === '') return
41
+ try {
42
+ JSON.parse(trimmed)
43
+ } catch {
44
+ corrupt.push(i)
45
+ }
46
+ })
47
+ return corrupt
48
+ }
49
+
50
+ /** Drop the given line numbers from a document, keeping everything else verbatim. */
51
+ export function removeCorruptLines(content: string, corrupt: number[]): string {
52
+ const corruptSet = new Set(corrupt)
53
+ return content
54
+ .split('\n')
55
+ .filter((_, i) => !corruptSet.has(i))
56
+ .join('\n')
57
+ }
58
+
59
+ /**
60
+ * Select the CLAUDE.md files that live inside the current repository (project and
61
+ * directory levels), excluding group/company/user policy files outside the repo.
62
+ */
63
+ export function selectRepoClaudeFiles(
64
+ files: Array<{ path: string; level: string }>,
65
+ ): Array<{ path: string }> {
66
+ return files
67
+ .filter(
68
+ (f) => f.path.endsWith('CLAUDE.md') && (f.level === 'project' || f.level === 'directory'),
69
+ )
70
+ .map((f) => ({ path: f.path }))
71
+ }
72
+
73
+ export interface DoctorReport {
74
+ fixed: Array<{ path: string; added: string[] }>
75
+ }
76
+
77
+ /** Read each CLAUDE.md, apply the derivable-section fix, and write it back. */
78
+ export function fixDoctor(
79
+ files: Array<{ path: string }>,
80
+ io: { read: (path: string) => string | null; write: (path: string, content: string) => void },
81
+ ): DoctorReport {
82
+ const fixed: DoctorReport['fixed'] = []
83
+ for (const f of files) {
84
+ const raw = io.read(f.path)
85
+ if (raw === null) continue
86
+ const result = computeDoctorFix(raw)
87
+ if (result) {
88
+ io.write(f.path, result.content)
89
+ fixed.push({ path: f.path, added: result.added })
90
+ }
91
+ }
92
+ return { fixed }
93
+ }
94
+
95
+ export interface ConfigFixReport {
96
+ /** Config files whose YAML failed to parse (detected). */
97
+ corruptConfigs: string[]
98
+ /** Corrupt config files actually restored from backup. */
99
+ restoredConfigs: string[]
100
+ /** Hooks currently disabled (detected). */
101
+ disabledHooks: string[]
102
+ /** Disabled hooks actually re-enabled. */
103
+ reenabledHooks: string[]
104
+ }
105
+
106
+ /**
107
+ * Detect corrupt config.yml files and disabled hooks. Repairs (restore from
108
+ * backup / re-enable) only happen when `dryRun` is false; in dry-run the report
109
+ * still lists what was detected without mutating anything.
110
+ */
111
+ export function fixConfig(deps: {
112
+ configPaths: string[]
113
+ read: (path: string) => string | null
114
+ parseYaml: (raw: string) => unknown
115
+ restore: (path: string) => boolean
116
+ hookHealth: () => Array<{ key: string; disabled: boolean }>
117
+ reEnableHook: (key: string) => boolean
118
+ dryRun?: boolean
119
+ }): ConfigFixReport {
120
+ const report: ConfigFixReport = {
121
+ corruptConfigs: [],
122
+ restoredConfigs: [],
123
+ disabledHooks: [],
124
+ reenabledHooks: [],
125
+ }
126
+
127
+ for (const p of deps.configPaths) {
128
+ const raw = deps.read(p)
129
+ if (raw === null) continue
130
+ let corrupt = false
131
+ try {
132
+ deps.parseYaml(raw)
133
+ } catch {
134
+ corrupt = true
135
+ }
136
+ if (!corrupt) continue
137
+ report.corruptConfigs.push(p)
138
+ if (!deps.dryRun && deps.restore(p)) report.restoredConfigs.push(p)
139
+ }
140
+
141
+ for (const h of deps.hookHealth()) {
142
+ if (!h.disabled) continue
143
+ report.disabledHooks.push(h.key)
144
+ if (!deps.dryRun && deps.reEnableHook(h.key)) report.reenabledHooks.push(h.key)
145
+ }
146
+ return report
147
+ }
148
+
149
+ export interface CacheFixReport {
150
+ files: Array<{ path: string; corruptLines: number[] }>
151
+ cleaned: Array<{ path: string; removed: number }>
152
+ }
153
+
154
+ /**
155
+ * Scan JSONL state files for corrupt lines. Reports them always; only rewrites the
156
+ * file (dropping corrupt lines) when `apply` is true.
157
+ */
158
+ export function fixCache(
159
+ files: string[],
160
+ io: { read: (path: string) => string | null; write: (path: string, content: string) => void },
161
+ apply: boolean,
162
+ ): CacheFixReport {
163
+ const report: CacheFixReport = { files: [], cleaned: [] }
164
+ for (const path of files) {
165
+ const raw = io.read(path)
166
+ if (raw === null) continue
167
+ const corrupt = findCorruptJsonlLines(raw)
168
+ if (corrupt.length === 0) continue
169
+ report.files.push({ path, corruptLines: corrupt })
170
+ if (apply) {
171
+ io.write(path, removeCorruptLines(raw, corrupt))
172
+ report.cleaned.push({ path, removed: corrupt.length })
173
+ }
174
+ }
175
+ return report
176
+ }
@@ -1,22 +1,27 @@
1
1
  import type { HookConfig, HookEvent, HookDefinition, HookContext } from '../shared/index.ts'
2
2
  import { executeHook } from './hooks-executor'
3
3
 
4
- interface HookConfigEntry {
4
+ export interface HookConfigEntry {
5
5
  matcher: string
6
6
  hooks: HookConfig[]
7
7
  }
8
8
 
9
- interface SettingsHooks {
9
+ /** `settings.json` `hooks` section — one matcher-group list per event. */
10
+ export interface SettingsHooks {
10
11
  PreToolUse?: HookConfigEntry[]
11
12
  PostToolUse?: HookConfigEntry[]
13
+ PostToolUseFailure?: HookConfigEntry[]
14
+ SessionStart?: HookConfigEntry[]
15
+ SessionEnd?: HookConfigEntry[]
16
+ Notification?: HookConfigEntry[]
12
17
  Stop?: HookConfigEntry[]
13
18
  UserPromptSubmit?: HookConfigEntry[]
14
19
  PreCompact?: HookConfigEntry[]
15
20
  PostCompact?: HookConfigEntry[]
16
- SessionStart?: HookConfigEntry[]
17
- SessionEnd?: HookConfigEntry[]
18
- Notification?: HookConfigEntry[]
19
21
  ConfigChange?: HookConfigEntry[]
22
+ SubagentStart?: HookConfigEntry[]
23
+ SubagentStop?: HookConfigEntry[]
24
+ PreInference?: HookConfigEntry[]
20
25
  }
21
26
 
22
27
  /**
@@ -30,22 +30,101 @@ function substituteVars(template: string, ctx: HookContext): string {
30
30
  .replace(/\$SESSION_ID/g, ctx.sessionId)
31
31
  }
32
32
 
33
+ /**
34
+ * Build the Claude Code protocol stdin JSON for a hook script. Mirrors the
35
+ * fields Claude Code passes (session_id / hook_event_name / cwd / tool_name /
36
+ * tool_input / tool_response) so hand-written Claude hooks can migrate
37
+ * unchanged.
38
+ */
39
+ export function buildHookStdin(ctx: HookContext, cwd: string): Record<string, unknown> {
40
+ const payload: Record<string, unknown> = {
41
+ session_id: ctx.sessionId,
42
+ hook_event_name: ctx.event,
43
+ cwd,
44
+ }
45
+ if (ctx.toolName) payload.tool_name = ctx.toolName
46
+ if (ctx.toolInput) payload.tool_input = ctx.toolInput
47
+ if (ctx.toolResult) payload.tool_response = ctx.toolResult
48
+ return payload
49
+ }
50
+
51
+ /**
52
+ * Parse a hook script's stdout JSON into a HookResult, following the Claude
53
+ * Code output contract. Supports the modern `hookSpecificOutput` carrier
54
+ * (permissionDecision / updatedInput / additionalContext) plus the legacy
55
+ * root-level `decision` and `continue` fields. Non-JSON or empty stdout = allow.
56
+ */
57
+ export function parseHookStdout(stdout: string | null | undefined, _ctx: HookContext): HookResult {
58
+ if (!stdout) return { allowed: true }
59
+
60
+ let parsed: Record<string, unknown>
61
+ try {
62
+ parsed = JSON.parse(stdout) as Record<string, unknown>
63
+ } catch {
64
+ return { allowed: true }
65
+ }
66
+
67
+ const hso = parsed.hookSpecificOutput as Record<string, unknown> | undefined
68
+ if (hso) {
69
+ const decision = hso.permissionDecision as string | undefined
70
+ const reason = hso.permissionDecisionReason as string | undefined
71
+ const additionalContext = hso.additionalContext as string | undefined
72
+ const updatedInput = hso.updatedInput as Record<string, unknown> | undefined
73
+
74
+ if (decision === 'deny') {
75
+ return { allowed: false, reason: reason ?? 'Denied by hook', additionalContext }
76
+ }
77
+ if (decision === 'allow') {
78
+ return {
79
+ allowed: true,
80
+ permissionDecision: 'allow',
81
+ modifiedInput: updatedInput,
82
+ additionalContext,
83
+ }
84
+ }
85
+ if (decision === 'ask') {
86
+ return { allowed: true, permissionDecision: 'ask', additionalContext }
87
+ }
88
+ if (decision === 'defer') {
89
+ return { allowed: true, permissionDecision: 'defer', additionalContext }
90
+ }
91
+ if (additionalContext) {
92
+ return { allowed: true, additionalContext }
93
+ }
94
+ }
95
+
96
+ // Legacy root-level decision: block / approve
97
+ if (parsed.decision === 'block') {
98
+ return { allowed: false, reason: (parsed.reason as string) ?? 'Blocked by hook' }
99
+ }
100
+
101
+ // Stop-style events: continue:false
102
+ if (parsed.continue === false) {
103
+ return { allowed: false, reason: (parsed.stopReason as string) ?? 'Stopped by hook' }
104
+ }
105
+
106
+ return { allowed: true }
107
+ }
108
+
33
109
  function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
34
110
  if (!cfg.command) return { allowed: true }
35
111
 
36
112
  try {
37
113
  const args = cfg.args ? cfg.args.map((a) => substituteVars(a, ctx)) : []
38
114
 
39
- // Use spawnSync with array args — no shell, no command injection
115
+ // Use spawnSync with array args — no shell, no command injection.
116
+ // Pass the Claude-protocol stdin JSON so scripts can read structured context.
117
+ const input = JSON.stringify(buildHookStdin(ctx, process.cwd()))
40
118
  const result = spawnSync(cfg.command, args, {
41
- timeout: 30_000,
119
+ timeout: (cfg.timeout ?? 60) * 1000,
42
120
  encoding: 'utf-8',
43
- stdio: ['ignore', 'pipe', 'pipe'],
121
+ stdio: ['pipe', 'pipe', 'pipe'],
122
+ input,
44
123
  })
45
124
 
46
- // Exit code 0 = success, allow
125
+ // Exit code 0 = success — parse the stdout JSON for structured decisions.
47
126
  if (result.status === 0) {
48
- return { allowed: true }
127
+ return parseHookStdout(result.stdout, ctx)
49
128
  }
50
129
 
51
130
  // Non-zero exit: check for block signal (exit code 2)
@@ -11,12 +11,12 @@ import {
11
11
  type CrsiLessonSummary,
12
12
  } from './crsi-producer'
13
13
 
14
- interface FrontmatterResult {
14
+ export interface FrontmatterResult {
15
15
  data: Record<string, unknown>
16
16
  content: string
17
17
  }
18
18
 
19
- function parseFrontmatter(raw: string): FrontmatterResult {
19
+ export function parseFrontmatter(raw: string): FrontmatterResult {
20
20
  // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
21
21
  // BOM-prefixed file is silently treated as body text (effectively ignored).
22
22
  const src = raw.replace(/^\uFEFF/, '')
@@ -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