pi-okf-memory 0.1.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,376 @@
1
+ /**
2
+ * okf-memory — 会话记忆 → OKF 知识沉淀:dsh 适配层(src/server/index.ts)。
3
+ * pi 侧入口见 src/pi/index.ts;两者共用本目录下的运行时无关核心。
4
+ *
5
+ * 工具:okf_remember / okf_search / okf_read / okf_forget / okf_graph
6
+ * 服务:ctx.okfMemory(root, search, read, write, consolidate, meta, preload, graph)
7
+ * 记忆库:OKF v0.1 bundle,默认 ~/.dsh/memory/(环境变量 OKF_MEMORY_ROOT 可覆盖)
8
+ */
9
+ import path from 'node:path'
10
+ import { promises as fs } from 'node:fs'
11
+ import {
12
+ ensureRoot, writeConcept, readConcept, defaultRoot, scanBundle,
13
+ type WriteResult,
14
+ } from './store.js'
15
+ import { search, type SearchHit } from './dedupe.js'
16
+ import { preload, recall } from './recall.js'
17
+ import { loadMeta, consolidate, rank, startConsolidation, type MemoryMeta } from './learning.js'
18
+ import { MEMORY_DISCIPLINE, RECALL_GUIDE } from './capture.js'
19
+ import { TYPE_VOCAB, type ConceptMeta } from './concept.js'
20
+ import { buildGraph, type GraphData } from './graph.js'
21
+ // 运行时无关的写入/撤回核心(与 pi 适配层共用)
22
+ import {
23
+ rememberCore, forgetCore,
24
+ type RememberArgs, type RememberOpts, type RememberResult, type ForgetResult,
25
+ } from './memory.js'
26
+
27
+ /**
28
+ * defineTool 动态解析:dsh 运行时提供 @deepseek-ai/dsh-tools 时用官方 API,
29
+ * 不可用时降级为透传定义对象(兼容运行时不暴露该包的情况)。
30
+ */
31
+ async function loadDefineTool(): Promise<(def: unknown) => unknown> {
32
+ try {
33
+ const mod = await import('@deepseek-ai/dsh-tools')
34
+ return (mod as { defineTool?: (d: unknown) => unknown }).defineTool || ((def: unknown) => def)
35
+ } catch {
36
+ return (def: unknown) => def
37
+ }
38
+ }
39
+
40
+ export const name = 'okf-memory'
41
+
42
+ export const inject = ['tools', 'systemPrompt']
43
+
44
+ /** 会话启动预取条数 */
45
+ const PRELOAD_LIMIT = 5
46
+
47
+ /** 最小 dsh Cordis 上下文类型(实际以运行时为准;只声明本插件用到的面) */
48
+ export interface DshContext {
49
+ settings?: { okfMemory?: { root?: string } }
50
+ systemPrompt?: { section: (opts: { name: string; order: number; text: string }) => void }
51
+ provide?: (name: string, impl: unknown) => void
52
+ tools: { register: (def: unknown) => void }
53
+ inject?: (services: string[], fn: (scope: Record<string, unknown>) => void) => void
54
+ okfMemory?: unknown
55
+ [key: string]: unknown
56
+ }
57
+
58
+ /** 解析记忆库根目录(优先级:settings > env > 默认) */
59
+ function resolveRoot(ctx: DshContext): string {
60
+ try {
61
+ const s = ctx.settings?.okfMemory?.root
62
+ if (s) return path.resolve(String(s))
63
+ } catch { /* settings API 不可用时忽略 */ }
64
+ return defaultRoot()
65
+ }
66
+
67
+ /**
68
+ * 注入系统提示片段(正确 API:dsh-system-prompt 的 section,字段 name/order/text。
69
+ * 之前误用 add() 静默失效;不能用 register()。失败时静默跳过,不阻塞插件加载)。
70
+ */
71
+ function addPrompt(ctx: DshContext, name: string, content: string, order: number): void {
72
+ try {
73
+ if (ctx.systemPrompt?.section) {
74
+ ctx.systemPrompt.section({ name, order, text: content })
75
+ }
76
+ } catch { /* 无 systemPrompt 扩展点时跳过 */ }
77
+ }
78
+
79
+ /** 把根 index.md 摘要整理成提示片段(模型每轮可见"库里有啥") */
80
+ async function buildIndexPrompt(root: string): Promise<string> {
81
+ try {
82
+ const text = await fs.readFile(path.join(root, 'index.md'), 'utf8')
83
+ const concepts = await scanBundle(root)
84
+ return `记忆库共有 ${concepts.length} 个概念(路径即概念 ID)。库目录:\n${text.slice(0, 3000)}`
85
+ } catch {
86
+ return 'OKF 记忆库为空或不可读。'
87
+ }
88
+ }
89
+
90
+ interface OkfMemoryService {
91
+ root: string
92
+ search: (q: string, opts?: object) => Promise<SearchHit[]>
93
+ read: (id: string) => Promise<unknown>
94
+ write: (meta: ConceptMeta, body: string) => Promise<WriteResult>
95
+ remember: (meta: RememberArgs, body: string, opts?: RememberOpts) => Promise<RememberResult>
96
+ consolidate: () => Promise<MemoryMeta>
97
+ meta: () => Promise<MemoryMeta>
98
+ preload: (query: string, opts?: object) => Promise<unknown>
99
+ graph: (opts?: object) => Promise<GraphData>
100
+ }
101
+
102
+ export async function apply(ctx: DshContext): Promise<() => void> {
103
+ const defineTool = await loadDefineTool()
104
+ const root = resolveRoot(ctx)
105
+ await ensureRoot(root)
106
+
107
+ // ── 服务:ctx.okfMemory(供其他插件/工具消费) ──
108
+ const service: OkfMemoryService = {
109
+ root,
110
+ search: (q, opts) => search(root, q, opts),
111
+ read: (id) => recall(root, id),
112
+ write: (meta, body) => writeConcept(root, meta, body),
113
+ remember: (meta, body, opts) => rememberCore(root, meta, body, opts),
114
+ consolidate: () => consolidate(root),
115
+ meta: () => loadMeta(root),
116
+ preload: (query, opts) => preload(root, query, opts),
117
+ graph: (opts) => buildGraph(root, opts),
118
+ }
119
+ try {
120
+ ctx.provide?.('okfMemory', service)
121
+ } catch {
122
+ ctx.okfMemory = service
123
+ }
124
+ ctx.okfMemory = service
125
+
126
+ // ── Web 路由:/okf-graph 供 client 插件 fetch 记忆图谱 JSON(M2) ──
127
+ // webServer 只在 web profile 存在;声明式 inject 会在 headless 加载失败,
128
+ // 故用 modlens 同款的运行时按需注入 ctx.inject(['webServer'], scope).
129
+ if (typeof ctx.inject === 'function') {
130
+ ctx.inject(['webServer'], (scope) => {
131
+ try {
132
+ const ws = scope.webServer as { register: (opts: { name: string; kind: string; path: string; handler: (req: unknown, res: { writeHead: (s: number, h: Record<string, string>) => void; end: (b: string) => void }) => Promise<void> | void }) => void }
133
+ ws.register({
134
+ name: 'okf-memory-graph',
135
+ kind: 'exact',
136
+ path: '/okf-graph',
137
+ handler: async (_req, res) => {
138
+ try {
139
+ const g = await buildGraph(root)
140
+ res.writeHead(200, { 'content-type': 'application/json' })
141
+ res.end(JSON.stringify(g))
142
+ } catch (e) {
143
+ res.writeHead(500, { 'content-type': 'application/json' })
144
+ res.end(JSON.stringify({ error: String((e as Error).message || e) }))
145
+ }
146
+ },
147
+ })
148
+ } catch {
149
+ /* 无 webServer 时跳过 */
150
+ }
151
+ })
152
+ }
153
+
154
+ // ── 工具 1:okf_remember(核心:概念化→去重→写入→索引) ──
155
+ ctx.tools.register(defineTool({
156
+ name: 'okf_remember',
157
+ description:
158
+ '把一条新知识按 OKF v0.1 规范写入长期记忆库(概念文档 + index/log 更新)。' +
159
+ 'type 词表:Fact/Preference/Decision/Method/Insight/Idea/Lesson/TechChoice。' +
160
+ 'Decision/Insight 正文建议用 # 数据/# 分析/# 结论 三段式;TechChoice 用 # Options 候选表 + # Active。' +
161
+ '写入前自动去重:标题相同则更新/跳过,相近则返回建议。' +
162
+ 'Write a new piece of knowledge into the long-term memory library as an OKF v0.1 concept (updates index/log). ' +
163
+ 'Types: Fact/Preference/Decision/Method/Insight/Idea/Lesson/TechChoice. Deduplicates automatically: same title → update or skip; similar → returns suggestion.',
164
+ parameters: {
165
+ title: { type: 'string', required: true, description: '概念标题(简洁,一句话可懂)' },
166
+ type: { type: 'string', required: true, description: `概念类型,可选:${TYPE_VOCAB.join('/')}` },
167
+ content: { type: 'string', required: true, description: '结构化正文(Markdown,含 # 小节标题)。Decision/Insight 传三段式;TechChoice 传 Options 表与 Active' },
168
+ tags: { type: 'array', items: { type: 'string' }, description: '横切标签' },
169
+ related: { type: 'array', items: { type: 'string' }, description: '相关概念 ID 列表(将互建交叉链接)' },
170
+ },
171
+ output: {
172
+ schema: {
173
+ type: 'object',
174
+ additionalProperties: true,
175
+ properties: {
176
+ status: { type: 'string' },
177
+ conceptId: { type: 'string' },
178
+ filePath: { type: 'string' },
179
+ reason: { type: 'string' },
180
+ },
181
+ },
182
+ render: (_args: unknown, value: RememberResult) => [{
183
+ type: 'text',
184
+ text: value.status === 'error'
185
+ ? `记忆写入失败:${value.reason}`
186
+ : value.status === 'created'
187
+ ? `已沉淀记忆 ${value.conceptId}`
188
+ : value.status === 'updated'
189
+ ? `已更新记忆 ${value.conceptId}`
190
+ : value.status === 'skipped'
191
+ ? `跳过写入:${value.reason}`
192
+ : `记忆写入:${value.status}`,
193
+ }],
194
+ },
195
+ async execute(args: { title: string; type: string; content: string; tags?: string[]; related?: string[] }) {
196
+ try {
197
+ return await rememberCore(root, {
198
+ title: args.title,
199
+ type: args.type,
200
+ tags: args.tags,
201
+ related: args.related,
202
+ }, args.content)
203
+ } catch (e) {
204
+ return { status: 'error', conceptId: null, reason: String((e as Error).message || e) }
205
+ }
206
+ },
207
+ }))
208
+
209
+ // ── 工具 2:okf_search(检索,命中 TechChoice 返回完整候选表) ──
210
+ ctx.tools.register(defineTool({
211
+ name: 'okf_search',
212
+ description:
213
+ '检索 OKF 长期记忆库,按唤起评分(相关度×权重×近因)排序返回概念摘要。' +
214
+ '命中 TechChoice 类型时附加返回完整 Options 候选表,供技术选型三档规则展示。' +
215
+ '写入新记忆前必须先搜索去重。' +
216
+ 'Search the OKF long-term memory library, returning concept summaries ranked by recall score (relevance × weight × recency). ' +
217
+ 'TechChoice hits additionally return the full Options table. Always search before writing new memory.',
218
+ parameters: {
219
+ query: { type: 'string', required: true, description: '检索关键词' },
220
+ type: { type: 'string', description: '按类型过滤(如 TechChoice/Fact/Decision)' },
221
+ tags: { type: 'array', items: { type: 'string' }, description: '按标签过滤' },
222
+ limit: { type: 'number', description: '返回条数,默认 8' },
223
+ },
224
+ output: {
225
+ schema: {
226
+ type: 'object',
227
+ additionalProperties: true,
228
+ properties: {
229
+ count: { type: 'number' },
230
+ results: { type: 'array', items: { type: 'object', additionalProperties: true } },
231
+ },
232
+ },
233
+ render: (_args: unknown, value: { count: number; results: Array<{ conceptId: string; type: string; weight: number; description: string }> }) => [{
234
+ type: 'text',
235
+ text: value.count === 0
236
+ ? '记忆库无匹配。'
237
+ : `检索到 ${value.count} 条记忆:\n` + value.results.map((r) => `- ${r.conceptId} (${r.type}, 权重 ${r.weight}) ${r.description}`).join('\n'),
238
+ }],
239
+ },
240
+ async execute(args: { query: string; type?: string; tags?: string[]; limit?: number }) {
241
+ const raw = await search(root, args.query, {
242
+ type: args.type,
243
+ tags: args.tags,
244
+ limit: Math.min(args.limit || 8, 30),
245
+ })
246
+ const ranked = await rank(root, raw)
247
+ const results: Array<Record<string, unknown>> = []
248
+ for (const h of ranked) {
249
+ const item: Record<string, unknown> = { ...h }
250
+ if (h.type === 'TechChoice') {
251
+ // 附加候选表:读全文 Options 节
252
+ try {
253
+ const { body } = await readConcept(root, h.conceptId)
254
+ const optsMatch = /## Options[\s\S]*?(?=## |$)/.exec(body || '')
255
+ item.options = optsMatch ? optsMatch[0].trim() : null
256
+ } catch { /* 读取失败则不带候选表 */ }
257
+ }
258
+ results.push(item)
259
+ }
260
+ return { count: results.length, results }
261
+ },
262
+ }))
263
+
264
+ // ── 工具 3:okf_read(精读 + 交叉链接 + 命中反馈) ──
265
+ ctx.tools.register(defineTool({
266
+ name: 'okf_read',
267
+ description:
268
+ '读取记忆库中某个概念全文(含交叉链接),并记录一次使用反馈(权重更新)。' +
269
+ 'Read a full concept from the memory library (with cross-links) and record one usage feedback (weight update).',
270
+ parameters: {
271
+ concept_id: { type: 'string', required: true, description: '概念 ID(如 facts/meituan-data-source,可省略 .md)' },
272
+ },
273
+ output: {
274
+ schema: {
275
+ type: 'object',
276
+ additionalProperties: true,
277
+ properties: {
278
+ conceptId: { type: 'string' },
279
+ title: { type: 'string' },
280
+ type: { type: 'string' },
281
+ body: { type: 'string' },
282
+ links: { type: 'array', items: { type: 'object', additionalProperties: true } },
283
+ },
284
+ },
285
+ render: (_args: unknown, value: { title: string; type: string; body: string }) => [{
286
+ type: 'text',
287
+ text: `# ${value.title} (${value.type})\n\n${value.body}`,
288
+ }],
289
+ },
290
+ async execute(args: { concept_id: string }) {
291
+ const id = String(args.concept_id).replace(/\.md$/, '')
292
+ const concept = await recall(root, id)
293
+ return {
294
+ conceptId: concept.conceptId,
295
+ title: concept.meta?.title || id,
296
+ type: concept.meta?.type || '',
297
+ body: concept.body || '',
298
+ links: concept.links || [],
299
+ }
300
+ },
301
+ }))
302
+
303
+ // ── 工具 5:okf_graph(导出记忆图谱 JSON,供可视化前端) ──
304
+ ctx.tools.register(defineTool({
305
+ name: 'okf_graph',
306
+ description:
307
+ '导出记忆库的图谱 JSON:nodes(概念:title/type/tags/weight/state)+edges(交叉链接)+timeline(权重历史)。' +
308
+ '供图谱可视化前端渲染,契约稳定。' +
309
+ 'Export the memory graph JSON: nodes (concepts: title/type/tags/weight/state) + edges (cross-links) + timeline (weight history).',
310
+ parameters: {
311
+ limit: { type: 'number', description: '可选:节点上限,默认全部' },
312
+ },
313
+ output: {
314
+ schema: { type: 'object', additionalProperties: true, properties: { meta: { type: 'object', additionalProperties: true }, nodes: { type: 'array', items: { type: 'object', additionalProperties: true } }, edges: { type: 'array', items: { type: 'object', additionalProperties: true } }, timeline: { type: 'array', items: { type: 'object', additionalProperties: true } } } },
315
+ render: (_args: unknown, value: GraphData) => [{
316
+ type: 'text',
317
+ text: `记忆图谱:${value.nodes?.length || 0} 节点 · ${value.edges?.length || 0} 边 · ${value.timeline?.length || 0} 权重历史`,
318
+ }],
319
+ },
320
+ async execute(args: { limit?: number }) {
321
+ const g = await buildGraph(root)
322
+ if (args.limit && args.limit > 0) g.nodes = g.nodes.slice(0, Math.min(args.limit, 500))
323
+ return { meta: g.meta, nodes: g.nodes, edges: g.edges, timeline: g.timeline }
324
+ },
325
+ }))
326
+
327
+ // ── 工具 4:okf_forget(撤回记忆) ──
328
+ ctx.tools.register(defineTool({
329
+ name: 'okf_forget',
330
+ description:
331
+ '从记忆库索引撤回一条概念(默认保留文件,可从 index/log 追溯;可选删除文件)。' +
332
+ 'Withdraw a concept from the memory library index (keeps the file by default, traceable via index/log; optionally deletes the file).',
333
+ parameters: {
334
+ concept_id: { type: 'string', required: true, description: '概念 ID' },
335
+ delete_file: { type: 'boolean', description: 'true 时同时删除文件(默认 false 仅移出索引)' },
336
+ },
337
+ output: {
338
+ schema: { type: 'object', additionalProperties: true, properties: { status: { type: 'string' }, conceptId: { type: 'string' }, reason: { type: 'string' } } },
339
+ render: (_args: unknown, value: ForgetResult) => [{
340
+ type: 'text',
341
+ text: value.status === 'forgotten'
342
+ ? `已撤回记忆 ${value.conceptId}${value.reason ? `(${value.reason})` : ''}`
343
+ : value.status === 'not_found'
344
+ ? `记忆 ${value.conceptId} 不存在或已撤回`
345
+ : `撤回失败:${value.reason || value.status}`,
346
+ }],
347
+ },
348
+ async execute(args: { concept_id: string; delete_file?: boolean }) {
349
+ const id = String(args.concept_id).replace(/\.md$/, '')
350
+ try {
351
+ return await forgetCore(root, id, args.delete_file === true)
352
+ } catch (e) {
353
+ return { status: 'error', conceptId: id, reason: String((e as Error).message || e) }
354
+ }
355
+ },
356
+ }))
357
+
358
+ // ── 系统提示注入:记忆纪律 + 库摘要 + 召回指引(用正确的 section API) ──
359
+ addPrompt(ctx, 'okf-memory-discipline', MEMORY_DISCIPLINE, 50)
360
+ const indexPrompt = await buildIndexPrompt(root)
361
+ addPrompt(ctx, 'okf-memory-index', indexPrompt, 150)
362
+ addPrompt(ctx, 'okf-memory-recall-guide', RECALL_GUIDE, 160)
363
+
364
+ // ── 预取增强(暂移除) ──
365
+ // 之前用 ctx.on('agent/request', ...) 做会话预取,但 agent/request 是瀑布事件,
366
+ // 监听器必须调用 next() 委托;未正确实现会卡死模型请求链,导致
367
+ // "Cannot read properties of undefined (reading 'provider')"。
368
+ // 预取是增强功能,先移除保稳定;后续按瀑布事件规范(带 next())重新实现。
369
+
370
+ // 卸载时清理:停掉巩固定时器(注册均为 effect-based,自动撤销)
371
+ const stopConsolidation = startConsolidation(root)
372
+ return () => {
373
+ stopConsolidation()
374
+ }
375
+ }
376
+
@@ -0,0 +1,219 @@
1
+ /**
2
+ * learning.ts — 神经自我学习核心:记忆权重元数据、强化反馈回路、巩固与遗忘。
3
+ * 唤起评分 = relevance × weight × recency_factor(相关度 × 历史权重 × 近因)。
4
+ * 元数据存 <root>/.meta/weights.json(点目录,不影响 OKF 符合性)。
5
+ */
6
+ import { promises as fs } from 'node:fs'
7
+ import path from 'node:path'
8
+ import { withLock } from './store.js'
9
+
10
+ /** 学习参数(起步默认值,可随使用校准) */
11
+ export const PARAMS = {
12
+ SELECT_DELTA: 1.0, // 用户选中某候选/概念 → 权重增量
13
+ SKIP_DELTA: 0.5, // 用户跳过/否定 → 权重减量
14
+ HIT_DELTA: 0.1, // 被唤起且使用 → 小幅增量
15
+ MIN_WEIGHT: 0.05, // 权重下限
16
+ MAX_WEIGHT: 10, // 权重上限
17
+ DECAY_DAYS: 30, // 宽限期:距上次「触摸」(被读 / 入表)未超过 N 天不衰减
18
+ DECAY_FACTOR: 0.9, // 每超出宽限期一个 DECAY_DAYS 的衰减系数
19
+ MAX_DECAY_STEPS: 30, // 衰减指数上限(防极端天数把权重一步打到地板)
20
+ ARCHIVE_THRESHOLD: 0.3, // 权重低于该值 → 归档 inactive(不删除,可复活)
21
+ ARCHIVE_RECOVER: 0.6, // 归档后再次被触摸 → 权重至少恢复到该值(防「复活即再归档」抖动)
22
+ CONSOLIDATE_INTERVAL_MS: 24 * 60 * 60 * 1000, // 巩固周期:24h
23
+ } as const
24
+
25
+ export interface WeightEntry {
26
+ weight: number
27
+ accessCount: number
28
+ lastAccessed: string | null
29
+ state: 'active' | 'inactive'
30
+ /** 概念首次进入权重表的时间(从未被读取时的衰减基线;旧数据可能缺失) */
31
+ createdAt?: string
32
+ /** 上次应用衰减时的「距触摸天数」——用于把衰减做成增量式,保证 consolidate 幂等 */
33
+ lastDecayDays?: number
34
+ }
35
+
36
+ export interface MemoryMeta {
37
+ version: number
38
+ updatedAt: string | null
39
+ entries: Record<string, WeightEntry>
40
+ }
41
+
42
+ function metaFile(root: string): string {
43
+ return path.join(root, '.meta', 'weights.json')
44
+ }
45
+
46
+ function emptyMeta(): MemoryMeta {
47
+ return { version: 1, updatedAt: null, entries: {} }
48
+ }
49
+
50
+ async function ensureMetaDir(root: string): Promise<void> {
51
+ await fs.mkdir(path.dirname(metaFile(root)), { recursive: true })
52
+ }
53
+
54
+ /** 加载元数据(不存在则初始化) */
55
+ export async function loadMeta(root: string): Promise<MemoryMeta> {
56
+ await ensureMetaDir(root)
57
+ try {
58
+ const raw = await fs.readFile(metaFile(root), 'utf8')
59
+ const m = JSON.parse(raw) as MemoryMeta
60
+ if (!m.entries) m.entries = {}
61
+ return m
62
+ } catch {
63
+ return emptyMeta()
64
+ }
65
+ }
66
+
67
+ export async function saveMeta(root: string, meta: MemoryMeta): Promise<void> {
68
+ meta.updatedAt = new Date().toISOString()
69
+ await ensureMetaDir(root)
70
+ await fs.writeFile(metaFile(root), JSON.stringify(meta, null, 2), 'utf8')
71
+ }
72
+
73
+ function entryOf(meta: MemoryMeta, conceptId: string): WeightEntry {
74
+ if (!meta.entries[conceptId]) {
75
+ meta.entries[conceptId] = {
76
+ weight: 1.0, accessCount: 0, lastAccessed: null, state: 'active',
77
+ // 记下入表时间作为衰减基线 —— 否则「只被跳过、从未被读」的记忆会因为
78
+ // lastAccessed=null 而被当成无限陈旧,第一次巩固就瞬间衰减+归档
79
+ createdAt: new Date().toISOString(),
80
+ }
81
+ }
82
+ return meta.entries[conceptId]
83
+ }
84
+
85
+ /** 触摸时间基线:读过用 lastAccessed;只入表未读过用 createdAt;两者都缺(旧数据)→ 视为现在(保守,不衰减) */
86
+ function touchedAtMs(e: WeightEntry, now: number): number {
87
+ const t = e.lastAccessed || e.createdAt
88
+ if (!t) return now
89
+ const ms = new Date(t).getTime()
90
+ return Number.isFinite(ms) ? ms : now
91
+ }
92
+
93
+ /** 衰减因子:宽限期内为 1;超出后按 (超出天数 / DECAY_DAYS) 做 DECAY_FACTOR 幂衰减 */
94
+ function decayFactor(elapsedDays: number): number {
95
+ if (elapsedDays <= PARAMS.DECAY_DAYS) return 1
96
+ const over = (elapsedDays - PARAMS.DECAY_DAYS) / PARAMS.DECAY_DAYS
97
+ return Math.pow(PARAMS.DECAY_FACTOR, Math.min(over, PARAMS.MAX_DECAY_STEPS))
98
+ }
99
+
100
+ /** 交互反馈:用户选中(如 TechChoice 候选被拍板);写锁内串行防权重丢失 */
101
+ export async function recordSelect(root: string, conceptId: string, delta: number = PARAMS.SELECT_DELTA): Promise<number> {
102
+ return withLock(async () => {
103
+ const meta = await loadMeta(root)
104
+ const e = entryOf(meta, conceptId)
105
+ e.weight = Math.min(PARAMS.MAX_WEIGHT, e.weight + delta)
106
+ e.accessCount += 1
107
+ e.lastAccessed = new Date().toISOString()
108
+ // 触摸后衰减基线归零:下次从宽限期重新起算
109
+ e.lastDecayDays = 0
110
+ if (e.state === 'inactive') {
111
+ // 复活:权重至少抬到 ARCHIVE_RECOVER —— 否则 okf_read 只 +0.1,
112
+ // 复活后仍低于归档线,下次巩固立刻再归档(来回抖动)
113
+ e.weight = Math.max(e.weight, PARAMS.ARCHIVE_RECOVER)
114
+ e.state = 'active'
115
+ }
116
+ await saveMeta(root, meta)
117
+ return e.weight
118
+ })
119
+ }
120
+
121
+ /** 交互反馈:用户跳过/否定;写锁内串行防权重丢失 */
122
+ export async function recordSkip(root: string, conceptId: string, delta: number = PARAMS.SKIP_DELTA): Promise<number> {
123
+ return withLock(async () => {
124
+ const meta = await loadMeta(root)
125
+ const e = entryOf(meta, conceptId)
126
+ e.weight = Math.max(PARAMS.MIN_WEIGHT, e.weight - delta)
127
+ await saveMeta(root, meta)
128
+ return e.weight
129
+ })
130
+ }
131
+
132
+ /** 交互反馈:被唤起且被使用 */
133
+ export async function recordHit(root: string, conceptId: string): Promise<number> {
134
+ return recordSelect(root, conceptId, PARAMS.HIT_DELTA)
135
+ }
136
+
137
+ /**
138
+ * 巩固:增量衰减 + 阈值归档(不删除,可复活);写锁内串行。
139
+ *
140
+ * 幂等性:衰减按「本次因子 / 上次已应用的因子」增量施加。两次调用之间 elapsedDays
141
+ * 不变时比值为 1,所以时间没流逝就不会重复扣血 —— 这正是旧实现的问题:
142
+ * 它每次都把 factor 乘到已衰减的权重上,连调 N 次就衰减 N 次(24h 定时器 + 每次
143
+ * 重启/reload 多跑一次即反复扣血)。
144
+ */
145
+ export async function consolidate(root: string): Promise<MemoryMeta> {
146
+ return withLock(async () => {
147
+ const meta = await loadMeta(root)
148
+ const now = Date.now()
149
+ let changed = false
150
+ for (const e of Object.values(meta.entries)) {
151
+ const elapsedDays = (now - touchedAtMs(e, now)) / 86400000
152
+ if (elapsedDays > PARAMS.DECAY_DAYS) {
153
+ const prev = typeof e.lastDecayDays === 'number' ? e.lastDecayDays : PARAMS.DECAY_DAYS
154
+ const ratio = Math.min(1, decayFactor(elapsedDays) / decayFactor(prev))
155
+ if (ratio < 1) {
156
+ e.weight = Math.max(PARAMS.MIN_WEIGHT, e.weight * ratio)
157
+ changed = true
158
+ }
159
+ if (e.lastDecayDays !== elapsedDays) {
160
+ e.lastDecayDays = elapsedDays
161
+ changed = true
162
+ }
163
+ }
164
+ if (e.state === 'active' && e.weight < PARAMS.ARCHIVE_THRESHOLD) {
165
+ e.state = 'inactive'
166
+ changed = true
167
+ }
168
+ }
169
+ if (changed) await saveMeta(root, meta)
170
+ return meta
171
+ })
172
+ }
173
+
174
+ /**
175
+ * 启动巩固定时器:每 intervalMs 对记忆库做一次巩固(衰减+归档),返回停止函数。
176
+ * 定时器 unref(不阻止进程退出);内部吞异常,单次失败不中断进程。
177
+ */
178
+ export function startConsolidation(root: string, intervalMs: number = PARAMS.CONSOLIDATE_INTERVAL_MS): () => void {
179
+ const id = setInterval(() => {
180
+ consolidate(root).catch(() => {})
181
+ }, intervalMs)
182
+ if (typeof id.unref === 'function') id.unref()
183
+ return () => clearInterval(id)
184
+ }
185
+
186
+ /**
187
+ * 唤起评分:relevance × weight × recency_factor。
188
+ */
189
+ export function recallScore(relevance: number, weight: number = 1.0, lastAccessed: string | null = null): number {
190
+ let recency = 1.0
191
+ if (lastAccessed) {
192
+ const days = (Date.now() - new Date(lastAccessed).getTime()) / 86400000
193
+ recency = Math.max(0.4, 1 / (1 + days / 30))
194
+ }
195
+ return relevance * weight * recency
196
+ }
197
+
198
+ /**
199
+ * 可排序命中项:只要带 conceptId 与 score 即可。
200
+ * 不要求索引签名 —— 否则 SearchHit 这类具体接口会被判为不可赋值。
201
+ */
202
+ export interface RankableHit {
203
+ conceptId: string
204
+ score: number
205
+ }
206
+
207
+ /** 把检索结果与学习权重合并,按唤起评分排序 */
208
+ export async function rank<T extends RankableHit>(root: string, searchHits: T[]): Promise<Array<T & { weight: number; state: string; score: number }>> {
209
+ const meta = await loadMeta(root)
210
+ const ranked = searchHits.map((h) => {
211
+ const e = meta.entries[h.conceptId]
212
+ const weight = e ? e.weight : 1.0
213
+ const state = e ? e.state : 'active'
214
+ const score = recallScore(h.score, weight, e ? e.lastAccessed : null)
215
+ return { ...h, weight: +weight.toFixed(2), state, score: +score.toFixed(3) }
216
+ })
217
+ ranked.sort((a, b) => b.score - a.score)
218
+ return ranked
219
+ }