@kenz1117/dsh-engram 0.6.1 → 0.7.2

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 CHANGED
@@ -1,10 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
4
5
  import { homedir } from "node:os";
5
6
  import z from "@deepseek-ai/schemastery";
6
- import { mkdir, writeFile } from "node:fs/promises";
7
- import { createHash, randomUUID } from "node:crypto";
7
+ import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { createGzip, gunzip } from "node:zlib";
9
+ import { promisify } from "node:util";
8
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
11
  //#region src/config.ts
10
12
  /**
@@ -26,7 +28,9 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
26
28
  "injectTokenBudget",
27
29
  "rankRecencyWeight",
28
30
  "rankProofWeight",
29
- "queryRewrite"
31
+ "queryRewrite",
32
+ "autoSlot",
33
+ "reviewScheduling"
30
34
  ]);
31
35
  const INGEST_MODES = /* @__PURE__ */ new Set([
32
36
  "off",
@@ -48,7 +52,9 @@ const Config = z.object({
48
52
  injectTokenBudget: z.number().step(1).min(128).max(8192),
49
53
  rankRecencyWeight: z.number().min(0).max(2),
50
54
  rankProofWeight: z.number().min(0).max(2),
51
- queryRewrite: z.boolean()
55
+ queryRewrite: z.boolean(),
56
+ autoSlot: z.boolean(),
57
+ reviewScheduling: z.boolean()
52
58
  });
53
59
  /**
54
60
  * 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
@@ -85,7 +91,9 @@ function resolveConfig(config = {}) {
85
91
  injectTokenBudget: config.injectTokenBudget ?? 1024,
86
92
  rankRecencyWeight: config.rankRecencyWeight ?? .2,
87
93
  rankProofWeight: config.rankProofWeight ?? .1,
88
- queryRewrite: config.queryRewrite ?? true
94
+ queryRewrite: config.queryRewrite ?? true,
95
+ autoSlot: config.autoSlot ?? true,
96
+ reviewScheduling: config.reviewScheduling ?? true
89
97
  };
90
98
  }
91
99
  //#endregion
@@ -257,6 +265,8 @@ async function streamText(ctx, params) {
257
265
  const MEMORY_CONTEXT_TAG = "engram_memory_context";
258
266
  /** 当前用户请求协议标签。 */
259
267
  const CURRENT_USER_REQUEST_TAG = "current_user_request";
268
+ /** 用户显式禁记标签:在对话中用 <no-palace>...</no-palace> 包裹的整段不会被记忆。 */
269
+ const NO_PALACE_TAG = "no-palace";
260
270
  /**
261
271
  * 入库剥离时识别的记忆上下文标签集合:不区分来源——历史正文可能携带
262
272
  * 其他记忆插件(如 memmy/memos)的包裹标签,一律按不可信协议块剥离。
@@ -274,7 +284,7 @@ const MEMORY_CONTEXT_TAGS = [
274
284
  * @returns 可安全入库/复用的正文。
275
285
  */
276
286
  function sanitizeProtocolText(value) {
277
- return normalizeWhitespace(unwrapCurrentUserRequestBlocks(stripMemoryContextBlocks(value)));
287
+ return normalizeWhitespace(unwrapCurrentUserRequestBlocks(stripMemoryContextBlocks(stripNoPalaceBlocks(value))));
278
288
  }
279
289
  /**
280
290
  * 渲染记忆包:内容先清洗再包裹协议标签,附三条使用警告;当前请求独立成段。
@@ -320,6 +330,10 @@ function stripMemoryContextBlocks(value) {
320
330
  for (const tag of MEMORY_CONTEXT_TAGS) text = replaceTaggedBlocks(text, tag, () => "", { removeUnclosedTail: true });
321
331
  return text;
322
332
  }
333
+ /** 剥离 <no-palace>...</no-palace> 整段:用户显式禁记(默认整段移除,不留存任何痕迹)。 */
334
+ function stripNoPalaceBlocks(value) {
335
+ return replaceTaggedBlocks(value, NO_PALACE_TAG, () => "", { removeUnclosedTail: true });
336
+ }
323
337
  /** 解包 current_user_request 块,保留内部文本(当前请求是可信正文,只是去除标签)。 */
324
338
  function unwrapCurrentUserRequestBlocks(value) {
325
339
  return replaceTaggedBlocks(value, CURRENT_USER_REQUEST_TAG, (inner) => inner);
@@ -507,8 +521,71 @@ function collectTexts(events, includeAssistant) {
507
521
  minSeq
508
522
  };
509
523
  }
510
- /** turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。 */
524
+ /** 寒暄正则:整段用户输入只含问候/感谢等无信息内容时跳过摄取。 */
525
+ const CHITCHAT_RE = /^(?:你好|您好|嗨|哈喽|hello|hi|hey|谢谢|感谢|ok|okay|好的|在吗|收到|辛苦了)[!!,.,。??~\s]*$/iu;
526
+ /** 显式禁记正则:用户明确要求不要记住本轮内容时跳过摄取(窄匹配整句指令,避免误伤)。 */
527
+ const NO_CAPTURE_RE = /(?:不要|别|不用|无需)(?:把这?[个件些条]?|把上一?轮|把刚才)?(?:记|存)(?:住|录|下来|进去|到记忆|进记忆)|don'?t\s+(?:remember|record|save)\s+(?:this|that|it)/i;
528
+ /** 工具多样性得分:无工具 0;1-2 种 1;≥3 种 2。 */
529
+ function toolDiversity(distinct) {
530
+ return distinct === 0 ? 0 : distinct <= 2 ? 1 : 2;
531
+ }
532
+ /**
533
+ * 活动评分(四信号,< ACTIVITY_THRESHOLD 跳过摄取):
534
+ * min(floor(userChars/50), 3) + completedTurns + min(floor(toolResults/5), 2) + toolDiversity。
535
+ * @param signals - 上一轮活动信号。
536
+ * @returns 0-8 的整数评分。
537
+ */
538
+ function activityScore(signals) {
539
+ return Math.min(Math.floor(signals.userChars / 50), 3) + signals.completedTurns + Math.min(Math.floor(signals.toolResults / 5), 2) + toolDiversity(signals.toolNames.size);
540
+ }
541
+ /** 判断用户输入是否为纯寒暄(无信息内容)。 */
542
+ function isChitchat(text) {
543
+ return CHITCHAT_RE.test(text.trim());
544
+ }
545
+ /** 判断用户输入是否显式要求不要记住。 */
546
+ function forbidsCapture(text) {
547
+ return NO_CAPTURE_RE.test(text);
548
+ }
549
+ /** 从事件切片提取活动信号(用户文本排除插件注入的快照消息)。 */
550
+ function turnSignals(events) {
551
+ let userChars = 0;
552
+ let completedTurns = 0;
553
+ let toolResults = 0;
554
+ const toolNames = /* @__PURE__ */ new Set();
555
+ for (const event of events) if (event.type === "user/message") {
556
+ const data = event.data;
557
+ if (data?.source?.kind === "plugin") continue;
558
+ for (const block of data?.content ?? []) if (block?.type === "text" && typeof block.text === "string") userChars += block.text.length;
559
+ } else if (event.type === "assistant/message") {
560
+ if ((event.data?.content ?? []).some((block) => block?.type === "text" && typeof block.text === "string" && block.text !== "")) completedTurns = 1;
561
+ } else if (event.type === "tool/result") toolResults += 1;
562
+ else if (event.type === "tool/call") {
563
+ const name = event.data?.name;
564
+ if (typeof name === "string") toolNames.add(name);
565
+ }
566
+ return {
567
+ userChars,
568
+ completedTurns,
569
+ toolResults,
570
+ toolNames
571
+ };
572
+ }
573
+ /**
574
+ * 上一轮自动摄取的节流判定(只作用于 previous 切片;末轮/pending 重放补做不受限)。
575
+ * @returns 跳过原因;null = 允许摄取。
576
+ */
577
+ function throttleDecision(events) {
578
+ if (activityScore(turnSignals(events)) < 5) return "low-activity";
579
+ const joined = collectTexts(events, false).texts.join(" ");
580
+ if (joined !== "" && isChitchat(joined)) return "chitchat";
581
+ if (forbidsCapture(joined)) return "capture-forbidden";
582
+ return null;
583
+ }
584
+ /** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。
585
+ * events 允许 undefined:会话 dispose 后事件源已 detach,宿主可能给不出日志,
586
+ * 此时按空日志处理而不是抛 TypeError(调用方在会话生命周期之外,异常会变成未处理 rejection)。 */
511
587
  function turnStarts(events) {
588
+ if (events === void 0) return [];
512
589
  const starts = [];
513
590
  for (let i = 0; i < events.length; i++) {
514
591
  if (events[i]?.type !== "turn/start") continue;
@@ -579,6 +656,15 @@ async function ingestPreviousTurn(deps) {
579
656
  written: 0,
580
657
  skipped: "already-ingested"
581
658
  };
659
+ if (sliceMode === "previous") {
660
+ const throttled = throttleDecision(slice);
661
+ if (throttled !== null) return {
662
+ scannedEvents: slice.length,
663
+ candidates: 0,
664
+ written: 0,
665
+ skipped: throttled
666
+ };
667
+ }
582
668
  const scoped = omitRecallToolResults(slice);
583
669
  const { texts, minSeq } = collectTexts(scoped, limits.includeAssistant);
584
670
  const cleaned = texts.map((text) => redactSecrets(sanitizeProtocolText(text)));
@@ -674,18 +760,22 @@ async function markPendingIngest(store, sessionId, turn) {
674
760
  * 会话结束时的末轮摄取:切片为最后一个 turn/start 到日志末尾,复用提炼管线。
675
761
  * 失败/超时只告警并把 (sessionId, turn) pending 键写入 op_log(下次会话首次
676
762
  * pre-step 重放补做),绝不影响对话。
763
+ * 本函数不 reject:从取轮次到摄取全程在 try 内,异常一律降级为告警 + pending 键
764
+ * (dispose 观察器是 fire-and-forget,逃逸的 rejection 会被宿主的 fail-loud 当作致命错误)。
677
765
  * @returns 摄取结果;无末轮或失败(已落 pending)时返回 null。
678
766
  */
679
767
  async function ingestFinalTurn(deps) {
680
- const round = lastTurnNumber(deps.events);
681
- if (round === void 0) return null;
768
+ /** 末轮轮次号;取不到(无 turn/start 或事件源不可用)时不落 pending——键需要轮次。 */
769
+ let round;
682
770
  try {
771
+ round = lastTurnNumber(deps.events);
772
+ if (round === void 0) return null;
683
773
  return await ingestPreviousTurn({
684
774
  ...deps,
685
775
  slice: "last"
686
776
  });
687
777
  } catch (error) {
688
- try {
778
+ if (round !== void 0) try {
689
779
  await markPendingIngest(await deps.openStore(), deps.sessionId, round);
690
780
  } catch {}
691
781
  console.warn("[dsh-engram] 会话结束的末轮摄取失败(已记入待补做队列,不影响对话):", error);
@@ -734,6 +824,726 @@ async function replayPendingIngests(deps) {
734
824
  };
735
825
  }
736
826
  //#endregion
827
+ //#region src/consolidation/run.ts
828
+ /** 单条整理动作(写入 op_log 的统一标记)。 */
829
+ const CONSOLIDATION_OP = "consolidation";
830
+ /** 默认参数。 */
831
+ const DEFAULTS = {
832
+ olderThanDays: 30,
833
+ importanceBelow: .3,
834
+ mergeThreshold: .92
835
+ };
836
+ /** 闭馆整理主函数:纯异步,不抛错(失败仅记日志 / 落 pending)。 */
837
+ async function runConsolidation(store, embedder, opts = {}) {
838
+ const start = Date.now();
839
+ const options = {
840
+ ...DEFAULTS,
841
+ ...opts
842
+ };
843
+ const scope = options.scope ?? "user";
844
+ const stats = await store.stats();
845
+ const records = await store.topActive(scope, Math.max(stats.active, 0));
846
+ const archivedIds = /* @__PURE__ */ new Set();
847
+ let archived = 0;
848
+ let merged = 0;
849
+ let skipped = 0;
850
+ const now = Date.now();
851
+ const ageCutoff = options.olderThanDays * 864e5;
852
+ for (const record of records) {
853
+ if (record.importance >= options.importanceBelow) continue;
854
+ if (now - record.createdAt < ageCutoff) continue;
855
+ if (record.confidence >= .3) continue;
856
+ try {
857
+ await store.forget(record.id);
858
+ archivedIds.add(record.id);
859
+ archived += 1;
860
+ } catch {
861
+ skipped += 1;
862
+ }
863
+ }
864
+ const emb = await embedder;
865
+ const buckets = /* @__PURE__ */ new Map();
866
+ for (const record of records) {
867
+ if (archivedIds.has(record.id)) continue;
868
+ const list = buckets.get(record.kind) ?? [];
869
+ list.push(record);
870
+ buckets.set(record.kind, list);
871
+ }
872
+ const seedIds = options.mergeCandidateIds !== void 0 ? new Set(options.mergeCandidateIds) : null;
873
+ for (const list of buckets.values()) {
874
+ if (list.length < 2) continue;
875
+ if (seedIds !== null && !list.some((record) => seedIds.has(record.id))) continue;
876
+ if (emb === void 0) {
877
+ const seen = /* @__PURE__ */ new Map();
878
+ for (const record of list) {
879
+ const key = record.content.slice(0, 60);
880
+ const existing = seen.get(key);
881
+ if (existing === void 0) seen.set(key, record);
882
+ else if (record.importance > existing.importance) {
883
+ await store.supersedeMany({
884
+ scope,
885
+ kind: record.kind,
886
+ content: record.content,
887
+ importance: record.importance,
888
+ confidence: record.confidence
889
+ }, [existing.id]).catch(() => {
890
+ skipped += 1;
891
+ });
892
+ merged += 1;
893
+ seen.set(key, record);
894
+ }
895
+ }
896
+ continue;
897
+ }
898
+ const vectors = /* @__PURE__ */ new Map();
899
+ for (const record of list) {
900
+ if (seedIds !== null && !seedIds.has(record.id)) continue;
901
+ try {
902
+ const vec = (await emb.embed([record.content]))[0];
903
+ if (vec !== void 0) vectors.set(record.id, vec);
904
+ } catch {
905
+ skipped += 1;
906
+ }
907
+ }
908
+ const arr = list.filter((record) => vectors.has(record.id));
909
+ const visited = /* @__PURE__ */ new Set();
910
+ for (let i = 0; i < arr.length; i += 1) {
911
+ const a = arr[i];
912
+ if (visited.has(a.id)) continue;
913
+ const dupes = [];
914
+ for (let j = i + 1; j < list.length; j += 1) {
915
+ const b = list[j];
916
+ if (visited.has(b.id)) continue;
917
+ const bVec = vectors.get(b.id);
918
+ if (bVec === void 0) continue;
919
+ if (cosine$1(vectors.get(a.id), bVec) >= options.mergeThreshold) dupes.push(b);
920
+ }
921
+ if (dupes.length === 0) continue;
922
+ const ids = dupes.map((record) => record.id);
923
+ try {
924
+ await store.supersedeMany({
925
+ scope,
926
+ kind: a.kind,
927
+ content: a.content,
928
+ importance: a.importance,
929
+ confidence: a.confidence
930
+ }, ids);
931
+ for (const id of ids) visited.add(id);
932
+ visited.add(a.id);
933
+ merged += 1;
934
+ } catch {
935
+ skipped += 1;
936
+ }
937
+ }
938
+ }
939
+ const report = {
940
+ archived,
941
+ merged,
942
+ skipped,
943
+ tookMs: Date.now() - start
944
+ };
945
+ try {
946
+ await store.audit(CONSOLIDATION_OP, "AUX", JSON.stringify({
947
+ scope,
948
+ archived: report.archived,
949
+ merged: report.merged,
950
+ skipped: report.skipped,
951
+ tookMs: report.tookMs
952
+ }));
953
+ } catch {}
954
+ return report;
955
+ }
956
+ /** cosine 相似度(两个等长非零向量)。 */
957
+ function cosine$1(a, b) {
958
+ let dot = 0;
959
+ let na = 0;
960
+ let nb = 0;
961
+ const n = Math.min(a.length, b.length);
962
+ for (let i = 0; i < n; i += 1) {
963
+ dot += a[i] * b[i];
964
+ na += a[i] * a[i];
965
+ nb += b[i] * b[i];
966
+ }
967
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
968
+ return denom === 0 ? 0 : dot / denom;
969
+ }
970
+ //#endregion
971
+ //#region src/mirror/markdown.ts
972
+ /**
973
+ * Markdown 镜像:把 SQLite 记忆库导出为可被 Obsidian / VS Code / git 直接漫游的
974
+ * 文件树,每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md。
975
+ * 写入过程全部幂等:重复执行只会覆盖同名文件,不会向 SQLite 写任何东西(只读)。
976
+ * @module @kenz1117/dsh-engram/mirror/markdown
977
+ */
978
+ /** 房间铭牌 URL/路径安全的 slug(仅 ASCII、连字符分隔)。 */
979
+ function slugify(input) {
980
+ const stripped = input.toLowerCase().replace(/[\u4e00-\u9fa5]+/g, "记").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
981
+ return stripped === "" ? "untitled" : stripped.slice(0, 32);
982
+ }
983
+ /** frontmatter 字段值序列化(string 原样、数字 / 布尔直接、null 空字符串)。 */
984
+ function yaml(value) {
985
+ if (value === null || value === void 0) return "\"\"";
986
+ if (typeof value === "string") return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", " ")}"`;
987
+ return JSON.stringify(value);
988
+ }
989
+ /** ISO 时间戳(秒级,frontmatter 与 _meta 通用)。 */
990
+ function iso(ms) {
991
+ return new Date(ms).toISOString();
992
+ }
993
+ /** 楼层中文别名(与面板 i18n 的 kindLabel 保持一致;只用做目录名)。 */
994
+ const FLOOR_LABEL = {
995
+ fact: "fact",
996
+ preference: "preference",
997
+ decision: "decision",
998
+ episode: "episode",
999
+ skill: "skill"
1000
+ };
1001
+ /** 顶层 _index.md 的纯文本模板(楼层卡片 + 房间清单链接)。 */
1002
+ function renderIndex(scope, data, floors) {
1003
+ const total = data.records.length;
1004
+ const active = data.records.filter((r) => r.status === "active").length;
1005
+ const edges = data.edges.length;
1006
+ return `# 记忆宫殿 · ${scope === "user" ? "私人宫殿" : "项目宫殿"}\n` + [
1007
+ "",
1008
+ `> 导出时间 ${iso(data.exportedAt)} · 共 **${total}** 间房间(对外开放 ${active}) · 走廊 ${edges} 条`,
1009
+ "",
1010
+ "## 楼层导览",
1011
+ "",
1012
+ ...floors.map((f) => `- **${FLOOR_LABEL[f.kind] ?? f.kind} 层** · ${f.roomCount} 间(开放 ${f.active} · 展厅 ${f.archived} · 闭馆 ${f.forgotten})`),
1013
+ "",
1014
+ "## 房间清单",
1015
+ "",
1016
+ ...data.records.slice().sort((a, b) => b.importance - a.importance).map((record) => `- [${record.status === "active" ? "●" : record.status === "archived" ? "◇" : "×"}][${FLOOR_LABEL[record.kind] ?? record.kind}] ${record.content.slice(0, 60)}${record.content.length > 60 ? "…" : ""} — [[${record.id.slice(0, 8)}]]`),
1017
+ "",
1018
+ "## 走廊(关系边)",
1019
+ "",
1020
+ ...edges === 0 ? ["(暂无走廊)"] : data.edges.map((edge) => `- \`${edge.from.slice(0, 8)}\` --${edge.type}--> \`${edge.to.slice(0, 8)}\``),
1021
+ ""
1022
+ ].join("\n");
1023
+ }
1024
+ /** 单房间 .md 模板:YAML frontmatter + 铭牌正文 + 走廊列表。 */
1025
+ function renderRoom(record, edges) {
1026
+ const relEdges = edges.filter((edge) => edge.from === record.id || edge.to === record.id);
1027
+ const tags = [];
1028
+ if (record.outcome !== void 0) tags.push(`outcome-${record.outcome}`);
1029
+ if (record.content.includes("[REDACTED:")) tags.push("redacted");
1030
+ const imagery = record.imagery;
1031
+ const caption = imagery?.caption;
1032
+ const sensory = imagery?.sensoryTags ?? [];
1033
+ const frontmatter = [
1034
+ "---",
1035
+ `id: ${record.id}`,
1036
+ `scope: ${record.scope}`,
1037
+ `kind: ${record.kind}`,
1038
+ `status: ${record.status}`,
1039
+ `importance: ${record.importance.toFixed(3)}`,
1040
+ `confidence: ${record.confidence.toFixed(3)}`,
1041
+ `createdAt: "${iso(record.createdAt)}"`,
1042
+ `accessCount: ${record.accessCount}`,
1043
+ `sourceSession: ${record.sourceSessionId === null ? "\"\"" : yaml(record.sourceSessionId)}`,
1044
+ `sourceRound: ${record.sourceRound ?? "\"\""}`,
1045
+ ...tags.length === 0 ? [] : [`tags: [${tags.join(", ")}]`],
1046
+ ...caption !== void 0 && caption !== null ? [`imageryCaption: ${yaml(caption)}`] : [],
1047
+ ...sensory.length > 0 ? [`imagerySensory: [${sensory.map((s) => yaml(s)).join(", ")}]`, `imageryValence: ${(imagery?.emotionalValence ?? 0).toFixed(3)}`] : [],
1048
+ "---"
1049
+ ].join("\n");
1050
+ const body = [
1051
+ `# ${record.content.split("\n")[0]?.slice(0, 80) ?? "房间铭牌"}`,
1052
+ "",
1053
+ record.content,
1054
+ "",
1055
+ "## 走廊",
1056
+ "",
1057
+ ...relEdges.length === 0 ? ["(此房间暂未连接任何走廊)"] : relEdges.map((edge) => {
1058
+ const other = edge.from === record.id ? edge.to : edge.from;
1059
+ return `- ${edge.from === record.id ? "→" : "←"} \`${other.slice(0, 8)}\`(${edge.type})`;
1060
+ }),
1061
+ ""
1062
+ ].join("\n");
1063
+ return frontmatter + "\n" + body;
1064
+ }
1065
+ /** 楼层 _meta.json 模板。 */
1066
+ function renderMeta(scope, data, floors) {
1067
+ const payload = {
1068
+ scope,
1069
+ exportedAt: data.exportedAt,
1070
+ total: data.records.length,
1071
+ floors
1072
+ };
1073
+ return JSON.stringify(payload, null, 2) + "\n";
1074
+ }
1075
+ /** 计算楼层摘要(按 kind 分组统计 active/archived/forgotten)。 */
1076
+ function summarizeFloors(records) {
1077
+ const map = /* @__PURE__ */ new Map();
1078
+ for (const record of records) {
1079
+ const entry = map.get(record.kind) ?? {
1080
+ roomCount: 0,
1081
+ active: 0,
1082
+ archived: 0,
1083
+ forgotten: 0
1084
+ };
1085
+ entry.roomCount += 1;
1086
+ if (record.status === "active") entry.active += 1;
1087
+ else if (record.status === "archived") entry.archived += 1;
1088
+ else if (record.status === "forgotten") entry.forgotten += 1;
1089
+ map.set(record.kind, entry);
1090
+ }
1091
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([kind, count]) => ({
1092
+ kind,
1093
+ ...count
1094
+ }));
1095
+ }
1096
+ /**
1097
+ * 把一份 exportAll 数据写入镜像目录。
1098
+ * @param rootDir - 镜像根目录(通常 `${exportDir}/mirror/<scope>/`,由调用方拼)。
1099
+ * @param data - exportAll 的产物(含 records + edges)。
1100
+ * @returns 写入摘要(rootDir / fileCount / floors)。
1101
+ */
1102
+ async function writeMirror(rootDir, data) {
1103
+ const scope = data.records[0]?.scope ?? "user";
1104
+ await mkdir(rootDir, {
1105
+ recursive: true,
1106
+ mode: 448
1107
+ });
1108
+ const floors = summarizeFloors(data.records);
1109
+ const recordsByKind = /* @__PURE__ */ new Map();
1110
+ for (const record of data.records) {
1111
+ const list = recordsByKind.get(record.kind) ?? [];
1112
+ list.push(record);
1113
+ recordsByKind.set(record.kind, list);
1114
+ }
1115
+ let fileCount = 0;
1116
+ for (const record of data.records) {
1117
+ const floor = recordsByKind.get(record.kind);
1118
+ if (floor === void 0) continue;
1119
+ const indexInFloor = floor.indexOf(record);
1120
+ const floorDir = join(rootDir, FLOOR_LABEL[record.kind] ?? record.kind);
1121
+ await mkdir(floorDir, {
1122
+ recursive: true,
1123
+ mode: 448
1124
+ });
1125
+ const fileName = `${record.id.slice(0, 8)}-${slugify(record.content)}-${String(indexInFloor).padStart(3, "0")}.md`;
1126
+ await writeFile(join(floorDir, fileName), renderRoom(record, data.edges), { mode: 384 });
1127
+ fileCount += 1;
1128
+ }
1129
+ await writeFile(join(rootDir, "_index.md"), renderIndex(scope, data, floors), { mode: 384 });
1130
+ await writeFile(join(rootDir, "_meta.json"), renderMeta(scope, data, floors), { mode: 384 });
1131
+ fileCount += 2;
1132
+ if (scope === "shared") {
1133
+ const manifest = buildShareManifest(data);
1134
+ await writeFile(join(rootDir, "share-manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
1135
+ fileCount += 1;
1136
+ }
1137
+ return {
1138
+ rootDir,
1139
+ fileCount,
1140
+ floors,
1141
+ exportedAt: data.exportedAt
1142
+ };
1143
+ }
1144
+ /**
1145
+ * 从导出数据构造借阅归还清单(仅 shared scope 调用)。
1146
+ * firstLentAt 用 createdAt 替代(数据库没记首次公开时刻,这是务实选择)。
1147
+ */
1148
+ function buildShareManifest(data, now = Date.now()) {
1149
+ const loans = data.records.filter((record) => record.scope === "shared").map((record) => ({
1150
+ id: record.id,
1151
+ kind: record.kind,
1152
+ status: record.status,
1153
+ importance: record.importance,
1154
+ confidence: record.confidence,
1155
+ firstLentAt: record.createdAt,
1156
+ lastAccessedAt: record.lastAccessedAt,
1157
+ accessCount: record.accessCount
1158
+ }));
1159
+ return {
1160
+ generatedAt: now,
1161
+ scope: "shared",
1162
+ roomCount: loans.length,
1163
+ loans
1164
+ };
1165
+ }
1166
+ //#endregion
1167
+ //#region src/backup/tar-stream.ts
1168
+ /**
1169
+ * 极简 tar 流的写入与解包:UStar 格式(POSIX.1-1988),足够本插件「打包几个 .db + _meta.json」场景。
1170
+ * 文件名长度 ≤ 99 字节、文件 ≤ 8 GiB 已覆盖本插件的备份规模;超过则抛错。
1171
+ * 不依赖 npm 包(无第三方 = 无供应链风险);可被测试套件无障碍运行。
1172
+ * @module @kenz1117/dsh-engram/backup/tar-stream
1173
+ */
1174
+ /** UStar 头字段宽度(POSIX.1-1988)。 */
1175
+ const BLOCK_SIZE = 512;
1176
+ const NAME_LEN = 100;
1177
+ const SIZE_LEN = 12;
1178
+ /** 把数字按八进制 + ASCII 写入固定宽度(末尾置空)。 */
1179
+ function writeOctal(buf, offset, length, value) {
1180
+ const str = value.toString(8).padStart(length - 1, "0");
1181
+ buf.write(str, offset, length - 1, "ascii");
1182
+ buf.write("\0", offset + length - 1, 1, "ascii");
1183
+ }
1184
+ /** 校验和:头块 0-511 字节所有字节求和(chksum 字段本身按空格处理)。 */
1185
+ function computeChecksum(header) {
1186
+ let sum = 0;
1187
+ for (let i = 0; i < BLOCK_SIZE; i += 1) sum += header[i];
1188
+ return sum;
1189
+ }
1190
+ /** 写一个文件条目到 tar 流(UStar 头 + 数据 + 填充到 512 边界)。 */
1191
+ function createTarPack(out, name, data) {
1192
+ if (name.length >= NAME_LEN) throw new Error(`tar 文件名过长(${String(name.length)} / ${String(99)}):${name}`);
1193
+ if (data.length >= 2 ** 88 - 1) throw new Error(`tar 文件过大:${name}(${String(data.length)} bytes)`);
1194
+ const header = Buffer.alloc(BLOCK_SIZE);
1195
+ header.write(name, 0, NAME_LEN, "ascii");
1196
+ writeOctal(header, 100, 8, 384);
1197
+ writeOctal(header, 108, 8, 0);
1198
+ writeOctal(header, 116, 8, 0);
1199
+ writeOctal(header, 124, SIZE_LEN, data.length);
1200
+ writeOctal(header, 136, 12, Math.floor(Date.now() / 1e3));
1201
+ header.write("0", 156, 1, "ascii");
1202
+ header.write("ustar\0", 257, 6, "ascii");
1203
+ header.write("00", 263, 2, "ascii");
1204
+ header.write(" ", 148, 8, "ascii");
1205
+ writeOctal(header, 148, 8, computeChecksum(header));
1206
+ out.write(header);
1207
+ out.write(data);
1208
+ const padLen = (BLOCK_SIZE - data.length % BLOCK_SIZE) % BLOCK_SIZE;
1209
+ if (padLen > 0) out.write(Buffer.alloc(padLen));
1210
+ }
1211
+ /** 写两个全 0 块作为 tar 结束标记(EOF)。 */
1212
+ function endTarPack(out) {
1213
+ out.write(Buffer.alloc(BLOCK_SIZE * 2));
1214
+ }
1215
+ /** 解 tar 字节流到目标目录。整段读完再解析(备份场景文件小、可接受)。 */
1216
+ async function extractTar(buffer, destDir) {
1217
+ const { writeFile, mkdir } = await import("node:fs/promises");
1218
+ const { dirname } = await import("node:path");
1219
+ let offset = 0;
1220
+ while (offset + BLOCK_SIZE <= buffer.length) {
1221
+ const header = buffer.subarray(offset, offset + BLOCK_SIZE);
1222
+ if (header.every((b) => b === 0)) return;
1223
+ const name = header.toString("ascii", 0, NAME_LEN).replace(/\0+$/, "");
1224
+ const sizeOct = header.toString("ascii", 124, 136).replace(/\0+$/, "");
1225
+ const size = parseInt(sizeOct, 8);
1226
+ offset += BLOCK_SIZE;
1227
+ if (size > 0) {
1228
+ const data = buffer.subarray(offset, offset + size);
1229
+ const target = join(destDir, name);
1230
+ await mkdir(dirname(target), {
1231
+ recursive: true,
1232
+ mode: 448
1233
+ });
1234
+ await writeFile(target, data, { mode: 384 });
1235
+ const pad = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
1236
+ offset += size + pad;
1237
+ } else offset += (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
1238
+ }
1239
+ }
1240
+ //#endregion
1241
+ //#region src/backup/tar.ts
1242
+ /**
1243
+ * 宫殿备份与恢复:把当前 user/project 两库的 .db + 元信息打包成单一 .tar.gz 文件;
1244
+ * 恢复时校验 tar 内的 _meta.json schema 版本并覆盖原库(恢复前自动备份现状为 .before-restore-<ts>)。
1245
+ * 设计原则:
1246
+ * 1. tar 内文件路径用相对形式 `palace.db` / `palace-<scope>.db` / `_meta.json`,避免暴露绝对路径。
1247
+ * 2. 元信息必须含 schema_version(与插件同构),恢复时若不匹配返回明确的兼容错误而不是默默覆盖。
1248
+ * 3. 整个过程失败原子:恢复前若任何前置校验失败,原库不动。
1249
+ * @module @kenz1117/dsh-engram/backup/tar
1250
+ */
1251
+ const gunzipAsync = promisify(gunzip);
1252
+ /**
1253
+ * 创建备份:把 dbDir 下所有 *.db 与 `_meta.json` 一起打包成 .tar.gz。
1254
+ * @param dbDir - 插件数据目录(含 user.db / project-*.db)。
1255
+ * @param pluginVersion - 写进 _meta.json 的插件版本(与 package.json 同步)。
1256
+ */
1257
+ async function createBackup(dbDir, pluginVersion) {
1258
+ await mkdir(dbDir, {
1259
+ recursive: true,
1260
+ mode: 448
1261
+ });
1262
+ const files = (await readdir(dbDir)).filter((name) => name.endsWith(".db"));
1263
+ const scopes = [];
1264
+ const entries = [];
1265
+ for (const name of files) {
1266
+ const path = join(dbDir, name);
1267
+ if (!(await stat(path)).isFile()) continue;
1268
+ entries.push({
1269
+ name,
1270
+ data: await readFileBuffer(path)
1271
+ });
1272
+ scopes.push({
1273
+ scope: name === "user.db" ? "user" : "project",
1274
+ recordCount: 0,
1275
+ edgeCount: 0
1276
+ });
1277
+ }
1278
+ const meta = {
1279
+ schemaVersion: 1,
1280
+ pluginVersion,
1281
+ exportedAt: Date.now(),
1282
+ scopes
1283
+ };
1284
+ entries.push({
1285
+ name: "_meta.json",
1286
+ data: Buffer.from(JSON.stringify(meta, null, 2), "utf8")
1287
+ });
1288
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1289
+ const archivePath = join(dbDir, `palace-backup-${stamp}.tar.gz`);
1290
+ await writeTarGz(archivePath, entries);
1291
+ return {
1292
+ archivePath,
1293
+ meta,
1294
+ bytes: (await stat(archivePath)).size
1295
+ };
1296
+ }
1297
+ /** 异步读文件为 Buffer(小文件路径,备份场景可接受)。 */
1298
+ async function readFileBuffer(path) {
1299
+ const { readFile } = await import("node:fs/promises");
1300
+ return readFile(path);
1301
+ }
1302
+ /**
1303
+ * 把 entries 写入 .tar.gz。tar 头由本模块自带实现(避免引入 tar 依赖),gzip 用 zlib.createGzip。
1304
+ */
1305
+ async function writeTarGz(outPath, entries) {
1306
+ await new Promise((resolve, reject) => {
1307
+ const sink = createWriteStream(outPath, { mode: 384 });
1308
+ sink.on("error", reject);
1309
+ sink.on("finish", () => resolve());
1310
+ const gz = createGzip();
1311
+ gz.on("error", reject);
1312
+ gz.pipe(sink);
1313
+ for (const entry of entries) createTarPack(gz, entry.name, entry.data);
1314
+ endTarPack(gz);
1315
+ gz.end();
1316
+ });
1317
+ }
1318
+ /**
1319
+ * 从 .tar.gz 恢复:解包到临时目录 → 校验 _meta.json → 把 .db 文件原子搬到 dbDir。
1320
+ * 任何前置校验失败抛错,原 dbDir 不变。
1321
+ */
1322
+ async function restoreBackup(archivePath, dbDir, options = {}) {
1323
+ const tempDir = join(dbDir, `.restore-tmp-${Date.now()}`);
1324
+ await mkdir(tempDir, {
1325
+ recursive: true,
1326
+ mode: 448
1327
+ });
1328
+ try {
1329
+ await untarGz(archivePath, tempDir);
1330
+ const metaRaw = await readFileBuffer(join(tempDir, "_meta.json")).then((buf) => buf.toString("utf8"));
1331
+ const meta = JSON.parse(metaRaw);
1332
+ if (meta.schemaVersion !== 1) throw new Error(`备份 schema 版本 ${meta.schemaVersion} 与当前 1 不兼容(请升级插件或使用旧版恢复)`);
1333
+ if (typeof meta.pluginVersion !== "string" || meta.pluginVersion === "") throw new Error("备份 _meta.json 缺少 pluginVersion 字段,可能已损坏");
1334
+ const restored = [];
1335
+ if (options.keepCurrent !== false) {
1336
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1337
+ for (const name of (await readdir(dbDir)).filter((n) => n.endsWith(".db"))) {
1338
+ const path = join(dbDir, name);
1339
+ await rename(path, `${path}.before-restore-${stamp}`);
1340
+ }
1341
+ }
1342
+ for (const name of (await readdir(tempDir)).filter((n) => n.endsWith(".db"))) {
1343
+ const target = join(dbDir, name);
1344
+ await rename(join(tempDir, name), target);
1345
+ restored.push(target);
1346
+ }
1347
+ return {
1348
+ restored,
1349
+ archiveMeta: meta
1350
+ };
1351
+ } finally {
1352
+ await rm(tempDir, {
1353
+ recursive: true,
1354
+ force: true
1355
+ });
1356
+ }
1357
+ }
1358
+ /** 解 .tar.gz 到目标目录:先解压到 buffer,再 inline 解 tar。 */
1359
+ async function untarGz(archivePath, destDir) {
1360
+ const compressed = await readFileBuffer(archivePath);
1361
+ await extractTar(await gunzipAsync(compressed), destDir);
1362
+ }
1363
+ //#endregion
1364
+ //#region src/telemetry/aggregate.ts
1365
+ /** 聚合窗口(默认 7 天,可由路由 query 覆盖)。 */
1366
+ const DEFAULT_WINDOW_DAYS = 7;
1367
+ /** 从单库 op_log 统计各 op 的计数(窗口内)。 */
1368
+ async function countByOp(store, sinceMs) {
1369
+ const ops = await store.recentOps(1e5);
1370
+ const counts = {};
1371
+ for (const op of ops) {
1372
+ if (op.at < sinceMs) continue;
1373
+ counts[op.op] = (counts[op.op] ?? 0) + 1;
1374
+ }
1375
+ return counts;
1376
+ }
1377
+ /** 聚合两库的指标 + 各 scope 状态。
1378
+ * @param scopeFilter - 限定只聚合指定 scope;undefined = 三库全聚合(向后兼容)。 */
1379
+ async function aggregateTelemetry(openStore, windowDays = DEFAULT_WINDOW_DAYS, scopeFilter) {
1380
+ const untilMs = Date.now();
1381
+ const sinceMs = untilMs - windowDays * 864e5;
1382
+ const scopes = scopeFilter !== void 0 ? [scopeFilter] : [
1383
+ "user",
1384
+ "project",
1385
+ "shared"
1386
+ ];
1387
+ const countsAgg = {
1388
+ ingestRequests: 0,
1389
+ ingestDones: 0,
1390
+ searches: 0,
1391
+ writes: 0,
1392
+ updates: 0,
1393
+ forgets: 0,
1394
+ restores: 0,
1395
+ distillRequests: 0,
1396
+ compressRequests: 0,
1397
+ searchRewrites: 0,
1398
+ consumptions: 0,
1399
+ consolidations: 0
1400
+ };
1401
+ const scopeViews = [];
1402
+ for (const scope of scopes) try {
1403
+ const store = await openStore(scope);
1404
+ const [counts, stats] = await Promise.all([countByOp(store, sinceMs), store.stats()]);
1405
+ countsAgg.ingestRequests += counts["ingest-request"] ?? 0;
1406
+ countsAgg.ingestDones += counts["ingest-done"] ?? 0;
1407
+ countsAgg.searches += counts["search-rewrite-request"] ?? 0;
1408
+ countsAgg.writes += counts["write"] ?? 0;
1409
+ countsAgg.updates += counts["update"] ?? 0;
1410
+ countsAgg.forgets += counts["forget"] ?? 0;
1411
+ countsAgg.restores += counts["restore"] ?? 0;
1412
+ countsAgg.distillRequests += counts["distill-request"] ?? 0;
1413
+ countsAgg.compressRequests += counts["compress-request"] ?? 0;
1414
+ countsAgg.searchRewrites += counts["search-rewrite-request"] ?? 0;
1415
+ countsAgg.consumptions += counts["outcome-report"] ?? 0;
1416
+ countsAgg.consolidations += counts["consolidation"] ?? 0;
1417
+ scopeViews.push({
1418
+ scope,
1419
+ active: stats.active,
1420
+ total: stats.total,
1421
+ signalRatio: stats.signalRatio
1422
+ });
1423
+ } catch {}
1424
+ return {
1425
+ windowDays,
1426
+ sinceMs,
1427
+ untilMs,
1428
+ counts: countsAgg,
1429
+ scopes: scopeViews
1430
+ };
1431
+ }
1432
+ //#endregion
1433
+ //#region src/tour-proposal.ts
1434
+ /**
1435
+ * 构造入殿建议。
1436
+ * @param scope - 作用域(user/project;shared 单独处理)
1437
+ * @param records - 当前 scope 的 active 条目(已按 importance × confidence 倒序)
1438
+ * @param focusKind - 本轮 user 输入的主题(可选;存在时优先选同 kind)
1439
+ */
1440
+ function buildTourProposal(scope, records, focusKind) {
1441
+ const active = records.filter((r) => r.status === "active");
1442
+ const empty = active.length === 0;
1443
+ const limit = Math.min(5, Math.max(1, active.length));
1444
+ const scored = [...active].sort((a, b) => {
1445
+ const focusBoost = (record) => focusKind !== void 0 && record.kind === focusKind ? 1 : 0;
1446
+ const scoreA = focusBoost(a) * 1e3 + a.importance * a.confidence * 100;
1447
+ return focusBoost(b) * 1e3 + b.importance * b.confidence * 100 - scoreA;
1448
+ }).slice(0, limit);
1449
+ return {
1450
+ greeting: empty ? `[${scope}] 宫殿尚空。建议:先放第一段记忆(例如一条 fact 或 preference),让后续会话有锚点可循。` : `[${scope}] 宫殿现存 ${active.length} 间 active 房间。建议开场巡游:${scored.map((r, i) => `第 ${i + 1} 站「${r.content.slice(0, 24)}${r.content.length > 24 ? "…" : ""}」`).join(";")}。`,
1451
+ suggestedStops: scored,
1452
+ activeCount: active.length,
1453
+ empty
1454
+ };
1455
+ }
1456
+ //#endregion
1457
+ //#region src/refurb.ts
1458
+ /** 缺省参数。 */
1459
+ const DEFAULT_REFURB_OPTIONS = {
1460
+ demoteBelow: .2,
1461
+ staleDays: 60,
1462
+ duplicateTitleWindow: 0
1463
+ };
1464
+ /**
1465
+ * 扫描一组 active 条目,按规则生成翻新建议。
1466
+ * 仅扫描同 scope 内的内容;跨 scope 的相似合并留给上层判定。
1467
+ */
1468
+ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
1469
+ const suggestions = [];
1470
+ const now = Date.now();
1471
+ const active = records.filter((r) => r.status === "active");
1472
+ const byScope = /* @__PURE__ */ new Map();
1473
+ for (const record of active) {
1474
+ const list = byScope.get(record.scope) ?? [];
1475
+ list.push(record);
1476
+ byScope.set(record.scope, list);
1477
+ }
1478
+ for (const record of active) {
1479
+ const daysSinceAccess = (now - record.lastAccessedAt) / 864e5;
1480
+ if (record.importance < options.demoteBelow && daysSinceAccess > options.staleDays) suggestions.push({
1481
+ action: "demote",
1482
+ primaryId: record.id,
1483
+ candidates: [],
1484
+ scope: record.scope,
1485
+ reason: `重要性 ${record.importance.toFixed(2)} < ${options.demoteBelow} 且 ${Math.floor(daysSinceAccess)} 天未访问,建议降级为 archived。`,
1486
+ confidence: .7
1487
+ });
1488
+ }
1489
+ for (const [scope, list] of byScope) {
1490
+ const byKind = /* @__PURE__ */ new Map();
1491
+ for (const record of list) {
1492
+ const bucket = byKind.get(record.kind) ?? [];
1493
+ bucket.push(record);
1494
+ byKind.set(record.kind, bucket);
1495
+ }
1496
+ for (const [, items] of byKind) {
1497
+ if (items.length < 2) continue;
1498
+ const seen = /* @__PURE__ */ new Set();
1499
+ for (const record of items) {
1500
+ const key = record.content.slice(0, 20);
1501
+ if (seen.has(key)) continue;
1502
+ seen.add(key);
1503
+ const dupes = items.filter((other) => other.id !== record.id && other.content.startsWith(key));
1504
+ if (dupes.length > 0) suggestions.push({
1505
+ action: "merge",
1506
+ primaryId: record.id,
1507
+ candidates: dupes.map((d) => d.id),
1508
+ scope,
1509
+ reason: `与 ${dupes.length} 间房间内容前 20 字重复,建议蒸馏合并。`,
1510
+ confidence: .6
1511
+ });
1512
+ }
1513
+ }
1514
+ }
1515
+ for (const record of active) if (record.accessCount === 0 && record.importance >= .7) suggestions.push({
1516
+ action: "review",
1517
+ primaryId: record.id,
1518
+ candidates: [],
1519
+ scope: record.scope,
1520
+ reason: `重要性 ${record.importance.toFixed(2)} 但从未被参观,可能埋没在走廊,建议复习。`,
1521
+ confidence: .5
1522
+ });
1523
+ for (const record of active) if (record.content.length > 400) suggestions.push({
1524
+ action: "split",
1525
+ primaryId: record.id,
1526
+ candidates: [],
1527
+ scope: record.scope,
1528
+ reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立房间。`,
1529
+ confidence: .6
1530
+ });
1531
+ for (const record of active) {
1532
+ const score = record.imageryScore;
1533
+ if (score !== void 0 && score >= .5) continue;
1534
+ const slot = record.slot === void 0 ? "" : `${record.slot.room}#${record.slot.index} `;
1535
+ suggestions.push({
1536
+ action: "review",
1537
+ primaryId: record.id,
1538
+ candidates: [],
1539
+ scope: record.scope,
1540
+ reason: score === void 0 ? `${slot}未挂门牌:宫殿纪律要求每个标记唯一、差异化、带日期,建议用 engram_update 的 placard 参数补挂。` : `${slot}门牌得分 ${score.toFixed(2)} < 0.5(不合唯一/差异化/带日期纪律),建议重写铭牌。`,
1541
+ confidence: .4
1542
+ });
1543
+ }
1544
+ return suggestions;
1545
+ }
1546
+ //#endregion
737
1547
  //#region src/routes.ts
738
1548
  /** 回环 peer:IPv4 127/8、IPv6 ::1、IPv4-mapped IPv6。 */
739
1549
  function isLoopbackPeer(req) {
@@ -804,7 +1614,8 @@ function json(res, status, body) {
804
1614
  }
805
1615
  /** 从 URL searchParams 收敛 scope(缺省 user)。 */
806
1616
  function scopeOf$1(raw, fallback) {
807
- return raw === "user" || raw === "project" ? raw : fallback;
1617
+ if (raw === "user" || raw === "project" || raw === "shared") return raw;
1618
+ return fallback;
808
1619
  }
809
1620
  /**
810
1621
  * 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
@@ -840,12 +1651,103 @@ function registerEngramRoutes(ctx, deps) {
840
1651
  ...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
841
1652
  ...q !== null && q !== "" ? { q } : {},
842
1653
  ...redacted === "true" || redacted === "false" ? { redacted: redacted === "true" } : {},
1654
+ ...url.searchParams.get("sort") === "tour" ? { sort: "tour" } : {},
843
1655
  limit,
844
1656
  offset
845
1657
  };
846
1658
  json(res, 200, await (await deps.openStore(scope)).list(filter));
847
1659
  return;
848
1660
  }
1661
+ if (req.method === "GET" && route === "search-test") {
1662
+ const q = url.searchParams.get("q");
1663
+ if (q === null || q.trim() === "") {
1664
+ json(res, 400, { error: "q required" });
1665
+ return;
1666
+ }
1667
+ const scopeParam = url.searchParams.get("scope");
1668
+ const scopes = scopeParam === "all" || scopeParam === null || scopeParam === "" ? ["user", "project"] : [scopeOf$1(scopeParam, "user")];
1669
+ const kind = url.searchParams.get("kind");
1670
+ const limit = Math.min(20, Math.max(1, Number(url.searchParams.get("limit") ?? 10) || 10));
1671
+ const embedder = deps.embedder === void 0 ? void 0 : await deps.embedder;
1672
+ const vector = embedder === void 0 ? void 0 : (await embedder.embed([q.trim()]))[0];
1673
+ const results = await Promise.all(scopes.map(async (scope) => {
1674
+ return (await deps.openStore(scope)).search({
1675
+ text: q,
1676
+ scopes: [scope],
1677
+ limit
1678
+ }, vector);
1679
+ }));
1680
+ const degraded = results.some((result) => result.degraded);
1681
+ let hits = results.flatMap((result) => result.hits);
1682
+ if (kind !== null && kind !== "" && kind !== "all") hits = hits.filter((hit) => hit.record.kind === kind);
1683
+ json(res, 200, {
1684
+ degraded,
1685
+ hits: hits.map((hit) => ({
1686
+ id: hit.record.id,
1687
+ score: hit.score,
1688
+ via: hit.via,
1689
+ ...hit.viaEdge === void 0 ? {} : { viaEdge: hit.viaEdge },
1690
+ scope: hit.record.scope,
1691
+ kind: hit.record.kind,
1692
+ status: hit.record.status,
1693
+ content: hit.record.content,
1694
+ createdAt: hit.record.createdAt
1695
+ }))
1696
+ });
1697
+ return;
1698
+ }
1699
+ if (req.method === "GET" && route === "activity") {
1700
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
1701
+ json(res, 200, { operations: (await Promise.all(["user", "project"].map(async (scope) => {
1702
+ return (await (await deps.openStore(scope)).recentOps(limit)).map((op) => ({
1703
+ ...op,
1704
+ scope
1705
+ }));
1706
+ }))).flat().sort((a, b) => b.at - a.at).slice(0, limit) });
1707
+ return;
1708
+ }
1709
+ if (req.method === "GET" && route === "review-due") {
1710
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1711
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
1712
+ const now = Date.now();
1713
+ json(res, 200, {
1714
+ scope,
1715
+ items: (await (await deps.openStore(scope)).dueReviews(now, limit)).map((record) => ({
1716
+ id: record.id,
1717
+ kind: record.kind,
1718
+ ...record.slot === void 0 ? {} : { slot: record.slot },
1719
+ caption: record.imagery?.caption ?? null,
1720
+ nextReviewAt: record.review?.nextReviewAt ?? null,
1721
+ overdueDays: record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)),
1722
+ reps: record.review?.reps ?? 0
1723
+ }))
1724
+ });
1725
+ return;
1726
+ }
1727
+ if (req.method === "POST" && route === "review-answer") {
1728
+ if (!guardWrite(req, res)) return;
1729
+ const body = await readJsonBody(req);
1730
+ if (body === null || typeof body.id !== "string" || body.id === "") {
1731
+ json(res, 400, { error: "id required" });
1732
+ return;
1733
+ }
1734
+ const grade = typeof body.grade === "number" && Number.isInteger(body.grade) && body.grade >= 0 && body.grade <= 5 ? body.grade : null;
1735
+ if (grade === null) {
1736
+ json(res, 400, { error: "grade must be an integer 0-5" });
1737
+ return;
1738
+ }
1739
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
1740
+ const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
1741
+ if (record === void 0) {
1742
+ json(res, 404, { error: `未找到条目 ${body.id}` });
1743
+ return;
1744
+ }
1745
+ json(res, 200, {
1746
+ id: record.id,
1747
+ review: record.review ?? null
1748
+ });
1749
+ return;
1750
+ }
849
1751
  if (req.method === "GET" && route === "review") {
850
1752
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
851
1753
  const id = url.searchParams.get("id");
@@ -854,7 +1756,6 @@ function registerEngramRoutes(ctx, deps) {
854
1756
  return;
855
1757
  }
856
1758
  const view = await (await deps.openStore(scope)).review(id);
857
- view === void 0 || view.operations;
858
1759
  if (view === void 0) {
859
1760
  json(res, 404, { error: `未找到条目 ${id}` });
860
1761
  return;
@@ -862,6 +1763,53 @@ function registerEngramRoutes(ctx, deps) {
862
1763
  json(res, 200, view);
863
1764
  return;
864
1765
  }
1766
+ if (req.method === "GET" && route === "review-due") {
1767
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1768
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
1769
+ const now = Date.now();
1770
+ const due = await (await deps.openStore(scope)).dueReviews(now, limit);
1771
+ json(res, 200, {
1772
+ scope,
1773
+ count: due.length,
1774
+ now,
1775
+ items: due.map((record) => ({
1776
+ id: record.id,
1777
+ kind: record.kind,
1778
+ scope: record.scope,
1779
+ importance: record.importance,
1780
+ confidence: record.confidence,
1781
+ ...record.slot === void 0 ? {} : { slot: record.slot },
1782
+ ...typeof record.imagery?.caption === "string" ? { caption: record.imagery.caption } : {},
1783
+ ...record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? {} : { overdueDays: Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)) },
1784
+ ...record.review === void 0 ? {} : {
1785
+ reps: record.review.reps,
1786
+ intervalDays: record.review.intervalDays
1787
+ }
1788
+ }))
1789
+ });
1790
+ return;
1791
+ }
1792
+ if (req.method === "POST" && route === "review-answer") {
1793
+ if (!guardWrite(req, res)) return;
1794
+ const body = await readJsonBody(req);
1795
+ if (body === null || typeof body.id !== "string" || body.id === "") {
1796
+ json(res, 400, { error: "id required" });
1797
+ return;
1798
+ }
1799
+ const grade = Number(body.grade);
1800
+ if (!Number.isInteger(grade) || grade < 0 || grade > 5) {
1801
+ json(res, 400, { error: "grade must be an integer 0-5" });
1802
+ return;
1803
+ }
1804
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
1805
+ const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
1806
+ if (record === void 0) {
1807
+ json(res, 404, { error: `未找到条目 ${body.id}` });
1808
+ return;
1809
+ }
1810
+ json(res, 200, { record });
1811
+ return;
1812
+ }
865
1813
  if (req.method === "GET" && route === "export") {
866
1814
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
867
1815
  const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
@@ -883,12 +1831,194 @@ function registerEngramRoutes(ctx, deps) {
883
1831
  res.end(body);
884
1832
  return;
885
1833
  }
886
- if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
887
- if (!guardWrite(req, res)) return;
888
- const body = await readJsonBody(req);
889
- if (body === null || typeof body.id !== "string" || body.id === "") {
890
- json(res, 400, { error: "id required" });
891
- return;
1834
+ if (req.method === "GET" && route === "mirror") {
1835
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1836
+ const data = await (await deps.openStore(scope)).exportAll();
1837
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1838
+ json(res, 200, {
1839
+ ...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
1840
+ scope,
1841
+ roomCount: data.records.length,
1842
+ edgeCount: data.edges.length
1843
+ });
1844
+ return;
1845
+ }
1846
+ if (req.method === "GET" && route === "health") {
1847
+ const rawScope = url.searchParams.get("scope");
1848
+ const scopes = rawScope === "user" || rawScope === "project" || rawScope === "shared" ? [rawScope] : ["user", "project"];
1849
+ const parts = await Promise.all(scopes.map(async (scope) => {
1850
+ const store = await deps.openStore(scope);
1851
+ const s = await store.stats();
1852
+ const edgeCount = (await store.exportAll()).edges.length;
1853
+ const total = Math.max(s.total, 1);
1854
+ const signal = s.signalRatio;
1855
+ const activeRatio = s.active / total;
1856
+ const corridorRatio = Math.min(.5, edgeCount / Math.max(s.active, 1)) / .5;
1857
+ const redactedPenalty = Math.max(0, 1 - s.redacted / total);
1858
+ const archivedRatio = s.archived / total;
1859
+ const isShared = scope === "shared";
1860
+ const targetActive = isShared ? .7 : .85;
1861
+ const decayLow = isShared ? .25 : .15;
1862
+ const decayHigh = isShared ? .5 : .45;
1863
+ const decayWindow = isShared ? .25 : .3;
1864
+ const decayCoverage = archivedRatio >= decayLow && archivedRatio <= decayHigh ? 1 : Math.max(0, 1 - Math.min(Math.abs(archivedRatio - decayLow), Math.abs(archivedRatio - decayHigh)) / decayWindow);
1865
+ const score = Math.round(signal * 40 + (1 - Math.abs(activeRatio - targetActive) / targetActive) * 25 + corridorRatio * 15 + redactedPenalty * 10 + decayCoverage * 10);
1866
+ return {
1867
+ scope,
1868
+ score: Math.min(100, Math.max(0, score)),
1869
+ signal,
1870
+ activeRatio,
1871
+ edgeCount,
1872
+ redacted: s.redacted,
1873
+ archivedRatio
1874
+ };
1875
+ }));
1876
+ json(res, 200, {
1877
+ overall: Math.round(parts.reduce((sum, part) => sum + part.score, 0) / parts.length),
1878
+ parts,
1879
+ evaluatedAt: Date.now()
1880
+ });
1881
+ return;
1882
+ }
1883
+ if (req.method === "GET" && route === "corridor") {
1884
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1885
+ const includeStatuses = /* @__PURE__ */ new Set(["active"]);
1886
+ const rawStatus = url.searchParams.get("status");
1887
+ if (rawStatus !== null && rawStatus !== "" && rawStatus !== "all") {
1888
+ for (const s of rawStatus.split(",")) if (s === "active" || s === "archived" || s === "forgotten") includeStatuses.add(s);
1889
+ }
1890
+ const data = await (await deps.openStore(scope)).exportAll();
1891
+ const nodes = data.records.filter((r) => includeStatuses.has(r.status)).map((r) => ({
1892
+ id: r.id,
1893
+ scope: r.scope,
1894
+ kind: r.kind,
1895
+ status: r.status,
1896
+ importance: r.importance,
1897
+ confidence: r.confidence,
1898
+ title: r.content.length > 40 ? `${r.content.slice(0, 40)}…` : r.content,
1899
+ content: r.content
1900
+ }));
1901
+ const allowedIds = new Set(nodes.map((n) => n.id));
1902
+ json(res, 200, {
1903
+ scope,
1904
+ nodes,
1905
+ edges: data.edges.filter((e) => allowedIds.has(e.from) && allowedIds.has(e.to)).map((e) => ({
1906
+ id: `${e.from}-${e.to}-${e.type}`,
1907
+ from: e.from,
1908
+ to: e.to,
1909
+ type: e.type
1910
+ }))
1911
+ });
1912
+ return;
1913
+ }
1914
+ if (req.method === "GET" && route === "telemetry") {
1915
+ const windowDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 7) || 7));
1916
+ const rawScope = url.searchParams.get("scope");
1917
+ const scopeFilter = rawScope === "user" ? "user" : rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : void 0;
1918
+ json(res, 200, await aggregateTelemetry(deps.openStore, windowDays, scopeFilter));
1919
+ return;
1920
+ }
1921
+ if (req.method === "GET" && route === "tour-proposal") {
1922
+ const rawScope = url.searchParams.get("scope");
1923
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1924
+ const filter = await (await deps.openStore(scope)).list({
1925
+ scope,
1926
+ status: "active",
1927
+ limit: 200,
1928
+ offset: 0
1929
+ });
1930
+ const focusKind = url.searchParams.get("focusKind");
1931
+ const proposal = buildTourProposal(scope, filter.records, focusKind ?? void 0);
1932
+ json(res, 200, {
1933
+ scope: proposal.empty ? "empty" : scope,
1934
+ greeting: proposal.greeting,
1935
+ activeCount: proposal.activeCount,
1936
+ empty: proposal.empty,
1937
+ suggestedStops: proposal.suggestedStops.map((record) => ({
1938
+ id: record.id,
1939
+ kind: record.kind,
1940
+ content: record.content,
1941
+ importance: record.importance,
1942
+ confidence: record.confidence
1943
+ }))
1944
+ });
1945
+ return;
1946
+ }
1947
+ if (req.method === "GET" && route === "refurb") {
1948
+ const rawScope = url.searchParams.get("scope");
1949
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1950
+ const suggestions = gatherRefurbSuggestions((await (await deps.openStore(scope)).list({
1951
+ scope,
1952
+ status: "active",
1953
+ limit: 500,
1954
+ offset: 0
1955
+ })).records, DEFAULT_REFURB_OPTIONS);
1956
+ json(res, 200, {
1957
+ scope,
1958
+ count: suggestions.length,
1959
+ suggestions: suggestions.map((suggestion) => ({
1960
+ action: suggestion.action,
1961
+ primaryId: suggestion.primaryId,
1962
+ candidates: [...suggestion.candidates],
1963
+ scope: suggestion.scope,
1964
+ reason: suggestion.reason,
1965
+ confidence: suggestion.confidence
1966
+ }))
1967
+ });
1968
+ return;
1969
+ }
1970
+ if (req.method === "POST" && route === "consolidate") {
1971
+ if (!guardWrite(req, res)) return;
1972
+ const body = await readJsonBody(req);
1973
+ const rawScope = typeof body?.scope === "string" ? body.scope : "user";
1974
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1975
+ const candidates = Array.isArray(body?.candidates) ? body.candidates.filter((value) => typeof value === "string") : void 0;
1976
+ json(res, 200, await runConsolidation(await deps.openStore(scope), deps.embedder, candidates === void 0 ? { scope } : {
1977
+ scope,
1978
+ mergeCandidateIds: candidates
1979
+ }));
1980
+ return;
1981
+ }
1982
+ if (req.method === "POST" && route === "backup") {
1983
+ if (!guardWrite(req, res)) return;
1984
+ const result = await createBackup(deps.dbDir, deps.pluginVersion);
1985
+ json(res, 200, {
1986
+ archivePath: result.archivePath,
1987
+ bytes: result.bytes,
1988
+ meta: result.meta,
1989
+ schemaVersion: 1
1990
+ });
1991
+ return;
1992
+ }
1993
+ if (req.method === "POST" && route === "restore-backup") {
1994
+ if (!guardWrite(req, res)) return;
1995
+ const body = await readJsonBody(req);
1996
+ if (body === null || typeof body.archivePath !== "string" || body.archivePath === "") {
1997
+ json(res, 400, { error: "archivePath required" });
1998
+ return;
1999
+ }
2000
+ const normalized = join(deps.dbDir, body.archivePath.replace(/^\/+/, ""));
2001
+ if (!normalized.startsWith(deps.dbDir + "/") && normalized !== deps.dbDir) {
2002
+ json(res, 400, { error: "archivePath 必须在 dbDir 内" });
2003
+ return;
2004
+ }
2005
+ try {
2006
+ const result = await restoreBackup(normalized, deps.dbDir);
2007
+ json(res, 200, {
2008
+ restored: result.restored,
2009
+ meta: result.archiveMeta
2010
+ });
2011
+ } catch (restoreError) {
2012
+ json(res, 400, { error: `恢复失败:${restoreError instanceof Error ? restoreError.message : String(restoreError)}` });
2013
+ }
2014
+ return;
2015
+ }
2016
+ if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
2017
+ if (!guardWrite(req, res)) return;
2018
+ const body = await readJsonBody(req);
2019
+ if (body === null || typeof body.id !== "string" || body.id === "") {
2020
+ json(res, 400, { error: "id required" });
2021
+ return;
892
2022
  }
893
2023
  const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
894
2024
  const store = await deps.openStore(scope);
@@ -1050,6 +2180,106 @@ function migrateProjectDb(dbDir, identity) {
1050
2180
  }
1051
2181
  return "none";
1052
2182
  }
2183
+ /** kind → 默认房间名。固定映射,保证同主题记忆总是聚在同一间(同类巩固)。 */
2184
+ const KIND_ROOMS = {
2185
+ fact: "事实厅",
2186
+ preference: "偏好阁",
2187
+ decision: "决策堂",
2188
+ episode: "往事廊",
2189
+ skill: "技法坊"
2190
+ };
2191
+ /**
2192
+ * 为新条目分配桩位。
2193
+ * @param kind - 记忆种类(决定默认房间)。
2194
+ * @param occupancy - 各房间占用状态(store.slotCountsByRoom() 的快照)。
2195
+ * @returns 分配的桩位与是否开了新房(开新房时调用方应记 op_log 提醒人工命名/拆分)。
2196
+ */
2197
+ function assignSlot(kind, occupancy) {
2198
+ const base = KIND_ROOMS[kind];
2199
+ for (let n = 1;; n += 1) {
2200
+ const room = n === 1 ? base : `${base}-${n}`;
2201
+ const state = occupancy[room];
2202
+ if (state === void 0) return {
2203
+ slot: {
2204
+ room,
2205
+ index: 1
2206
+ },
2207
+ openedNewRoom: n > 1
2208
+ };
2209
+ if (state.count < 9) return {
2210
+ slot: {
2211
+ room,
2212
+ index: state.maxIndex + 1
2213
+ },
2214
+ openedNewRoom: false
2215
+ };
2216
+ }
2217
+ }
2218
+ //#endregion
2219
+ //#region src/review/sm2.ts
2220
+ /** ease 系数下限(SM-2 标准值):低于此值记忆会陷入过密复习。 */
2221
+ const MIN_EASE = 1.3;
2222
+ const DAY_MS = 864e5;
2223
+ /**
2224
+ * 按回忆质量推进调度。
2225
+ * @param grade - 回忆质量 0-5(0/1 完全遗忘,2 模糊错误,3 勉强,4 正确有迟疑,5 完美)。
2226
+ * @param state - 当前调度状态。
2227
+ * @param now - 答题时刻(epoch 毫秒)。
2228
+ * @returns 新调度状态(不修改入参)。
2229
+ */
2230
+ function nextSchedule(grade, state, now) {
2231
+ if (grade < 3) {
2232
+ const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
2233
+ return {
2234
+ nextReviewAt: now + 1 * DAY_MS,
2235
+ easeFactor: Math.round(ease * 100) / 100,
2236
+ intervalDays: 1,
2237
+ reps: 0
2238
+ };
2239
+ }
2240
+ const reps = state.reps + 1;
2241
+ const intervalDays = reps === 1 ? 1 : reps === 2 ? 6 : Math.max(1, Math.round(state.intervalDays * state.easeFactor));
2242
+ const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
2243
+ return {
2244
+ nextReviewAt: now + intervalDays * DAY_MS,
2245
+ easeFactor: Math.round(ease * 100) / 100,
2246
+ intervalDays,
2247
+ reps
2248
+ };
2249
+ }
2250
+ /** 日期/时间锚点:ISO 日期、中文年月、相对时间词。 */
2251
+ const DATE_ANCHOR = /\d{4}[-/年.]\s?\d{1,2}|[今昨]天|本周|上周|周[一二三四五六日天]|\d{1,2}月\d{1,2}[日号]/;
2252
+ /** 差异化比较的前缀长度:前 6 字相同即视为「近似到无法区分」。 */
2253
+ const DIFF_PREFIX_LEN = 6;
2254
+ /**
2255
+ * 计算门牌质量分(0-1,保留两位小数)。
2256
+ * 构成:caption 有效(4-30 字)且全库唯一 +0.4;带日期/时间锚点 +0.3;
2257
+ * 与同房既有门牌差异化(前 6 字不重复)+0.3。无 caption 记 0 分。
2258
+ * @param caption - 门牌文字(ImageryLabel.caption);null/undefined 表示未挂牌。
2259
+ * @param context - 既有门牌快照。
2260
+ * @returns 0-1 的质量分。
2261
+ */
2262
+ function scorePlacard(caption, context) {
2263
+ if (caption === null || caption === void 0) return 0;
2264
+ const text = caption.trim();
2265
+ let score = 0;
2266
+ const valid = text.length >= 4 && text.length <= 30;
2267
+ if (valid && !context.existingCaptions.includes(text)) score += .4;
2268
+ if (DATE_ANCHOR.test(text)) score += .3;
2269
+ const prefix = text.slice(0, DIFF_PREFIX_LEN);
2270
+ const clashes = context.roomCaptions.some((other) => other.slice(0, DIFF_PREFIX_LEN) === prefix);
2271
+ if (valid && !clashes) score += .3;
2272
+ return Math.round(Math.min(1, score) * 100) / 100;
2273
+ }
2274
+ /**
2275
+ * 低分门牌的增强建议(附在 engram_save 输出末尾,中文一行)。
2276
+ * @param score - scorePlacard 的得分。
2277
+ * @returns 建议文本;非低分返回 null。
2278
+ */
2279
+ function placardImprovementHint(score) {
2280
+ if (score >= .5) return null;
2281
+ return "门牌不合规(宫殿纪律:唯一 · 差异化 · 带日期):建议用 engram_update 的 imagery 参数挂一个 4-30 字、含日期锚点、与同房其他门牌前 6 字不重复的铭牌。";
2282
+ }
1053
2283
  //#endregion
1054
2284
  //#region src/store/sqlite.ts
1055
2285
  /**
@@ -1059,8 +2289,33 @@ function migrateProjectDb(dbDir, identity) {
1059
2289
  * 本包声明兼容 node ^22.19。
1060
2290
  * @module @kenz1117/dsh-engram/store/sqlite
1061
2291
  */
1062
- /** 当前 schema 版本;结构性变更必须 +1 并拒绝旧库(pre-release 无兼容承诺)。 */
1063
- const SCHEMA_VERSION = 2;
2292
+ /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
2293
+ const SCHEMA_VERSION = 6;
2294
+ /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
2295
+ * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
2296
+ * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
2297
+ * v4 → v5:nodes 补 imagery_json 列(意象铭牌:caption + sensoryTags + emotionalValence + provisional)。
2298
+ * v5 → v6:桩位(slot_room/slot_index)、意象质量分(imagery_score)、SM-2 调度
2299
+ * (next_review_at/ease_factor/interval_days/review_reps)+ 固定巡游路线表 tour_routes。
2300
+ * 全部可空或带默认值,存量条目零搬运;排桩由 backfillSlots 幂等补齐。 */
2301
+ const MIGRATIONS = {
2302
+ "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
2303
+ "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
2304
+ node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2305
+ importance REAL NOT NULL, superseded_at INTEGER NOT NULL);`,
2306
+ "4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT",
2307
+ "5": `ALTER TABLE nodes ADD COLUMN slot_room TEXT;
2308
+ ALTER TABLE nodes ADD COLUMN slot_index INTEGER;
2309
+ ALTER TABLE nodes ADD COLUMN imagery_score REAL;
2310
+ ALTER TABLE nodes ADD COLUMN next_review_at INTEGER;
2311
+ ALTER TABLE nodes ADD COLUMN ease_factor REAL;
2312
+ ALTER TABLE nodes ADD COLUMN interval_days REAL;
2313
+ ALTER TABLE nodes ADD COLUMN review_reps INTEGER DEFAULT 0;
2314
+ CREATE TABLE IF NOT EXISTS tour_routes (
2315
+ position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
2316
+ CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
2317
+ CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`
2318
+ };
1064
2319
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
1065
2320
  const RRF_K = 60;
1066
2321
  /** 向量道的语义门槛:低于该余弦的条目不参与排序。 */
@@ -1073,9 +2328,38 @@ const RANK_POOL = 64;
1073
2328
  const EXPANSION_LIMIT = 32;
1074
2329
  /** 命中强化:每次检索命中的置信度增量。 */
1075
2330
  const CONFIDENCE_BUMP = .05;
2331
+ /** 效果回报降权:failure 回报的置信度扣减(success 复用 CONFIDENCE_BUMP)。 */
2332
+ const OUTCOME_PENALTY = .1;
1076
2333
  /** 审计视图返回的操作日志条数上限。 */
1077
2334
  const REVIEW_LOG_LIMIT = 20;
2335
+ /** 把意象铭牌序列化为 JSON(缺省序列化为 null,落库)。 */
2336
+ function imageryToJson(imagery) {
2337
+ if (imagery === void 0) return null;
2338
+ return JSON.stringify({
2339
+ caption: imagery.caption,
2340
+ sensoryTags: [...imagery.sensoryTags],
2341
+ emotionalValence: imagery.emotionalValence,
2342
+ provisional: imagery.provisional
2343
+ });
2344
+ }
2345
+ /** 从 JSON 反序列化意象铭牌;空串或解析失败返回 undefined(视作未铭刻)。 */
2346
+ function jsonToImagery(raw) {
2347
+ if (raw === null || raw === "") return void 0;
2348
+ try {
2349
+ const parsed = JSON.parse(raw);
2350
+ return {
2351
+ caption: typeof parsed.caption === "string" ? parsed.caption : null,
2352
+ sensoryTags: Array.isArray(parsed.sensoryTags) ? parsed.sensoryTags.filter((s) => typeof s === "string") : [],
2353
+ emotionalValence: typeof parsed.emotionalValence === "number" && Number.isFinite(parsed.emotionalValence) ? Math.min(1, Math.max(0, parsed.emotionalValence)) : 0,
2354
+ provisional: parsed.provisional === true
2355
+ };
2356
+ } catch {
2357
+ return;
2358
+ }
2359
+ }
1078
2360
  function rowToRecord(row) {
2361
+ const imagery = jsonToImagery(row.imagery_json);
2362
+ const hasReviewState = row.next_review_at !== null || row.ease_factor !== null || row.interval_days !== null;
1079
2363
  return {
1080
2364
  id: asMemoryId(row.id),
1081
2365
  scope: row.scope,
@@ -1084,12 +2368,25 @@ function rowToRecord(row) {
1084
2368
  importance: row.importance,
1085
2369
  confidence: row.confidence,
1086
2370
  status: row.status,
2371
+ ...row.outcome === "success" || row.outcome === "failure" ? { outcome: row.outcome } : {},
1087
2372
  createdAt: row.created_at,
1088
2373
  lastAccessedAt: row.last_accessed_at,
1089
2374
  accessCount: row.access_count,
1090
2375
  sourceSessionId: row.source_session_id,
1091
2376
  sourceRound: row.source_round,
1092
- sourceSeq: row.source_seq
2377
+ sourceSeq: row.source_seq,
2378
+ ...imagery === void 0 ? {} : { imagery },
2379
+ ...row.slot_room === null || row.slot_index === null ? {} : { slot: {
2380
+ room: row.slot_room,
2381
+ index: row.slot_index
2382
+ } },
2383
+ ...row.imagery_score === null ? {} : { imageryScore: row.imagery_score },
2384
+ ...hasReviewState ? { review: {
2385
+ nextReviewAt: row.next_review_at,
2386
+ easeFactor: row.ease_factor ?? 2.5,
2387
+ intervalDays: row.interval_days ?? 0,
2388
+ reps: row.review_reps ?? 0
2389
+ } } : {}
1093
2390
  };
1094
2391
  }
1095
2392
  function blobToVec(blob) {
@@ -1142,14 +2439,20 @@ const NO_BOOST = {
1142
2439
  proofWeight: 0,
1143
2440
  decayAfterDays: 30
1144
2441
  };
2442
+ /** 缺省全开(config 的默认值也在此处对齐)。 */
2443
+ const DEFAULT_AUTOMATION = {
2444
+ autoSlot: true,
2445
+ reviewScheduling: true
2446
+ };
1145
2447
  /**
1146
2448
  * 打开(必要时创建)一个 scope 分库。
1147
2449
  * @param path - SQLite 文件路径;目录不存在会自动创建(0o700)。
1148
2450
  * @param rankBoost - 排序 boost 参数;缺省不乘任何因子。
2451
+ * @param automation - 写入期自动化开关(自动排桩/初始排期);缺省全开。
1149
2452
  * @returns 就绪的 EngramStore。
1150
2453
  * @throws EngramError(code=SCHEMA_INCOMPATIBLE) 当库的 schema 版本高于当前实现。
1151
2454
  */
1152
- async function openEngramStore(path, rankBoost = NO_BOOST) {
2455
+ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_AUTOMATION) {
1153
2456
  await mkdir(dirname(path), {
1154
2457
  recursive: true,
1155
2458
  mode: 448
@@ -1173,36 +2476,89 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1173
2476
  id TEXT PRIMARY KEY, scope TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
1174
2477
  importance REAL NOT NULL, confidence REAL NOT NULL, status TEXT NOT NULL,
1175
2478
  created_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, access_count INTEGER NOT NULL,
1176
- source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB);
2479
+ source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB, outcome TEXT,
2480
+ imagery_json TEXT,
2481
+ slot_room TEXT, slot_index INTEGER, imagery_score REAL,
2482
+ next_review_at INTEGER, ease_factor REAL, interval_days REAL,
2483
+ review_reps INTEGER DEFAULT 0);
1177
2484
  CREATE TABLE IF NOT EXISTS edges (
1178
2485
  from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL,
1179
2486
  PRIMARY KEY (from_id, to_id, type));
1180
2487
  CREATE TABLE IF NOT EXISTS op_log (
1181
2488
  seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, op TEXT NOT NULL,
1182
2489
  target_id TEXT NOT NULL, detail TEXT);
2490
+ CREATE TABLE IF NOT EXISTS nodes_revisions (
2491
+ node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2492
+ importance REAL NOT NULL, superseded_at INTEGER NOT NULL);
2493
+ CREATE TABLE IF NOT EXISTS tour_routes (
2494
+ position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
1183
2495
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
1184
2496
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
1185
2497
  `);
1186
2498
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1187
- if (versionRow === void 0) db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
1188
- else if (Number(versionRow.value) !== SCHEMA_VERSION) {
1189
- db.close();
1190
- throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${versionRow.value} 与插件支持的 ${SCHEMA_VERSION} 不一致:请备份并删除旧库文件(${path})后重试`);
2499
+ if (versionRow === void 0) {
2500
+ db.exec(`CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
2501
+ CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`);
2502
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
2503
+ } else {
2504
+ let version = Number(versionRow.value);
2505
+ if (!Number.isInteger(version) || version > SCHEMA_VERSION) {
2506
+ db.close();
2507
+ throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${versionRow.value} 高于插件支持的 ${SCHEMA_VERSION}:请升级插件或备份后删除旧库文件(${path})`);
2508
+ }
2509
+ while (version < SCHEMA_VERSION) {
2510
+ const migration = MIGRATIONS[String(version)];
2511
+ if (migration === void 0) {
2512
+ db.close();
2513
+ throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${version} 无法升到 ${SCHEMA_VERSION}(缺迁移步骤):请备份并删除旧库文件(${path})后重试`);
2514
+ }
2515
+ withTransaction(() => {
2516
+ const safe = migration.split(";").map((stmt) => stmt.trim()).filter((stmt) => stmt !== "").filter((stmt) => {
2517
+ const match = /^ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)/i.exec(stmt);
2518
+ if (match === null) return true;
2519
+ const table = match[1];
2520
+ const column = match[2];
2521
+ return !db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
2522
+ });
2523
+ for (const stmt of safe) db.exec(stmt);
2524
+ version += 1;
2525
+ db.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'").run(String(version));
2526
+ });
2527
+ }
1191
2528
  }
1192
2529
  const sqlGet = db.prepare("SELECT * FROM nodes WHERE id = ?");
1193
2530
  const sqlInsert = db.prepare(`INSERT INTO nodes
1194
2531
  (id, scope, kind, content, importance, confidence, status, created_at, last_accessed_at, access_count,
1195
- source_session_id, source_round, source_seq, embedding)
1196
- VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`);
2532
+ source_session_id, source_round, source_seq, embedding, imagery_json,
2533
+ slot_room, slot_index, imagery_score, next_review_at, ease_factor, interval_days)
2534
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1197
2535
  const sqlFtsInsert = db.prepare("INSERT INTO nodes_fts (node_id, content) VALUES (?, ?)");
1198
2536
  const sqlSetStatus = db.prepare("UPDATE nodes SET status = ?, last_accessed_at = ? WHERE id = ?");
2537
+ const sqlSetOutcome = db.prepare(`UPDATE nodes
2538
+ SET outcome = ?,
2539
+ confidence = CASE WHEN ? = 'success' THEN min(1.0, confidence + ${CONFIDENCE_BUMP}) ELSE max(0.0, confidence - ${OUTCOME_PENALTY}) END
2540
+ WHERE id = ?`);
1199
2541
  const sqlTouch = db.prepare(`UPDATE nodes SET access_count = access_count + 1, last_accessed_at = ?,
1200
2542
  confidence = MIN(1, confidence + ${CONFIDENCE_BUMP}) WHERE id = ?`);
1201
2543
  const sqlLog = db.prepare("INSERT INTO op_log (at, op, target_id, detail) VALUES (?, ?, ?, ?)");
1202
2544
  const sqlHasAudit = db.prepare("SELECT 1 AS x FROM op_log WHERE op = ? AND detail = ? LIMIT 1");
1203
2545
  const sqlListAudit = db.prepare("SELECT detail FROM op_log WHERE op = ? AND detail IS NOT NULL ORDER BY seq");
1204
2546
  const sqlClearAudit = db.prepare("DELETE FROM op_log WHERE op = ? AND detail = ?");
1205
- const sqlOpLogById = db.prepare("SELECT at, op, detail FROM op_log WHERE target_id = ? ORDER BY seq DESC LIMIT ?");
2547
+ const sqlOpLogById = db.prepare("SELECT at, op, target_id, detail FROM op_log WHERE target_id = ? ORDER BY seq DESC LIMIT ?");
2548
+ const sqlGetManyByIds = db.prepare("SELECT * FROM nodes WHERE id IN (SELECT value FROM json_each(?))");
2549
+ const sqlNeighborsBounded = db.prepare(`SELECT * FROM nodes WHERE id IN (
2550
+ WITH RECURSIVE walk(id, depth) AS (
2551
+ SELECT ?, 0
2552
+ UNION
2553
+ SELECT CASE WHEN e.from_id = walk.id THEN e.to_id ELSE e.from_id END, walk.depth + 1
2554
+ FROM edges e JOIN walk ON (e.from_id = walk.id OR e.to_id = walk.id)
2555
+ WHERE walk.depth < ? AND e.type IN ('supports','refines','related','supersedes','contradicts')
2556
+ )
2557
+ SELECT id FROM walk WHERE depth > 0
2558
+ )`);
2559
+ const sqlRecentOps = db.prepare("SELECT at, op, target_id, detail FROM op_log ORDER BY seq DESC LIMIT ?");
2560
+ const sqlRevisionInsert = db.prepare("INSERT INTO nodes_revisions (node_id, content, kind, importance, superseded_at) VALUES (?, ?, ?, ?, ?)");
2561
+ const sqlRevisionsById = db.prepare("SELECT content, kind, importance, superseded_at FROM nodes_revisions WHERE node_id = ? ORDER BY superseded_at DESC");
1206
2562
  const sqlTopActive = db.prepare("SELECT * FROM nodes WHERE scope = ? AND status = 'active' ORDER BY importance DESC, confidence DESC LIMIT ?");
1207
2563
  const sqlEdgeUpsert = db.prepare("INSERT OR IGNORE INTO edges (from_id, to_id, type, created_at) VALUES (?, ?, ?, ?)");
1208
2564
  const sqlNeighbors = db.prepare(`SELECT * FROM edges WHERE from_id IN (SELECT value FROM json_each(?))
@@ -1216,31 +2572,54 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1216
2572
  const sqlAllNodes = db.prepare("SELECT * FROM nodes ORDER BY created_at");
1217
2573
  const sqlAllEdges = db.prepare("SELECT * FROM edges");
1218
2574
  const sqlDecay = db.prepare(`UPDATE nodes SET status = 'archived'
1219
- WHERE status = 'active' AND importance < ? AND last_accessed_at < ?`);
2575
+ WHERE status = 'active' AND importance < ? AND last_accessed_at < ? AND next_review_at IS NULL`);
2576
+ const sqlScheduleReview = db.prepare(`UPDATE nodes
2577
+ SET next_review_at = ?, ease_factor = ?, interval_days = ?, review_reps = ?, last_accessed_at = ?
2578
+ WHERE id = ?`);
2579
+ const sqlDueReviews = db.prepare(`SELECT * FROM nodes
2580
+ WHERE status = 'active' AND next_review_at IS NOT NULL AND next_review_at <= ?
2581
+ ORDER BY next_review_at ASC LIMIT ?`);
2582
+ const sqlSlotCounts = db.prepare(`SELECT slot_room AS room, MAX(slot_index) AS maxIndex, COUNT(*) AS n
2583
+ FROM nodes WHERE slot_room IS NOT NULL AND status != 'forgotten' GROUP BY slot_room`);
2584
+ const sqlSetSlot = db.prepare("UPDATE nodes SET slot_room = ?, slot_index = ? WHERE id = ?");
2585
+ const sqlUnslotted = db.prepare(`SELECT * FROM nodes WHERE slot_room IS NULL AND status = 'active'
2586
+ ORDER BY kind, created_at`);
2587
+ const sqlRouteAppend = db.prepare(`INSERT INTO tour_routes (position, node_id)
2588
+ VALUES ((SELECT COALESCE(MAX(position), -1) + 1 FROM tour_routes), ?)`);
2589
+ const sqlRouteList = db.prepare("SELECT position, node_id FROM tour_routes ORDER BY position");
2590
+ const sqlRouteHas = db.prepare("SELECT 1 AS x FROM tour_routes WHERE node_id = ? LIMIT 1");
2591
+ const sqlSlotNeighbors = db.prepare(`SELECT id FROM nodes
2592
+ WHERE slot_room = ? AND slot_index IN (?, ?) AND status = 'active' AND id != ?`);
2593
+ const sqlListPlacards = db.prepare(`SELECT slot_room AS room, json_extract(imagery_json, '$.caption') AS caption
2594
+ FROM nodes WHERE imagery_json IS NOT NULL AND status = 'active'`);
1220
2595
  const sqlPurgeNodes = db.prepare("DELETE FROM nodes");
1221
2596
  const sqlPurgeEdges = db.prepare("DELETE FROM edges");
1222
2597
  const sqlPurgeFts = db.prepare("DELETE FROM nodes_fts");
1223
2598
  const sqlPurgeLog = db.prepare("DELETE FROM op_log");
1224
- /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重)。 */
1225
- const ftsSearch = (match, scopes) => {
2599
+ const sqlPurgeRoutes = db.prepare("DELETE FROM tour_routes");
2600
+ /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
2601
+ const ftsSearch = (match, scopes, rooms) => {
1226
2602
  const placeholders = scopes.map(() => "?").join(",");
2603
+ const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND n.slot_room IN (${rooms.map(() => "?").join(",")})`;
1227
2604
  return db.prepare(`SELECT n.* FROM nodes_fts f JOIN nodes n ON n.id = f.node_id
1228
- WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})
1229
- ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes);
2605
+ WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})${roomCond}
2606
+ ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes, ...rooms ?? []);
1230
2607
  };
1231
- /** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成)。 */
1232
- const vectorPool = (scopes) => {
2608
+ /** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成);rooms 非空时只查指定房间。 */
2609
+ const vectorPool = (scopes, rooms) => {
1233
2610
  const placeholders = scopes.map(() => "?").join(",");
1234
- return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})`).all(...scopes);
2611
+ const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND slot_room IN (${rooms.map(() => "?").join(",")})`;
2612
+ return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})${roomCond}`).all(...scopes, ...rooms ?? []);
1235
2613
  };
1236
2614
  const getRow = (id) => sqlGet.get(id);
1237
2615
  /**
1238
2616
  * 写入公共体:插入节点 + FTS + 操作日志(不建边、不开事务)。
1239
2617
  * 事务由调用方持有(withTransaction)。
1240
2618
  */
1241
- const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, op) => {
2619
+ const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, imagery, op) => {
1242
2620
  const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
1243
- sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored);
2621
+ const initialReview = input.initialReviewAt ?? null;
2622
+ sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery), input.slot?.room ?? null, input.slot?.index ?? null, input.imageryScore ?? null, initialReview, initialReview === null ? null : 2.5, initialReview === null ? null : 0);
1244
2623
  sqlFtsInsert.run(id, tokenizeForFts(content));
1245
2624
  sqlLog.run(at, op, id, JSON.stringify({
1246
2625
  kind: input.kind,
@@ -1266,6 +2645,45 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1266
2645
  related: related.map(asMemoryId)
1267
2646
  };
1268
2647
  };
2648
+ /**
2649
+ * 写入期自动化(save/批量/摄取/update/蒸馏全部写入路径统一在此落地;调用方须已持事务):
2650
+ * 1) 排桩——显式 slot 优先,否则按 kind 分房自动分配(满员开新房并记 op_log);
2651
+ * 2) 门牌评分——有铭牌时按「唯一/差异化/带日期」启发式落库;
2652
+ * 3) 初始排期——reviewScheduling 开启且未显式指定时,1 天后首次到期;
2653
+ * 4) 巡游路线——有桩位的条目登记到固定路线末尾。
2654
+ * @returns 合入 WriteInput 的自动化字段。
2655
+ */
2656
+ const applyWriteAutomation = (input, id, at, imagery) => {
2657
+ let slot = input.slot;
2658
+ if (slot === void 0 && automation.autoSlot) {
2659
+ const occupancy = {};
2660
+ for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
2661
+ count: row.n,
2662
+ maxIndex: row.maxIndex
2663
+ };
2664
+ const assigned = assignSlot(input.kind, occupancy);
2665
+ slot = assigned.slot;
2666
+ if (assigned.openedNewRoom) sqlLog.run(at, "room-open", "BATCH", JSON.stringify({
2667
+ room: slot.room,
2668
+ kind: input.kind
2669
+ }));
2670
+ }
2671
+ let imageryScore = input.imageryScore;
2672
+ if (imageryScore === void 0 && imagery !== void 0) {
2673
+ const placards = sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
2674
+ imageryScore = scorePlacard(imagery.caption, {
2675
+ existingCaptions: placards.map((row) => row.caption),
2676
+ roomCaptions: slot === void 0 ? [] : placards.filter((row) => row.room === slot.room).map((row) => row.caption)
2677
+ });
2678
+ }
2679
+ const initialReviewAt = input.initialReviewAt ?? (automation.reviewScheduling ? at + 864e5 : void 0);
2680
+ if (slot !== void 0 && sqlRouteHas.get(id) === void 0) sqlRouteAppend.run(id);
2681
+ return {
2682
+ ...slot === void 0 ? {} : { slot },
2683
+ ...imageryScore === void 0 ? {} : { imageryScore },
2684
+ ...initialReviewAt === void 0 ? {} : { initialReviewAt }
2685
+ };
2686
+ };
1269
2687
  return {
1270
2688
  async write(input) {
1271
2689
  const content = input.content.trim();
@@ -1273,7 +2691,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1273
2691
  const id = asMemoryId(randomUUID());
1274
2692
  const at = Date.now();
1275
2693
  withTransaction(() => {
1276
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "write");
2694
+ const automationFields = applyWriteAutomation(input, id, at, input.imagery);
2695
+ insertRecord(id, {
2696
+ ...input,
2697
+ ...automationFields
2698
+ }, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
1277
2699
  });
1278
2700
  return rowToRecord(sqlGet.get(id));
1279
2701
  },
@@ -1281,11 +2703,36 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1281
2703
  const row = getRow(id);
1282
2704
  return row === void 0 ? void 0 : rowToRecord(row);
1283
2705
  },
2706
+ async getMany(ids) {
2707
+ if (ids.length === 0) return [];
2708
+ const seen = /* @__PURE__ */ new Set();
2709
+ const unique = [];
2710
+ for (const id of ids) if (!seen.has(id)) {
2711
+ seen.add(id);
2712
+ unique.push(id);
2713
+ }
2714
+ const rows = sqlGetManyByIds.all(JSON.stringify(unique.map((id) => String(id))));
2715
+ const map = /* @__PURE__ */ new Map();
2716
+ for (const row of rows) map.set(row.id, rowToRecord(row));
2717
+ return unique.map((id) => map.get(id)).filter((r) => r !== void 0);
2718
+ },
2719
+ async neighbors(id, depth) {
2720
+ const boundedDepth = Math.min(Math.max(1, Math.floor(depth)), 3);
2721
+ const rows = sqlNeighborsBounded.all(String(id), boundedDepth);
2722
+ const seen = /* @__PURE__ */ new Set([String(id)]);
2723
+ const result = [];
2724
+ for (const row of rows) {
2725
+ if (seen.has(row.id)) continue;
2726
+ seen.add(row.id);
2727
+ result.push(rowToRecord(row));
2728
+ }
2729
+ return result;
2730
+ },
1284
2731
  async search(query, queryVector) {
1285
2732
  const limit = query.limit ?? 8;
1286
2733
  const scores = /* @__PURE__ */ new Map();
1287
2734
  const match = ftsMatchExpression(query.text);
1288
- if (match !== void 0) ftsSearch(match, query.scopes).forEach((row, index) => {
2735
+ if (match !== void 0) ftsSearch(match, query.scopes, query.rooms).forEach((row, index) => {
1289
2736
  scores.set(row.id, {
1290
2737
  score: 1 / (RRF_K + index + 1),
1291
2738
  via: "fts"
@@ -1294,7 +2741,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1294
2741
  let degraded = true;
1295
2742
  if (queryVector !== void 0) {
1296
2743
  degraded = false;
1297
- vectorPool(query.scopes).map((row) => ({
2744
+ vectorPool(query.scopes, query.rooms).map((row) => ({
1298
2745
  row,
1299
2746
  sim: cosine(queryVector, blobToVec(row.embedding))
1300
2747
  })).filter((entry) => entry.sim >= MIN_COSINE).sort((a, b) => b.sim - a.sim).slice(0, RANK_POOL).forEach((entry, index) => {
@@ -1347,11 +2794,13 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1347
2794
  const row = getRow(id);
1348
2795
  if (row === void 0) continue;
1349
2796
  const viaEdge = viaEdgeOf.get(id);
2797
+ const cueNeighbors = hits.length < 5 && row.slot_room !== null && row.slot_index !== null ? sqlSlotNeighbors.all(row.slot_room, row.slot_index - 1, row.slot_index + 1, id).map((neighbor) => asMemoryId(neighbor.id)) : [];
1350
2798
  hits.push({
1351
2799
  record: rowToRecord(row),
1352
2800
  score: info.score,
1353
2801
  via: info.via,
1354
- ...viaEdge === void 0 ? {} : { viaEdge }
2802
+ ...viaEdge === void 0 ? {} : { viaEdge },
2803
+ ...cueNeighbors.length === 0 ? {} : { cues: { neighbors: cueNeighbors } }
1355
2804
  });
1356
2805
  sqlTouch.run(Date.now(), id);
1357
2806
  }
@@ -1376,13 +2825,20 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1376
2825
  const id = asMemoryId(randomUUID());
1377
2826
  const at = Date.now();
1378
2827
  withTransaction(() => {
2828
+ sqlRevisionInsert.run(input.id, old.content, old.kind, old.importance, at);
1379
2829
  sqlSetStatus.run("archived", at, input.id);
1380
2830
  sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
1381
- insertRecord(id, {
2831
+ const imagery = input.imagery ?? jsonToImagery(old.imagery_json);
2832
+ const updateInput = {
1382
2833
  scope: input.scope,
1383
2834
  kind: input.kind,
1384
2835
  content
1385
- }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, "update");
2836
+ };
2837
+ const automationFields = applyWriteAutomation(updateInput, id, at, imagery);
2838
+ insertRecord(id, {
2839
+ ...updateInput,
2840
+ ...automationFields
2841
+ }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, imagery, "update");
1386
2842
  sqlEdgeUpsert.run(id, input.id, "supersedes", at);
1387
2843
  });
1388
2844
  return rowToRecord(sqlGet.get(id));
@@ -1393,6 +2849,132 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1393
2849
  sqlLog.run(Date.now(), "forget", id, null);
1394
2850
  return rowToRecord(sqlGet.get(id));
1395
2851
  },
2852
+ async forgetWithTombstone(id, tombstone) {
2853
+ if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
2854
+ const at = Date.now();
2855
+ const cleaned = {
2856
+ reason: tombstone.reason.trim().slice(0, 200),
2857
+ affects: tombstone.affects.trim().slice(0, 200),
2858
+ stillUseful: tombstone.stillUseful.trim().slice(0, 200)
2859
+ };
2860
+ sqlSetStatus.run("forgotten", at, id);
2861
+ sqlLog.run(at, "forget", id, JSON.stringify({ tombstone: cleaned }));
2862
+ return rowToRecord(sqlGet.get(id));
2863
+ },
2864
+ async listForgottenWithTombs(limit) {
2865
+ const boundedLimit = Math.max(1, Math.min(limit, 200));
2866
+ return db.prepare(`SELECT n.*, o.at AS forgotten_at, o.detail AS tombstone_json
2867
+ FROM nodes n
2868
+ INNER JOIN op_log o ON o.target_id = n.id AND o.op = 'forget'
2869
+ WHERE n.status = 'forgotten'
2870
+ ORDER BY o.seq DESC
2871
+ LIMIT ?`).all(boundedLimit).map((row) => {
2872
+ let tombstone = null;
2873
+ if (row.tombstone_json !== null) try {
2874
+ const inner = JSON.parse(row.tombstone_json).tombstone;
2875
+ if (inner !== void 0 && typeof inner.reason === "string") tombstone = {
2876
+ reason: inner.reason,
2877
+ affects: typeof inner.affects === "string" ? inner.affects : "",
2878
+ stillUseful: typeof inner.stillUseful === "string" ? inner.stillUseful : ""
2879
+ };
2880
+ } catch {
2881
+ tombstone = null;
2882
+ }
2883
+ const base = rowToRecord(row);
2884
+ return {
2885
+ id: base.id,
2886
+ scope: base.scope,
2887
+ kind: base.kind,
2888
+ content: base.content,
2889
+ importance: base.importance,
2890
+ lastAccessedAt: base.lastAccessedAt,
2891
+ tombstone,
2892
+ forgottenAt: row.forgotten_at
2893
+ };
2894
+ });
2895
+ },
2896
+ async reportOutcome(id, outcome) {
2897
+ if (getRow(id) === void 0) return void 0;
2898
+ sqlSetOutcome.run(outcome, outcome, id);
2899
+ sqlLog.run(Date.now(), "outcome-report", id, outcome);
2900
+ return rowToRecord(sqlGet.get(id));
2901
+ },
2902
+ async scheduleReview(id, grade) {
2903
+ const row = getRow(id);
2904
+ if (row === void 0) return void 0;
2905
+ const now = Date.now();
2906
+ const next = nextSchedule(grade, rowToRecord(row).review ?? {
2907
+ nextReviewAt: null,
2908
+ easeFactor: 2.5,
2909
+ intervalDays: 0,
2910
+ reps: row.review_reps ?? 0
2911
+ }, now);
2912
+ sqlScheduleReview.run(next.nextReviewAt, next.easeFactor, next.intervalDays, next.reps, now, id);
2913
+ sqlLog.run(now, "review-answer", id, JSON.stringify({
2914
+ grade,
2915
+ nextIntervalDays: next.intervalDays
2916
+ }));
2917
+ return rowToRecord(sqlGet.get(id));
2918
+ },
2919
+ async dueReviews(now, limit) {
2920
+ return sqlDueReviews.all(now, Math.max(1, limit)).map(rowToRecord);
2921
+ },
2922
+ async slotCountsByRoom() {
2923
+ const rows = sqlSlotCounts.all();
2924
+ const result = {};
2925
+ for (const row of rows) result[row.room] = {
2926
+ count: row.n,
2927
+ maxIndex: row.maxIndex
2928
+ };
2929
+ return result;
2930
+ },
2931
+ async assignSlot(id, slot) {
2932
+ sqlSetSlot.run(slot.room, slot.index, id);
2933
+ sqlLog.run(Date.now(), "slot-assign", id, JSON.stringify(slot));
2934
+ },
2935
+ async backfillSlots(capacityNote) {
2936
+ const rows = sqlUnslotted.all();
2937
+ if (rows.length === 0) return 0;
2938
+ const now = Date.now();
2939
+ withTransaction(() => {
2940
+ const occupancy = {};
2941
+ for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
2942
+ count: row.n,
2943
+ maxIndex: row.maxIndex
2944
+ };
2945
+ for (const row of rows) {
2946
+ const { slot, openedNewRoom } = assignSlot(row.kind, occupancy);
2947
+ sqlSetSlot.run(slot.room, slot.index, row.id);
2948
+ if (sqlRouteHas.get(row.id) === void 0) sqlRouteAppend.run(row.id);
2949
+ const state = occupancy[slot.room] ?? {
2950
+ count: 0,
2951
+ maxIndex: 0
2952
+ };
2953
+ occupancy[slot.room] = {
2954
+ count: state.count + 1,
2955
+ maxIndex: Math.max(state.maxIndex, slot.index)
2956
+ };
2957
+ if (openedNewRoom) capacityNote(slot.room);
2958
+ }
2959
+ sqlLog.run(now, "slot-backfill", "BATCH", JSON.stringify({ assigned: rows.length }));
2960
+ });
2961
+ return rows.length;
2962
+ },
2963
+ async routeAppend(id) {
2964
+ sqlRouteAppend.run(id);
2965
+ },
2966
+ async routeHas(id) {
2967
+ return sqlRouteHas.get(id) !== void 0;
2968
+ },
2969
+ async routeList() {
2970
+ return sqlRouteList.all().map((row) => ({
2971
+ position: row.position,
2972
+ id: asMemoryId(row.node_id)
2973
+ }));
2974
+ },
2975
+ async listPlacards() {
2976
+ return sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
2977
+ },
1396
2978
  async restore(id) {
1397
2979
  if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
1398
2980
  sqlSetStatus.run("active", Date.now(), id);
@@ -1421,7 +3003,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1421
3003
  const where = conds.join(" AND ");
1422
3004
  const total = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE ${where}`).get(...params).n;
1423
3005
  return {
1424
- records: db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(rowToRecord),
3006
+ records: (filter.sort === "tour" ? db.prepare(`SELECT nodes.* FROM nodes LEFT JOIN tour_routes ON tour_routes.node_id = nodes.id
3007
+ WHERE ${where} ORDER BY tour_routes.position IS NULL, tour_routes.position ASC, created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset) : db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset)).map(rowToRecord),
1425
3008
  total
1426
3009
  };
1427
3010
  },
@@ -1429,12 +3012,32 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1429
3012
  const row = getRow(id);
1430
3013
  if (row === void 0) return void 0;
1431
3014
  const operations = sqlOpLogById.all(id, REVIEW_LOG_LIMIT);
3015
+ const revisions = sqlRevisionsById.all(id);
1432
3016
  return {
1433
3017
  record: rowToRecord(row),
1434
3018
  ...edgeGroups(id),
1435
- operations
3019
+ revisions: revisions.map((rev) => ({
3020
+ content: rev.content,
3021
+ kind: rev.kind,
3022
+ importance: rev.importance,
3023
+ supersededAt: rev.superseded_at
3024
+ })),
3025
+ operations: operations.map((op) => ({
3026
+ at: op.at,
3027
+ op: op.op,
3028
+ targetId: op.target_id,
3029
+ detail: op.detail
3030
+ }))
1436
3031
  };
1437
3032
  },
3033
+ async recentOps(limit) {
3034
+ return sqlRecentOps.all(Math.max(1, limit)).map((op) => ({
3035
+ at: op.at,
3036
+ op: op.op,
3037
+ targetId: op.target_id,
3038
+ detail: op.detail
3039
+ }));
3040
+ },
1438
3041
  async stats() {
1439
3042
  const statusRows = sqlCountBy.all();
1440
3043
  const kindRows = sqlCountKind.all();
@@ -1500,7 +3103,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1500
3103
  const id = asMemoryId(randomUUID());
1501
3104
  const at = Date.now();
1502
3105
  withTransaction(() => {
1503
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "distill");
3106
+ const automationFields = applyWriteAutomation(input, id, at, input.imagery);
3107
+ insertRecord(id, {
3108
+ ...input,
3109
+ ...automationFields
3110
+ }, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
1504
3111
  for (const oldId of oldIds) {
1505
3112
  sqlSetStatus.run("archived", at, oldId);
1506
3113
  sqlEdgeUpsert.run(id, oldId, "supersedes", at);
@@ -1526,6 +3133,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1526
3133
  sqlPurgeEdges.run();
1527
3134
  sqlPurgeFts.run();
1528
3135
  sqlPurgeLog.run();
3136
+ sqlPurgeRoutes.run();
1529
3137
  });
1530
3138
  },
1531
3139
  async close() {
@@ -1692,9 +3300,50 @@ function mergeQueryResults(retrievals, limit, rrfConstant, minPerQuery) {
1692
3300
  return [...ranked.filter((entry) => reserved.has(entry.hit.record.id)), ...ranked.filter((entry) => !reserved.has(entry.hit.record.id))].slice(0, Math.max(0, limit)).map((entry) => entry.hit);
1693
3301
  }
1694
3302
  //#endregion
3303
+ //#region src/retrieve/budget.ts
3304
+ /**
3305
+ * 召回输出的字符预算:单条与总量双重截断,控制记忆正文占用的模型上下文
3306
+ * (协议内定值,对齐同类记忆插件的单轮召回配额,非部署可变项)。
3307
+ * @module @kenz1117/dsh-engram/retrieve/budget
3308
+ */
3309
+ /** 单条召回行最大字符数(超长截断加省略号)。 */
3310
+ const RECALL_PER_ITEM_CHARS = 1200;
3311
+ /** 单次召回输出正文的总字符预算(含 id 与元信息行)。 */
3312
+ const RECALL_TOTAL_CHARS = 4800;
3313
+ /**
3314
+ * 截断单条文本到 maxChars(超长加省略号)。
3315
+ * @param text - 原文。
3316
+ * @param maxChars - 上限,默认 RECALL_PER_ITEM_CHARS。
3317
+ */
3318
+ function truncateItem(text, maxChars = RECALL_PER_ITEM_CHARS) {
3319
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars)}…`;
3320
+ }
3321
+ /**
3322
+ * 总量预算内贪心装填行(保序;某行装不下时继续尝试更短的后续行),
3323
+ * 被跳过的行计数并在末尾追加提示行。
3324
+ * @param lines - 候选行(已按相关性排序)。
3325
+ * @param totalBudget - 总字符预算,默认 RECALL_TOTAL_CHARS。
3326
+ * @returns 装填后的行;有丢弃时末尾含「另有 N 条…」提示。
3327
+ */
3328
+ function enforceBudget(lines, totalBudget = RECALL_TOTAL_CHARS) {
3329
+ const kept = [];
3330
+ let used = 0;
3331
+ let dropped = 0;
3332
+ for (const line of lines) {
3333
+ if (used + line.length > totalBudget) {
3334
+ dropped += 1;
3335
+ continue;
3336
+ }
3337
+ kept.push(line);
3338
+ used += line.length;
3339
+ }
3340
+ if (dropped > 0) kept.push(`(另有 ${dropped} 条未展示:缩小查询范围或降低 limit 后重试)`);
3341
+ return kept;
3342
+ }
3343
+ //#endregion
1695
3344
  //#region src/tools/create.ts
1696
3345
  /**
1697
- * 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
3346
+ * 15 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
1698
3347
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
1699
3348
  * @module @kenz1117/dsh-engram/tools/create
1700
3349
  */
@@ -1707,13 +3356,19 @@ const KINDS = [
1707
3356
  ];
1708
3357
  /** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
1709
3358
  function scopeOf(raw, fallback) {
1710
- return raw === "user" || raw === "project" ? raw : fallback;
3359
+ if (raw === "user" || raw === "project" || raw === "shared") return raw;
3360
+ return fallback;
1711
3361
  }
1712
3362
  /** 把 search scope 参数收敛为分库集合。 */
1713
3363
  function scopesOf(raw) {
1714
3364
  if (raw === "user") return ["user"];
1715
3365
  if (raw === "project") return ["project"];
1716
- return ["user", "project"];
3366
+ if (raw === "shared") return ["shared"];
3367
+ return [
3368
+ "user",
3369
+ "project",
3370
+ "shared"
3371
+ ];
1717
3372
  }
1718
3373
  /** 查询向量:嵌入可用时返回查询文本的向量,否则 undefined(降级)。 */
1719
3374
  async function queryVectorOf(deps, text) {
@@ -1731,7 +3386,7 @@ async function rewriteQueries(deps, exec, query) {
1731
3386
  queries: [query],
1732
3387
  rewritten: false
1733
3388
  };
1734
- const events = exec.agent?.session?.events ?? [];
3389
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
1735
3390
  const route = deps.routeOverride ?? routeFromEvents(events);
1736
3391
  if (route === void 0) return {
1737
3392
  queries: [query],
@@ -1768,7 +3423,8 @@ async function rewriteQueries(deps, exec, query) {
1768
3423
  }
1769
3424
  }
1770
3425
  /**
1771
- * 构造 9 个工具定义(engram_save/search/timeline/update/forget/review/stats/export/distill)。
3426
+ * 构造 15 个工具定义(engram_save/search/timeline/update/forget/report/review/review_queue/
3427
+ * stats/export/distill/examine/neighbors/audit_forgotten/tour)。
1772
3428
  * @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
1773
3429
  * @returns 可直接 register 的工具定义数组。
1774
3430
  */
@@ -1779,7 +3435,10 @@ function createEngramTools(deps) {
1779
3435
  function renderSaveResultText(value) {
1780
3436
  if (value.count !== void 0) {
1781
3437
  const parts = [`已批量保存 ${value.count} 条记忆`];
1782
- for (const item of value.items ?? []) parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance})`);
3438
+ for (const item of value.items ?? []) {
3439
+ const slot = item.slot === void 0 ? "" : `, ${item.slot.room}#${item.slot.index}`;
3440
+ parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance}${slot})`);
3441
+ }
1783
3442
  const failures = value.failed ?? [];
1784
3443
  if (failures.length > 0) parts.push(`${failures.length} 条失败:${failures.map((entry) => `#${entry.index + 1} ${entry.reason}`).join(";")}`);
1785
3444
  parts.push("后续会话可用 engram_search 召回。");
@@ -1797,7 +3456,8 @@ function createEngramTools(deps) {
1797
3456
  content: item.content,
1798
3457
  ...item.importance === void 0 ? {} : { importance: item.importance },
1799
3458
  sourceSessionId: item.sourceSessionId,
1800
- ...embedding === void 0 ? {} : { embedding }
3459
+ ...embedding === void 0 ? {} : { embedding },
3460
+ ...item.imagery === void 0 ? {} : { imagery: item.imagery }
1801
3461
  });
1802
3462
  const candidates = embedding === void 0 ? [] : await store.findContradictions(embedding);
1803
3463
  for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
@@ -1806,6 +3466,18 @@ function createEngramTools(deps) {
1806
3466
  candidates
1807
3467
  };
1808
3468
  }
3469
+ /** 门牌参数收敛:非空字符串转 ImageryLabel(感官/情绪维度留空——AI 不需要人脑补丁),非法返回 undefined。 */
3470
+ function placardOf(raw) {
3471
+ if (typeof raw !== "string") return void 0;
3472
+ const caption = raw.trim();
3473
+ if (caption === "") return void 0;
3474
+ return {
3475
+ caption,
3476
+ sensoryTags: [],
3477
+ emotionalValence: 0,
3478
+ provisional: false
3479
+ };
3480
+ }
1809
3481
  /** 批量保存:统一清洗/校验/批量内去重,一次批量嵌入,逐条写入;单条失败不阻塞其余。 */
1810
3482
  async function saveBatch(sourceSessionId, items, rawScope) {
1811
3483
  if (items.length > MAX_SAVE_BATCH) throw new Error(`engram_save: 单次最多保存 ${MAX_SAVE_BATCH} 条`);
@@ -1877,7 +3549,8 @@ function createEngramTools(deps) {
1877
3549
  saved.push({
1878
3550
  id: record.id,
1879
3551
  kind: record.kind,
1880
- importance: record.importance
3552
+ importance: record.importance,
3553
+ ...record.slot === void 0 ? {} : { slot: record.slot }
1881
3554
  });
1882
3555
  } catch (error) {
1883
3556
  failed.push({
@@ -1891,307 +3564,597 @@ function createEngramTools(deps) {
1891
3564
  failed
1892
3565
  };
1893
3566
  }
1894
- return [
1895
- defineTool({
1896
- name: "engram_save",
1897
- description: "保存长期记忆(跨会话可用),支持单条(content/kind)或批量(items,最多 10 条,单条失败不影响其余)。kind:fact 事实 / preference 偏好 / decision 决策 / episode 经历 / skill 方法。scope:project 仅当前项目,user 全局。",
1898
- parameters: {
1899
- content: {
1900
- type: "string",
1901
- description: "记忆正文(单条模式必填),一句话完整表达"
1902
- },
1903
- kind: {
1904
- type: "string",
1905
- enum: [...KINDS],
1906
- description: "记忆种类(单条模式必填)"
1907
- },
3567
+ const save = defineTool({
3568
+ name: "engram_save",
3569
+ description: "保存长期记忆(跨会话可用),支持单条(content/kind)或批量(items,最多 10 条,单条失败不影响其余)。kind:fact 事实 / preference 偏好 / decision 决策 / episode 经历 / skill 方法。scope:project 仅当前项目,user 全局。",
3570
+ parameters: {
3571
+ content: {
3572
+ type: "string",
3573
+ description: "记忆正文(单条模式必填),一句话完整表达"
3574
+ },
3575
+ kind: {
3576
+ type: "string",
3577
+ enum: [...KINDS],
3578
+ description: "记忆种类(单条模式必填)"
3579
+ },
3580
+ items: {
3581
+ type: "array",
3582
+ description: "批量保存条目数组,每项 {content, kind, importance?};与 content/kind 二选一",
1908
3583
  items: {
1909
- type: "array",
1910
- description: "批量保存条目数组,每项 {content, kind, importance?};与 content/kind 二选一",
1911
- items: {
1912
- type: "object",
1913
- additionalProperties: false,
1914
- properties: {
1915
- content: {
1916
- type: "string",
1917
- required: true,
1918
- description: "记忆正文"
1919
- },
1920
- kind: {
1921
- type: "string",
1922
- enum: [...KINDS],
1923
- required: true,
1924
- description: "记忆种类"
1925
- },
1926
- importance: {
1927
- type: "number",
1928
- description: "重要性 0-1"
1929
- }
3584
+ type: "object",
3585
+ additionalProperties: false,
3586
+ properties: {
3587
+ content: {
3588
+ type: "string",
3589
+ required: true,
3590
+ description: "记忆正文"
3591
+ },
3592
+ kind: {
3593
+ type: "string",
3594
+ enum: [...KINDS],
3595
+ required: true,
3596
+ description: "记忆种类"
3597
+ },
3598
+ importance: {
3599
+ type: "number",
3600
+ description: "重要性 0-1"
1930
3601
  }
1931
3602
  }
1932
- },
1933
- scope: {
1934
- type: "string",
1935
- enum: ["user", "project"],
1936
- description: "作用域,默认 project"
1937
- },
1938
- importance: {
1939
- type: "number",
1940
- description: "重要性 0-1,默认 0.5(仅单条模式)"
1941
3603
  }
1942
3604
  },
1943
- output: {
1944
- schema: {
1945
- type: "object",
1946
- additionalProperties: false,
1947
- properties: {
1948
- id: { type: "string" },
1949
- kind: { type: "string" },
1950
- importance: { type: "number" },
1951
- text: { type: "string" },
1952
- count: { type: "number" },
3605
+ scope: {
3606
+ type: "string",
3607
+ enum: [
3608
+ "user",
3609
+ "project",
3610
+ "shared"
3611
+ ],
3612
+ description: "作用域,默认 project"
3613
+ },
3614
+ importance: {
3615
+ type: "number",
3616
+ description: "重要性 0-1,默认 0.5(仅单条模式)"
3617
+ },
3618
+ placard: {
3619
+ type: "string",
3620
+ description: "门牌(可选,仅单条模式):4-30 字铭牌。宫殿纪律:唯一 · 差异化 · 带日期锚点(如「2026-09 向量检索选型」),禁止与既有门牌近似到无法区分"
3621
+ }
3622
+ },
3623
+ output: {
3624
+ schema: {
3625
+ type: "object",
3626
+ additionalProperties: false,
3627
+ properties: {
3628
+ id: { type: "string" },
3629
+ kind: { type: "string" },
3630
+ importance: { type: "number" },
3631
+ text: { type: "string" },
3632
+ count: { type: "number" },
3633
+ items: {
3634
+ type: "array",
1953
3635
  items: {
1954
- type: "array",
1955
- items: {
1956
- type: "object",
1957
- additionalProperties: false,
1958
- properties: {
1959
- id: {
1960
- type: "string",
1961
- required: true
1962
- },
1963
- kind: {
1964
- type: "string",
1965
- required: true
1966
- },
1967
- importance: {
1968
- type: "number",
1969
- required: true
3636
+ type: "object",
3637
+ additionalProperties: false,
3638
+ properties: {
3639
+ id: {
3640
+ type: "string",
3641
+ required: true
3642
+ },
3643
+ kind: {
3644
+ type: "string",
3645
+ required: true
3646
+ },
3647
+ importance: {
3648
+ type: "number",
3649
+ required: true
3650
+ },
3651
+ slot: {
3652
+ type: "object",
3653
+ additionalProperties: false,
3654
+ properties: {
3655
+ room: {
3656
+ type: "string",
3657
+ required: true
3658
+ },
3659
+ index: {
3660
+ type: "number",
3661
+ required: true
3662
+ }
1970
3663
  }
1971
3664
  }
1972
3665
  }
1973
- },
1974
- failed: {
1975
- type: "array",
1976
- items: {
1977
- type: "object",
1978
- additionalProperties: false,
1979
- properties: {
1980
- index: {
1981
- type: "number",
1982
- required: true
1983
- },
1984
- reason: {
1985
- type: "string",
1986
- required: true
1987
- }
3666
+ }
3667
+ },
3668
+ failed: {
3669
+ type: "array",
3670
+ items: {
3671
+ type: "object",
3672
+ additionalProperties: false,
3673
+ properties: {
3674
+ index: {
3675
+ type: "number",
3676
+ required: true
3677
+ },
3678
+ reason: {
3679
+ type: "string",
3680
+ required: true
1988
3681
  }
1989
3682
  }
1990
3683
  }
1991
3684
  }
1992
- },
1993
- render: (_args, value) => [{
1994
- type: "text",
1995
- text: renderSaveResultText(value)
1996
- }]
1997
- },
1998
- async execute(args, exec) {
1999
- const input = args;
2000
- const sourceSessionId = exec.agent?.id ?? null;
2001
- if (input.items !== void 0) {
2002
- if (input.content !== void 0 || input.kind !== void 0) throw new Error("engram_save: items 与 content/kind 参数不能同时使用");
2003
- if (!Array.isArray(input.items) || input.items.length === 0) throw new Error("engram_save: items 必须是非空数组");
2004
- return saveBatch(sourceSessionId, input.items, input.scope);
2005
- }
2006
- if (typeof input.content !== "string" || typeof input.kind !== "string") throw new Error("engram_save: 需要 content/kind(单条)或 items(批量)参数");
2007
- const content = redactSecrets(sanitizeProtocolText(input.content));
2008
- if (content.trim() === "") throw new Error("engram_save: 清洗后内容为空(原文只含协议标签或密钥)");
2009
- const scope = scopeOf(input.scope, "project");
2010
- const store = await deps.openStore(scope);
2011
- const embedder = await deps.embedder;
2012
- const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
2013
- const { record, candidates } = await writeWithContradictions(store, {
2014
- scope,
2015
- kind: input.kind,
2016
- content,
2017
- ...typeof input.importance === "number" ? { importance: input.importance } : {},
2018
- sourceSessionId,
2019
- ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
2020
- });
2021
- if (candidates.length > 0) {
2022
- const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
2023
- return {
2024
- id: record.id,
2025
- kind: record.kind,
2026
- importance: record.importance,
2027
- text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
2028
- };
2029
3685
  }
3686
+ },
3687
+ render: (_args, value) => [{
3688
+ type: "text",
3689
+ text: renderSaveResultText(value)
3690
+ }]
3691
+ },
3692
+ async execute(args, exec) {
3693
+ const input = args;
3694
+ const sourceSessionId = exec.agent?.id ?? null;
3695
+ if (input.items !== void 0) {
3696
+ if (input.content !== void 0 || input.kind !== void 0) throw new Error("engram_save: items 与 content/kind 参数不能同时使用");
3697
+ if (!Array.isArray(input.items) || input.items.length === 0) throw new Error("engram_save: items 必须是非空数组");
3698
+ return saveBatch(sourceSessionId, input.items, input.scope);
3699
+ }
3700
+ if (typeof input.content !== "string" || typeof input.kind !== "string") throw new Error("engram_save: 需要 content/kind(单条)或 items(批量)参数");
3701
+ const content = redactSecrets(sanitizeProtocolText(input.content));
3702
+ if (content.trim() === "") throw new Error("engram_save: 清洗后内容为空(原文只含协议标签或密钥)");
3703
+ const scope = scopeOf(input.scope, "project");
3704
+ const store = await deps.openStore(scope);
3705
+ const embedder = await deps.embedder;
3706
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
3707
+ const imagery = placardOf(input.placard);
3708
+ const { record, candidates } = await writeWithContradictions(store, {
3709
+ scope,
3710
+ kind: input.kind,
3711
+ content,
3712
+ ...typeof input.importance === "number" ? { importance: input.importance } : {},
3713
+ sourceSessionId,
3714
+ ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] },
3715
+ ...imagery === void 0 ? {} : { imagery }
3716
+ });
3717
+ const placardHint = imagery === void 0 || record.imageryScore === void 0 ? "" : `\n${placardImprovementHint(record.imageryScore) ?? ""}`;
3718
+ if (candidates.length > 0) {
3719
+ const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
2030
3720
  return {
2031
3721
  id: record.id,
2032
3722
  kind: record.kind,
2033
- importance: record.importance
3723
+ importance: record.importance,
3724
+ text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。${placardHint}`
2034
3725
  };
2035
3726
  }
2036
- }),
2037
- defineTool({
2038
- name: "engram_search",
2039
- description: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
2040
- parameters: {
2041
- query: {
2042
- type: "string",
2043
- required: true,
2044
- description: "检索文本"
2045
- },
2046
- scope: {
2047
- type: "string",
2048
- enum: [
2049
- "user",
2050
- "project",
2051
- "all"
2052
- ],
2053
- description: "作用域,默认 all"
2054
- },
2055
- limit: {
2056
- type: "number",
2057
- description: "返回条数上限,默认 8"
2058
- }
3727
+ const base = `已保存记忆 ${record.id}(kind=${record.kind}, importance=${record.importance}${record.slot === void 0 ? "" : `, ${record.slot.room}#${record.slot.index}`})。后续会话可用 engram_search 召回。`;
3728
+ return {
3729
+ id: record.id,
3730
+ kind: record.kind,
3731
+ importance: record.importance,
3732
+ text: `${base}${placardHint}`
3733
+ };
3734
+ }
3735
+ });
3736
+ const search = defineTool({
3737
+ name: "engram_search",
3738
+ description: "语义 + 关键词混合检索长期记忆。宫殿纪律:先想进哪个房间——事实厅(fact)/偏好阁(preference)/决策堂(decision)/往事廊(episode)/技法坊(skill),带上 room 参数只查该房间,更快更准;不确定房间时缺省全库检索。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
3739
+ parameters: {
3740
+ query: {
3741
+ type: "string",
3742
+ required: true,
3743
+ description: "检索文本"
2059
3744
  },
2060
- output: {
2061
- schema: {
2062
- type: "object",
2063
- additionalProperties: false,
2064
- properties: {
2065
- degraded: {
2066
- type: "boolean",
2067
- required: true
2068
- },
2069
- text: {
2070
- type: "string",
2071
- required: true
2072
- }
3745
+ scope: {
3746
+ type: "string",
3747
+ enum: [
3748
+ "user",
3749
+ "project",
3750
+ "shared",
3751
+ "all"
3752
+ ],
3753
+ description: "作用域,默认 all"
3754
+ },
3755
+ room: {
3756
+ type: "string",
3757
+ description: "房间路由:只在指定房间内检索(如「决策堂」)。房间目录见 engram_stats 输出"
3758
+ },
3759
+ limit: {
3760
+ type: "number",
3761
+ description: "返回条数上限,默认 8"
3762
+ }
3763
+ },
3764
+ output: {
3765
+ schema: {
3766
+ type: "object",
3767
+ additionalProperties: false,
3768
+ properties: {
3769
+ degraded: {
3770
+ type: "boolean",
3771
+ required: true
3772
+ },
3773
+ text: {
3774
+ type: "string",
3775
+ required: true
2073
3776
  }
2074
- },
2075
- render: (_args, value) => [{
2076
- type: "text",
2077
- text: value.text
2078
- }]
3777
+ }
2079
3778
  },
2080
- async execute(args, exec) {
2081
- const input = args;
2082
- const scopes = scopesOf(input.scope);
2083
- const limit = input.limit ?? 8;
2084
- const rewrite = await rewriteQueries(deps, exec, input.query);
2085
- const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
2086
- const vector = await queryVectorOf(deps, queryText);
2087
- const results = await Promise.all(scopes.map(async (scope) => {
2088
- return (await deps.openStore(scope)).search({
2089
- text: queryText,
2090
- scopes: [scope],
2091
- limit
2092
- }, vector);
2093
- }));
2094
- return {
2095
- hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
2096
- degraded: results.some((result) => result.degraded)
2097
- };
3779
+ render: (_args, value) => [{
3780
+ type: "text",
3781
+ text: value.text
3782
+ }]
3783
+ },
3784
+ async execute(args, exec) {
3785
+ const input = args;
3786
+ const scopes = scopesOf(input.scope);
3787
+ const rooms = typeof input.room === "string" && input.room.trim() !== "" ? [input.room.trim()] : void 0;
3788
+ const limit = input.limit ?? 8;
3789
+ const rewrite = await rewriteQueries(deps, exec, input.query);
3790
+ const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
3791
+ const vector = await queryVectorOf(deps, queryText);
3792
+ const results = await Promise.all(scopes.map(async (scope) => {
3793
+ return (await deps.openStore(scope)).search({
3794
+ text: queryText,
3795
+ scopes: [scope],
3796
+ limit,
3797
+ ...rooms === void 0 ? {} : { rooms }
3798
+ }, vector);
2098
3799
  }));
2099
- const degraded = retrievals.some((retrieval) => retrieval.degraded);
2100
- const lines = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
2101
- const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
2102
- return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${hit.record.content}(id=${hit.record.id})${edge}`;
2103
- });
2104
3800
  return {
2105
- degraded,
2106
- text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
3801
+ hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
3802
+ degraded: results.some((result) => result.degraded)
2107
3803
  };
3804
+ }));
3805
+ const degraded = retrievals.some((retrieval) => retrieval.degraded);
3806
+ const lines = enforceBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
3807
+ const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
3808
+ const slot = hit.record.slot === void 0 ? "" : ` ${hit.record.slot.room}#${hit.record.slot.index}`;
3809
+ const date = ` 刻于 ${new Date(hit.record.createdAt).toISOString().slice(0, 10)}`;
3810
+ const cues = hit.cues === void 0 ? "" : ` 相邻桩位: ${hit.cues.neighbors.join(", ")}`;
3811
+ return `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id})${edge}${cues}`;
3812
+ }));
3813
+ return {
3814
+ degraded,
3815
+ text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${rooms === void 0 ? "" : `(房间路由:${rooms.join("、")})\n`}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
3816
+ };
3817
+ }
3818
+ });
3819
+ const timeline = defineTool({
3820
+ name: "engram_timeline",
3821
+ description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
3822
+ parameters: {
3823
+ scope: {
3824
+ type: "string",
3825
+ enum: [
3826
+ "user",
3827
+ "project",
3828
+ "shared",
3829
+ "all"
3830
+ ],
3831
+ description: "作用域,默认 all"
3832
+ },
3833
+ topic: {
3834
+ type: "string",
3835
+ description: "主题子串"
3836
+ },
3837
+ since: {
3838
+ type: "string",
3839
+ description: "起始时间(ISO 或可解析日期)"
3840
+ },
3841
+ until: {
3842
+ type: "string",
3843
+ description: "结束时间"
2108
3844
  }
2109
- }),
2110
- defineTool({
2111
- name: "engram_timeline",
2112
- description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
2113
- parameters: {
2114
- scope: {
2115
- type: "string",
2116
- enum: [
2117
- "user",
2118
- "project",
2119
- "all"
2120
- ],
2121
- description: "作用域,默认 all"
2122
- },
2123
- topic: {
2124
- type: "string",
2125
- description: "主题子串"
2126
- },
2127
- since: {
2128
- type: "string",
2129
- description: "起始时间(ISO 或可解析日期)"
2130
- },
2131
- until: {
3845
+ },
3846
+ output: {
3847
+ schema: {
3848
+ type: "object",
3849
+ additionalProperties: false,
3850
+ properties: { text: {
2132
3851
  type: "string",
2133
- description: "结束时间"
2134
- }
3852
+ required: true
3853
+ } }
2135
3854
  },
2136
- output: {
2137
- schema: {
2138
- type: "object",
2139
- additionalProperties: false,
2140
- properties: { text: {
3855
+ render: (_args, value) => [{
3856
+ type: "text",
3857
+ text: value.text
3858
+ }]
3859
+ },
3860
+ async execute(args) {
3861
+ const input = args;
3862
+ const scopes = scopesOf(input.scope);
3863
+ const parseTime = (raw, field) => {
3864
+ if (raw === void 0) return void 0;
3865
+ const ms = Date.parse(raw);
3866
+ if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
3867
+ return ms;
3868
+ };
3869
+ const since = parseTime(input.since, "since");
3870
+ const until = parseTime(input.until, "until");
3871
+ return { text: renderMemoryPacket(enforceBudget((await Promise.all(scopes.map(async (scope) => {
3872
+ return (await deps.openStore(scope)).timeline({
3873
+ scopes: [scope],
3874
+ ...input.topic === void 0 ? {} : { topic: input.topic },
3875
+ ...since === void 0 ? {} : { since },
3876
+ ...until === void 0 ? {} : { until },
3877
+ limit: 20
3878
+ });
3879
+ }))).flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, 20).map((record) => `${new Date(record.createdAt).toISOString()} [${record.scope}/${record.kind}] ${truncateItem(record.content)}(id=${record.id})`)).join("\n") || "时间线为空", "tool_timeline", input.topic ?? "(对话继续)") };
3880
+ }
3881
+ });
3882
+ const update = defineTool({
3883
+ name: "engram_update",
3884
+ description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
3885
+ parameters: {
3886
+ id: {
3887
+ type: "string",
3888
+ required: true,
3889
+ description: "要修正的旧条目 id"
3890
+ },
3891
+ content: {
3892
+ type: "string",
3893
+ required: true,
3894
+ description: "修正后的正文"
3895
+ },
3896
+ scope: {
3897
+ type: "string",
3898
+ enum: [
3899
+ "user",
3900
+ "project",
3901
+ "shared"
3902
+ ],
3903
+ description: "旧条目作用域,默认 project"
3904
+ },
3905
+ kind: {
3906
+ type: "string",
3907
+ enum: [...KINDS],
3908
+ description: "种类,默认继承旧条目"
3909
+ },
3910
+ placard: {
3911
+ type: "string",
3912
+ description: "门牌(可选):4-30 字铭牌,替换旧条目门牌。宫殿纪律:唯一 · 差异化 · 带日期锚点"
3913
+ }
3914
+ },
3915
+ output: {
3916
+ schema: {
3917
+ type: "object",
3918
+ additionalProperties: false,
3919
+ properties: {
3920
+ id: {
2141
3921
  type: "string",
2142
3922
  required: true
2143
- } }
2144
- },
2145
- render: (_args, value) => [{
2146
- type: "text",
2147
- text: value.text
2148
- }]
3923
+ },
3924
+ superseded: {
3925
+ type: "string",
3926
+ required: true
3927
+ }
3928
+ }
2149
3929
  },
2150
- async execute(args) {
2151
- const input = args;
2152
- const scopes = scopesOf(input.scope);
2153
- const parseTime = (raw, field) => {
2154
- if (raw === void 0) return void 0;
2155
- const ms = Date.parse(raw);
2156
- if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
2157
- return ms;
2158
- };
2159
- const since = parseTime(input.since, "since");
2160
- const until = parseTime(input.until, "until");
2161
- return { text: renderMemoryPacket((await Promise.all(scopes.map(async (scope) => {
2162
- return (await deps.openStore(scope)).timeline({
2163
- scopes: [scope],
2164
- ...input.topic === void 0 ? {} : { topic: input.topic },
2165
- ...since === void 0 ? {} : { since },
2166
- ...until === void 0 ? {} : { until },
2167
- limit: 20
2168
- });
2169
- }))).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") || "时间线为空", "tool_timeline", input.topic ?? "(对话继续)") };
3930
+ render: (_args, value) => [{
3931
+ type: "text",
3932
+ text: `已写入修正记忆 ${value.id};旧条目 ${value.superseded} 已归档并建立取代链。`
3933
+ }]
3934
+ },
3935
+ async execute(args) {
3936
+ const input = args;
3937
+ const content = redactSecrets(sanitizeProtocolText(input.content));
3938
+ if (content.trim() === "") throw new Error("engram_update: 清洗后内容为空(原文只含协议标签或密钥)");
3939
+ const scope = scopeOf(input.scope, "project");
3940
+ const store = await deps.openStore(scope);
3941
+ const old = await store.get(input.id);
3942
+ if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
3943
+ const embedder = await deps.embedder;
3944
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
3945
+ const imagery = placardOf(input.placard);
3946
+ return {
3947
+ id: (await store.update({
3948
+ id: input.id,
3949
+ scope,
3950
+ kind: input.kind ?? old.kind,
3951
+ content,
3952
+ ...embeddings === void 0 ? {} : { embedding: embeddings[0] },
3953
+ ...imagery === void 0 ? {} : { imagery }
3954
+ })).id,
3955
+ superseded: input.id
3956
+ };
3957
+ }
3958
+ });
3959
+ const forget = defineTool({
3960
+ name: "engram_forget",
3961
+ description: "闭馆仪式(软删,可恢复):遗忘前必须留下「为什么关 / 影响谁 / 还有用吗」三问答案作为墓志铭,便于日后考古。id 与 scope 来自 engram_search 结果。",
3962
+ parameters: {
3963
+ id: {
3964
+ type: "string",
3965
+ required: true,
3966
+ description: "条目 id"
3967
+ },
3968
+ scope: {
3969
+ type: "string",
3970
+ enum: [
3971
+ "user",
3972
+ "project",
3973
+ "shared"
3974
+ ],
3975
+ description: "条目作用域,默认 project"
3976
+ },
3977
+ reason: {
3978
+ type: "string",
3979
+ required: true,
3980
+ description: "闭馆原因:被取代 / 过期 / 与现实不符 / 隐私 等"
3981
+ },
3982
+ affects: {
3983
+ type: "string",
3984
+ required: true,
3985
+ description: "影响哪些条目/人/项目,空串表示不适用"
3986
+ },
3987
+ stillUseful: {
3988
+ type: "string",
3989
+ required: true,
3990
+ description: "遗留价值:可考古 / 可复习 / 回滚时如何理解"
2170
3991
  }
2171
- }),
3992
+ },
3993
+ output: {
3994
+ schema: {
3995
+ type: "object",
3996
+ additionalProperties: false,
3997
+ properties: { id: {
3998
+ type: "string",
3999
+ required: true
4000
+ } }
4001
+ },
4002
+ render: (_args, value) => [{
4003
+ type: "text",
4004
+ text: `房间 ${value.id} 已闭馆(软删,可恢复),墓志铭已刻入操作日志。`
4005
+ }]
4006
+ },
4007
+ async execute(args) {
4008
+ const input = args;
4009
+ const scope = scopeOf(input.scope, "project");
4010
+ const reason = typeof input.reason === "string" ? input.reason.trim() : "";
4011
+ const affects = typeof input.affects === "string" ? input.affects.trim() : "";
4012
+ const stillUseful = typeof input.stillUseful === "string" ? input.stillUseful.trim() : "";
4013
+ if (reason === "" || affects === "" || stillUseful === "") throw new Error("engram_forget: 闭馆三问(reason / affects / stillUseful)都必须填写,方便日后考古");
4014
+ return { id: (await (await deps.openStore(scope)).forgetWithTombstone(input.id, {
4015
+ reason,
4016
+ affects,
4017
+ stillUseful
4018
+ })).id };
4019
+ }
4020
+ });
4021
+ /** P0-3 闭馆考古:返回最近 N 条 forgotten 条目 + 墓志铭。 */
4022
+ const auditForgotten = defineTool({
4023
+ name: "engram_audit_forgotten",
4024
+ description: "闭馆考古:列出最近 N 条已闭馆条目 + 墓志铭(为什么关 / 影响谁 / 还有用吗),便于复核过去的遗忘是否得当。",
4025
+ parameters: {
4026
+ scope: {
4027
+ type: "string",
4028
+ enum: [
4029
+ "user",
4030
+ "project",
4031
+ "shared",
4032
+ "all"
4033
+ ],
4034
+ description: "作用域,默认 all"
4035
+ },
4036
+ limit: {
4037
+ type: "integer",
4038
+ description: "返回条数上限,默认 20"
4039
+ }
4040
+ },
4041
+ output: {
4042
+ schema: {
4043
+ type: "object",
4044
+ additionalProperties: false,
4045
+ properties: { text: {
4046
+ type: "string",
4047
+ required: true
4048
+ } }
4049
+ },
4050
+ render: (_args, value) => [{
4051
+ type: "text",
4052
+ text: value.text
4053
+ }]
4054
+ },
4055
+ async execute(args) {
4056
+ const input = args;
4057
+ const scopes = scopesOf(input.scope);
4058
+ const limit = Number.isInteger(input.limit) ? Math.min(Math.max(1, input.limit), 50) : 20;
4059
+ const all = (await Promise.all(scopes.map(async (scope) => (await deps.openStore(scope)).listForgottenWithTombs(limit)))).flat();
4060
+ all.sort((a, b) => b.forgottenAt - a.forgottenAt);
4061
+ const sliced = all.slice(0, limit);
4062
+ if (sliced.length === 0) return { text: "尚无闭馆条目。" };
4063
+ const lines = sliced.map((row, index) => {
4064
+ const stamp = new Date(row.forgottenAt).toISOString();
4065
+ const tomb = row.tombstone === null ? "(墓志铭缺失:旧版无三问数据)" : `\n - 为什么关:${row.tombstone.reason}\n - 影响谁:${row.tombstone.affects}\n - 还有用吗:${row.tombstone.stillUseful}`;
4066
+ return `${index + 1}. [${row.scope}/${row.kind}] ${row.content.slice(0, 80)}${row.content.length > 80 ? "…" : ""}\n id=${row.id} · importance=${row.importance.toFixed(2)} · 闭馆于 ${stamp}${tomb}`;
4067
+ });
4068
+ return { text: `闭馆考古(共 ${sliced.length} 条):\n${lines.join("\n")}` };
4069
+ }
4070
+ });
4071
+ const reviewQueue = defineTool({
4072
+ name: "engram_review_queue",
4073
+ description: "今日待回忆队列:列出已到期间隔重复的记忆,每条只给宫殿坐标与门牌线索(不给正文)。用法:对每条先尝试回忆内容,然后 engram_review 揭示核对,再 engram_report 传 grade(0-5)自评——主动回忆比重复阅读的记忆强化效果强得多。会话开始注入会提示今日是否有待回忆。",
4074
+ parameters: {
4075
+ scope: {
4076
+ type: "string",
4077
+ enum: [
4078
+ "user",
4079
+ "project",
4080
+ "shared",
4081
+ "all"
4082
+ ],
4083
+ description: "作用域,默认 all"
4084
+ },
4085
+ limit: {
4086
+ type: "integer",
4087
+ description: "返回条数上限,默认 10(最逾期在前)"
4088
+ }
4089
+ },
4090
+ output: {
4091
+ schema: {
4092
+ type: "object",
4093
+ additionalProperties: false,
4094
+ properties: { text: {
4095
+ type: "string",
4096
+ required: true
4097
+ } }
4098
+ },
4099
+ render: (_args, value) => [{
4100
+ type: "text",
4101
+ text: value.text
4102
+ }]
4103
+ },
4104
+ async execute(args) {
4105
+ const input = args;
4106
+ const scopes = scopesOf(input.scope);
4107
+ const limit = Number.isInteger(input.limit) ? Math.min(Math.max(1, input.limit), 50) : 10;
4108
+ const now = Date.now();
4109
+ const groups = await Promise.all(scopes.map(async (scope) => ({
4110
+ scope,
4111
+ due: await (await deps.openStore(scope)).dueReviews(now, limit)
4112
+ })));
4113
+ const total = groups.reduce((sum, group) => sum + group.due.length, 0);
4114
+ if (total === 0) return { text: "今日无待回忆条目(队列空)。新记忆保存后次日首次到期。" };
4115
+ const lines = [`今日待回忆 ${total} 段(最逾期在前)。对每段:先回忆 → engram_review 核对 → engram_report 传 grade 自评。`];
4116
+ for (const { scope, due } of groups) for (const [index, record] of due.entries()) {
4117
+ const slot = record.slot === void 0 ? "(未排桩)" : `${record.slot.room} #${record.slot.index}`;
4118
+ const placard = record.imagery?.caption ?? "(无门牌)";
4119
+ const overdueDays = record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5));
4120
+ const overdue = overdueDays === 0 ? "今日到期" : `逾期 ${overdueDays} 天`;
4121
+ lines.push(`${index + 1}. [${scope}] ${slot} · 门牌「${placard}」 · ${overdue} · id=${record.id}`);
4122
+ }
4123
+ return { text: lines.join("\n") };
4124
+ }
4125
+ });
4126
+ return [
4127
+ save,
4128
+ search,
4129
+ timeline,
4130
+ update,
4131
+ forget,
2172
4132
  defineTool({
2173
- name: "engram_update",
2174
- description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
4133
+ name: "engram_report",
4134
+ description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。也可作为复习自评入口:传 grade(0 完全遗忘 … 5 完美回忆)显式报告回忆质量。效果影响后续召回排序与复习排期,长期无效的记忆会被衰减归档。",
2175
4135
  parameters: {
2176
4136
  id: {
2177
4137
  type: "string",
2178
4138
  required: true,
2179
- description: "要修正的旧条目 id"
4139
+ description: "条目 id"
2180
4140
  },
2181
- content: {
4141
+ outcome: {
2182
4142
  type: "string",
2183
- required: true,
2184
- description: "修正后的正文"
4143
+ enum: ["success", "failure"],
4144
+ description: "使用效果(与 grade 二选一;同传时 grade 优先)"
2185
4145
  },
2186
- scope: {
2187
- type: "string",
2188
- enum: ["user", "project"],
2189
- description: "旧条目作用域,默认 project"
4146
+ grade: {
4147
+ type: "integer",
4148
+ description: "回忆质量自评 0-5(复习答题用;0/1 完全遗忘,3 勉强,5 完美)"
2190
4149
  },
2191
- kind: {
4150
+ scope: {
2192
4151
  type: "string",
2193
- enum: [...KINDS],
2194
- description: "种类,默认继承旧条目"
4152
+ enum: [
4153
+ "user",
4154
+ "project",
4155
+ "shared"
4156
+ ],
4157
+ description: "条目作用域,默认 project"
2195
4158
  }
2196
4159
  },
2197
4160
  output: {
@@ -2203,74 +4166,43 @@ function createEngramTools(deps) {
2203
4166
  type: "string",
2204
4167
  required: true
2205
4168
  },
2206
- superseded: {
4169
+ outcome: {
2207
4170
  type: "string",
2208
4171
  required: true
2209
- }
4172
+ },
4173
+ confidence: {
4174
+ type: "number",
4175
+ required: true
4176
+ },
4177
+ nextReviewAt: { type: "number" }
2210
4178
  }
2211
4179
  },
2212
4180
  render: (_args, value) => [{
2213
4181
  type: "text",
2214
- text: `已写入修正记忆 ${value.id};旧条目 ${value.superseded} 已归档并建立取代链。`
4182
+ text: (value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id} 标记为无效/遗忘(confidence=${value.confidence})。排序将下降,持续无效会被衰减归档。`) + (value.nextReviewAt === void 0 ? "" : ` 下次复习:${new Date(value.nextReviewAt).toISOString().slice(0, 10)}。`)
2215
4183
  }]
2216
4184
  },
2217
4185
  async execute(args) {
2218
4186
  const input = args;
2219
- const content = redactSecrets(sanitizeProtocolText(input.content));
2220
- if (content.trim() === "") throw new Error("engram_update: 清洗后内容为空(原文只含协议标签或密钥)");
4187
+ const hasGrade = Number.isInteger(input.grade) && input.grade >= 0 && input.grade <= 5;
4188
+ if (input.grade !== void 0 && !hasGrade) throw new Error("engram_report: grade 必须是 0-5 的整数");
4189
+ if (input.outcome !== "success" && input.outcome !== "failure" && !hasGrade) throw new Error("engram_report: 需要 outcome(success/failure)或 grade(0-5)参数");
4190
+ const outcome = input.outcome === "success" || input.outcome === "failure" ? input.outcome : input.grade >= 3 ? "success" : "failure";
4191
+ const grade = hasGrade ? input.grade : outcome === "success" ? 5 : 1;
2221
4192
  const scope = scopeOf(input.scope, "project");
2222
4193
  const store = await deps.openStore(scope);
2223
- const old = await store.get(input.id);
2224
- if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
2225
- const embedder = await deps.embedder;
2226
- const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
4194
+ const record = await store.reportOutcome(input.id, outcome);
4195
+ if (record === void 0) throw new Error(`engram_report: 条目 ${input.id} 不存在(scope=${scope})`);
4196
+ const scheduled = await store.scheduleReview(input.id, grade);
2227
4197
  return {
2228
- id: (await store.update({
2229
- id: input.id,
2230
- scope,
2231
- kind: input.kind ?? old.kind,
2232
- content,
2233
- ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
2234
- })).id,
2235
- superseded: input.id
4198
+ id: record.id,
4199
+ outcome,
4200
+ confidence: record.confidence,
4201
+ ...scheduled?.review?.nextReviewAt === null || scheduled?.review?.nextReviewAt === void 0 ? {} : { nextReviewAt: scheduled.review.nextReviewAt }
2236
4202
  };
2237
4203
  }
2238
4204
  }),
2239
- defineTool({
2240
- name: "engram_forget",
2241
- description: "遗忘一条记忆(软删,用户可从库中恢复)。id 与 scope 来自 engram_search 结果。",
2242
- parameters: {
2243
- id: {
2244
- type: "string",
2245
- required: true,
2246
- description: "条目 id"
2247
- },
2248
- scope: {
2249
- type: "string",
2250
- enum: ["user", "project"],
2251
- description: "条目作用域,默认 project"
2252
- }
2253
- },
2254
- output: {
2255
- schema: {
2256
- type: "object",
2257
- additionalProperties: false,
2258
- properties: { id: {
2259
- type: "string",
2260
- required: true
2261
- } }
2262
- },
2263
- render: (_args, value) => [{
2264
- type: "text",
2265
- text: `记忆 ${value.id} 已遗忘(软删,可恢复)。`
2266
- }]
2267
- },
2268
- async execute(args) {
2269
- const input = args;
2270
- const scope = scopeOf(input.scope, "project");
2271
- return { id: (await (await deps.openStore(scope)).forget(input.id)).id };
2272
- }
2273
- }),
4205
+ reviewQueue,
2274
4206
  defineTool({
2275
4207
  name: "engram_review",
2276
4208
  description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
@@ -2282,7 +4214,11 @@ function createEngramTools(deps) {
2282
4214
  },
2283
4215
  scope: {
2284
4216
  type: "string",
2285
- enum: ["user", "project"],
4217
+ enum: [
4218
+ "user",
4219
+ "project",
4220
+ "shared"
4221
+ ],
2286
4222
  description: "条目作用域,默认 project"
2287
4223
  }
2288
4224
  },
@@ -2315,18 +4251,20 @@ function createEngramTools(deps) {
2315
4251
  section("取代了谁", view.supersedes.map(String)),
2316
4252
  section("矛盾候选", view.contradicts.map(String)),
2317
4253
  section("关联", view.related.map(String)),
4254
+ view.revisions.length === 0 ? "" : `\n修订历史:\n${view.revisions.map((rev) => `- ${new Date(rev.supersededAt).toISOString()} [${rev.kind}] ${truncateItem(rev.content)}`).join("\n")}`,
2318
4255
  view.operations.length === 0 ? "" : `\n最近操作:\n${view.operations.map((op) => `- ${new Date(op.at).toISOString()} ${op.op}${op.detail === null ? "" : ` ${op.detail}`}`).join("\n")}`
2319
4256
  ].filter((part) => part !== "").join("\n"), "tool_review", "(对话继续)") };
2320
4257
  }
2321
4258
  }),
2322
4259
  defineTool({
2323
4260
  name: "engram_stats",
2324
- description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量。scope=all 时合并两库。",
4261
+ description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量,以及房间目录(房名/桩位数/最新门牌——检索前先看目录决定进哪个房间,配 engram_search 的 room 参数)。scope=all 时合并两库。",
2325
4262
  parameters: { scope: {
2326
4263
  type: "string",
2327
4264
  enum: [
2328
4265
  "user",
2329
4266
  "project",
4267
+ "shared",
2330
4268
  "all"
2331
4269
  ],
2332
4270
  description: "作用域,默认 all"
@@ -2347,37 +4285,61 @@ function createEngramTools(deps) {
2347
4285
  },
2348
4286
  async execute(args) {
2349
4287
  const scopes = scopesOf(args.scope);
2350
- return { text: (await Promise.all(scopes.map(async (scope) => ({
2351
- scope,
2352
- stats: await (await deps.openStore(scope)).stats()
2353
- })))).map(({ scope, stats }) => [
2354
- `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
2355
- `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
2356
- `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`
2357
- ].join("\n")).join("\n\n") };
4288
+ return { text: (await Promise.all(scopes.map(async (scope) => {
4289
+ const store = await deps.openStore(scope);
4290
+ const [storeStats, rooms, placards] = await Promise.all([
4291
+ store.stats(),
4292
+ store.slotCountsByRoom(),
4293
+ store.listPlacards()
4294
+ ]);
4295
+ return {
4296
+ scope,
4297
+ stats: storeStats,
4298
+ rooms,
4299
+ placards
4300
+ };
4301
+ }))).map(({ scope, stats, rooms, placards }) => {
4302
+ const latestPlacardByRoom = /* @__PURE__ */ new Map();
4303
+ for (const row of placards) if (row.room !== null) latestPlacardByRoom.set(row.room, row.caption);
4304
+ const roomLines = Object.entries(rooms).sort(([a], [b]) => a.localeCompare(b, "zh-Hans-CN")).map(([room, state]) => {
4305
+ const placard = latestPlacardByRoom.get(room);
4306
+ return ` ${room}: ${state.count}/${state.maxIndex} 桩${placard === void 0 ? "" : ` · 最新门牌「${placard}」`}`;
4307
+ });
4308
+ return [
4309
+ `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
4310
+ `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
4311
+ `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`,
4312
+ roomLines.length === 0 ? "房间目录: (尚未排桩)" : `房间目录(engram_search 用 room 参数直进):\n${roomLines.join("\n")}`
4313
+ ].join("\n");
4314
+ }).join("\n\n") };
2358
4315
  }
2359
4316
  }),
2360
4317
  defineTool({
2361
4318
  name: "engram_export",
2362
- description: "把记忆库导出为文件(Markdown JSON,含全部状态与关系边),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。",
4319
+ description: "把记忆库导出为文件(Markdown / JSON / 镜像目录),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。format=markdown-mirror 时每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md,可直接用 Obsidian / git 漫游。",
2363
4320
  parameters: {
2364
4321
  format: {
2365
4322
  type: "string",
2366
- enum: ["markdown", "json"],
2367
- description: "导出格式,默认 markdown"
4323
+ enum: [
4324
+ "markdown",
4325
+ "json",
4326
+ "markdown-mirror"
4327
+ ],
4328
+ description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每房间一文件(默认 markdown)"
2368
4329
  },
2369
4330
  scope: {
2370
4331
  type: "string",
2371
4332
  enum: [
2372
4333
  "user",
2373
4334
  "project",
4335
+ "shared",
2374
4336
  "all"
2375
4337
  ],
2376
4338
  description: "作用域,默认 all"
2377
4339
  },
2378
4340
  redactedView: {
2379
4341
  type: "boolean",
2380
- description: "脱敏视图:内容二次脱敏并截断为预览(默认 false 完整导出)"
4342
+ description: "脱敏视图:内容二次脱敏并截断为预览(默认 false 完整导出;镜像模式忽略此参数)"
2381
4343
  }
2382
4344
  },
2383
4345
  output: {
@@ -2396,7 +4358,7 @@ function createEngramTools(deps) {
2396
4358
  },
2397
4359
  async execute(args) {
2398
4360
  const input = args;
2399
- const format = input.format === "json" ? "json" : "markdown";
4361
+ const format = input.format === "json" ? "json" : input.format === "markdown-mirror" ? "markdown-mirror" : "markdown";
2400
4362
  const redactedView = input.redactedView === true;
2401
4363
  const scopes = scopesOf(input.scope);
2402
4364
  const preview = (content) => {
@@ -2410,6 +4372,13 @@ function createEngramTools(deps) {
2410
4372
  const written = [];
2411
4373
  for (const scope of scopes) {
2412
4374
  const data = await (await deps.openStore(scope)).exportAll();
4375
+ if (format === "markdown-mirror") {
4376
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
4377
+ const mirrorRoot = join(deps.exportDir, `mirror-${scope}-${stamp}`);
4378
+ const report = await writeMirror(mirrorRoot, data);
4379
+ written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length} 间房间,${report.floors.length} 个楼层)`);
4380
+ continue;
4381
+ }
2413
4382
  const payload = redactedView ? {
2414
4383
  ...data,
2415
4384
  records: data.records.map((record) => ({
@@ -2440,7 +4409,11 @@ function createEngramTools(deps) {
2440
4409
  description: "蒸馏整理:把同主题的记忆簇合并提炼为更高层的规律(旧条目归档、supersedes 链保留)。建议记忆较多时周期性执行。",
2441
4410
  parameters: { scope: {
2442
4411
  type: "string",
2443
- enum: ["user", "project"],
4412
+ enum: [
4413
+ "user",
4414
+ "project",
4415
+ "shared"
4416
+ ],
2444
4417
  description: "作用域,默认 user"
2445
4418
  } },
2446
4419
  output: {
@@ -2461,7 +4434,7 @@ function createEngramTools(deps) {
2461
4434
  const scope = scopeOf(args.scope, "user");
2462
4435
  if (deps.call === void 0) throw new Error("engram_distill: 辅助 LLM 不可用(宿主未提供 llm 服务),无法蒸馏");
2463
4436
  const call = deps.call;
2464
- const events = exec.agent?.session?.events ?? [];
4437
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
2465
4438
  const route = deps.routeOverride ?? routeFromEvents(events);
2466
4439
  if (route === void 0) throw new Error("engram_distill: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
2467
4440
  const embedder = await deps.embedder;
@@ -2483,19 +4456,334 @@ function createEngramTools(deps) {
2483
4456
  });
2484
4457
  return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
2485
4458
  }
2486
- })
4459
+ }),
4460
+ defineTool({
4461
+ name: "engram_examine",
4462
+ description: "渐进式披露:按 id 批量拉取房间完整铭牌(content + 楼层 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
4463
+ parameters: { ids: {
4464
+ type: "array",
4465
+ items: { type: "string" },
4466
+ description: "房间 id 列表"
4467
+ } },
4468
+ output: {
4469
+ schema: {
4470
+ type: "object",
4471
+ additionalProperties: false,
4472
+ properties: { text: {
4473
+ type: "string",
4474
+ required: true
4475
+ } }
4476
+ },
4477
+ render: (_args, value) => [{
4478
+ type: "text",
4479
+ text: value.text
4480
+ }]
4481
+ },
4482
+ async execute(args) {
4483
+ const input = args;
4484
+ const rawIds = Array.isArray(input.ids) ? input.ids.filter((x) => typeof x === "string") : [];
4485
+ if (rawIds.length === 0) throw new Error("engram_examine: ids 必填且至少含 1 条");
4486
+ const ids = rawIds.slice(0, 16).map((id) => asMemoryId(id));
4487
+ const store = await deps.openStore("user");
4488
+ const projectStore = await deps.openStore("project");
4489
+ const [fromUser, fromProject] = await Promise.all([store.getMany(ids), projectStore.getMany(ids)]);
4490
+ const records = [...fromUser, ...fromProject];
4491
+ if (records.length === 0) throw new Error(`engram_examine: 全部 ${ids.length} 个 id 都找不到`);
4492
+ return { text: records.map((record, index) => [
4493
+ `### ${index + 1}. [${record.scope}/${record.kind}/${record.status}] id=${record.id}`,
4494
+ `铭牌: ${record.content}`,
4495
+ `地标亮度=${record.importance.toFixed(2)} · 考据可靠度=${record.confidence.toFixed(2)} · 参观=${record.accessCount}人次`
4496
+ ].join("\n")).join("\n\n") };
4497
+ }
4498
+ }),
4499
+ defineTool({
4500
+ name: "engram_neighbors",
4501
+ description: "走廊漫步:从某间出发走 1-3 跳内的 related/supersedes/contradicts 边,返回邻居房间简表(仅 id + scope + kind + status + content),便于判断下一站。",
4502
+ parameters: {
4503
+ id: {
4504
+ type: "string",
4505
+ description: "起点房间 id"
4506
+ },
4507
+ depth: {
4508
+ type: "integer",
4509
+ description: "跳数(默认 1,最多 3,由执行器夹逼)"
4510
+ }
4511
+ },
4512
+ output: {
4513
+ schema: {
4514
+ type: "object",
4515
+ additionalProperties: false,
4516
+ properties: { text: {
4517
+ type: "string",
4518
+ required: true
4519
+ } }
4520
+ },
4521
+ render: (_args, value) => [{
4522
+ type: "text",
4523
+ text: value.text
4524
+ }]
4525
+ },
4526
+ async execute(args) {
4527
+ const input = args;
4528
+ if (typeof input.id !== "string" || input.id === "") throw new Error("engram_neighbors: id 必填");
4529
+ const depth = Number.isInteger(input.depth) ? Math.min(Math.max(1, input.depth), 3) : 1;
4530
+ const seed = asMemoryId(input.id);
4531
+ const userStore = await deps.openStore("user");
4532
+ const projectStore = await deps.openStore("project");
4533
+ const seedRow = await userStore.get(seed) ?? await projectStore.get(seed);
4534
+ if (seedRow === void 0) throw new Error(`engram_neighbors: 起点 ${seed} 不存在`);
4535
+ const seedScope = seedRow.scope;
4536
+ const neighbors = await (seedScope === "user" ? userStore : projectStore).neighbors(seed, depth);
4537
+ if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth} 跳内无邻居房间。` };
4538
+ const lines = neighbors.map((record, index) => `${index + 1}. [${record.scope}/${record.kind}/${record.status}] ${record.content.slice(0, 80)}${record.content.length > 80 ? "…" : ""}(id=${record.id})`);
4539
+ return { text: `起点 ${seed}(${seedScope})→ ${depth} 跳走廊共访 ${neighbors.length} 间:\n${lines.join("\n")}` };
4540
+ }
4541
+ }),
4542
+ defineTool({
4543
+ name: "engram_tour",
4544
+ description: "巡游路由。mode=fixed:按固定巡游路线走全宫(桩位顺序恒定,骨架长期复用——宫殿的路线永远不变,靠顺序提取);mode=thematic(默认):按主题动态规划 3-7 站(同类巩固 → 走廊相邻 → 反差补位),适合用户问起某主题时给出一条可走的导览路线。",
4545
+ parameters: {
4546
+ query: {
4547
+ type: "string",
4548
+ description: "巡游主题(thematic 模式必填,与 engram_search 同义)"
4549
+ },
4550
+ mode: {
4551
+ type: "string",
4552
+ enum: ["fixed", "thematic"],
4553
+ description: "fixed 固定路线全宫巡游 / thematic 主题动态路线(默认 thematic)"
4554
+ },
4555
+ scope: {
4556
+ type: "string",
4557
+ enum: [
4558
+ "user",
4559
+ "project",
4560
+ "shared",
4561
+ "all"
4562
+ ],
4563
+ description: "作用域,默认 all"
4564
+ },
4565
+ maxStops: {
4566
+ type: "integer",
4567
+ description: "最多站数(默认 6,3-7 之间;fixed 模式默认 20)"
4568
+ }
4569
+ },
4570
+ output: {
4571
+ schema: {
4572
+ type: "object",
4573
+ additionalProperties: false,
4574
+ properties: { text: {
4575
+ type: "string",
4576
+ required: true
4577
+ } }
4578
+ },
4579
+ render: (_args, value) => [{
4580
+ type: "text",
4581
+ text: value.text
4582
+ }]
4583
+ },
4584
+ async execute(args, exec) {
4585
+ const input = args;
4586
+ const scopes = scopesOf(input.scope);
4587
+ if ((input.mode === "fixed" ? "fixed" : "thematic") === "fixed") {
4588
+ const maxStops = Number.isInteger(input.maxStops) ? Math.max(1, input.maxStops) : 20;
4589
+ const sections = [];
4590
+ let shown = 0;
4591
+ let skipped = 0;
4592
+ for (const scope of scopes) {
4593
+ const store = await deps.openStore(scope);
4594
+ const route = await store.routeList();
4595
+ if (route.length === 0) continue;
4596
+ const records = await store.getMany(route.map((stop) => stop.id));
4597
+ const byId = new Map(records.map((record) => [String(record.id), record]));
4598
+ const lines = [];
4599
+ for (const stop of route) {
4600
+ if (shown >= maxStops) break;
4601
+ const record = byId.get(String(stop.id));
4602
+ if (record === void 0 || record.status !== "active") {
4603
+ skipped += 1;
4604
+ continue;
4605
+ }
4606
+ shown += 1;
4607
+ const slot = record.slot === void 0 ? "" : `${record.slot.room} #${record.slot.index} · `;
4608
+ const placard = record.imagery?.caption;
4609
+ lines.push(`第 ${stop.position + 1} 站 · ${slot}[${record.kind}] ${truncateItem(record.content)}(id=${record.id})${placard === null || placard === void 0 ? "" : ` · 门牌「${placard}」`}`);
4610
+ }
4611
+ if (lines.length > 0) sections.push(`【${scope} 宫殿 · 固定巡游】\n${lines.join("\n")}`);
4612
+ }
4613
+ if (shown === 0) return { text: "巡游路线为空:尚无排桩记忆(保存记忆后自动登记路线)。" };
4614
+ const tail = skipped > 0 ? `\n(另有 ${skipped} 个空桩:原记忆已闭馆或归档,桩位保留不回收)` : "";
4615
+ return { text: `${sections.join("\n\n")}${tail}` };
4616
+ }
4617
+ if (typeof input.query !== "string" || input.query.trim() === "") throw new Error("engram_tour: thematic 模式需要 query 参数(巡游主题)");
4618
+ const tourQuery = input.query;
4619
+ const limit = 12;
4620
+ const maxStops = Number.isInteger(input.maxStops) ? Math.min(Math.max(3, input.maxStops), 7) : 6;
4621
+ const rewrite = await rewriteQueries(deps, exec, tourQuery);
4622
+ const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
4623
+ const vector = await queryVectorOf(deps, queryText);
4624
+ const results = await Promise.all(scopes.map(async (scope) => {
4625
+ return (await deps.openStore(scope)).search({
4626
+ text: queryText,
4627
+ scopes: [scope],
4628
+ limit
4629
+ }, vector);
4630
+ }));
4631
+ return {
4632
+ hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
4633
+ degraded: results.some((result) => result.degraded)
4634
+ };
4635
+ }));
4636
+ const degraded = retrievals.some((retrieval) => retrieval.degraded);
4637
+ const merged = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length)));
4638
+ const userStore = await deps.openStore("user");
4639
+ const projectStore = await deps.openStore("project");
4640
+ const poolLookup = async (id) => await userStore.get(id) ?? await projectStore.get(id);
4641
+ const route = await planTour(merged, poolLookup, tourQuery, maxStops);
4642
+ return { text: `${degraded ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${route.narrative}` };
4643
+ }
4644
+ }),
4645
+ auditForgotten
4646
+ ];
4647
+ }
4648
+ /**
4649
+ * 构造巡游路径:在 search 命中的基础上按路径策略重排。
4650
+ * 1) 起点簇:取命中中 kind 出现频次最高的前 N 个同 kind 节点(同类巩固)。
4651
+ * 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0 的房间。
4652
+ * 3) 反差收束:从剩余命中挑一个 emotionalValence ≥ 0.7 的做收束(强反差唤醒)。
4653
+ * 命中不足时按可用性回退;命中为 0 时返回空 stops。
4654
+ */
4655
+ async function planTour(hits, poolLookup, query, maxStops = 6) {
4656
+ if (hits.length === 0) return {
4657
+ stops: [],
4658
+ narrative: "无命中,无巡游路径可规划。",
4659
+ query
4660
+ };
4661
+ const used = /* @__PURE__ */ new Set();
4662
+ const stops = [];
4663
+ const kindCounts = /* @__PURE__ */ new Map();
4664
+ for (const hit of hits) kindCounts.set(hit.record.kind, (kindCounts.get(hit.record.kind) ?? 0) + 1);
4665
+ const topKinds = [...kindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([kind]) => kind);
4666
+ for (const kind of topKinds) for (const hit of hits) {
4667
+ if (stops.length >= maxStops) break;
4668
+ if (hit.record.kind !== kind || used.has(hit.record.id)) continue;
4669
+ stops.push({
4670
+ record: hit.record,
4671
+ reason: "same-kind"
4672
+ });
4673
+ used.add(hit.record.id);
4674
+ }
4675
+ for (const stop of [...stops]) {
4676
+ if (stops.length >= maxStops - 1) break;
4677
+ await poolLookup(stop.record.id);
4678
+ for (const hit of hits) {
4679
+ if (stops.length >= maxStops - 1) break;
4680
+ if (used.has(hit.record.id) || hit.record.id === stop.record.id) continue;
4681
+ if (hit.record.scope !== stop.record.scope) continue;
4682
+ stops.push({
4683
+ record: hit.record,
4684
+ reason: "neighbor"
4685
+ });
4686
+ used.add(hit.record.id);
4687
+ }
4688
+ }
4689
+ const emotionCut = hits.find((hit) => !used.has(hit.record.id) && (hit.record.imagery?.emotionalValence ?? 0) >= .7);
4690
+ if (emotionCut !== void 0 && stops.length < maxStops) {
4691
+ stops.push({
4692
+ record: emotionCut.record,
4693
+ reason: "emotional-peak"
4694
+ });
4695
+ used.add(emotionCut.record.id);
4696
+ }
4697
+ for (const hit of hits) {
4698
+ if (stops.length >= maxStops) break;
4699
+ if (used.has(hit.record.id)) continue;
4700
+ stops.push({
4701
+ record: hit.record,
4702
+ reason: "contrast"
4703
+ });
4704
+ used.add(hit.record.id);
4705
+ }
4706
+ return {
4707
+ stops,
4708
+ narrative: renderTourNarrative(stops, query),
4709
+ query
4710
+ };
4711
+ }
4712
+ /** 把巡游路径渲染为一段含回声触发器的可读文本(供 engram_tour 的 text 字段)。 */
4713
+ function renderTourNarrative(stops, query) {
4714
+ if (stops.length === 0) return "无巡游路径。";
4715
+ const reasonLabel = (reason) => {
4716
+ switch (reason) {
4717
+ case "same-kind": return "同类巩固";
4718
+ case "neighbor": return "走廊相邻";
4719
+ case "contrast": return "反差补位";
4720
+ case "emotional-peak": return "情绪强反差";
4721
+ }
4722
+ };
4723
+ const lines = [`巡游路径(查询:${query}):按「同类巩固 → 走廊相邻 → 反差补位」顺序组织,共 ${stops.length} 站。`];
4724
+ stops.forEach((stop, index) => {
4725
+ const caption = stop.record.imagery?.caption;
4726
+ const sensory = stop.record.imagery?.sensoryTags ?? [];
4727
+ const echo = caption === null || caption === void 0 ? "(未铭刻意象,请先调用 engram_examine 读铭牌)" : `回声:似曾「${caption}」${sensory.length > 0 ? `(${sensory.slice(0, 3).join("、")})` : ""}`;
4728
+ lines.push(`${index + 1}. [${stop.record.scope}/${stop.record.kind}] ${stop.record.content.slice(0, 60)}${stop.record.content.length > 60 ? "…" : ""}(id=${stop.record.id},理由:${reasonLabel(stop.reason)})— ${echo}`);
4729
+ });
4730
+ return lines.join("\n");
4731
+ }
4732
+ //#endregion
4733
+ //#region src/selection-rationale.ts
4734
+ /** 给定一组入选房间,返回归因 XML 字符串;空 rooms 返回空串。 */
4735
+ function buildSelectionRationale(records) {
4736
+ if (records.length === 0) return "";
4737
+ const now = Date.now();
4738
+ const lines = [
4739
+ "<engram_selection_rationale>",
4740
+ `本轮画像共 ${String(records.length)} 间房间,挑选理由如下:`,
4741
+ ""
2487
4742
  ];
4743
+ for (const record of records) {
4744
+ const reasons = classifyRecord(record, now);
4745
+ lines.push(`- #${record.id.slice(0, 8)} [${record.kind}/${record.scope}] ${reasonsLabel(reasons)}:${record.content.slice(0, 40)}${record.content.length > 40 ? "…" : ""}`);
4746
+ }
4747
+ lines.push("", "如果某条理由错误,请通过 engram_report 反馈,越准的画像越能帮你。", "</engram_selection_rationale>");
4748
+ return lines.join("\n");
4749
+ }
4750
+ /** 把画像正文 + 归因拼成最终注入文本。 */
4751
+ function wrapWithRationale(profileText, records) {
4752
+ const rationale = buildSelectionRationale(records);
4753
+ return rationale === "" ? profileText : `${rationale}\n\n${profileText}`;
4754
+ }
4755
+ /** 启发式:4 类原因按阈值判定,可叠加。 */
4756
+ function classifyRecord(record, now) {
4757
+ const reasons = [];
4758
+ if (now - record.lastAccessedAt < 6048e5) reasons.push("recent");
4759
+ if (record.importance * record.confidence >= .7) reasons.push("bright");
4760
+ if (record.accessCount >= 5) reasons.push("corridor-hit");
4761
+ if (record.outcome === "success") reasons.push("strong-evidence");
4762
+ return reasons;
4763
+ }
4764
+ /** 中文/英文 reason 标签。 */
4765
+ function reasonsLabel(reasons) {
4766
+ if (reasons.length === 0) return "基础入选";
4767
+ const LABELS = {
4768
+ recent: "近期被参观",
4769
+ bright: "地标明亮",
4770
+ "corridor-hit": "走廊常客",
4771
+ "strong-evidence": "管家验证有效"
4772
+ };
4773
+ return reasons.map((r) => LABELS[r]).join(" / ");
2488
4774
  }
2489
4775
  //#endregion
2490
4776
  //#region src/index.ts
2491
4777
  /**
2492
4778
  * dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
2493
- * 注册 9 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
4779
+ * 注册 10 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
2494
4780
  * 蒸馏/衰减飞轮与审计能力。
2495
4781
  * @module @kenz1117/dsh-engram
2496
4782
  */
2497
4783
  /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
2498
4784
  const name = "dsh-engram";
4785
+ /** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
4786
+ const VERSION = "0.7.2";
2499
4787
  /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
2500
4788
  const inject = ["tools", "llm"];
2501
4789
  /**
@@ -2504,17 +4792,18 @@ const inject = ["tools", "llm"];
2504
4792
  * 索引行也装不下的折成末尾 `+N more; use engram_search` 计数行。
2505
4793
  * @param records - 候选条目(调用方已按重要性排序、按条数截断)。
2506
4794
  * @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
2507
- * @returns 注入文本。
4795
+ * @returns 渲染文本与溢出条目(调用方可用辅助 LLM 压缩后重渲染)。
2508
4796
  */
2509
- function renderProfile(records, tokenBudget) {
4797
+ function renderProfileDetailed(records, tokenBudget) {
2510
4798
  const estimate = (text) => Math.ceil(text.length / 4);
2511
- const header = "User memory profile (dsh-engram, cross-session):";
2512
- const footer = "Use engram_search to recall details; use engram_save to persist new facts.";
4799
+ const header = "User memory profile (dsh-engram, cross-session) — Grand Hall (always present):";
4800
+ const footer = "Use engram_search to recall details (pass room to search inside one room); use engram_save to persist new facts.";
2513
4801
  let remaining = Math.max(0, tokenBudget - estimate(header) - estimate(footer));
2514
4802
  const lines = [];
2515
4803
  const overflow = [];
2516
4804
  for (const record of records) {
2517
- const line = `- [${record.kind}] ${record.content}`;
4805
+ const slot = record.slot === void 0 ? "" : ` ${record.slot.room}#${record.slot.index}`;
4806
+ const line = `- [${record.kind}]${slot} ${record.content}`;
2518
4807
  const cost = estimate(line);
2519
4808
  if (cost <= remaining) {
2520
4809
  lines.push(line);
@@ -2531,16 +4820,76 @@ function renderProfile(records, tokenBudget) {
2531
4820
  } else more += 1;
2532
4821
  }
2533
4822
  if (more > 0) lines.push(`+${more} more; use engram_search`);
2534
- return [
2535
- header,
2536
- ...lines,
2537
- footer
2538
- ].join("\n");
4823
+ return {
4824
+ text: [
4825
+ header,
4826
+ ...lines,
4827
+ footer
4828
+ ].join("\n"),
4829
+ overflow
4830
+ };
4831
+ }
4832
+ /**
4833
+ * 会话开始注入的画像渲染(只取文本;溢出明细见 renderProfileDetailed)。
4834
+ * @param records - 候选条目(调用方已按重要性排序、按条数截断)。
4835
+ * @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
4836
+ * @returns 注入文本。
4837
+ */
4838
+ function renderProfile(records, tokenBudget) {
4839
+ return renderProfileDetailed(records, tokenBudget).text;
4840
+ }
4841
+ /** 压缩辅助调用的输出 token 上限(40 字 × 若干条,短输出足够)。 */
4842
+ const COMPRESS_MAX_TOKENS = 800;
4843
+ /** 压缩辅助调用超时:压缩在 pre-step 关键路径上,必须限时防阻塞首轮请求。 */
4844
+ const COMPRESS_TIMEOUT_MS = 8e3;
4845
+ const COMPRESS_SYSTEM = [
4846
+ "把记忆条目压缩为更短的一句话表述(每条不超过 40 个字符),保留可跨会话复用的关键信息(事实、偏好、决策、方法)。",
4847
+ "只输出一个 JSON 数组,每项形如 {\"id\": \"原样返回的id\", \"content\": \"压缩后表述\"},条目数量与 id 必须与输入一一对应。",
4848
+ "不要输出 JSON 以外的任何内容。"
4849
+ ].join("\n");
4850
+ /**
4851
+ * 画像超预算的辅助压缩:把装不下的条目交给辅助 LLM 压短,返回 id → 压缩文本。
4852
+ * 任何失败(无路由、输出不可解析、超时、调用异常)返回 undefined,调用方
4853
+ * 降级回索引行装填。压缩请求审计到 user 库 op_log(model-visible ⟺ logged:
4854
+ * 压缩产物本身会随注入消息进会话日志)。
4855
+ */
4856
+ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, signal) {
4857
+ try {
4858
+ const events = agent.session.snapshotEvents();
4859
+ const route = routeOverride ?? routeFromEvents(events);
4860
+ if (route === void 0) return void 0;
4861
+ const userText = JSON.stringify(overflow.map((record) => ({
4862
+ id: record.id,
4863
+ content: record.content
4864
+ })));
4865
+ const parsed = parseJsonArray(await streamText(ctx, {
4866
+ route,
4867
+ system: COMPRESS_SYSTEM,
4868
+ userText,
4869
+ maxTokens: COMPRESS_MAX_TOKENS,
4870
+ purpose: "engram-compress",
4871
+ sessionId: agent.session.id,
4872
+ signal: AbortSignal.any([signal, AbortSignal.timeout(COMPRESS_TIMEOUT_MS)])
4873
+ }));
4874
+ if (parsed === void 0) return void 0;
4875
+ const ids = new Set(overflow.map((record) => record.id));
4876
+ const compressed = /* @__PURE__ */ new Map();
4877
+ for (const item of parsed) {
4878
+ const entry = item;
4879
+ if (typeof entry.id !== "string" || typeof entry.content !== "string") continue;
4880
+ if (!ids.has(entry.id) || entry.content.trim() === "" || entry.content.length >= overflow.find((record) => record.id === entry.id).content.length) continue;
4881
+ compressed.set(entry.id, entry.content.trim());
4882
+ }
4883
+ return compressed.size > 0 ? compressed : void 0;
4884
+ } catch {
4885
+ return;
4886
+ }
2539
4887
  }
2540
4888
  /**
2541
- * agent/pre-step waterfall:每轮第一步注入画像;同时 fire-and-forget 触发
2542
- * 上一轮的自动摄取(不阻塞请求);进程内首次第一步重放待补做的末轮摄取。
2543
- * 必须调用 next() 委托链路;reject 决策原样透传,记忆库为空或非首轮时不追加消息。
4889
+ * agent/pre-step waterfall:每轮第一步注入画像(内容与上次相同则跳过重复注入);
4890
+ * 同时 fire-and-forget 触发上一轮的自动摄取(不阻塞请求);进程内首次第一步
4891
+ * 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
4892
+ * 记忆库为空或非首轮时不追加消息。
2544
4893
  */
2545
4894
  async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
2546
4895
  const decision = await next();
@@ -2566,7 +4915,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2566
4915
  });
2567
4916
  }
2568
4917
  if (turn > 1) ingestPreviousTurn({
2569
- events: agent.session.events,
4918
+ events: agent.session.snapshotEvents(),
2570
4919
  sessionId: String(agent.id),
2571
4920
  turn,
2572
4921
  openStore: () => openStore("user"),
@@ -2586,7 +4935,29 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2586
4935
  if (step !== 1) return decision;
2587
4936
  const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
2588
4937
  if (top.length === 0) return decision;
2589
- const packet = renderMemoryPacket(renderProfile(top, resolved.injectTokenBudget), "turn_start", currentUserRequestText(decision.messages));
4938
+ const dueTotal = resolved.reviewScheduling ? (await Promise.all(["user", "project"].map(async (scope) => (await openStore(scope)).dueReviews(Date.now(), 50)))).reduce((sum, rows) => sum + rows.length, 0) : 0;
4939
+ const detailed = renderProfileDetailed(top, resolved.injectTokenBudget);
4940
+ let text = detailed.text;
4941
+ if (detailed.overflow.length > 0) {
4942
+ const compressed = await compressProfileOverflow(ctx, agent, resolved.routeOverride, detailed.overflow, signal);
4943
+ if (compressed !== void 0) {
4944
+ openStore("user").then((auditStore) => auditStore.audit("compress-request", "AUX", JSON.stringify({ count: detailed.overflow.length }))).catch(() => {});
4945
+ text = renderProfileDetailed(top.map((record) => {
4946
+ const shorter = compressed.get(record.id);
4947
+ return shorter === void 0 ? record : {
4948
+ ...record,
4949
+ content: shorter
4950
+ };
4951
+ }), resolved.injectTokenBudget).text;
4952
+ }
4953
+ }
4954
+ const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
4955
+ const textWithRationale = wrapWithRationale(text, top) + dueLine;
4956
+ const hash = createHash("sha256").update(textWithRationale).digest("hex");
4957
+ if (state.lastProfileAgent === String(agent.id) && hash === state.lastProfileHash) return decision;
4958
+ state.lastProfileAgent = String(agent.id);
4959
+ state.lastProfileHash = hash;
4960
+ const packet = renderMemoryPacket(text, "turn_start", currentUserRequestText(decision.messages));
2590
4961
  return {
2591
4962
  ...decision,
2592
4963
  messages: [...decision.messages, createUserMessage({
@@ -2613,7 +4984,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2613
4984
  */
2614
4985
  function makeEventResolver(ctx, agent) {
2615
4986
  return async (sessionId) => {
2616
- if (sessionId === String(agent.id)) return agent.session.events;
4987
+ if (sessionId === String(agent.id)) return agent.session.snapshotEvents();
2617
4988
  const persistence = ctx.get("sessionPersistence");
2618
4989
  if (persistence === void 0) return void 0;
2619
4990
  try {
@@ -2624,7 +4995,7 @@ function makeEventResolver(ctx, agent) {
2624
4995
  };
2625
4996
  }
2626
4997
  /**
2627
- * 插件体:预热分库与嵌入器,注册 9 个工具、画像注入、自动摄取与衰减调度。
4998
+ * 插件体:预热分库与嵌入器,注册 10 个工具、画像注入、自动摄取与衰减调度。
2628
4999
  * @param ctx - host 上下文。
2629
5000
  * @param config - cordis.yml 传入的可选配置;非法值在加载时 loud 失败。
2630
5001
  */
@@ -2647,7 +5018,17 @@ function apply(ctx, config = {}) {
2647
5018
  const openStore = (scope) => {
2648
5019
  const existing = stores.get(scope);
2649
5020
  if (existing !== void 0) return existing;
2650
- const created = openEngramStore(scope === "user" ? join(resolved.dbDir, "user.db") : join(resolved.dbDir, identity.dbName), rankBoost);
5021
+ const path = scope === "user" ? join(resolved.dbDir, "user.db") : scope === "shared" ? join(resolved.dbDir, "shared.db") : join(resolved.dbDir, identity.dbName);
5022
+ const created = openEngramStore(path, rankBoost, {
5023
+ autoSlot: resolved.autoSlot,
5024
+ reviewScheduling: resolved.reviewScheduling
5025
+ }).then(async (store) => {
5026
+ const assigned = await store.backfillSlots((room) => {
5027
+ console.warn(`[dsh-engram] 房间已满,自动开新房「${room}」(可在管理面板翻新清单中人工拆分/命名)`);
5028
+ });
5029
+ if (assigned > 0) console.warn(`[dsh-engram] 存量记忆排桩完成:${assigned} 条已钉入宫殿(${path})`);
5030
+ return store;
5031
+ });
2651
5032
  stores.set(scope, created);
2652
5033
  return created;
2653
5034
  };
@@ -2668,21 +5049,29 @@ function apply(ctx, config = {}) {
2668
5049
  ctx.inject(["webServer"], (webCtx) => {
2669
5050
  registerEngramRoutes(webCtx, {
2670
5051
  openStore,
2671
- exportDir: `${resolved.dbDir}/exports`
5052
+ exportDir: `${resolved.dbDir}/exports`,
5053
+ mirrorDir: `${resolved.dbDir}/palaces`,
5054
+ dbDir: resolved.dbDir,
5055
+ pluginVersion: VERSION,
5056
+ embedder
2672
5057
  });
2673
5058
  });
2674
5059
  const logIngestRequest = (data) => {
2675
5060
  openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
2676
5061
  };
2677
5062
  if (resolved.injectProfile || resolved.ingest !== "off") {
2678
- const state = { pendingReplayed: false };
5063
+ const state = {
5064
+ pendingReplayed: false,
5065
+ lastProfileAgent: null,
5066
+ lastProfileHash: null
5067
+ };
2679
5068
  ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, state, logIngestRequest, payload, next), { prepend: true });
2680
5069
  }
2681
5070
  if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
2682
5071
  const mode = resolved.ingest;
2683
5072
  if (mode === "off") return;
2684
5073
  ingestFinalTurn({
2685
- events: session.events,
5074
+ events: session.snapshotEvents(),
2686
5075
  sessionId: String(session.id),
2687
5076
  turn: 0,
2688
5077
  slice: "last",
@@ -2696,6 +5085,8 @@ function apply(ctx, config = {}) {
2696
5085
  }),
2697
5086
  logRequest: logIngestRequest,
2698
5087
  signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
5088
+ }).catch((error) => {
5089
+ console.warn("[dsh-engram] 会话结束的末轮摄取异常(不影响对话):", error);
2699
5090
  });
2700
5091
  });
2701
5092
  const runDecay = async () => {
@@ -2720,6 +5111,26 @@ function apply(ctx, config = {}) {
2720
5111
  clearInterval(timer);
2721
5112
  };
2722
5113
  }, "dsh-engram: decay timer");
5114
+ const runConsolidateOnce = async () => {
5115
+ try {
5116
+ const report = await runConsolidation(await openStore("user"), embedder, {
5117
+ olderThanDays: resolved.decayAfterDays,
5118
+ importanceBelow: resolved.decayImportanceBelow
5119
+ });
5120
+ console.log(`[dsh-engram] 闭馆整理完成:归档 ${String(report.archived)},合并 ${String(report.merged)},跳过 ${String(report.skipped)}(${String(report.tookMs)} ms)`);
5121
+ } catch (error) {
5122
+ console.warn("[dsh-engram] 闭馆整理失败(不影响对话):", error);
5123
+ }
5124
+ };
5125
+ runConsolidateOnce();
5126
+ ctx.effect(() => {
5127
+ const timer = setInterval(() => {
5128
+ runConsolidateOnce();
5129
+ }, 864e5);
5130
+ return () => {
5131
+ clearInterval(timer);
5132
+ };
5133
+ }, "dsh-engram: consolidation timer");
2723
5134
  }
2724
5135
  //#endregion
2725
- export { Config, apply, inject, name, renderProfile };
5136
+ export { Config, VERSION, apply, inject, name, renderProfile, renderProfileDetailed };