@kenz1117/dsh-engram 0.2.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.mjs ADDED
@@ -0,0 +1,1577 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import z from "@deepseek-ai/schemastery";
6
+ import { randomUUID } from "node:crypto";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ //#region src/config.ts
9
+ /**
10
+ * 插件 Config:所有部署可变项集中于此,禁止在实现里内嵌默认值。
11
+ * @module @kenz1117/dsh-engram/config
12
+ */
13
+ /** 合法配置键集合(未知键 loud 失败)。 */
14
+ const CONFIG_KEYS = /* @__PURE__ */ new Set([
15
+ "dbDir",
16
+ "injectProfile",
17
+ "profileTopN",
18
+ "modelCacheDir",
19
+ "hfEndpoint",
20
+ "ingest",
21
+ "provider",
22
+ "model",
23
+ "decayAfterDays",
24
+ "decayImportanceBelow"
25
+ ]);
26
+ const INGEST_MODES = /* @__PURE__ */ new Set([
27
+ "off",
28
+ "light",
29
+ "eager"
30
+ ]);
31
+ /** Schemastery 校验面(cordis.yml 读取时校验)。 */
32
+ const Config = z.object({
33
+ dbDir: z.string(),
34
+ injectProfile: z.boolean(),
35
+ profileTopN: z.number().step(1).min(1).max(64),
36
+ modelCacheDir: z.string(),
37
+ hfEndpoint: z.string(),
38
+ ingest: z.string(),
39
+ provider: z.string(),
40
+ model: z.string(),
41
+ decayAfterDays: z.number().step(1).min(1).max(3650),
42
+ decayImportanceBelow: z.number().min(0).max(1)
43
+ });
44
+ /**
45
+ * 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
46
+ * @param config - cordis.yml 传入的未校验配置。
47
+ * @returns 完整解析配置。
48
+ * @throws 未知键、ingest 档位非法、provider/model 只给其一、decay 参数越界时抛错。
49
+ */
50
+ function resolveConfig(config = {}) {
51
+ for (const key of Object.keys(config)) if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-engram: unknown config key "${key}"`);
52
+ if (config.ingest !== void 0 && !INGEST_MODES.has(config.ingest)) throw new Error(`dsh-engram: ingest must be one of off|light|eager, got "${String(config.ingest)}"`);
53
+ if (config.profileTopN !== void 0 && (!Number.isInteger(config.profileTopN) || config.profileTopN < 1 || config.profileTopN > 64)) throw new Error("dsh-engram: profileTopN must be an integer in [1, 64]");
54
+ const hasProvider = config.provider !== void 0;
55
+ const hasModel = config.model !== void 0;
56
+ if (hasProvider !== hasModel) throw new Error("dsh-engram: provider and model must be supplied together");
57
+ if (config.decayAfterDays !== void 0 && (!Number.isInteger(config.decayAfterDays) || config.decayAfterDays < 1 || config.decayAfterDays > 3650)) throw new Error("dsh-engram: decayAfterDays must be an integer in [1, 3650]");
58
+ if (config.decayImportanceBelow !== void 0 && (config.decayImportanceBelow < 0 || config.decayImportanceBelow > 1)) throw new Error("dsh-engram: decayImportanceBelow must be in [0, 1]");
59
+ const dbDir = config.dbDir ?? join(homedir(), ".dsh", "engram");
60
+ return {
61
+ dbDir,
62
+ injectProfile: config.injectProfile ?? true,
63
+ profileTopN: config.profileTopN ?? 8,
64
+ modelCacheDir: config.modelCacheDir ?? join(dbDir, "models"),
65
+ hfEndpoint: config.hfEndpoint,
66
+ ingest: config.ingest ?? "off",
67
+ routeOverride: hasProvider && hasModel ? {
68
+ provider: config.provider,
69
+ model: config.model
70
+ } : void 0,
71
+ decayAfterDays: config.decayAfterDays ?? 30,
72
+ decayImportanceBelow: config.decayImportanceBelow ?? .3
73
+ };
74
+ }
75
+ //#endregion
76
+ //#region src/types.ts
77
+ /** 从任意字符串铸造品牌化 id(存储层入库前调用)。 */
78
+ function asMemoryId(raw) {
79
+ return raw;
80
+ }
81
+ /** dsh-engram 统一错误:加载/使用期的可诊断失败都抛此类型。 */
82
+ var EngramError = class extends Error {
83
+ /** 机器可读原因码,如 EMBEDDER_DOWNLOAD_FAILED / SCHEMA_INCOMPATIBLE。 */
84
+ code;
85
+ constructor(code, message, options) {
86
+ super(message, options);
87
+ this.name = "EngramError";
88
+ this.code = code;
89
+ }
90
+ };
91
+ //#endregion
92
+ //#region src/embedder/local.ts
93
+ /**
94
+ * transformers.js 本地嵌入实现:Xenova/bge-small-zh-v1.5,量化权重(q8),离线推理。
95
+ * 模型首次使用需联网下载到 cacheDir;之后完全离线,数据不出机。
96
+ * @module @kenz1117/dsh-engram/embedder/local
97
+ */
98
+ /** 模型标识(bge 中文小模型,512 维输出)。 */
99
+ const ENGRAM_MODEL_ID = "Xenova/bge-small-zh-v1.5";
100
+ /** 进程内单例:重复调用 createLocalEmbedder 共享同一模型实例,避免重复加载权重。 */
101
+ let cached;
102
+ /**
103
+ * 创建本地嵌入器。
104
+ * @param cacheDir - 模型缓存目录;不存在会自动创建(0o700)。
105
+ * @param remoteHost - 模型下载端点;默认 huggingface.co,网络受限环境可配镜像(如 https://hf-mirror.com)。
106
+ * @returns 就绪的 EngramEmbedder。
107
+ * @throws EngramError(code=EMBEDDER_DOWNLOAD_FAILED) 模型下载/加载失败(调用方据此降级)。
108
+ */
109
+ async function createLocalEmbedder(cacheDir, remoteHost) {
110
+ if (cached?.dir === cacheDir) return cached.embedder;
111
+ await mkdir(cacheDir, {
112
+ recursive: true,
113
+ mode: 448
114
+ });
115
+ try {
116
+ const { pipeline, env } = await import("@huggingface/transformers");
117
+ env.cacheDir = cacheDir;
118
+ if (remoteHost !== void 0 && remoteHost !== "") env.remoteHost = remoteHost;
119
+ const extractor = await pipeline("feature-extraction", ENGRAM_MODEL_ID, { dtype: "q8" });
120
+ const embedder = {
121
+ model: ENGRAM_MODEL_ID,
122
+ async embed(texts) {
123
+ if (texts.length === 0) return [];
124
+ return (await extractor([...texts], {
125
+ pooling: "mean",
126
+ normalize: true
127
+ })).tolist().map((list) => new Float32Array(list));
128
+ },
129
+ close: async () => {
130
+ if (cached?.embedder === embedder) cached = void 0;
131
+ }
132
+ };
133
+ cached = {
134
+ dir: cacheDir,
135
+ embedder
136
+ };
137
+ return embedder;
138
+ } catch (error) {
139
+ throw new EngramError("EMBEDDER_DOWNLOAD_FAILED", "嵌入模型下载/加载失败;检索已降级为纯关键词模式,可检查网络后重试", { cause: error });
140
+ }
141
+ }
142
+ //#endregion
143
+ //#region src/llm/client.ts
144
+ /**
145
+ * 辅助 LLM 调用客户端:封装 ctx.llm.stream + BlockAssembler,路由从会话日志解析。
146
+ * 遵守 model-visible ⟺ logged:每次辅助调用的完整请求由调用方 append 到会话日志。
147
+ * @module @kenz1117/dsh-engram/llm/client
148
+ */
149
+ /** 从会话事件流解析最近一条模型路由(request/header → header.config)。 */
150
+ function routeFromEvents(events) {
151
+ for (let i = events.length - 1; i >= 0; i--) {
152
+ const event = events[i];
153
+ if (event?.type !== "request/header") continue;
154
+ const config = event.data?.header?.config;
155
+ if (typeof config?.provider === "string" && typeof config?.model === "string" && config.provider !== "" && config.model !== "") return {
156
+ provider: config.provider,
157
+ model: config.model
158
+ };
159
+ }
160
+ }
161
+ /** 从模型输出提取 JSON 数组:剥 Markdown 代码栅栏后解析;无有效数组返回 undefined。 */
162
+ function parseJsonArray(text) {
163
+ const candidate = (/```(?:json)?\s*([\s\S]*?)```/.exec(text)?.[1] ?? text).trim();
164
+ const start = candidate.indexOf("[");
165
+ const end = candidate.lastIndexOf("]");
166
+ if (start < 0 || end <= start) return void 0;
167
+ try {
168
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
169
+ return Array.isArray(parsed) ? parsed : void 0;
170
+ } catch {
171
+ return;
172
+ }
173
+ }
174
+ /** 终止原因转错误(session-title-llm 同款语义)。 */
175
+ function finishError(finish) {
176
+ switch (finish.kind) {
177
+ case "stop": return;
178
+ case "error":
179
+ case "aborted": {
180
+ const error = new Error(finish.failure.message);
181
+ error.code = finish.failure.code;
182
+ return error;
183
+ }
184
+ case "max-tokens": return /* @__PURE__ */ new Error("dsh-engram: 辅助调用输出达到 maxTokens 上限");
185
+ case "tool-calls": return /* @__PURE__ */ new Error("dsh-engram: 辅助调用意外请求工具");
186
+ default: return /* @__PURE__ */ new Error(`dsh-engram: 不支持的终止原因 "${String(finish.kind)}"`);
187
+ }
188
+ }
189
+ /**
190
+ * 一次辅助 LLM 调用:流式收集文本输出。
191
+ * @param ctx - 提供 llm 服务的上下文。
192
+ * @param params.route - 模型路由(来自日志解析或显式配置)。
193
+ * @param params.system - 系统指令。
194
+ * @param params.userText - 用户侧 JSON 框定输入。
195
+ * @param params.sessionId - 归属会话 id 字符串(调用方来自 agent.session.id,内部转 branded)。
196
+ * @param params.maxTokens - 输出上限。
197
+ * @param params.purpose - 用途标记(宿主 purpose 枚举未开放第三方注册,运行时经 cast 传入,
198
+ * token-meter 归因由 sessionId/provider/model 承载)。
199
+ * @param params.signal - 取消信号。
200
+ * @returns 模型输出全文。
201
+ */
202
+ async function streamText(ctx, params) {
203
+ const messages = [{
204
+ role: "user",
205
+ content: [{
206
+ type: "text",
207
+ text: params.userText
208
+ }],
209
+ source: {
210
+ kind: "plugin",
211
+ plugin: "dsh-engram"
212
+ }
213
+ }];
214
+ const options = {
215
+ provider: params.route.provider,
216
+ model: params.route.model,
217
+ messages,
218
+ system: params.system,
219
+ maxTokens: params.maxTokens,
220
+ sessionId: params.sessionId,
221
+ purpose: params.purpose,
222
+ signal: params.signal
223
+ };
224
+ const assembler = new BlockAssembler();
225
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
226
+ const terminalError = finishError(assembler.finish);
227
+ if (terminalError !== void 0) throw terminalError;
228
+ const text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("");
229
+ if (text.trim() === "") throw new EngramError("LLM_EMPTY_OUTPUT", "辅助调用返回空输出");
230
+ return text;
231
+ }
232
+ //#endregion
233
+ //#region src/ingest/hook.ts
234
+ /** 档位参数(协议内常量,非部署 tunables)。 */
235
+ const MODE_LIMITS = {
236
+ light: {
237
+ maxCandidates: 2,
238
+ confidence: .3,
239
+ includeAssistant: false
240
+ },
241
+ eager: {
242
+ maxCandidates: 5,
243
+ confidence: .4,
244
+ includeAssistant: true
245
+ }
246
+ };
247
+ /** 摄取输出 token 上限(提取 JSON 数组,短输出足够)。 */
248
+ const INGEST_MAX_TOKENS = 600;
249
+ const INGEST_SYSTEM = [
250
+ "从对话记录中提取值得跨会话长期记住的用户信息(事实/偏好/决策/经历/做事方法)。",
251
+ "只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"importance\": 0到1的小数}。",
252
+ "只提取明确、可复用的信息;寒暄、临时上下文、你自己的回答不要提取。没有值得记的就输出 []。",
253
+ "不要输出 JSON 以外的任何内容。"
254
+ ].join("\n");
255
+ /** 从事件里按类型收集文本块,跳过插件注入的 user 快照(它们不是用户说的话)。 */
256
+ function collectTexts(events, includeAssistant) {
257
+ const texts = [];
258
+ let minSeq = null;
259
+ for (const event of events) if (event.type === "user/message") {
260
+ const data = event.data;
261
+ if (data?.source?.kind === "plugin") continue;
262
+ const segments = (data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
263
+ if (segments.length > 0) {
264
+ texts.push(...segments);
265
+ if (event.seq !== void 0 && (minSeq === null || event.seq < minSeq)) minSeq = event.seq;
266
+ }
267
+ } else if (includeAssistant && event.type === "assistant/message") {
268
+ const segments = (event.data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
269
+ if (segments.length > 0) texts.push(...segments);
270
+ }
271
+ return {
272
+ texts,
273
+ minSeq
274
+ };
275
+ }
276
+ /** 上一轮事件切片:最后一个 turn/start 之前的那一轮(含其中的全部事件)。 */
277
+ function previousTurnSlice(events) {
278
+ const starts = [];
279
+ for (let i = 0; i < events.length; i++) if (events[i]?.type === "turn/start") starts.push(i);
280
+ if (starts.length < 2) return [];
281
+ const previousStart = starts[starts.length - 2];
282
+ const currentStart = starts[starts.length - 1];
283
+ return events.slice(previousStart, currentStart);
284
+ }
285
+ /**
286
+ * 执行一次上一轮摄取。
287
+ * @returns 结果摘要;异常由调用方捕获计数(不重试)。
288
+ */
289
+ async function ingestPreviousTurn(deps) {
290
+ const limits = MODE_LIMITS[deps.mode];
291
+ const slice = previousTurnSlice(deps.events);
292
+ if (slice.length === 0) return {
293
+ scannedEvents: 0,
294
+ candidates: 0,
295
+ written: 0,
296
+ skipped: "no-previous-turn"
297
+ };
298
+ const { texts, minSeq } = collectTexts(slice, limits.includeAssistant);
299
+ if (texts.length === 0) return {
300
+ scannedEvents: slice.length,
301
+ candidates: 0,
302
+ written: 0,
303
+ skipped: "no-user-content"
304
+ };
305
+ const route = deps.routeOverride ?? routeFromEvents(deps.events);
306
+ if (route === void 0) return {
307
+ scannedEvents: slice.length,
308
+ candidates: 0,
309
+ written: 0,
310
+ skipped: "no-route-in-log"
311
+ };
312
+ const userText = `从下面这轮对话(JSON 数组)提取值得长期记住的信息:\n${JSON.stringify(texts)}`;
313
+ deps.logRequest({
314
+ route,
315
+ round: Math.max(0, deps.turn - 1),
316
+ userText,
317
+ maxTokens: INGEST_MAX_TOKENS,
318
+ mode: deps.mode
319
+ });
320
+ const parsed = parseJsonArray(await deps.call({
321
+ route,
322
+ system: INGEST_SYSTEM,
323
+ userText,
324
+ maxTokens: INGEST_MAX_TOKENS,
325
+ purpose: "engram-ingest",
326
+ signal: deps.signal
327
+ }));
328
+ if (parsed === void 0) return {
329
+ scannedEvents: slice.length,
330
+ candidates: 0,
331
+ written: 0,
332
+ skipped: "unparseable-output"
333
+ };
334
+ const store = await deps.openStore();
335
+ const embedder = await deps.embedder;
336
+ const writtenContents = [];
337
+ let written = 0;
338
+ for (const item of parsed.slice(0, limits.maxCandidates)) {
339
+ const candidate = item;
340
+ if (typeof candidate.content !== "string" || candidate.content.trim() === "") continue;
341
+ const content = candidate.content.trim();
342
+ const kind = typeof candidate.kind === "string" && [
343
+ "fact",
344
+ "preference",
345
+ "decision",
346
+ "episode",
347
+ "skill"
348
+ ].includes(candidate.kind) ? candidate.kind : "fact";
349
+ const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .5;
350
+ if (embedder !== void 0) {
351
+ const vector = (await embedder.embed([content]))[0];
352
+ if (vector !== void 0 && (await store.findContradictions(vector, 1)).length > 0) continue;
353
+ }
354
+ if (writtenContents.includes(content)) continue;
355
+ await store.write({
356
+ scope: "user",
357
+ kind,
358
+ content,
359
+ importance,
360
+ confidence: limits.confidence,
361
+ sourceSessionId: deps.sessionId,
362
+ sourceRound: Math.max(0, deps.turn - 1),
363
+ ...minSeq === null ? {} : { sourceSeq: minSeq },
364
+ ...embedder === void 0 ? {} : { embedding: (await embedder.embed([content]))[0] }
365
+ });
366
+ writtenContents.push(content);
367
+ written += 1;
368
+ }
369
+ return {
370
+ scannedEvents: slice.length,
371
+ candidates: parsed.length,
372
+ written,
373
+ skipped: null
374
+ };
375
+ }
376
+ //#endregion
377
+ //#region src/store/sqlite.ts
378
+ /**
379
+ * EngramStore 的 node:sqlite 实现:节点表 + 边表 + FTS5(unicode61 + 中文 2-gram 预切词)
380
+ * + 操作日志,单调 SCHEMA_VERSION,打开时校验、不兼容拒绝加载(不写兼容 shim)。
381
+ * 事务用手工 BEGIN/COMMIT——DatabaseSync.prototype.transaction 仅新引擎可用,
382
+ * 本包声明兼容 node ^22.19。
383
+ * @module @kenz1117/dsh-engram/store/sqlite
384
+ */
385
+ /** 当前 schema 版本;结构性变更必须 +1 并拒绝旧库(pre-release 无兼容承诺)。 */
386
+ const SCHEMA_VERSION = 2;
387
+ /** RRF 融合常数:score = Σ 1/(K + rank)。 */
388
+ const RRF_K = 60;
389
+ /** 向量道的语义门槛:低于该余弦的条目不参与排序。 */
390
+ const MIN_COSINE = .2;
391
+ /** 矛盾候选门槛:近邻余弦达到该值即报告(由模型/用户裁决)。 */
392
+ const CONTRADICTION_COSINE = .88;
393
+ /** 每道参与融合的候选上限。 */
394
+ const RANK_POOL = 64;
395
+ /** 一跳扩展引入的邻居上限。 */
396
+ const EXPANSION_LIMIT = 32;
397
+ /** 命中强化:每次检索命中的置信度增量。 */
398
+ const CONFIDENCE_BUMP = .05;
399
+ /** 审计视图返回的操作日志条数上限。 */
400
+ const REVIEW_LOG_LIMIT = 20;
401
+ function rowToRecord(row) {
402
+ return {
403
+ id: asMemoryId(row.id),
404
+ scope: row.scope,
405
+ kind: row.kind,
406
+ content: row.content,
407
+ importance: row.importance,
408
+ confidence: row.confidence,
409
+ status: row.status,
410
+ createdAt: row.created_at,
411
+ lastAccessedAt: row.last_accessed_at,
412
+ accessCount: row.access_count,
413
+ sourceSessionId: row.source_session_id,
414
+ sourceRound: row.source_round,
415
+ sourceSeq: row.source_seq
416
+ };
417
+ }
418
+ function blobToVec(blob) {
419
+ return new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
420
+ }
421
+ function vecToBlob(vector) {
422
+ return new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
423
+ }
424
+ /** 余弦相似度;任一向量零范数时返回 0。 */
425
+ function cosine(a, b) {
426
+ let dot = 0;
427
+ let na = 0;
428
+ let nb = 0;
429
+ const len = Math.min(a.length, b.length);
430
+ for (let i = 0; i < len; i++) {
431
+ dot += a[i] * b[i];
432
+ na += a[i] * a[i];
433
+ nb += b[i] * b[i];
434
+ }
435
+ if (na === 0 || nb === 0) return 0;
436
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
437
+ }
438
+ /**
439
+ * 中文 2-gram + 西文词元切词:unicode61 把连续汉字当作单个 token,无法支撑短语检索,
440
+ * 因此入库与查询前都把中文按两字窗口切开、西文按词保留,用空格分隔交给 FTS5。
441
+ */
442
+ function tokenizeForFts(text) {
443
+ const tokens = [];
444
+ for (const segment of text.split(/([a-zA-Z0-9_]+)/)) {
445
+ if (segment === "") continue;
446
+ if (/^[a-zA-Z0-9_]+$/.test(segment)) {
447
+ tokens.push(segment.toLowerCase());
448
+ continue;
449
+ }
450
+ const cjk = segment.replace(/\s+/gu, "");
451
+ if (cjk.length === 1) tokens.push(cjk);
452
+ else for (let i = 0; i + 2 <= cjk.length; i++) tokens.push(cjk.slice(i, i + 2));
453
+ }
454
+ return tokens.join(" ");
455
+ }
456
+ /** 把切词结果转成 FTS5 MATCH 表达式(每个词元双引号包裹,OR 连接);无有效词元返回 undefined。 */
457
+ function ftsMatchExpression(text) {
458
+ const tokens = tokenizeForFts(text).split(" ").filter((token) => token !== "");
459
+ if (tokens.length === 0) return void 0;
460
+ return tokens.map((token) => `"${token.replaceAll("\"", "\"\"")}"`).join(" OR ");
461
+ }
462
+ /**
463
+ * 打开(必要时创建)一个 scope 分库。
464
+ * @param path - SQLite 文件路径;目录不存在会自动创建(0o700)。
465
+ * @returns 就绪的 EngramStore。
466
+ * @throws EngramError(code=SCHEMA_INCOMPATIBLE) 当库的 schema 版本高于当前实现。
467
+ */
468
+ async function openEngramStore(path) {
469
+ await mkdir(dirname(path), {
470
+ recursive: true,
471
+ mode: 448
472
+ });
473
+ const { DatabaseSync } = await import("node:sqlite");
474
+ const db = new DatabaseSync(path);
475
+ /** 手工事务:BEGIN/COMMIT/ROLLBACK(兼容 ^22.19 引擎范围)。 */
476
+ const withTransaction = (fn) => {
477
+ db.exec("BEGIN");
478
+ try {
479
+ fn();
480
+ db.exec("COMMIT");
481
+ } catch (error) {
482
+ db.exec("ROLLBACK");
483
+ throw error;
484
+ }
485
+ };
486
+ db.exec(`
487
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
488
+ CREATE TABLE IF NOT EXISTS nodes (
489
+ id TEXT PRIMARY KEY, scope TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
490
+ importance REAL NOT NULL, confidence REAL NOT NULL, status TEXT NOT NULL,
491
+ created_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, access_count INTEGER NOT NULL,
492
+ source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB);
493
+ CREATE TABLE IF NOT EXISTS edges (
494
+ from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL,
495
+ PRIMARY KEY (from_id, to_id, type));
496
+ CREATE TABLE IF NOT EXISTS op_log (
497
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, op TEXT NOT NULL,
498
+ target_id TEXT NOT NULL, detail TEXT);
499
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
500
+ CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
501
+ `);
502
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
503
+ if (versionRow === void 0) db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
504
+ else if (Number(versionRow.value) !== SCHEMA_VERSION) {
505
+ db.close();
506
+ throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${versionRow.value} 与插件支持的 ${SCHEMA_VERSION} 不一致:请备份并删除旧库文件(${path})后重试`);
507
+ }
508
+ const sqlGet = db.prepare("SELECT * FROM nodes WHERE id = ?");
509
+ const sqlInsert = db.prepare(`INSERT INTO nodes
510
+ (id, scope, kind, content, importance, confidence, status, created_at, last_accessed_at, access_count,
511
+ source_session_id, source_round, source_seq, embedding)
512
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`);
513
+ const sqlFtsInsert = db.prepare("INSERT INTO nodes_fts (node_id, content) VALUES (?, ?)");
514
+ const sqlSetStatus = db.prepare("UPDATE nodes SET status = ?, last_accessed_at = ? WHERE id = ?");
515
+ const sqlTouch = db.prepare(`UPDATE nodes SET access_count = access_count + 1, last_accessed_at = ?,
516
+ confidence = MIN(1, confidence + ${CONFIDENCE_BUMP}) WHERE id = ?`);
517
+ const sqlLog = db.prepare("INSERT INTO op_log (at, op, target_id, detail) VALUES (?, ?, ?, ?)");
518
+ const sqlOpLogById = db.prepare("SELECT at, op, detail FROM op_log WHERE target_id = ? ORDER BY seq DESC LIMIT ?");
519
+ const sqlTopActive = db.prepare("SELECT * FROM nodes WHERE scope = ? AND status = 'active' ORDER BY importance DESC, confidence DESC LIMIT ?");
520
+ const sqlEdgeUpsert = db.prepare("INSERT OR IGNORE INTO edges (from_id, to_id, type, created_at) VALUES (?, ?, ?, ?)");
521
+ const sqlNeighbors = db.prepare(`SELECT * FROM edges WHERE from_id IN (SELECT value FROM json_each(?))
522
+ AND type IN ('supports','refines','related') LIMIT ?`);
523
+ const sqlEdgesTouching = db.prepare("SELECT from_id, to_id, type FROM edges WHERE from_id = ? OR to_id = ?");
524
+ const sqlCountBy = db.prepare("SELECT status, COUNT(*) AS n FROM nodes GROUP BY status");
525
+ const sqlCountKind = db.prepare("SELECT kind, COUNT(*) AS n FROM nodes GROUP BY kind");
526
+ const sqlCountEdges = db.prepare("SELECT COUNT(*) AS n FROM edges");
527
+ const sqlCountOpLog = db.prepare("SELECT COUNT(*) AS n FROM op_log");
528
+ const sqlAllNodes = db.prepare("SELECT * FROM nodes ORDER BY created_at");
529
+ const sqlAllEdges = db.prepare("SELECT * FROM edges");
530
+ const sqlDecay = db.prepare(`UPDATE nodes SET status = 'archived'
531
+ WHERE status = 'active' AND importance < ? AND last_accessed_at < ?`);
532
+ const sqlPurgeNodes = db.prepare("DELETE FROM nodes");
533
+ const sqlPurgeEdges = db.prepare("DELETE FROM edges");
534
+ const sqlPurgeFts = db.prepare("DELETE FROM nodes_fts");
535
+ const sqlPurgeLog = db.prepare("DELETE FROM op_log");
536
+ /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重)。 */
537
+ const ftsSearch = (match, scopes) => {
538
+ const placeholders = scopes.map(() => "?").join(",");
539
+ return db.prepare(`SELECT n.* FROM nodes_fts f JOIN nodes n ON n.id = f.node_id
540
+ WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})
541
+ ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes);
542
+ };
543
+ /** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成)。 */
544
+ const vectorPool = (scopes) => {
545
+ const placeholders = scopes.map(() => "?").join(",");
546
+ return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})`).all(...scopes);
547
+ };
548
+ const getRow = (id) => sqlGet.get(id);
549
+ /**
550
+ * 写入公共体:插入节点 + FTS + 操作日志(不建边、不开事务)。
551
+ * 事务由调用方持有(withTransaction)。
552
+ */
553
+ const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, op) => {
554
+ const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
555
+ sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored);
556
+ sqlFtsInsert.run(id, tokenizeForFts(content));
557
+ sqlLog.run(at, op, id, JSON.stringify({
558
+ kind: input.kind,
559
+ scope: input.scope
560
+ }));
561
+ };
562
+ /** 邻居收集:该 id 触及的全部边按类型分组(supersedes 分方向)。 */
563
+ const edgeGroups = (id) => {
564
+ const rows = sqlEdgesTouching.all(id, id);
565
+ const supersededBy = [];
566
+ const supersedes = [];
567
+ const contradicts = [];
568
+ const related = [];
569
+ for (const edge of rows) if (edge.type === "supersedes") {
570
+ if (edge.to_id === id) supersededBy.push(edge.from_id);
571
+ else supersedes.push(edge.to_id);
572
+ } else if (edge.type === "contradicts") contradicts.push(edge.from_id === id ? edge.to_id : edge.from_id);
573
+ else if (edge.type === "related" || edge.type === "supports" || edge.type === "refines") related.push(edge.from_id === id ? edge.to_id : edge.from_id);
574
+ return {
575
+ supersededBy: supersededBy.map(asMemoryId),
576
+ supersedes: supersedes.map(asMemoryId),
577
+ contradicts: contradicts.map(asMemoryId),
578
+ related: related.map(asMemoryId)
579
+ };
580
+ };
581
+ return {
582
+ async write(input) {
583
+ const content = input.content.trim();
584
+ if (content === "") throw new EngramError("EMPTY_CONTENT", "content 不能为空");
585
+ const id = asMemoryId(randomUUID());
586
+ const at = Date.now();
587
+ withTransaction(() => {
588
+ insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "write");
589
+ });
590
+ return rowToRecord(sqlGet.get(id));
591
+ },
592
+ async get(id) {
593
+ const row = getRow(id);
594
+ return row === void 0 ? void 0 : rowToRecord(row);
595
+ },
596
+ async search(query, queryVector) {
597
+ const limit = query.limit ?? 8;
598
+ const scores = /* @__PURE__ */ new Map();
599
+ const match = ftsMatchExpression(query.text);
600
+ if (match !== void 0) ftsSearch(match, query.scopes).forEach((row, index) => {
601
+ scores.set(row.id, {
602
+ score: 1 / (RRF_K + index + 1),
603
+ via: "fts"
604
+ });
605
+ });
606
+ let degraded = true;
607
+ if (queryVector !== void 0) {
608
+ degraded = false;
609
+ vectorPool(query.scopes).map((row) => ({
610
+ row,
611
+ sim: cosine(queryVector, blobToVec(row.embedding))
612
+ })).filter((entry) => entry.sim >= MIN_COSINE).sort((a, b) => b.sim - a.sim).slice(0, RANK_POOL).forEach((entry, index) => {
613
+ const add = 1 / (RRF_K + index + 1);
614
+ const existing = scores.get(entry.row.id);
615
+ if (existing === void 0) scores.set(entry.row.id, {
616
+ score: add,
617
+ via: "vec"
618
+ });
619
+ else scores.set(entry.row.id, {
620
+ score: existing.score + add,
621
+ via: "both"
622
+ });
623
+ });
624
+ }
625
+ const topIds = [...scores.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, limit).map(([id]) => id);
626
+ const viaEdgeOf = /* @__PURE__ */ new Map();
627
+ if (topIds.length > 0) {
628
+ const edges = sqlNeighbors.all(JSON.stringify(topIds), EXPANSION_LIMIT);
629
+ for (const edge of edges) {
630
+ if (scores.has(edge.to) || !topIds.includes(edge.from)) continue;
631
+ const row = getRow(edge.to);
632
+ if (row === void 0 || row.status !== "active" || !query.scopes.includes(row.scope)) continue;
633
+ const baseScore = scores.get(edge.from)?.score;
634
+ if (baseScore === void 0) continue;
635
+ scores.set(edge.to, {
636
+ score: baseScore * .5,
637
+ via: "fts"
638
+ });
639
+ viaEdgeOf.set(edge.to, {
640
+ from: asMemoryId(edge.from),
641
+ type: edge.type
642
+ });
643
+ }
644
+ }
645
+ const finalRows = [...scores.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, limit);
646
+ const hits = [];
647
+ for (const [id, info] of finalRows) {
648
+ const row = getRow(id);
649
+ if (row === void 0) continue;
650
+ const viaEdge = viaEdgeOf.get(id);
651
+ hits.push({
652
+ record: rowToRecord(row),
653
+ score: info.score,
654
+ via: info.via,
655
+ ...viaEdge === void 0 ? {} : { viaEdge }
656
+ });
657
+ sqlTouch.run(Date.now(), id);
658
+ }
659
+ return {
660
+ hits,
661
+ degraded
662
+ };
663
+ },
664
+ async timeline(query) {
665
+ const limit = query.limit ?? 20;
666
+ const placeholders = query.scopes.map(() => "?").join(",");
667
+ return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND scope IN (${placeholders})
668
+ AND (? IS NULL OR created_at >= ?) AND (? IS NULL OR created_at <= ?)
669
+ AND (? IS NULL OR instr(content, ?) > 0)
670
+ ORDER BY created_at DESC LIMIT ?`).all(...query.scopes, query.since ?? null, query.since ?? null, query.until ?? null, query.until ?? null, query.topic ?? null, query.topic ?? null, limit).map(rowToRecord);
671
+ },
672
+ async update(input) {
673
+ const old = getRow(input.id);
674
+ if (old === void 0) throw new EngramError("NOT_FOUND", `条目 ${input.id} 不存在`);
675
+ const content = input.content.trim();
676
+ if (content === "") throw new EngramError("EMPTY_CONTENT", "content 不能为空");
677
+ const id = asMemoryId(randomUUID());
678
+ const at = Date.now();
679
+ withTransaction(() => {
680
+ sqlSetStatus.run("archived", at, input.id);
681
+ sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
682
+ insertRecord(id, {
683
+ scope: input.scope,
684
+ kind: input.kind,
685
+ content
686
+ }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, "update");
687
+ sqlEdgeUpsert.run(id, input.id, "supersedes", at);
688
+ });
689
+ return rowToRecord(sqlGet.get(id));
690
+ },
691
+ async forget(id) {
692
+ if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
693
+ sqlSetStatus.run("forgotten", Date.now(), id);
694
+ sqlLog.run(Date.now(), "forget", id, null);
695
+ return rowToRecord(sqlGet.get(id));
696
+ },
697
+ async restore(id) {
698
+ if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
699
+ sqlSetStatus.run("active", Date.now(), id);
700
+ sqlLog.run(Date.now(), "restore", id, null);
701
+ return rowToRecord(sqlGet.get(id));
702
+ },
703
+ async topActive(scope, n) {
704
+ return sqlTopActive.all(scope, n).map(rowToRecord);
705
+ },
706
+ async review(id) {
707
+ const row = getRow(id);
708
+ if (row === void 0) return void 0;
709
+ const operations = sqlOpLogById.all(id, REVIEW_LOG_LIMIT);
710
+ return {
711
+ record: rowToRecord(row),
712
+ ...edgeGroups(id),
713
+ operations
714
+ };
715
+ },
716
+ async stats() {
717
+ const statusRows = sqlCountBy.all();
718
+ const kindRows = sqlCountKind.all();
719
+ const edgeCount = sqlCountEdges.get().n;
720
+ const opCount = sqlCountOpLog.get().n;
721
+ const byStatus = {
722
+ active: 0,
723
+ archived: 0,
724
+ forgotten: 0
725
+ };
726
+ for (const row of statusRows) byStatus[row.status] = row.n;
727
+ const byKind = {};
728
+ for (const row of kindRows) byKind[row.kind] = row.n;
729
+ const active = byStatus["active"] ?? 0;
730
+ const archived = byStatus["archived"] ?? 0;
731
+ const forgotten = byStatus["forgotten"] ?? 0;
732
+ const total = active + archived + forgotten;
733
+ return {
734
+ total,
735
+ active,
736
+ archived,
737
+ forgotten,
738
+ byKind,
739
+ edges: edgeCount,
740
+ opLogCount: opCount,
741
+ signalRatio: total === 0 ? 0 : active / total
742
+ };
743
+ },
744
+ async exportAll() {
745
+ const records = sqlAllNodes.all().map(rowToRecord);
746
+ const edgeRows = sqlAllEdges.all();
747
+ return {
748
+ exportedAt: Date.now(),
749
+ records,
750
+ edges: edgeRows.map((edge) => ({
751
+ from: asMemoryId(edge.from_id),
752
+ to: asMemoryId(edge.to_id),
753
+ type: edge.type,
754
+ createdAt: edge.created_at
755
+ }))
756
+ };
757
+ },
758
+ async decay(options) {
759
+ const cutoff = Date.now() - options.olderThanDays * 864e5;
760
+ const result = sqlDecay.run(options.importanceBelow, cutoff);
761
+ const changed = Number(result.changes);
762
+ if (changed > 0) sqlLog.run(Date.now(), "decay", "BATCH", JSON.stringify({ archived: changed }));
763
+ return changed;
764
+ },
765
+ async findContradictions(embedding, limit = 3) {
766
+ return db.prepare("SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN ('user','project')").all().map((row) => ({
767
+ row,
768
+ sim: cosine(embedding, blobToVec(row.embedding))
769
+ })).filter((entry) => entry.sim >= CONTRADICTION_COSINE).sort((a, b) => b.sim - a.sim).slice(0, limit).map((entry) => rowToRecord(entry.row));
770
+ },
771
+ async linkEdge(from, to, type) {
772
+ sqlEdgeUpsert.run(from, to, type, Date.now());
773
+ },
774
+ async supersedeMany(input, oldIds) {
775
+ const content = input.content.trim();
776
+ if (content === "") throw new EngramError("EMPTY_CONTENT", "content 不能为空");
777
+ const id = asMemoryId(randomUUID());
778
+ const at = Date.now();
779
+ withTransaction(() => {
780
+ insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "distill");
781
+ for (const oldId of oldIds) {
782
+ sqlSetStatus.run("archived", at, oldId);
783
+ sqlEdgeUpsert.run(id, oldId, "supersedes", at);
784
+ }
785
+ });
786
+ return rowToRecord(sqlGet.get(id));
787
+ },
788
+ async audit(op, targetId, detail) {
789
+ sqlLog.run(Date.now(), op, targetId, detail);
790
+ },
791
+ async purge() {
792
+ withTransaction(() => {
793
+ sqlPurgeNodes.run();
794
+ sqlPurgeEdges.run();
795
+ sqlPurgeFts.run();
796
+ sqlPurgeLog.run();
797
+ });
798
+ },
799
+ async close() {
800
+ db.close();
801
+ }
802
+ };
803
+ }
804
+ //#endregion
805
+ //#region src/flywheel/distill.ts
806
+ /**
807
+ * 知识飞轮的蒸馏步骤:把同主题的记忆簇由辅助 LLM 合并提炼为更高层的
808
+ * skill/fact,被合并条目归档并逐条建立 supersedes 链,新条目继承簇内
809
+ * 置信度均值。由 `engram_distill` 工具显式触发。
810
+ * @module @kenz1117/dsh-engram/flywheel/distill
811
+ */
812
+ /** 蒸馏输入条数上限(协议内常量:单次辅助调用的可控上下文)。 */
813
+ const DISTILL_MAX_INPUT = 30;
814
+ /** 蒸馏输出 token 上限。 */
815
+ const DISTILL_MAX_TOKENS = 900;
816
+ const DISTILL_SYSTEM = [
817
+ "下面是一组用户的长期记忆条目。把表达同一主题或可归纳为一条规律的若干条,合并提炼为更高层的一条。",
818
+ "只输出一个 JSON 数组,每项形如 {\"content\": \"提炼后的一句话规律\", \"kind\": \"skill或fact\", \"importance\": 0到1, \"supersedes\": [\"被合并条目的id\", ...]}。",
819
+ "supersedes 必须从输入给出的 id 中选取且每组至少 1 个;无法归并的条目不要输出。没有可归并的就输出 []。",
820
+ "不要输出 JSON 以外的任何内容。"
821
+ ].join("\n");
822
+ /**
823
+ * 执行一次蒸馏。
824
+ * @returns 结果摘要;LLM/解析失败抛错(工具层报给模型),单组写入失败中断本次。
825
+ */
826
+ async function distillMemories(deps) {
827
+ const rows = await deps.store.topActive(deps.scope, DISTILL_MAX_INPUT);
828
+ if (rows.length < 2) return {
829
+ input: rows.length,
830
+ distilled: 0,
831
+ superseded: 0
832
+ };
833
+ const userText = `记忆条目:\n${rows.map((record) => `[${record.id}] (${record.kind}, importance ${record.importance.toFixed(2)}) ${record.content}`).join("\n")}`;
834
+ deps.logRequest({
835
+ route: deps.route,
836
+ scope: deps.scope,
837
+ userText,
838
+ maxTokens: DISTILL_MAX_TOKENS
839
+ });
840
+ const parsed = parseJsonArray(await deps.call({
841
+ route: deps.route,
842
+ system: DISTILL_SYSTEM,
843
+ userText,
844
+ maxTokens: DISTILL_MAX_TOKENS,
845
+ purpose: "engram-distill",
846
+ signal: deps.signal
847
+ }));
848
+ if (parsed === void 0 || parsed.length === 0) return {
849
+ input: rows.length,
850
+ distilled: 0,
851
+ superseded: 0
852
+ };
853
+ const validIds = new Set(rows.map((record) => String(record.id)));
854
+ const byId = new Map(rows.map((record) => [String(record.id), record]));
855
+ let distilled = 0;
856
+ let superseded = 0;
857
+ for (const item of parsed) {
858
+ const candidate = item;
859
+ if (typeof candidate.content !== "string" || candidate.content.trim() === "") continue;
860
+ if (!Array.isArray(candidate.supersedes) || candidate.supersedes.length === 0) continue;
861
+ const oldIds = [];
862
+ for (const rawId of candidate.supersedes) {
863
+ if (typeof rawId !== "string" || !validIds.has(rawId)) continue;
864
+ const row = await deps.store.get(asMemoryId(rawId));
865
+ if (row !== void 0 && row.status === "active") oldIds.push(asMemoryId(rawId));
866
+ }
867
+ if (oldIds.length === 0) continue;
868
+ const kind = candidate.kind === "skill" || candidate.kind === "fact" ? candidate.kind : "skill";
869
+ const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .7;
870
+ const confidences = oldIds.map((oldId) => byId.get(String(oldId))?.confidence ?? .5).filter((value) => value > 0);
871
+ const confidence = confidences.length === 0 ? .5 : confidences.reduce((sum, value) => sum + value, 0) / confidences.length;
872
+ let embedding;
873
+ if (deps.embedder !== void 0) {
874
+ const vectors = [];
875
+ for (const oldId of oldIds) {
876
+ const vector = (await deps.embedder.embed([byId.get(String(oldId))?.content ?? ""]))[0];
877
+ if (vector !== void 0) vectors.push(vector);
878
+ }
879
+ if (vectors.length > 0) {
880
+ const average = new Float32Array(vectors[0].length);
881
+ for (const vector of vectors) for (let i = 0; i < vector.length; i++) average[i] = (average[i] ?? 0) + vector[i] / vectors.length;
882
+ embedding = average;
883
+ }
884
+ }
885
+ await deps.store.supersedeMany({
886
+ scope: deps.scope,
887
+ kind,
888
+ content: candidate.content.trim(),
889
+ importance,
890
+ confidence,
891
+ ...embedding === void 0 ? {} : { embedding }
892
+ }, oldIds);
893
+ distilled += 1;
894
+ superseded += oldIds.length;
895
+ }
896
+ return {
897
+ input: rows.length,
898
+ distilled,
899
+ superseded
900
+ };
901
+ }
902
+ //#endregion
903
+ //#region src/tools/create.ts
904
+ /**
905
+ * 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
906
+ * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
907
+ * @module @kenz1117/dsh-engram/tools/create
908
+ */
909
+ const KINDS = [
910
+ "fact",
911
+ "preference",
912
+ "decision",
913
+ "episode",
914
+ "skill"
915
+ ];
916
+ /** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
917
+ function scopeOf(raw, fallback) {
918
+ return raw === "user" || raw === "project" ? raw : fallback;
919
+ }
920
+ /** 把 search scope 参数收敛为分库集合。 */
921
+ function scopesOf(raw) {
922
+ if (raw === "user") return ["user"];
923
+ if (raw === "project") return ["project"];
924
+ return ["user", "project"];
925
+ }
926
+ /** 查询向量:嵌入可用时返回查询文本的向量,否则 undefined(降级)。 */
927
+ async function queryVectorOf(deps, text) {
928
+ const embedder = await deps.embedder;
929
+ if (embedder === void 0 || text.trim() === "") return void 0;
930
+ return (await embedder.embed([text.trim()]))[0];
931
+ }
932
+ /**
933
+ * 构造 9 个工具定义(engram_save/search/timeline/update/forget/review/stats/export/distill)。
934
+ * @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
935
+ * @returns 可直接 register 的工具定义数组。
936
+ */
937
+ function createEngramTools(deps) {
938
+ return [
939
+ defineTool({
940
+ name: "engram_save",
941
+ description: "保存一条长期记忆(跨会话可用)。kind:fact 事实 / preference 偏好 / decision 决策 / episode 经历 / skill 方法。scope:project 仅当前项目,user 全局。",
942
+ parameters: {
943
+ content: {
944
+ type: "string",
945
+ required: true,
946
+ description: "记忆正文,一句话完整表达"
947
+ },
948
+ kind: {
949
+ type: "string",
950
+ enum: [...KINDS],
951
+ required: true,
952
+ description: "记忆种类"
953
+ },
954
+ scope: {
955
+ type: "string",
956
+ enum: ["user", "project"],
957
+ description: "作用域,默认 project"
958
+ },
959
+ importance: {
960
+ type: "number",
961
+ description: "重要性 0-1,默认 0.5"
962
+ }
963
+ },
964
+ output: {
965
+ schema: {
966
+ type: "object",
967
+ additionalProperties: false,
968
+ properties: {
969
+ id: {
970
+ type: "string",
971
+ required: true
972
+ },
973
+ kind: {
974
+ type: "string",
975
+ required: true
976
+ },
977
+ importance: {
978
+ type: "number",
979
+ required: true
980
+ }
981
+ }
982
+ },
983
+ render: (_args, value) => [{
984
+ type: "text",
985
+ text: `已保存记忆 ${value.id}(kind=${value.kind}, importance=${value.importance})。后续会话可用 engram_search 召回。`
986
+ }]
987
+ },
988
+ async execute(args, exec) {
989
+ const input = args;
990
+ const scope = scopeOf(input.scope, "project");
991
+ const store = await deps.openStore(scope);
992
+ const embedder = await deps.embedder;
993
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([input.content.trim()]);
994
+ const record = await store.write({
995
+ scope,
996
+ kind: input.kind,
997
+ content: input.content,
998
+ ...input.importance === void 0 ? {} : { importance: input.importance },
999
+ sourceSessionId: exec.agent?.id ?? null,
1000
+ ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
1001
+ });
1002
+ if (embeddings?.[0] !== void 0) {
1003
+ const candidates = await store.findContradictions(embeddings[0]);
1004
+ for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
1005
+ if (candidates.length > 0) {
1006
+ const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
1007
+ return {
1008
+ id: record.id,
1009
+ kind: record.kind,
1010
+ importance: record.importance,
1011
+ text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
1012
+ };
1013
+ }
1014
+ }
1015
+ return {
1016
+ id: record.id,
1017
+ kind: record.kind,
1018
+ importance: record.importance
1019
+ };
1020
+ }
1021
+ }),
1022
+ defineTool({
1023
+ name: "engram_search",
1024
+ description: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
1025
+ parameters: {
1026
+ query: {
1027
+ type: "string",
1028
+ required: true,
1029
+ description: "检索文本"
1030
+ },
1031
+ scope: {
1032
+ type: "string",
1033
+ enum: [
1034
+ "user",
1035
+ "project",
1036
+ "all"
1037
+ ],
1038
+ description: "作用域,默认 all"
1039
+ },
1040
+ limit: {
1041
+ type: "number",
1042
+ description: "返回条数上限,默认 8"
1043
+ }
1044
+ },
1045
+ output: {
1046
+ schema: {
1047
+ type: "object",
1048
+ additionalProperties: false,
1049
+ properties: {
1050
+ degraded: {
1051
+ type: "boolean",
1052
+ required: true
1053
+ },
1054
+ text: {
1055
+ type: "string",
1056
+ required: true
1057
+ }
1058
+ }
1059
+ },
1060
+ render: (_args, value) => [{
1061
+ type: "text",
1062
+ text: value.text
1063
+ }]
1064
+ },
1065
+ async execute(args) {
1066
+ const input = args;
1067
+ const scopes = scopesOf(input.scope);
1068
+ const limit = input.limit ?? 8;
1069
+ const vector = await queryVectorOf(deps, input.query);
1070
+ const results = await Promise.all(scopes.map(async (scope) => {
1071
+ return (await deps.openStore(scope)).search({
1072
+ text: input.query,
1073
+ scopes: [scope],
1074
+ limit
1075
+ }, vector);
1076
+ }));
1077
+ const merged = results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit);
1078
+ const degraded = results.some((result) => result.degraded);
1079
+ const lines = merged.map((hit, index) => {
1080
+ const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
1081
+ return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${hit.record.content}(id=${hit.record.id})${edge}`;
1082
+ });
1083
+ return {
1084
+ degraded,
1085
+ text: `${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`
1086
+ };
1087
+ }
1088
+ }),
1089
+ defineTool({
1090
+ name: "engram_timeline",
1091
+ description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
1092
+ parameters: {
1093
+ scope: {
1094
+ type: "string",
1095
+ enum: [
1096
+ "user",
1097
+ "project",
1098
+ "all"
1099
+ ],
1100
+ description: "作用域,默认 all"
1101
+ },
1102
+ topic: {
1103
+ type: "string",
1104
+ description: "主题子串"
1105
+ },
1106
+ since: {
1107
+ type: "string",
1108
+ description: "起始时间(ISO 或可解析日期)"
1109
+ },
1110
+ until: {
1111
+ type: "string",
1112
+ description: "结束时间"
1113
+ }
1114
+ },
1115
+ output: {
1116
+ schema: {
1117
+ type: "object",
1118
+ additionalProperties: false,
1119
+ properties: { text: {
1120
+ type: "string",
1121
+ required: true
1122
+ } }
1123
+ },
1124
+ render: (_args, value) => [{
1125
+ type: "text",
1126
+ text: value.text
1127
+ }]
1128
+ },
1129
+ async execute(args) {
1130
+ const input = args;
1131
+ const scopes = scopesOf(input.scope);
1132
+ const parseTime = (raw, field) => {
1133
+ if (raw === void 0) return void 0;
1134
+ const ms = Date.parse(raw);
1135
+ if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
1136
+ return ms;
1137
+ };
1138
+ const since = parseTime(input.since, "since");
1139
+ const until = parseTime(input.until, "until");
1140
+ return { text: (await Promise.all(scopes.map(async (scope) => {
1141
+ return (await deps.openStore(scope)).timeline({
1142
+ scopes: [scope],
1143
+ ...input.topic === void 0 ? {} : { topic: input.topic },
1144
+ ...since === void 0 ? {} : { since },
1145
+ ...until === void 0 ? {} : { until },
1146
+ limit: 20
1147
+ });
1148
+ }))).flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, 20).map((record) => `${new Date(record.createdAt).toISOString()} [${record.scope}/${record.kind}] ${record.content}(id=${record.id})`).join("\n") || "时间线为空" };
1149
+ }
1150
+ }),
1151
+ defineTool({
1152
+ name: "engram_update",
1153
+ description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
1154
+ parameters: {
1155
+ id: {
1156
+ type: "string",
1157
+ required: true,
1158
+ description: "要修正的旧条目 id"
1159
+ },
1160
+ content: {
1161
+ type: "string",
1162
+ required: true,
1163
+ description: "修正后的正文"
1164
+ },
1165
+ scope: {
1166
+ type: "string",
1167
+ enum: ["user", "project"],
1168
+ description: "旧条目作用域,默认 project"
1169
+ },
1170
+ kind: {
1171
+ type: "string",
1172
+ enum: [...KINDS],
1173
+ description: "种类,默认继承旧条目"
1174
+ }
1175
+ },
1176
+ output: {
1177
+ schema: {
1178
+ type: "object",
1179
+ additionalProperties: false,
1180
+ properties: {
1181
+ id: {
1182
+ type: "string",
1183
+ required: true
1184
+ },
1185
+ superseded: {
1186
+ type: "string",
1187
+ required: true
1188
+ }
1189
+ }
1190
+ },
1191
+ render: (_args, value) => [{
1192
+ type: "text",
1193
+ text: `已写入修正记忆 ${value.id};旧条目 ${value.superseded} 已归档并建立取代链。`
1194
+ }]
1195
+ },
1196
+ async execute(args) {
1197
+ const input = args;
1198
+ const scope = scopeOf(input.scope, "project");
1199
+ const store = await deps.openStore(scope);
1200
+ const old = await store.get(input.id);
1201
+ if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
1202
+ const embedder = await deps.embedder;
1203
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([input.content.trim()]);
1204
+ return {
1205
+ id: (await store.update({
1206
+ id: input.id,
1207
+ scope,
1208
+ kind: input.kind ?? old.kind,
1209
+ content: input.content,
1210
+ ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
1211
+ })).id,
1212
+ superseded: input.id
1213
+ };
1214
+ }
1215
+ }),
1216
+ defineTool({
1217
+ name: "engram_forget",
1218
+ description: "遗忘一条记忆(软删,用户可从库中恢复)。id 与 scope 来自 engram_search 结果。",
1219
+ parameters: {
1220
+ id: {
1221
+ type: "string",
1222
+ required: true,
1223
+ description: "条目 id"
1224
+ },
1225
+ scope: {
1226
+ type: "string",
1227
+ enum: ["user", "project"],
1228
+ description: "条目作用域,默认 project"
1229
+ }
1230
+ },
1231
+ output: {
1232
+ schema: {
1233
+ type: "object",
1234
+ additionalProperties: false,
1235
+ properties: { id: {
1236
+ type: "string",
1237
+ required: true
1238
+ } }
1239
+ },
1240
+ render: (_args, value) => [{
1241
+ type: "text",
1242
+ text: `记忆 ${value.id} 已遗忘(软删,可恢复)。`
1243
+ }]
1244
+ },
1245
+ async execute(args) {
1246
+ const input = args;
1247
+ const scope = scopeOf(input.scope, "project");
1248
+ return { id: (await (await deps.openStore(scope)).forget(input.id)).id };
1249
+ }
1250
+ }),
1251
+ defineTool({
1252
+ name: "engram_review",
1253
+ description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
1254
+ parameters: {
1255
+ id: {
1256
+ type: "string",
1257
+ required: true,
1258
+ description: "条目 id"
1259
+ },
1260
+ scope: {
1261
+ type: "string",
1262
+ enum: ["user", "project"],
1263
+ description: "条目作用域,默认 project"
1264
+ }
1265
+ },
1266
+ output: {
1267
+ schema: {
1268
+ type: "object",
1269
+ additionalProperties: false,
1270
+ properties: { text: {
1271
+ type: "string",
1272
+ required: true
1273
+ } }
1274
+ },
1275
+ render: (_args, value) => [{
1276
+ type: "text",
1277
+ text: value.text
1278
+ }]
1279
+ },
1280
+ async execute(args) {
1281
+ const input = args;
1282
+ const view = await (await deps.openStore(scopeOf(input.scope, "project"))).review(input.id);
1283
+ if (view === void 0) return { text: `未找到条目 ${input.id}(用 engram_search 确认 id 与 scope)` };
1284
+ const record = view.record;
1285
+ const source = record.sourceSessionId === null ? "显式保存(无会话来源)" : `会话 ${record.sourceSessionId}` + (record.sourceRound === null ? "" : ` 第 ${record.sourceRound} 轮`) + (record.sourceSeq === null ? "" : `,事件 seq ${record.sourceSeq}`);
1286
+ const section = (title, ids) => ids.length === 0 ? "" : `\n${title}: ${ids.join(", ")}`;
1287
+ return { text: [
1288
+ `内容: ${record.content}`,
1289
+ `属性: kind=${record.kind}, scope=${record.scope}, status=${record.status}, importance=${record.importance}, confidence=${record.confidence}, 访问 ${record.accessCount} 次`,
1290
+ `来源: ${source}`,
1291
+ section("被谁取代", view.supersededBy.map(String)),
1292
+ section("取代了谁", view.supersedes.map(String)),
1293
+ section("矛盾候选", view.contradicts.map(String)),
1294
+ section("关联", view.related.map(String)),
1295
+ view.operations.length === 0 ? "" : `\n最近操作:\n${view.operations.map((op) => `- ${new Date(op.at).toISOString()} ${op.op}${op.detail === null ? "" : ` ${op.detail}`}`).join("\n")}`
1296
+ ].filter((part) => part !== "").join("\n") };
1297
+ }
1298
+ }),
1299
+ defineTool({
1300
+ name: "engram_stats",
1301
+ description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量。scope=all 时合并两库。",
1302
+ parameters: { scope: {
1303
+ type: "string",
1304
+ enum: [
1305
+ "user",
1306
+ "project",
1307
+ "all"
1308
+ ],
1309
+ description: "作用域,默认 all"
1310
+ } },
1311
+ output: {
1312
+ schema: {
1313
+ type: "object",
1314
+ additionalProperties: false,
1315
+ properties: { text: {
1316
+ type: "string",
1317
+ required: true
1318
+ } }
1319
+ },
1320
+ render: (_args, value) => [{
1321
+ type: "text",
1322
+ text: value.text
1323
+ }]
1324
+ },
1325
+ async execute(args) {
1326
+ const scopes = scopesOf(args.scope);
1327
+ return { text: (await Promise.all(scopes.map(async (scope) => ({
1328
+ scope,
1329
+ stats: await (await deps.openStore(scope)).stats()
1330
+ })))).map(({ scope, stats }) => [
1331
+ `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
1332
+ `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
1333
+ `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`
1334
+ ].join("\n")).join("\n\n") };
1335
+ }
1336
+ }),
1337
+ defineTool({
1338
+ name: "engram_export",
1339
+ description: "把记忆库导出为文件(Markdown 或 JSON,含全部状态与关系边),返回文件路径。数据可携带。",
1340
+ parameters: {
1341
+ format: {
1342
+ type: "string",
1343
+ enum: ["markdown", "json"],
1344
+ description: "导出格式,默认 markdown"
1345
+ },
1346
+ scope: {
1347
+ type: "string",
1348
+ enum: [
1349
+ "user",
1350
+ "project",
1351
+ "all"
1352
+ ],
1353
+ description: "作用域,默认 all"
1354
+ }
1355
+ },
1356
+ output: {
1357
+ schema: {
1358
+ type: "object",
1359
+ additionalProperties: false,
1360
+ properties: { text: {
1361
+ type: "string",
1362
+ required: true
1363
+ } }
1364
+ },
1365
+ render: (_args, value) => [{
1366
+ type: "text",
1367
+ text: value.text
1368
+ }]
1369
+ },
1370
+ async execute(args) {
1371
+ const input = args;
1372
+ const format = input.format === "json" ? "json" : "markdown";
1373
+ const scopes = scopesOf(input.scope);
1374
+ await mkdir(deps.exportDir, {
1375
+ recursive: true,
1376
+ mode: 448
1377
+ });
1378
+ const written = [];
1379
+ for (const scope of scopes) {
1380
+ const data = await (await deps.openStore(scope)).exportAll();
1381
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
1382
+ const path = join(deps.exportDir, `engram-${scope}-${stamp}.${format === "json" ? "json" : "md"}`);
1383
+ const body = format === "json" ? JSON.stringify(data, null, 2) : [
1384
+ `# dsh-engram 导出(${scope})`,
1385
+ "",
1386
+ ...data.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
1387
+ "",
1388
+ "## 关系边",
1389
+ ...data.edges.map((edge) => `- ${edge.from} --${edge.type}--> ${edge.to}`),
1390
+ ""
1391
+ ].join("\n");
1392
+ await writeFile(path, body, { mode: 384 });
1393
+ written.push(`${path}(${data.records.length} 条记忆,${data.edges.length} 条边)`);
1394
+ }
1395
+ return { text: `已导出:\n${written.join("\n")}` };
1396
+ }
1397
+ }),
1398
+ defineTool({
1399
+ name: "engram_distill",
1400
+ description: "蒸馏整理:把同主题的记忆簇合并提炼为更高层的规律(旧条目归档、supersedes 链保留)。建议记忆较多时周期性执行。",
1401
+ parameters: { scope: {
1402
+ type: "string",
1403
+ enum: ["user", "project"],
1404
+ description: "作用域,默认 user"
1405
+ } },
1406
+ output: {
1407
+ schema: {
1408
+ type: "object",
1409
+ additionalProperties: false,
1410
+ properties: { text: {
1411
+ type: "string",
1412
+ required: true
1413
+ } }
1414
+ },
1415
+ render: (_args, value) => [{
1416
+ type: "text",
1417
+ text: value.text
1418
+ }]
1419
+ },
1420
+ async execute(args, exec) {
1421
+ const scope = scopeOf(args.scope, "user");
1422
+ if (deps.call === void 0) throw new Error("engram_distill: 辅助 LLM 不可用(宿主未提供 llm 服务),无法蒸馏");
1423
+ const call = deps.call;
1424
+ const events = exec.agent?.session?.events ?? [];
1425
+ const route = deps.routeOverride ?? routeFromEvents(events);
1426
+ if (route === void 0) throw new Error("engram_distill: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
1427
+ const embedder = await deps.embedder;
1428
+ const outcome = await distillMemories({
1429
+ store: await deps.openStore(scope),
1430
+ embedder,
1431
+ scope,
1432
+ call: (params) => call({
1433
+ ...params,
1434
+ sessionId: exec.agent === void 0 ? void 0 : String(exec.agent.session.id)
1435
+ }),
1436
+ logRequest: (data) => {
1437
+ (async () => {
1438
+ await (await deps.openStore(scope)).audit("distill-request", "AUX", JSON.stringify(data));
1439
+ })().catch(() => {});
1440
+ },
1441
+ route,
1442
+ signal: exec.signal
1443
+ });
1444
+ return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
1445
+ }
1446
+ })
1447
+ ];
1448
+ }
1449
+ //#endregion
1450
+ //#region src/index.ts
1451
+ /**
1452
+ * dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
1453
+ * 注册 9 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
1454
+ * 蒸馏/衰减飞轮与审计能力。
1455
+ * @module @kenz1117/dsh-engram
1456
+ */
1457
+ /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
1458
+ const name = "dsh-engram";
1459
+ /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
1460
+ const inject = ["tools", "llm"];
1461
+ /** 会话开始注入的画像渲染:top-N 高重要性 user 记忆一行一条。 */
1462
+ function renderProfile(topN) {
1463
+ return [
1464
+ "User memory profile (dsh-engram, cross-session):",
1465
+ ...topN.map((record) => `- [${record.kind}] ${record.content}`),
1466
+ "Use engram_search to recall details; use engram_save to persist new facts."
1467
+ ].join("\n");
1468
+ }
1469
+ /**
1470
+ * agent/pre-step waterfall:每轮第一步注入画像;同时 fire-and-forget 触发
1471
+ * 上一轮的自动摄取(不阻塞请求)。必须调用 next() 委托链路;reject 决策
1472
+ * 原样透传,记忆库为空或非首轮时不追加消息。
1473
+ */
1474
+ async function preStep(ctx, openStore, resolved, embedder, { agent, step, turn, signal }, next) {
1475
+ const decision = await next();
1476
+ if (decision.kind === "reject") return decision;
1477
+ if (step === 1 && resolved.ingest !== "off" && turn > 1) ingestPreviousTurn({
1478
+ events: agent.session.events,
1479
+ sessionId: String(agent.id),
1480
+ turn,
1481
+ openStore: () => openStore("user"),
1482
+ embedder,
1483
+ mode: resolved.ingest,
1484
+ routeOverride: resolved.routeOverride,
1485
+ call: (params) => streamText(ctx, {
1486
+ ...params,
1487
+ sessionId: agent.session.id
1488
+ }),
1489
+ logRequest: (data) => {
1490
+ openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
1491
+ },
1492
+ signal
1493
+ }).catch((error) => {
1494
+ console.warn("[dsh-engram] 本轮自动摄取失败(已跳过,不影响对话):", error);
1495
+ });
1496
+ if (step !== 1) return decision;
1497
+ const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
1498
+ if (top.length === 0) return decision;
1499
+ const text = renderProfile(top);
1500
+ return {
1501
+ ...decision,
1502
+ messages: [...decision.messages, createUserMessage({
1503
+ content: [{
1504
+ type: "text",
1505
+ text
1506
+ }],
1507
+ source: {
1508
+ kind: "plugin",
1509
+ plugin: name,
1510
+ form: "snapshot",
1511
+ sections: [{
1512
+ name,
1513
+ text
1514
+ }]
1515
+ }
1516
+ })]
1517
+ };
1518
+ }
1519
+ /**
1520
+ * 插件体:预热分库与嵌入器,注册 9 个工具、画像注入、自动摄取与衰减调度。
1521
+ * @param ctx - host 上下文。
1522
+ * @param config - cordis.yml 传入的可选配置;非法值在加载时 loud 失败。
1523
+ */
1524
+ function apply(ctx, config = {}) {
1525
+ const resolved = resolveConfig(config);
1526
+ mkdir(resolved.dbDir, {
1527
+ recursive: true,
1528
+ mode: 448
1529
+ });
1530
+ const projectDbName = `project-${Buffer.from(process.cwd()).toString("hex").slice(0, 24)}.db`;
1531
+ const stores = /* @__PURE__ */ new Map();
1532
+ const openStore = (scope) => {
1533
+ const existing = stores.get(scope);
1534
+ if (existing !== void 0) return existing;
1535
+ const created = openEngramStore(scope === "user" ? `${resolved.dbDir}/user.db` : `${resolved.dbDir}/${projectDbName}`);
1536
+ stores.set(scope, created);
1537
+ return created;
1538
+ };
1539
+ const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
1540
+ console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
1541
+ });
1542
+ for (const tool of createEngramTools({
1543
+ openStore,
1544
+ embedder,
1545
+ call: (callParams) => streamText(ctx, {
1546
+ ...callParams,
1547
+ sessionId: callParams.sessionId ?? ""
1548
+ }),
1549
+ routeOverride: resolved.routeOverride,
1550
+ exportDir: `${resolved.dbDir}/exports`
1551
+ })) ctx.tools.register(tool);
1552
+ if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, payload, next), { prepend: true });
1553
+ const runDecay = async () => {
1554
+ for (const scope of ["user", "project"]) {
1555
+ const archived = await (await openStore(scope)).decay({
1556
+ importanceBelow: resolved.decayImportanceBelow,
1557
+ olderThanDays: resolved.decayAfterDays
1558
+ });
1559
+ if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${scope} 库归档 ${archived} 条低价值记忆(可在 engram_review 查证)`);
1560
+ }
1561
+ };
1562
+ runDecay().catch((error) => {
1563
+ console.warn("[dsh-engram] 启动衰减调度失败(跳过):", error);
1564
+ });
1565
+ ctx.effect(() => {
1566
+ const timer = setInterval(() => {
1567
+ runDecay().catch((error) => {
1568
+ console.warn("[dsh-engram] 周期衰减调度失败(跳过):", error);
1569
+ });
1570
+ }, 864e5);
1571
+ return () => {
1572
+ clearInterval(timer);
1573
+ };
1574
+ }, "dsh-engram: decay timer");
1575
+ }
1576
+ //#endregion
1577
+ export { Config, apply, inject, name, renderProfile };