@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.
- package/package.json +1 -1
- package/src/commands/loop-scaffold.ts +15 -44
- package/src/config/loader.ts +56 -1
- package/src/core/claude-md-fix.ts +28 -0
- 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/fix-code.ts +167 -0
- package/src/core/fix.ts +176 -0
- package/src/core/hooks-config.ts +10 -5
- package/src/core/hooks-executor.ts +84 -5
- package/src/core/instructions.ts +2 -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 +30 -2
- package/src/i18n-core/locales/zh-CN.json +30 -2
- package/src/index.tsx +12 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/community-registry.json +16 -0
- package/src/skills/marketplace.ts +233 -0
- package/src/skills/registry.ts +77 -63
- package/src/tools/agent/memory.ts +5 -1
- package/src/tools/exec/task.ts +21 -2
- package/src/ui/commands.ts +354 -34
package/src/core/fix.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/hooks-config.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
119
|
+
timeout: (cfg.timeout ?? 60) * 1000,
|
|
42
120
|
encoding: 'utf-8',
|
|
43
|
-
stdio: ['
|
|
121
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
|
+
input,
|
|
44
123
|
})
|
|
45
124
|
|
|
46
|
-
// Exit code 0 = success
|
|
125
|
+
// Exit code 0 = success — parse the stdout JSON for structured decisions.
|
|
47
126
|
if (result.status === 0) {
|
|
48
|
-
return
|
|
127
|
+
return parseHookStdout(result.stdout, ctx)
|
|
49
128
|
}
|
|
50
129
|
|
|
51
130
|
// Non-zero exit: check for block signal (exit code 2)
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|