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