@kenz1117/dsh-engram 0.7.1 → 0.7.3

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
@@ -28,7 +28,15 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
28
28
  "injectTokenBudget",
29
29
  "rankRecencyWeight",
30
30
  "rankProofWeight",
31
- "queryRewrite"
31
+ "queryRewrite",
32
+ "autoSlot",
33
+ "reviewScheduling",
34
+ "historyBackfillDays",
35
+ "historyBackfillMaxTurnsPerSession",
36
+ "historyBackfillMaxTotalTurns",
37
+ "historyBackfillIncludeSubagents",
38
+ "historyBackfillIncludeSeeded",
39
+ "historyBackfillIncludeNoCwd"
32
40
  ]);
33
41
  const INGEST_MODES = /* @__PURE__ */ new Set([
34
42
  "off",
@@ -50,13 +58,21 @@ const Config = z.object({
50
58
  injectTokenBudget: z.number().step(1).min(128).max(8192),
51
59
  rankRecencyWeight: z.number().min(0).max(2),
52
60
  rankProofWeight: z.number().min(0).max(2),
53
- queryRewrite: z.boolean()
61
+ queryRewrite: z.boolean(),
62
+ autoSlot: z.boolean(),
63
+ reviewScheduling: z.boolean(),
64
+ historyBackfillDays: z.number().step(1).min(0).max(3650),
65
+ historyBackfillMaxTurnsPerSession: z.number().step(1).min(1).max(500),
66
+ historyBackfillMaxTotalTurns: z.number().step(1).min(1).max(5e3),
67
+ historyBackfillIncludeSubagents: z.boolean(),
68
+ historyBackfillIncludeSeeded: z.boolean(),
69
+ historyBackfillIncludeNoCwd: z.boolean()
54
70
  });
55
71
  /**
56
72
  * 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
57
73
  * @param config - cordis.yml 传入的未校验配置。
58
74
  * @returns 完整解析配置。
59
- * @throws 未知键、ingest 档位非法、provider/model 只给其一、decay/预算/排序权重越界时抛错。
75
+ * @throws 未知键、ingest 档位非法、provider/model 只给其一、decay/预算/排序权重/历史回填规则越界时抛错。
60
76
  */
61
77
  function resolveConfig(config = {}) {
62
78
  for (const key of Object.keys(config)) if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-engram: unknown config key "${key}"`);
@@ -70,6 +86,9 @@ function resolveConfig(config = {}) {
70
86
  if (config.injectTokenBudget !== void 0 && (!Number.isInteger(config.injectTokenBudget) || config.injectTokenBudget < 128 || config.injectTokenBudget > 8192)) throw new Error("dsh-engram: injectTokenBudget must be an integer in [128, 8192]");
71
87
  if (config.rankRecencyWeight !== void 0 && (config.rankRecencyWeight < 0 || config.rankRecencyWeight > 2)) throw new Error("dsh-engram: rankRecencyWeight must be in [0, 2]");
72
88
  if (config.rankProofWeight !== void 0 && (config.rankProofWeight < 0 || config.rankProofWeight > 2)) throw new Error("dsh-engram: rankProofWeight must be in [0, 2]");
89
+ if (config.historyBackfillDays !== void 0 && (!Number.isInteger(config.historyBackfillDays) || config.historyBackfillDays < 0 || config.historyBackfillDays > 3650)) throw new Error("dsh-engram: historyBackfillDays must be an integer in [0, 3650] (0 = unlimited)");
90
+ if (config.historyBackfillMaxTurnsPerSession !== void 0 && (!Number.isInteger(config.historyBackfillMaxTurnsPerSession) || config.historyBackfillMaxTurnsPerSession < 1 || config.historyBackfillMaxTurnsPerSession > 500)) throw new Error("dsh-engram: historyBackfillMaxTurnsPerSession must be an integer in [1, 500]");
91
+ if (config.historyBackfillMaxTotalTurns !== void 0 && (!Number.isInteger(config.historyBackfillMaxTotalTurns) || config.historyBackfillMaxTotalTurns < 1 || config.historyBackfillMaxTotalTurns > 5e3)) throw new Error("dsh-engram: historyBackfillMaxTotalTurns must be an integer in [1, 5000]");
73
92
  const dbDir = config.dbDir ?? join(homedir(), ".dsh", "engram");
74
93
  return {
75
94
  dbDir,
@@ -87,7 +106,17 @@ function resolveConfig(config = {}) {
87
106
  injectTokenBudget: config.injectTokenBudget ?? 1024,
88
107
  rankRecencyWeight: config.rankRecencyWeight ?? .2,
89
108
  rankProofWeight: config.rankProofWeight ?? .1,
90
- queryRewrite: config.queryRewrite ?? true
109
+ queryRewrite: config.queryRewrite ?? true,
110
+ autoSlot: config.autoSlot ?? true,
111
+ reviewScheduling: config.reviewScheduling ?? true,
112
+ historyBackfill: {
113
+ days: config.historyBackfillDays ?? 7,
114
+ maxTurnsPerSession: config.historyBackfillMaxTurnsPerSession ?? 20,
115
+ maxTotalTurns: config.historyBackfillMaxTotalTurns ?? 200,
116
+ includeSubagents: config.historyBackfillIncludeSubagents ?? false,
117
+ includeSeeded: config.historyBackfillIncludeSeeded ?? false,
118
+ includeNoCwd: config.historyBackfillIncludeNoCwd ?? false
119
+ }
91
120
  };
92
121
  }
93
122
  //#endregion
@@ -575,8 +604,11 @@ function throttleDecision(events) {
575
604
  if (forbidsCapture(joined)) return "capture-forbidden";
576
605
  return null;
577
606
  }
578
- /** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。 */
607
+ /** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。
608
+ * events 允许 undefined:会话 dispose 后事件源已 detach,宿主可能给不出日志,
609
+ * 此时按空日志处理而不是抛 TypeError(调用方在会话生命周期之外,异常会变成未处理 rejection)。 */
579
610
  function turnStarts(events) {
611
+ if (events === void 0) return [];
580
612
  const starts = [];
581
613
  for (let i = 0; i < events.length; i++) {
582
614
  if (events[i]?.type !== "turn/start") continue;
@@ -647,7 +679,7 @@ async function ingestPreviousTurn(deps) {
647
679
  written: 0,
648
680
  skipped: "already-ingested"
649
681
  };
650
- if (sliceMode === "previous") {
682
+ if (sliceMode === "previous" || deps.throttle === true) {
651
683
  const throttled = throttleDecision(slice);
652
684
  if (throttled !== null) return {
653
685
  scannedEvents: slice.length,
@@ -717,7 +749,7 @@ async function ingestPreviousTurn(deps) {
717
749
  }
718
750
  if (writtenContents.includes(content)) continue;
719
751
  await store.write({
720
- scope: "user",
752
+ scope: deps.writeScope ?? "user",
721
753
  kind,
722
754
  content,
723
755
  importance,
@@ -725,7 +757,8 @@ async function ingestPreviousTurn(deps) {
725
757
  sourceSessionId: deps.sessionId,
726
758
  sourceRound: round,
727
759
  ...minSeq === null ? {} : { sourceSeq: minSeq },
728
- ...embedder === void 0 ? {} : { embedding: (await embedder.embed([content]))[0] }
760
+ ...embedder === void 0 ? {} : { embedding: (await embedder.embed([content]))[0] },
761
+ ...deps.history === true ? { initialReviewAt: null } : {}
729
762
  });
730
763
  writtenContents.push(content);
731
764
  written += 1;
@@ -751,18 +784,22 @@ async function markPendingIngest(store, sessionId, turn) {
751
784
  * 会话结束时的末轮摄取:切片为最后一个 turn/start 到日志末尾,复用提炼管线。
752
785
  * 失败/超时只告警并把 (sessionId, turn) pending 键写入 op_log(下次会话首次
753
786
  * pre-step 重放补做),绝不影响对话。
787
+ * 本函数不 reject:从取轮次到摄取全程在 try 内,异常一律降级为告警 + pending 键
788
+ * (dispose 观察器是 fire-and-forget,逃逸的 rejection 会被宿主的 fail-loud 当作致命错误)。
754
789
  * @returns 摄取结果;无末轮或失败(已落 pending)时返回 null。
755
790
  */
756
791
  async function ingestFinalTurn(deps) {
757
- const round = lastTurnNumber(deps.events);
758
- if (round === void 0) return null;
792
+ /** 末轮轮次号;取不到(无 turn/start 或事件源不可用)时不落 pending——键需要轮次。 */
793
+ let round;
759
794
  try {
795
+ round = lastTurnNumber(deps.events);
796
+ if (round === void 0) return null;
760
797
  return await ingestPreviousTurn({
761
798
  ...deps,
762
799
  slice: "last"
763
800
  });
764
801
  } catch (error) {
765
- try {
802
+ if (round !== void 0) try {
766
803
  await markPendingIngest(await deps.openStore(), deps.sessionId, round);
767
804
  } catch {}
768
805
  console.warn("[dsh-engram] 会话结束的末轮摄取失败(已记入待补做队列,不影响对话):", error);
@@ -811,6 +848,286 @@ async function replayPendingIngests(deps) {
811
848
  };
812
849
  }
813
850
  //#endregion
851
+ //#region src/ingest/history.ts
852
+ const DAY_MS$1 = 864e5;
853
+ /** 估算时并发读取的会话数(读取是 IO,适度并发;摄取阶段始终串行)。 */
854
+ const ESTIMATE_CONCURRENCY = 4;
855
+ /**
856
+ * 合并规则:默认值来自 Config,覆盖项来自面板/工具;总轮数上限是硬顶(只能调低)。
857
+ * @param defaults - 配置默认规则。
858
+ * @param overrides - 本次覆盖项。
859
+ * @returns 生效规则。
860
+ * @throws 覆盖项越界时抛错(与配置校验同界)。
861
+ */
862
+ function mergeHistoryRules(defaults, overrides) {
863
+ const days = overrides.days ?? defaults.days;
864
+ if (!Number.isInteger(days) || days < 0 || days > 3650) throw new Error("historyBackfill: days 必须是 [0, 3650] 的整数(0 = 不限)");
865
+ const maxTurnsPerSession = overrides.maxTurnsPerSession ?? defaults.maxTurnsPerSession;
866
+ if (!Number.isInteger(maxTurnsPerSession) || maxTurnsPerSession < 1 || maxTurnsPerSession > 500) throw new Error("historyBackfill: maxTurnsPerSession 必须是 [1, 500] 的整数");
867
+ const maxTotalTurns = overrides.maxTotalTurns ?? defaults.maxTotalTurns;
868
+ if (!Number.isInteger(maxTotalTurns) || maxTotalTurns < 1 || maxTotalTurns > 5e3) throw new Error("historyBackfill: maxTotalTurns 必须是 [1, 5000] 的整数");
869
+ if ((overrides.provider !== void 0 && overrides.provider !== "") !== (overrides.model !== void 0 && overrides.model !== "")) throw new Error("historyBackfill: provider 与 model 必须成对提供");
870
+ return {
871
+ days,
872
+ maxTurnsPerSession,
873
+ maxTotalTurns: Math.min(maxTotalTurns, defaults.maxTotalTurns),
874
+ includeSubagents: overrides.includeSubagents ?? defaults.includeSubagents,
875
+ includeSeeded: overrides.includeSeeded ?? defaults.includeSeeded,
876
+ includeNoCwd: overrides.includeNoCwd ?? defaults.includeNoCwd
877
+ };
878
+ }
879
+ /** 事件流里的轮次号(按出现顺序,缺 data.turn 的 turn/start 忽略)。 */
880
+ function turnNumbers(events) {
881
+ const turns = [];
882
+ for (const event of events) {
883
+ if (event.type !== "turn/start") continue;
884
+ const turn = event.data?.turn;
885
+ if (typeof turn === "number" && Number.isFinite(turn)) turns.push(turn);
886
+ }
887
+ return turns;
888
+ }
889
+ /** 单个会话的日志是否读取成功(失败返回 undefined,调用方计入 unreadable)。 */
890
+ async function loadSession(source, id) {
891
+ try {
892
+ return (await source.load(id)).events;
893
+ } catch {
894
+ return;
895
+ }
896
+ }
897
+ /**
898
+ * 枚举并筛选可回填的会话:过滤子代理/种子/无 cwd/超窗,按单会话与总轮数上限截断。
899
+ * @param deps - 历史回填依赖。
900
+ * @param rules - 生效规则。
901
+ * @returns 候选会话与排除统计。
902
+ */
903
+ async function selectSessions(deps, rules) {
904
+ const source = deps.source;
905
+ if (source === void 0) return {
906
+ candidates: [],
907
+ skipped: {
908
+ subagent: 0,
909
+ seeded: 0,
910
+ noCwd: 0,
911
+ tooOld: 0,
912
+ unreadable: 0
913
+ },
914
+ truncated: false
915
+ };
916
+ const now = (deps.now ?? Date.now)();
917
+ const headers = await source.list();
918
+ const skipped = {
919
+ subagent: 0,
920
+ seeded: 0,
921
+ noCwd: 0,
922
+ tooOld: 0,
923
+ unreadable: 0
924
+ };
925
+ /** 时间窗内、需要读日志的 header(按创建时间倒序,先处理最近的)。 */
926
+ const kept = [];
927
+ for (const header of [...headers].sort((a, b) => b.createdAt - a.createdAt)) {
928
+ if (header.origin === "subagent" && !rules.includeSubagents) {
929
+ skipped.subagent += 1;
930
+ continue;
931
+ }
932
+ if (header.isSeeded === true && !rules.includeSeeded) {
933
+ skipped.seeded += 1;
934
+ continue;
935
+ }
936
+ if ((header.cwd === void 0 || header.cwd === "") && !rules.includeNoCwd) {
937
+ skipped.noCwd += 1;
938
+ continue;
939
+ }
940
+ if (rules.days > 0 && header.createdAt < now - rules.days * DAY_MS$1) {
941
+ skipped.tooOld += 1;
942
+ continue;
943
+ }
944
+ kept.push(header);
945
+ }
946
+ const loaded = [];
947
+ for (let i = 0; i < kept.length; i += ESTIMATE_CONCURRENCY) {
948
+ const batch = kept.slice(i, i + ESTIMATE_CONCURRENCY);
949
+ const settled = await Promise.all(batch.map(async (header) => {
950
+ const events = await loadSession(source, header.id);
951
+ if (events === void 0) {
952
+ skipped.unreadable += 1;
953
+ return;
954
+ }
955
+ const all = turnNumbers(events);
956
+ const turns = all.slice(Math.max(0, all.length - rules.maxTurnsPerSession));
957
+ if (turns.length === 0) return void 0;
958
+ const cwd = header.cwd;
959
+ return cwd === void 0 || cwd === "" ? {
960
+ header,
961
+ events,
962
+ turns,
963
+ openStore: deps.openUserStore,
964
+ writeScope: "user",
965
+ storeKey: "user"
966
+ } : {
967
+ header,
968
+ events,
969
+ turns,
970
+ openStore: () => deps.resolveStore(cwd),
971
+ writeScope: "project",
972
+ storeKey: cwd
973
+ };
974
+ }));
975
+ loaded.push(...settled);
976
+ }
977
+ const candidates = [];
978
+ let budget = rules.maxTotalTurns;
979
+ let truncated = false;
980
+ for (const candidate of loaded) {
981
+ if (candidate === void 0) continue;
982
+ if (budget <= 0) {
983
+ truncated = true;
984
+ continue;
985
+ }
986
+ const allowed = candidate.turns.slice(Math.max(0, candidate.turns.length - budget));
987
+ if (allowed.length < candidate.turns.length) truncated = true;
988
+ candidates.push({
989
+ ...candidate,
990
+ turns: allowed
991
+ });
992
+ budget -= allowed.length;
993
+ }
994
+ return {
995
+ candidates,
996
+ skipped,
997
+ truncated
998
+ };
999
+ }
1000
+ /** 环境不支持读取历史会话时的统一说明。 */
1001
+ const UNAVAILABLE_REASON = "当前环境未挂载会话持久化服务(会话日志不可读,例如 headless 组合),无法回填历史会话";
1002
+ /**
1003
+ * 估算:候选会话数、规则内轮数、以及扣掉已摄取后的真正待处理轮数。
1004
+ * 估算阶段只在库文件已存在时打开目标库(不创建空库、不写入)。
1005
+ * @param deps - 历史回填依赖。
1006
+ * @param defaults - 配置默认规则。
1007
+ * @param overrides - 本次覆盖项。
1008
+ * @returns 估算结果;环境不支持时 `unavailable` 有值。
1009
+ */
1010
+ async function estimateHistoryBackfill(deps, defaults, overrides = {}) {
1011
+ const rules = mergeHistoryRules(defaults, overrides);
1012
+ if (deps.source === void 0) return {
1013
+ rules,
1014
+ candidates: 0,
1015
+ eligibleTurns: 0,
1016
+ pendingTurns: 0,
1017
+ alreadyIngested: 0,
1018
+ skipped: {
1019
+ subagent: 0,
1020
+ seeded: 0,
1021
+ noCwd: 0,
1022
+ tooOld: 0,
1023
+ unreadable: 0
1024
+ },
1025
+ truncated: false,
1026
+ unavailable: UNAVAILABLE_REASON
1027
+ };
1028
+ const { candidates, skipped, truncated } = await selectSessions(deps, rules);
1029
+ let eligibleTurns = 0;
1030
+ let alreadyIngested = 0;
1031
+ for (const candidate of candidates) {
1032
+ eligibleTurns += candidate.turns.length;
1033
+ const store = candidate.writeScope === "user" ? await deps.resolveExistingUserStore() : await deps.resolveExistingStore(candidate.storeKey);
1034
+ if (store === void 0) continue;
1035
+ for (const turn of candidate.turns) if (await store.hasAudit("ingest-done", encodeTurnKey(candidate.header.id, turn))) alreadyIngested += 1;
1036
+ }
1037
+ return {
1038
+ rules,
1039
+ candidates: candidates.length,
1040
+ eligibleTurns,
1041
+ pendingTurns: eligibleTurns - alreadyIngested,
1042
+ alreadyIngested,
1043
+ skipped,
1044
+ truncated
1045
+ };
1046
+ }
1047
+ /**
1048
+ * 执行回填:逐会话、逐轮调用实时摄取管线;单轮失败只记录并继续,中断后重跑只补未完成轮次。
1049
+ * @param deps - 历史回填依赖。
1050
+ * @param defaults - 配置默认规则。
1051
+ * @param overrides - 本次覆盖项。
1052
+ * @param onProgress - 进度回调(每个会话结束时调用一次)。
1053
+ * @param signal - 取消信号(面板「暂停」/工具超时)。
1054
+ * @returns 运行结果。
1055
+ */
1056
+ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal) {
1057
+ const rules = mergeHistoryRules(defaults, overrides);
1058
+ const { candidates } = deps.source === void 0 ? { candidates: [] } : await selectSessions(deps, rules);
1059
+ const routeOverride = overrides.provider !== void 0 && overrides.model !== void 0 ? {
1060
+ provider: overrides.provider,
1061
+ model: overrides.model
1062
+ } : deps.routeOverride;
1063
+ const failures = [];
1064
+ /** 内部可变累加器(对外以只读的 HistoryRunProgress 暴露)。 */
1065
+ const progress = {
1066
+ state: "running",
1067
+ sessionsTotal: candidates.length,
1068
+ sessionsDone: 0,
1069
+ turnsPlanned: candidates.reduce((sum, candidate) => sum + candidate.turns.length, 0),
1070
+ turnsDone: 0,
1071
+ memoriesWritten: 0,
1072
+ turnsSkipped: 0,
1073
+ turnsFailed: 0,
1074
+ skipReasons: {}
1075
+ };
1076
+ onProgress({ ...progress });
1077
+ for (const candidate of candidates) {
1078
+ if (signal.aborted) break;
1079
+ for (const turn of candidate.turns) {
1080
+ if (signal.aborted) break;
1081
+ try {
1082
+ const outcome = await ingestPreviousTurn({
1083
+ events: candidate.events,
1084
+ sessionId: candidate.header.id,
1085
+ turn,
1086
+ slice: turn,
1087
+ openStore: () => candidate.openStore(),
1088
+ embedder: deps.embedder,
1089
+ mode: deps.mode,
1090
+ routeOverride,
1091
+ call: deps.call,
1092
+ logRequest: deps.logRequest,
1093
+ signal,
1094
+ throttle: true,
1095
+ history: true,
1096
+ writeScope: candidate.writeScope
1097
+ });
1098
+ progress.memoriesWritten += outcome.written;
1099
+ if (outcome.skipped !== null) {
1100
+ progress.turnsSkipped += 1;
1101
+ progress.skipReasons[outcome.skipped] = (progress.skipReasons[outcome.skipped] ?? 0) + 1;
1102
+ }
1103
+ } catch (error) {
1104
+ progress.turnsFailed += 1;
1105
+ failures.push({
1106
+ sessionId: candidate.header.id,
1107
+ turn,
1108
+ reason: error instanceof Error ? error.message : String(error)
1109
+ });
1110
+ }
1111
+ progress.turnsDone += 1;
1112
+ }
1113
+ progress.sessionsDone += 1;
1114
+ onProgress({
1115
+ ...progress,
1116
+ currentSession: candidate.header.id
1117
+ });
1118
+ }
1119
+ const state = signal.aborted ? "cancelled" : "done";
1120
+ onProgress({
1121
+ ...progress,
1122
+ state
1123
+ });
1124
+ return {
1125
+ ...progress,
1126
+ state,
1127
+ failures
1128
+ };
1129
+ }
1130
+ //#endregion
814
1131
  //#region src/consolidation/run.ts
815
1132
  /** 单条整理动作(写入 op_log 的统一标记)。 */
816
1133
  const CONSOLIDATION_OP = "consolidation";
@@ -958,11 +1275,11 @@ function cosine$1(a, b) {
958
1275
  //#region src/mirror/markdown.ts
959
1276
  /**
960
1277
  * Markdown 镜像:把 SQLite 记忆库导出为可被 Obsidian / VS Code / git 直接漫游的
961
- * 文件树,每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md。
1278
+ * 文件树:按房间(kind)分目录,每条记忆一个 .md + frontmatter,附房间清单 _meta.json 与全宫殿入口 _index.md。
962
1279
  * 写入过程全部幂等:重复执行只会覆盖同名文件,不会向 SQLite 写任何东西(只读)。
963
1280
  * @module @kenz1117/dsh-engram/mirror/markdown
964
1281
  */
965
- /** 房间铭牌 URL/路径安全的 slug(仅 ASCII、连字符分隔)。 */
1282
+ /** 记忆铭牌 URL/路径安全的 slug(仅 ASCII、连字符分隔)。 */
966
1283
  function slugify(input) {
967
1284
  const stripped = input.toLowerCase().replace(/[\u4e00-\u9fa5]+/g, "记").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
968
1285
  return stripped === "" ? "untitled" : stripped.slice(0, 32);
@@ -977,30 +1294,31 @@ function yaml(value) {
977
1294
  function iso(ms) {
978
1295
  return new Date(ms).toISOString();
979
1296
  }
980
- /** 楼层中文别名(与面板 i18n kindLabel 保持一致;只用做目录名)。 */
981
- const FLOOR_LABEL = {
982
- fact: "fact",
983
- preference: "preference",
984
- decision: "decision",
985
- episode: "episode",
986
- skill: "skill"
1297
+ /** 房间展示名(kind 中文房间名;与 client 词典的 kindFact… 对应,host 侧不引 client 模块)。
1298
+ * 仅用于 _index.md 的可读标签,目录名仍用 kind 原值以保持路径稳定。 */
1299
+ const ROOM_LABEL = {
1300
+ fact: "事实厅",
1301
+ preference: "偏好阁",
1302
+ decision: "决策堂",
1303
+ episode: "往事廊",
1304
+ skill: "技法坊"
987
1305
  };
988
- /** 顶层 _index.md 的纯文本模板(楼层卡片 + 房间清单链接)。 */
989
- function renderIndex(scope, data, floors) {
1306
+ /** 顶层 _index.md 的纯文本模板(房间导览 + 记忆清单链接)。 */
1307
+ function renderIndex(scope, data, rooms) {
990
1308
  const total = data.records.length;
991
1309
  const active = data.records.filter((r) => r.status === "active").length;
992
1310
  const edges = data.edges.length;
993
1311
  return `# 记忆宫殿 · ${scope === "user" ? "私人宫殿" : "项目宫殿"}\n` + [
994
1312
  "",
995
- `> 导出时间 ${iso(data.exportedAt)} · 共 **${total}** 间房间(对外开放 ${active}) · 走廊 ${edges} 条`,
1313
+ `> 导出时间 ${iso(data.exportedAt)} · 共 **${total}** 条记忆(对外开放 ${active}) · 走廊 ${edges} 条`,
996
1314
  "",
997
- "## 楼层导览",
1315
+ "## 房间导览",
998
1316
  "",
999
- ...floors.map((f) => `- **${FLOOR_LABEL[f.kind] ?? f.kind} 层** · ${f.roomCount} 间(开放 ${f.active} · 展厅 ${f.archived} · 闭馆 ${f.forgotten})`),
1317
+ ...rooms.map((room) => `- **${ROOM_LABEL[room.kind] ?? room.kind}** · ${room.memoryCount} 条记忆(开放 ${room.active} · 展厅 ${room.archived} · 闭馆 ${room.forgotten})`),
1000
1318
  "",
1001
- "## 房间清单",
1319
+ "## 记忆清单",
1002
1320
  "",
1003
- ...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)}]]`),
1321
+ ...data.records.slice().sort((a, b) => b.importance - a.importance).map((record) => `- [${record.status === "active" ? "●" : record.status === "archived" ? "◇" : "×"}][${ROOM_LABEL[record.kind] ?? record.kind}] ${record.content.slice(0, 60)}${record.content.length > 60 ? "…" : ""} — [[${record.id.slice(0, 8)}]]`),
1004
1322
  "",
1005
1323
  "## 走廊(关系边)",
1006
1324
  "",
@@ -1008,8 +1326,8 @@ function renderIndex(scope, data, floors) {
1008
1326
  ""
1009
1327
  ].join("\n");
1010
1328
  }
1011
- /** 单房间 .md 模板:YAML frontmatter + 铭牌正文 + 走廊列表。 */
1012
- function renderRoom(record, edges) {
1329
+ /** 单条记忆 .md 模板:YAML frontmatter + 铭牌正文 + 走廊列表。 */
1330
+ function renderMemory(record, edges) {
1013
1331
  const relEdges = edges.filter((edge) => edge.from === record.id || edge.to === record.id);
1014
1332
  const tags = [];
1015
1333
  if (record.outcome !== void 0) tags.push(`outcome-${record.outcome}`);
@@ -1035,13 +1353,13 @@ function renderRoom(record, edges) {
1035
1353
  "---"
1036
1354
  ].join("\n");
1037
1355
  const body = [
1038
- `# ${record.content.split("\n")[0]?.slice(0, 80) ?? "房间铭牌"}`,
1356
+ `# ${record.content.split("\n")[0]?.slice(0, 80) ?? "记忆铭牌"}`,
1039
1357
  "",
1040
1358
  record.content,
1041
1359
  "",
1042
1360
  "## 走廊",
1043
1361
  "",
1044
- ...relEdges.length === 0 ? ["(此房间暂未连接任何走廊)"] : relEdges.map((edge) => {
1362
+ ...relEdges.length === 0 ? ["(此记忆暂未连接任何走廊)"] : relEdges.map((edge) => {
1045
1363
  const other = edge.from === record.id ? edge.to : edge.from;
1046
1364
  return `- ${edge.from === record.id ? "→" : "←"} \`${other.slice(0, 8)}\`(${edge.type})`;
1047
1365
  }),
@@ -1049,27 +1367,27 @@ function renderRoom(record, edges) {
1049
1367
  ].join("\n");
1050
1368
  return frontmatter + "\n" + body;
1051
1369
  }
1052
- /** 楼层 _meta.json 模板。 */
1053
- function renderMeta(scope, data, floors) {
1370
+ /** 房间清单 _meta.json 模板。 */
1371
+ function renderMeta(scope, data, rooms) {
1054
1372
  const payload = {
1055
1373
  scope,
1056
1374
  exportedAt: data.exportedAt,
1057
1375
  total: data.records.length,
1058
- floors
1376
+ rooms
1059
1377
  };
1060
1378
  return JSON.stringify(payload, null, 2) + "\n";
1061
1379
  }
1062
- /** 计算楼层摘要(按 kind 分组统计 active/archived/forgotten)。 */
1063
- function summarizeFloors(records) {
1380
+ /** 计算房间摘要(按 kind 分组统计 active/archived/forgotten 的记忆条数)。 */
1381
+ function summarizeRooms(records) {
1064
1382
  const map = /* @__PURE__ */ new Map();
1065
1383
  for (const record of records) {
1066
1384
  const entry = map.get(record.kind) ?? {
1067
- roomCount: 0,
1385
+ memoryCount: 0,
1068
1386
  active: 0,
1069
1387
  archived: 0,
1070
1388
  forgotten: 0
1071
1389
  };
1072
- entry.roomCount += 1;
1390
+ entry.memoryCount += 1;
1073
1391
  if (record.status === "active") entry.active += 1;
1074
1392
  else if (record.status === "archived") entry.archived += 1;
1075
1393
  else if (record.status === "forgotten") entry.forgotten += 1;
@@ -1084,7 +1402,7 @@ function summarizeFloors(records) {
1084
1402
  * 把一份 exportAll 数据写入镜像目录。
1085
1403
  * @param rootDir - 镜像根目录(通常 `${exportDir}/mirror/<scope>/`,由调用方拼)。
1086
1404
  * @param data - exportAll 的产物(含 records + edges)。
1087
- * @returns 写入摘要(rootDir / fileCount / floors)。
1405
+ * @returns 写入摘要(rootDir / fileCount / rooms)。
1088
1406
  */
1089
1407
  async function writeMirror(rootDir, data) {
1090
1408
  const scope = data.records[0]?.scope ?? "user";
@@ -1092,7 +1410,7 @@ async function writeMirror(rootDir, data) {
1092
1410
  recursive: true,
1093
1411
  mode: 448
1094
1412
  });
1095
- const floors = summarizeFloors(data.records);
1413
+ const rooms = summarizeRooms(data.records);
1096
1414
  const recordsByKind = /* @__PURE__ */ new Map();
1097
1415
  for (const record of data.records) {
1098
1416
  const list = recordsByKind.get(record.kind) ?? [];
@@ -1101,20 +1419,20 @@ async function writeMirror(rootDir, data) {
1101
1419
  }
1102
1420
  let fileCount = 0;
1103
1421
  for (const record of data.records) {
1104
- const floor = recordsByKind.get(record.kind);
1105
- if (floor === void 0) continue;
1106
- const indexInFloor = floor.indexOf(record);
1107
- const floorDir = join(rootDir, FLOOR_LABEL[record.kind] ?? record.kind);
1108
- await mkdir(floorDir, {
1422
+ const roomRecords = recordsByKind.get(record.kind);
1423
+ if (roomRecords === void 0) continue;
1424
+ const indexInRoom = roomRecords.indexOf(record);
1425
+ const roomDir = join(rootDir, record.kind);
1426
+ await mkdir(roomDir, {
1109
1427
  recursive: true,
1110
1428
  mode: 448
1111
1429
  });
1112
- const fileName = `${record.id.slice(0, 8)}-${slugify(record.content)}-${String(indexInFloor).padStart(3, "0")}.md`;
1113
- await writeFile(join(floorDir, fileName), renderRoom(record, data.edges), { mode: 384 });
1430
+ const fileName = `${record.id.slice(0, 8)}-${slugify(record.content)}-${String(indexInRoom).padStart(3, "0")}.md`;
1431
+ await writeFile(join(roomDir, fileName), renderMemory(record, data.edges), { mode: 384 });
1114
1432
  fileCount += 1;
1115
1433
  }
1116
- await writeFile(join(rootDir, "_index.md"), renderIndex(scope, data, floors), { mode: 384 });
1117
- await writeFile(join(rootDir, "_meta.json"), renderMeta(scope, data, floors), { mode: 384 });
1434
+ await writeFile(join(rootDir, "_index.md"), renderIndex(scope, data, rooms), { mode: 384 });
1435
+ await writeFile(join(rootDir, "_meta.json"), renderMeta(scope, data, rooms), { mode: 384 });
1118
1436
  fileCount += 2;
1119
1437
  if (scope === "shared") {
1120
1438
  const manifest = buildShareManifest(data);
@@ -1124,7 +1442,7 @@ async function writeMirror(rootDir, data) {
1124
1442
  return {
1125
1443
  rootDir,
1126
1444
  fileCount,
1127
- floors,
1445
+ rooms,
1128
1446
  exportedAt: data.exportedAt
1129
1447
  };
1130
1448
  }
@@ -1146,7 +1464,7 @@ function buildShareManifest(data, now = Date.now()) {
1146
1464
  return {
1147
1465
  generatedAt: now,
1148
1466
  scope: "shared",
1149
- roomCount: loans.length,
1467
+ memoryCount: loans.length,
1150
1468
  loans
1151
1469
  };
1152
1470
  }
@@ -1434,7 +1752,7 @@ function buildTourProposal(scope, records, focusKind) {
1434
1752
  return focusBoost(b) * 1e3 + b.importance * b.confidence * 100 - scoreA;
1435
1753
  }).slice(0, limit);
1436
1754
  return {
1437
- 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(";")}。`,
1755
+ 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(";")}。`,
1438
1756
  suggestedStops: scored,
1439
1757
  activeCount: active.length,
1440
1758
  empty
@@ -1444,6 +1762,7 @@ function buildTourProposal(scope, records, focusKind) {
1444
1762
  //#region src/refurb.ts
1445
1763
  /** 缺省参数。 */
1446
1764
  const DEFAULT_REFURB_OPTIONS = {
1765
+ minActive: 8,
1447
1766
  demoteBelow: .2,
1448
1767
  staleDays: 60,
1449
1768
  duplicateTitleWindow: 0
@@ -1451,11 +1770,13 @@ const DEFAULT_REFURB_OPTIONS = {
1451
1770
  /**
1452
1771
  * 扫描一组 active 条目,按规则生成翻新建议。
1453
1772
  * 仅扫描同 scope 内的内容;跨 scope 的相似合并留给上层判定。
1773
+ * active 条目少于 `options.minActive` 时返回空列表:小库样本太少,逐条命中噪声大于价值。
1454
1774
  */
1455
1775
  function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
1456
1776
  const suggestions = [];
1457
1777
  const now = Date.now();
1458
1778
  const active = records.filter((r) => r.status === "active");
1779
+ if (active.length < options.minActive) return [];
1459
1780
  const byScope = /* @__PURE__ */ new Map();
1460
1781
  for (const record of active) {
1461
1782
  const list = byScope.get(record.scope) ?? [];
@@ -1493,7 +1814,7 @@ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
1493
1814
  primaryId: record.id,
1494
1815
  candidates: dupes.map((d) => d.id),
1495
1816
  scope,
1496
- reason: `与 ${dupes.length} 间房间内容前 20 字重复,建议蒸馏合并。`,
1817
+ reason: `与 ${dupes.length} 条记忆内容前 20 字重复,建议蒸馏合并。`,
1497
1818
  confidence: .6
1498
1819
  });
1499
1820
  }
@@ -1512,9 +1833,22 @@ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
1512
1833
  primaryId: record.id,
1513
1834
  candidates: [],
1514
1835
  scope: record.scope,
1515
- reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立房间。`,
1836
+ reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立记忆。`,
1516
1837
  confidence: .6
1517
1838
  });
1839
+ for (const record of active) {
1840
+ const score = record.imageryScore;
1841
+ if (score !== void 0 && score >= .5) continue;
1842
+ const slot = record.slot === void 0 ? "" : `${record.slot.room}#${record.slot.index} `;
1843
+ suggestions.push({
1844
+ action: "review",
1845
+ primaryId: record.id,
1846
+ candidates: [],
1847
+ scope: record.scope,
1848
+ reason: score === void 0 ? `${slot}未挂门牌:宫殿纪律要求每个标记唯一、差异化、带日期,建议用 engram_update 的 placard 参数补挂。` : `${slot}门牌得分 ${score.toFixed(2)} < 0.5(不合唯一/差异化/带日期纪律),建议重写铭牌。`,
1849
+ confidence: .4
1850
+ });
1851
+ }
1518
1852
  return suggestions;
1519
1853
  }
1520
1854
  //#endregion
@@ -1591,6 +1925,36 @@ function scopeOf$1(raw, fallback) {
1591
1925
  if (raw === "user" || raw === "project" || raw === "shared") return raw;
1592
1926
  return fallback;
1593
1927
  }
1928
+ /** 宽松取数:数字与数字字符串都接受,其余返回 undefined(回退配置默认)。 */
1929
+ function toNumber(value) {
1930
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1931
+ if (typeof value === "string" && value.trim() !== "") {
1932
+ const parsed = Number(value);
1933
+ if (Number.isFinite(parsed)) return parsed;
1934
+ }
1935
+ }
1936
+ /** 宽松取布尔:真值/假值的字符串与布尔都接受,其余返回 undefined。 */
1937
+ function toBoolean(value) {
1938
+ if (value === true || value === "true") return true;
1939
+ if (value === false || value === "false") return false;
1940
+ }
1941
+ /** 从 query 或 body 收敛历史回填规则(未给或非法的字段留给配置默认值)。 */
1942
+ function historyRulesOf(raw) {
1943
+ const days = toNumber(raw["days"]);
1944
+ const maxTurnsPerSession = toNumber(raw["maxTurnsPerSession"]);
1945
+ const maxTotalTurns = toNumber(raw["maxTotalTurns"]);
1946
+ const includeSubagents = toBoolean(raw["includeSubagents"]);
1947
+ const includeSeeded = toBoolean(raw["includeSeeded"]);
1948
+ const includeNoCwd = toBoolean(raw["includeNoCwd"]);
1949
+ return {
1950
+ ...days === void 0 ? {} : { days },
1951
+ ...maxTurnsPerSession === void 0 ? {} : { maxTurnsPerSession },
1952
+ ...maxTotalTurns === void 0 ? {} : { maxTotalTurns },
1953
+ ...includeSubagents === void 0 ? {} : { includeSubagents },
1954
+ ...includeSeeded === void 0 ? {} : { includeSeeded },
1955
+ ...includeNoCwd === void 0 ? {} : { includeNoCwd }
1956
+ };
1957
+ }
1594
1958
  /**
1595
1959
  * 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
1596
1960
  * @param ctx - 携带 webServer 服务的宿主上下文。
@@ -1625,6 +1989,7 @@ function registerEngramRoutes(ctx, deps) {
1625
1989
  ...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
1626
1990
  ...q !== null && q !== "" ? { q } : {},
1627
1991
  ...redacted === "true" || redacted === "false" ? { redacted: redacted === "true" } : {},
1992
+ ...url.searchParams.get("sort") === "tour" ? { sort: "tour" } : {},
1628
1993
  limit,
1629
1994
  offset
1630
1995
  };
@@ -1679,6 +2044,48 @@ function registerEngramRoutes(ctx, deps) {
1679
2044
  }))).flat().sort((a, b) => b.at - a.at).slice(0, limit) });
1680
2045
  return;
1681
2046
  }
2047
+ if (req.method === "GET" && route === "review-due") {
2048
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2049
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
2050
+ const now = Date.now();
2051
+ json(res, 200, {
2052
+ scope,
2053
+ items: (await (await deps.openStore(scope)).dueReviews(now, limit)).map((record) => ({
2054
+ id: record.id,
2055
+ kind: record.kind,
2056
+ ...record.slot === void 0 ? {} : { slot: record.slot },
2057
+ caption: record.imagery?.caption ?? null,
2058
+ nextReviewAt: record.review?.nextReviewAt ?? null,
2059
+ overdueDays: record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)),
2060
+ reps: record.review?.reps ?? 0
2061
+ }))
2062
+ });
2063
+ return;
2064
+ }
2065
+ if (req.method === "POST" && route === "review-answer") {
2066
+ if (!guardWrite(req, res)) return;
2067
+ const body = await readJsonBody(req);
2068
+ if (body === null || typeof body.id !== "string" || body.id === "") {
2069
+ json(res, 400, { error: "id required" });
2070
+ return;
2071
+ }
2072
+ const grade = typeof body.grade === "number" && Number.isInteger(body.grade) && body.grade >= 0 && body.grade <= 5 ? body.grade : null;
2073
+ if (grade === null) {
2074
+ json(res, 400, { error: "grade must be an integer 0-5" });
2075
+ return;
2076
+ }
2077
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
2078
+ const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
2079
+ if (record === void 0) {
2080
+ json(res, 404, { error: `未找到条目 ${body.id}` });
2081
+ return;
2082
+ }
2083
+ json(res, 200, {
2084
+ id: record.id,
2085
+ review: record.review ?? null
2086
+ });
2087
+ return;
2088
+ }
1682
2089
  if (req.method === "GET" && route === "review") {
1683
2090
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1684
2091
  const id = url.searchParams.get("id");
@@ -1694,6 +2101,53 @@ function registerEngramRoutes(ctx, deps) {
1694
2101
  json(res, 200, view);
1695
2102
  return;
1696
2103
  }
2104
+ if (req.method === "GET" && route === "review-due") {
2105
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2106
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
2107
+ const now = Date.now();
2108
+ const due = await (await deps.openStore(scope)).dueReviews(now, limit);
2109
+ json(res, 200, {
2110
+ scope,
2111
+ count: due.length,
2112
+ now,
2113
+ items: due.map((record) => ({
2114
+ id: record.id,
2115
+ kind: record.kind,
2116
+ scope: record.scope,
2117
+ importance: record.importance,
2118
+ confidence: record.confidence,
2119
+ ...record.slot === void 0 ? {} : { slot: record.slot },
2120
+ ...typeof record.imagery?.caption === "string" ? { caption: record.imagery.caption } : {},
2121
+ ...record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? {} : { overdueDays: Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)) },
2122
+ ...record.review === void 0 ? {} : {
2123
+ reps: record.review.reps,
2124
+ intervalDays: record.review.intervalDays
2125
+ }
2126
+ }))
2127
+ });
2128
+ return;
2129
+ }
2130
+ if (req.method === "POST" && route === "review-answer") {
2131
+ if (!guardWrite(req, res)) return;
2132
+ const body = await readJsonBody(req);
2133
+ if (body === null || typeof body.id !== "string" || body.id === "") {
2134
+ json(res, 400, { error: "id required" });
2135
+ return;
2136
+ }
2137
+ const grade = Number(body.grade);
2138
+ if (!Number.isInteger(grade) || grade < 0 || grade > 5) {
2139
+ json(res, 400, { error: "grade must be an integer 0-5" });
2140
+ return;
2141
+ }
2142
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
2143
+ const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
2144
+ if (record === void 0) {
2145
+ json(res, 404, { error: `未找到条目 ${body.id}` });
2146
+ return;
2147
+ }
2148
+ json(res, 200, { record });
2149
+ return;
2150
+ }
1697
2151
  if (req.method === "GET" && route === "export") {
1698
2152
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1699
2153
  const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
@@ -1722,7 +2176,7 @@ function registerEngramRoutes(ctx, deps) {
1722
2176
  json(res, 200, {
1723
2177
  ...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
1724
2178
  scope,
1725
- roomCount: data.records.length,
2179
+ memoryCount: data.records.length,
1726
2180
  edgeCount: data.edges.length
1727
2181
  });
1728
2182
  return;
@@ -1851,6 +2305,47 @@ function registerEngramRoutes(ctx, deps) {
1851
2305
  });
1852
2306
  return;
1853
2307
  }
2308
+ if (req.method === "GET" && route === "models") {
2309
+ json(res, 200, await deps.history.models());
2310
+ return;
2311
+ }
2312
+ if (req.method === "GET" && route === "history-backfill") {
2313
+ json(res, 200, {
2314
+ defaults: deps.history.defaults,
2315
+ estimate: await deps.history.estimate(historyRulesOf(Object.fromEntries(url.searchParams)))
2316
+ });
2317
+ return;
2318
+ }
2319
+ if (req.method === "GET" && route === "history-backfill/status") {
2320
+ json(res, 200, deps.history.status());
2321
+ return;
2322
+ }
2323
+ if (req.method === "POST" && route === "history-backfill/start") {
2324
+ if (!guardWrite(req, res)) return;
2325
+ const body = await readJsonBody(req);
2326
+ const started = deps.history.start(historyRulesOf(body ?? {}));
2327
+ if (!started.ok) {
2328
+ json(res, 409, {
2329
+ ok: false,
2330
+ error: started.reason ?? "无法启动回填"
2331
+ });
2332
+ return;
2333
+ }
2334
+ json(res, 200, {
2335
+ ok: true,
2336
+ status: deps.history.status()
2337
+ });
2338
+ return;
2339
+ }
2340
+ if (req.method === "POST" && route === "history-backfill/cancel") {
2341
+ if (!guardWrite(req, res)) return;
2342
+ deps.history.cancel();
2343
+ json(res, 200, {
2344
+ ok: true,
2345
+ status: deps.history.status()
2346
+ });
2347
+ return;
2348
+ }
1854
2349
  if (req.method === "POST" && route === "consolidate") {
1855
2350
  if (!guardWrite(req, res)) return;
1856
2351
  const body = await readJsonBody(req);
@@ -2064,6 +2559,106 @@ function migrateProjectDb(dbDir, identity) {
2064
2559
  }
2065
2560
  return "none";
2066
2561
  }
2562
+ /** kind → 默认房间名。固定映射,保证同主题记忆总是聚在同一间(同类巩固)。 */
2563
+ const KIND_ROOMS = {
2564
+ fact: "事实厅",
2565
+ preference: "偏好阁",
2566
+ decision: "决策堂",
2567
+ episode: "往事廊",
2568
+ skill: "技法坊"
2569
+ };
2570
+ /**
2571
+ * 为新条目分配桩位。
2572
+ * @param kind - 记忆种类(决定默认房间)。
2573
+ * @param occupancy - 各房间占用状态(store.slotCountsByRoom() 的快照)。
2574
+ * @returns 分配的桩位与是否开了新房(开新房时调用方应记 op_log 提醒人工命名/拆分)。
2575
+ */
2576
+ function assignSlot(kind, occupancy) {
2577
+ const base = KIND_ROOMS[kind];
2578
+ for (let n = 1;; n += 1) {
2579
+ const room = n === 1 ? base : `${base}-${n}`;
2580
+ const state = occupancy[room];
2581
+ if (state === void 0) return {
2582
+ slot: {
2583
+ room,
2584
+ index: 1
2585
+ },
2586
+ openedNewRoom: n > 1
2587
+ };
2588
+ if (state.count < 9) return {
2589
+ slot: {
2590
+ room,
2591
+ index: state.maxIndex + 1
2592
+ },
2593
+ openedNewRoom: false
2594
+ };
2595
+ }
2596
+ }
2597
+ //#endregion
2598
+ //#region src/review/sm2.ts
2599
+ /** ease 系数下限(SM-2 标准值):低于此值记忆会陷入过密复习。 */
2600
+ const MIN_EASE = 1.3;
2601
+ const DAY_MS = 864e5;
2602
+ /**
2603
+ * 按回忆质量推进调度。
2604
+ * @param grade - 回忆质量 0-5(0/1 完全遗忘,2 模糊错误,3 勉强,4 正确有迟疑,5 完美)。
2605
+ * @param state - 当前调度状态。
2606
+ * @param now - 答题时刻(epoch 毫秒)。
2607
+ * @returns 新调度状态(不修改入参)。
2608
+ */
2609
+ function nextSchedule(grade, state, now) {
2610
+ if (grade < 3) {
2611
+ const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
2612
+ return {
2613
+ nextReviewAt: now + 1 * DAY_MS,
2614
+ easeFactor: Math.round(ease * 100) / 100,
2615
+ intervalDays: 1,
2616
+ reps: 0
2617
+ };
2618
+ }
2619
+ const reps = state.reps + 1;
2620
+ const intervalDays = reps === 1 ? 1 : reps === 2 ? 6 : Math.max(1, Math.round(state.intervalDays * state.easeFactor));
2621
+ const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
2622
+ return {
2623
+ nextReviewAt: now + intervalDays * DAY_MS,
2624
+ easeFactor: Math.round(ease * 100) / 100,
2625
+ intervalDays,
2626
+ reps
2627
+ };
2628
+ }
2629
+ /** 日期/时间锚点:ISO 日期、中文年月、相对时间词。 */
2630
+ const DATE_ANCHOR = /\d{4}[-/年.]\s?\d{1,2}|[今昨]天|本周|上周|周[一二三四五六日天]|\d{1,2}月\d{1,2}[日号]/;
2631
+ /** 差异化比较的前缀长度:前 6 字相同即视为「近似到无法区分」。 */
2632
+ const DIFF_PREFIX_LEN = 6;
2633
+ /**
2634
+ * 计算门牌质量分(0-1,保留两位小数)。
2635
+ * 构成:caption 有效(4-30 字)且全库唯一 +0.4;带日期/时间锚点 +0.3;
2636
+ * 与同房既有门牌差异化(前 6 字不重复)+0.3。无 caption 记 0 分。
2637
+ * @param caption - 门牌文字(ImageryLabel.caption);null/undefined 表示未挂牌。
2638
+ * @param context - 既有门牌快照。
2639
+ * @returns 0-1 的质量分。
2640
+ */
2641
+ function scorePlacard(caption, context) {
2642
+ if (caption === null || caption === void 0) return 0;
2643
+ const text = caption.trim();
2644
+ let score = 0;
2645
+ const valid = text.length >= 4 && text.length <= 30;
2646
+ if (valid && !context.existingCaptions.includes(text)) score += .4;
2647
+ if (DATE_ANCHOR.test(text)) score += .3;
2648
+ const prefix = text.slice(0, DIFF_PREFIX_LEN);
2649
+ const clashes = context.roomCaptions.some((other) => other.slice(0, DIFF_PREFIX_LEN) === prefix);
2650
+ if (valid && !clashes) score += .3;
2651
+ return Math.round(Math.min(1, score) * 100) / 100;
2652
+ }
2653
+ /**
2654
+ * 低分门牌的增强建议(附在 engram_save 输出末尾,中文一行)。
2655
+ * @param score - scorePlacard 的得分。
2656
+ * @returns 建议文本;非低分返回 null。
2657
+ */
2658
+ function placardImprovementHint(score) {
2659
+ if (score >= .5) return null;
2660
+ return "门牌不合规(宫殿纪律:唯一 · 差异化 · 带日期):建议用 engram_update 的 imagery 参数挂一个 4-30 字、含日期锚点、与同房其他门牌前 6 字不重复的铭牌。";
2661
+ }
2067
2662
  //#endregion
2068
2663
  //#region src/store/sqlite.ts
2069
2664
  /**
@@ -2074,18 +2669,31 @@ function migrateProjectDb(dbDir, identity) {
2074
2669
  * @module @kenz1117/dsh-engram/store/sqlite
2075
2670
  */
2076
2671
  /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
2077
- const SCHEMA_VERSION = 5;
2672
+ const SCHEMA_VERSION = 6;
2078
2673
  /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
2079
2674
  * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
2080
2675
  * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
2081
2676
  * v4 → v5:nodes 补 imagery_json 列(意象铭牌:caption + sensoryTags + emotionalValence + provisional)。
2082
- * v5 不再升版:闭馆三问用 op_log JSON 详情承载(已有 audit 接口),不改表结构。 */
2677
+ * v5 v6:桩位(slot_room/slot_index)、意象质量分(imagery_score)、SM-2 调度
2678
+ * (next_review_at/ease_factor/interval_days/review_reps)+ 固定巡游路线表 tour_routes。
2679
+ * 全部可空或带默认值,存量条目零搬运;排桩由 backfillSlots 幂等补齐。 */
2083
2680
  const MIGRATIONS = {
2084
2681
  "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
2085
2682
  "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
2086
2683
  node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2087
2684
  importance REAL NOT NULL, superseded_at INTEGER NOT NULL);`,
2088
- "4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT"
2685
+ "4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT",
2686
+ "5": `ALTER TABLE nodes ADD COLUMN slot_room TEXT;
2687
+ ALTER TABLE nodes ADD COLUMN slot_index INTEGER;
2688
+ ALTER TABLE nodes ADD COLUMN imagery_score REAL;
2689
+ ALTER TABLE nodes ADD COLUMN next_review_at INTEGER;
2690
+ ALTER TABLE nodes ADD COLUMN ease_factor REAL;
2691
+ ALTER TABLE nodes ADD COLUMN interval_days REAL;
2692
+ ALTER TABLE nodes ADD COLUMN review_reps INTEGER DEFAULT 0;
2693
+ CREATE TABLE IF NOT EXISTS tour_routes (
2694
+ position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
2695
+ CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
2696
+ CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`
2089
2697
  };
2090
2698
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
2091
2699
  const RRF_K = 60;
@@ -2130,6 +2738,7 @@ function jsonToImagery(raw) {
2130
2738
  }
2131
2739
  function rowToRecord(row) {
2132
2740
  const imagery = jsonToImagery(row.imagery_json);
2741
+ const hasReviewState = row.next_review_at !== null || row.ease_factor !== null || row.interval_days !== null;
2133
2742
  return {
2134
2743
  id: asMemoryId(row.id),
2135
2744
  scope: row.scope,
@@ -2145,7 +2754,18 @@ function rowToRecord(row) {
2145
2754
  sourceSessionId: row.source_session_id,
2146
2755
  sourceRound: row.source_round,
2147
2756
  sourceSeq: row.source_seq,
2148
- ...imagery === void 0 ? {} : { imagery }
2757
+ ...imagery === void 0 ? {} : { imagery },
2758
+ ...row.slot_room === null || row.slot_index === null ? {} : { slot: {
2759
+ room: row.slot_room,
2760
+ index: row.slot_index
2761
+ } },
2762
+ ...row.imagery_score === null ? {} : { imageryScore: row.imagery_score },
2763
+ ...hasReviewState ? { review: {
2764
+ nextReviewAt: row.next_review_at,
2765
+ easeFactor: row.ease_factor ?? 2.5,
2766
+ intervalDays: row.interval_days ?? 0,
2767
+ reps: row.review_reps ?? 0
2768
+ } } : {}
2149
2769
  };
2150
2770
  }
2151
2771
  function blobToVec(blob) {
@@ -2198,14 +2818,20 @@ const NO_BOOST = {
2198
2818
  proofWeight: 0,
2199
2819
  decayAfterDays: 30
2200
2820
  };
2821
+ /** 缺省全开(config 的默认值也在此处对齐)。 */
2822
+ const DEFAULT_AUTOMATION = {
2823
+ autoSlot: true,
2824
+ reviewScheduling: true
2825
+ };
2201
2826
  /**
2202
2827
  * 打开(必要时创建)一个 scope 分库。
2203
2828
  * @param path - SQLite 文件路径;目录不存在会自动创建(0o700)。
2204
2829
  * @param rankBoost - 排序 boost 参数;缺省不乘任何因子。
2830
+ * @param automation - 写入期自动化开关(自动排桩/初始排期);缺省全开。
2205
2831
  * @returns 就绪的 EngramStore。
2206
2832
  * @throws EngramError(code=SCHEMA_INCOMPATIBLE) 当库的 schema 版本高于当前实现。
2207
2833
  */
2208
- async function openEngramStore(path, rankBoost = NO_BOOST) {
2834
+ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_AUTOMATION) {
2209
2835
  await mkdir(dirname(path), {
2210
2836
  recursive: true,
2211
2837
  mode: 448
@@ -2230,7 +2856,10 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2230
2856
  importance REAL NOT NULL, confidence REAL NOT NULL, status TEXT NOT NULL,
2231
2857
  created_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, access_count INTEGER NOT NULL,
2232
2858
  source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB, outcome TEXT,
2233
- imagery_json TEXT);
2859
+ imagery_json TEXT,
2860
+ slot_room TEXT, slot_index INTEGER, imagery_score REAL,
2861
+ next_review_at INTEGER, ease_factor REAL, interval_days REAL,
2862
+ review_reps INTEGER DEFAULT 0);
2234
2863
  CREATE TABLE IF NOT EXISTS edges (
2235
2864
  from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL,
2236
2865
  PRIMARY KEY (from_id, to_id, type));
@@ -2240,12 +2869,17 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2240
2869
  CREATE TABLE IF NOT EXISTS nodes_revisions (
2241
2870
  node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2242
2871
  importance REAL NOT NULL, superseded_at INTEGER NOT NULL);
2872
+ CREATE TABLE IF NOT EXISTS tour_routes (
2873
+ position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
2243
2874
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
2244
2875
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
2245
2876
  `);
2246
2877
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
2247
- if (versionRow === void 0) db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
2248
- else {
2878
+ if (versionRow === void 0) {
2879
+ db.exec(`CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
2880
+ CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`);
2881
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
2882
+ } else {
2249
2883
  let version = Number(versionRow.value);
2250
2884
  if (!Number.isInteger(version) || version > SCHEMA_VERSION) {
2251
2885
  db.close();
@@ -2274,8 +2908,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2274
2908
  const sqlGet = db.prepare("SELECT * FROM nodes WHERE id = ?");
2275
2909
  const sqlInsert = db.prepare(`INSERT INTO nodes
2276
2910
  (id, scope, kind, content, importance, confidence, status, created_at, last_accessed_at, access_count,
2277
- source_session_id, source_round, source_seq, embedding, imagery_json)
2278
- VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?)`);
2911
+ source_session_id, source_round, source_seq, embedding, imagery_json,
2912
+ slot_room, slot_index, imagery_score, next_review_at, ease_factor, interval_days)
2913
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2279
2914
  const sqlFtsInsert = db.prepare("INSERT INTO nodes_fts (node_id, content) VALUES (?, ?)");
2280
2915
  const sqlSetStatus = db.prepare("UPDATE nodes SET status = ?, last_accessed_at = ? WHERE id = ?");
2281
2916
  const sqlSetOutcome = db.prepare(`UPDATE nodes
@@ -2316,22 +2951,44 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2316
2951
  const sqlAllNodes = db.prepare("SELECT * FROM nodes ORDER BY created_at");
2317
2952
  const sqlAllEdges = db.prepare("SELECT * FROM edges");
2318
2953
  const sqlDecay = db.prepare(`UPDATE nodes SET status = 'archived'
2319
- WHERE status = 'active' AND importance < ? AND last_accessed_at < ?`);
2954
+ WHERE status = 'active' AND importance < ? AND last_accessed_at < ? AND next_review_at IS NULL`);
2955
+ const sqlScheduleReview = db.prepare(`UPDATE nodes
2956
+ SET next_review_at = ?, ease_factor = ?, interval_days = ?, review_reps = ?, last_accessed_at = ?
2957
+ WHERE id = ?`);
2958
+ const sqlDueReviews = db.prepare(`SELECT * FROM nodes
2959
+ WHERE status = 'active' AND next_review_at IS NOT NULL AND next_review_at <= ?
2960
+ ORDER BY next_review_at ASC LIMIT ?`);
2961
+ const sqlSlotCounts = db.prepare(`SELECT slot_room AS room, MAX(slot_index) AS maxIndex, COUNT(*) AS n
2962
+ FROM nodes WHERE slot_room IS NOT NULL AND status != 'forgotten' GROUP BY slot_room`);
2963
+ const sqlSetSlot = db.prepare("UPDATE nodes SET slot_room = ?, slot_index = ? WHERE id = ?");
2964
+ const sqlUnslotted = db.prepare(`SELECT * FROM nodes WHERE slot_room IS NULL AND status = 'active'
2965
+ ORDER BY kind, created_at`);
2966
+ const sqlRouteAppend = db.prepare(`INSERT INTO tour_routes (position, node_id)
2967
+ VALUES ((SELECT COALESCE(MAX(position), -1) + 1 FROM tour_routes), ?)`);
2968
+ const sqlRouteList = db.prepare("SELECT position, node_id FROM tour_routes ORDER BY position");
2969
+ const sqlRouteHas = db.prepare("SELECT 1 AS x FROM tour_routes WHERE node_id = ? LIMIT 1");
2970
+ const sqlSlotNeighbors = db.prepare(`SELECT id FROM nodes
2971
+ WHERE slot_room = ? AND slot_index IN (?, ?) AND status = 'active' AND id != ?`);
2972
+ const sqlListPlacards = db.prepare(`SELECT slot_room AS room, json_extract(imagery_json, '$.caption') AS caption
2973
+ FROM nodes WHERE imagery_json IS NOT NULL AND status = 'active'`);
2320
2974
  const sqlPurgeNodes = db.prepare("DELETE FROM nodes");
2321
2975
  const sqlPurgeEdges = db.prepare("DELETE FROM edges");
2322
2976
  const sqlPurgeFts = db.prepare("DELETE FROM nodes_fts");
2323
2977
  const sqlPurgeLog = db.prepare("DELETE FROM op_log");
2324
- /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重)。 */
2325
- const ftsSearch = (match, scopes) => {
2978
+ const sqlPurgeRoutes = db.prepare("DELETE FROM tour_routes");
2979
+ /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
2980
+ const ftsSearch = (match, scopes, rooms) => {
2326
2981
  const placeholders = scopes.map(() => "?").join(",");
2982
+ const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND n.slot_room IN (${rooms.map(() => "?").join(",")})`;
2327
2983
  return db.prepare(`SELECT n.* FROM nodes_fts f JOIN nodes n ON n.id = f.node_id
2328
- WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})
2329
- ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes);
2984
+ WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})${roomCond}
2985
+ ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes, ...rooms ?? []);
2330
2986
  };
2331
- /** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成)。 */
2332
- const vectorPool = (scopes) => {
2987
+ /** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成);rooms 非空时只查指定房间。 */
2988
+ const vectorPool = (scopes, rooms) => {
2333
2989
  const placeholders = scopes.map(() => "?").join(",");
2334
- return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})`).all(...scopes);
2990
+ const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND slot_room IN (${rooms.map(() => "?").join(",")})`;
2991
+ return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})${roomCond}`).all(...scopes, ...rooms ?? []);
2335
2992
  };
2336
2993
  const getRow = (id) => sqlGet.get(id);
2337
2994
  /**
@@ -2340,7 +2997,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2340
2997
  */
2341
2998
  const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, imagery, op) => {
2342
2999
  const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
2343
- sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery));
3000
+ const initialReview = input.initialReviewAt ?? null;
3001
+ 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);
2344
3002
  sqlFtsInsert.run(id, tokenizeForFts(content));
2345
3003
  sqlLog.run(at, op, id, JSON.stringify({
2346
3004
  kind: input.kind,
@@ -2366,6 +3024,45 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2366
3024
  related: related.map(asMemoryId)
2367
3025
  };
2368
3026
  };
3027
+ /**
3028
+ * 写入期自动化(save/批量/摄取/update/蒸馏全部写入路径统一在此落地;调用方须已持事务):
3029
+ * 1) 排桩——显式 slot 优先,否则按 kind 分房自动分配(满员开新房并记 op_log);
3030
+ * 2) 门牌评分——有铭牌时按「唯一/差异化/带日期」启发式落库;
3031
+ * 3) 初始排期——reviewScheduling 开启且未显式指定时,1 天后首次到期(显式 null = 明确不排期);
3032
+ * 4) 巡游路线——有桩位的条目登记到固定路线末尾。
3033
+ * @returns 合入 WriteInput 的自动化字段。
3034
+ */
3035
+ const applyWriteAutomation = (input, id, at, imagery) => {
3036
+ let slot = input.slot;
3037
+ if (slot === void 0 && automation.autoSlot) {
3038
+ const occupancy = {};
3039
+ for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
3040
+ count: row.n,
3041
+ maxIndex: row.maxIndex
3042
+ };
3043
+ const assigned = assignSlot(input.kind, occupancy);
3044
+ slot = assigned.slot;
3045
+ if (assigned.openedNewRoom) sqlLog.run(at, "room-open", "BATCH", JSON.stringify({
3046
+ room: slot.room,
3047
+ kind: input.kind
3048
+ }));
3049
+ }
3050
+ let imageryScore = input.imageryScore;
3051
+ if (imageryScore === void 0 && imagery !== void 0) {
3052
+ const placards = sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
3053
+ imageryScore = scorePlacard(imagery.caption, {
3054
+ existingCaptions: placards.map((row) => row.caption),
3055
+ roomCaptions: slot === void 0 ? [] : placards.filter((row) => row.room === slot.room).map((row) => row.caption)
3056
+ });
3057
+ }
3058
+ const initialReviewAt = input.initialReviewAt === void 0 ? automation.reviewScheduling ? at + 864e5 : void 0 : input.initialReviewAt ?? void 0;
3059
+ if (slot !== void 0 && sqlRouteHas.get(id) === void 0) sqlRouteAppend.run(id);
3060
+ return {
3061
+ ...slot === void 0 ? {} : { slot },
3062
+ ...imageryScore === void 0 ? {} : { imageryScore },
3063
+ ...initialReviewAt === void 0 ? {} : { initialReviewAt }
3064
+ };
3065
+ };
2369
3066
  return {
2370
3067
  async write(input) {
2371
3068
  const content = input.content.trim();
@@ -2373,7 +3070,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2373
3070
  const id = asMemoryId(randomUUID());
2374
3071
  const at = Date.now();
2375
3072
  withTransaction(() => {
2376
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
3073
+ const automationFields = applyWriteAutomation(input, id, at, input.imagery);
3074
+ insertRecord(id, {
3075
+ ...input,
3076
+ ...automationFields
3077
+ }, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
2377
3078
  });
2378
3079
  return rowToRecord(sqlGet.get(id));
2379
3080
  },
@@ -2410,7 +3111,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2410
3111
  const limit = query.limit ?? 8;
2411
3112
  const scores = /* @__PURE__ */ new Map();
2412
3113
  const match = ftsMatchExpression(query.text);
2413
- if (match !== void 0) ftsSearch(match, query.scopes).forEach((row, index) => {
3114
+ if (match !== void 0) ftsSearch(match, query.scopes, query.rooms).forEach((row, index) => {
2414
3115
  scores.set(row.id, {
2415
3116
  score: 1 / (RRF_K + index + 1),
2416
3117
  via: "fts"
@@ -2419,7 +3120,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2419
3120
  let degraded = true;
2420
3121
  if (queryVector !== void 0) {
2421
3122
  degraded = false;
2422
- vectorPool(query.scopes).map((row) => ({
3123
+ vectorPool(query.scopes, query.rooms).map((row) => ({
2423
3124
  row,
2424
3125
  sim: cosine(queryVector, blobToVec(row.embedding))
2425
3126
  })).filter((entry) => entry.sim >= MIN_COSINE).sort((a, b) => b.sim - a.sim).slice(0, RANK_POOL).forEach((entry, index) => {
@@ -2472,11 +3173,13 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2472
3173
  const row = getRow(id);
2473
3174
  if (row === void 0) continue;
2474
3175
  const viaEdge = viaEdgeOf.get(id);
3176
+ 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)) : [];
2475
3177
  hits.push({
2476
3178
  record: rowToRecord(row),
2477
3179
  score: info.score,
2478
3180
  via: info.via,
2479
- ...viaEdge === void 0 ? {} : { viaEdge }
3181
+ ...viaEdge === void 0 ? {} : { viaEdge },
3182
+ ...cueNeighbors.length === 0 ? {} : { cues: { neighbors: cueNeighbors } }
2480
3183
  });
2481
3184
  sqlTouch.run(Date.now(), id);
2482
3185
  }
@@ -2487,11 +3190,21 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2487
3190
  },
2488
3191
  async timeline(query) {
2489
3192
  const limit = query.limit ?? 20;
2490
- const placeholders = query.scopes.map(() => "?").join(",");
2491
- return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND scope IN (${placeholders})
2492
- AND (? IS NULL OR created_at >= ?) AND (? IS NULL OR created_at <= ?)
2493
- AND (? IS NULL OR instr(content, ?) > 0)
2494
- ORDER BY created_at DESC LIMIT ?`).all(...query.scopes, query.since ?? null, query.since ?? null, query.until ?? null, query.until ?? null, query.topic ?? null, query.topic ?? null, limit).map(rowToRecord);
3193
+ const where = `nodes.status = 'active' AND nodes.scope IN (${query.scopes.map(() => "?").join(",")})
3194
+ AND (? IS NULL OR nodes.created_at >= ?) AND (? IS NULL OR nodes.created_at <= ?)
3195
+ AND (? IS NULL OR instr(nodes.content, ?) > 0)`;
3196
+ const params = [
3197
+ ...query.scopes,
3198
+ query.since ?? null,
3199
+ query.since ?? null,
3200
+ query.until ?? null,
3201
+ query.until ?? null,
3202
+ query.topic ?? null,
3203
+ query.topic ?? null,
3204
+ limit
3205
+ ];
3206
+ return (query.order === "tour" ? db.prepare(`SELECT nodes.* FROM nodes LEFT JOIN tour_routes ON tour_routes.node_id = nodes.id
3207
+ WHERE ${where} ORDER BY tour_routes.position IS NULL, tour_routes.position ASC, nodes.created_at DESC LIMIT ?`).all(...params) : db.prepare(`SELECT nodes.* FROM nodes WHERE ${where} ORDER BY nodes.created_at DESC LIMIT ?`).all(...params)).map(rowToRecord);
2495
3208
  },
2496
3209
  async update(input) {
2497
3210
  const old = getRow(input.id);
@@ -2504,11 +3217,17 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2504
3217
  sqlRevisionInsert.run(input.id, old.content, old.kind, old.importance, at);
2505
3218
  sqlSetStatus.run("archived", at, input.id);
2506
3219
  sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
2507
- insertRecord(id, {
3220
+ const imagery = input.imagery ?? jsonToImagery(old.imagery_json);
3221
+ const updateInput = {
2508
3222
  scope: input.scope,
2509
3223
  kind: input.kind,
2510
3224
  content
2511
- }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, input.imagery ?? jsonToImagery(old.imagery_json), "update");
3225
+ };
3226
+ const automationFields = applyWriteAutomation(updateInput, id, at, imagery);
3227
+ insertRecord(id, {
3228
+ ...updateInput,
3229
+ ...automationFields
3230
+ }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, imagery, "update");
2512
3231
  sqlEdgeUpsert.run(id, input.id, "supersedes", at);
2513
3232
  });
2514
3233
  return rowToRecord(sqlGet.get(id));
@@ -2569,6 +3288,82 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2569
3288
  sqlLog.run(Date.now(), "outcome-report", id, outcome);
2570
3289
  return rowToRecord(sqlGet.get(id));
2571
3290
  },
3291
+ async scheduleReview(id, grade) {
3292
+ const row = getRow(id);
3293
+ if (row === void 0) return void 0;
3294
+ const now = Date.now();
3295
+ const next = nextSchedule(grade, rowToRecord(row).review ?? {
3296
+ nextReviewAt: null,
3297
+ easeFactor: 2.5,
3298
+ intervalDays: 0,
3299
+ reps: row.review_reps ?? 0
3300
+ }, now);
3301
+ sqlScheduleReview.run(next.nextReviewAt, next.easeFactor, next.intervalDays, next.reps, now, id);
3302
+ sqlLog.run(now, "review-answer", id, JSON.stringify({
3303
+ grade,
3304
+ nextIntervalDays: next.intervalDays
3305
+ }));
3306
+ return rowToRecord(sqlGet.get(id));
3307
+ },
3308
+ async dueReviews(now, limit) {
3309
+ return sqlDueReviews.all(now, Math.max(1, limit)).map(rowToRecord);
3310
+ },
3311
+ async slotCountsByRoom() {
3312
+ const rows = sqlSlotCounts.all();
3313
+ const result = {};
3314
+ for (const row of rows) result[row.room] = {
3315
+ count: row.n,
3316
+ maxIndex: row.maxIndex
3317
+ };
3318
+ return result;
3319
+ },
3320
+ async assignSlot(id, slot) {
3321
+ sqlSetSlot.run(slot.room, slot.index, id);
3322
+ sqlLog.run(Date.now(), "slot-assign", id, JSON.stringify(slot));
3323
+ },
3324
+ async backfillSlots(capacityNote) {
3325
+ const rows = sqlUnslotted.all();
3326
+ if (rows.length === 0) return 0;
3327
+ const now = Date.now();
3328
+ withTransaction(() => {
3329
+ const occupancy = {};
3330
+ for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
3331
+ count: row.n,
3332
+ maxIndex: row.maxIndex
3333
+ };
3334
+ for (const row of rows) {
3335
+ const { slot, openedNewRoom } = assignSlot(row.kind, occupancy);
3336
+ sqlSetSlot.run(slot.room, slot.index, row.id);
3337
+ if (sqlRouteHas.get(row.id) === void 0) sqlRouteAppend.run(row.id);
3338
+ const state = occupancy[slot.room] ?? {
3339
+ count: 0,
3340
+ maxIndex: 0
3341
+ };
3342
+ occupancy[slot.room] = {
3343
+ count: state.count + 1,
3344
+ maxIndex: Math.max(state.maxIndex, slot.index)
3345
+ };
3346
+ if (openedNewRoom) capacityNote(slot.room);
3347
+ }
3348
+ sqlLog.run(now, "slot-backfill", "BATCH", JSON.stringify({ assigned: rows.length }));
3349
+ });
3350
+ return rows.length;
3351
+ },
3352
+ async routeAppend(id) {
3353
+ sqlRouteAppend.run(id);
3354
+ },
3355
+ async routeHas(id) {
3356
+ return sqlRouteHas.get(id) !== void 0;
3357
+ },
3358
+ async routeList() {
3359
+ return sqlRouteList.all().map((row) => ({
3360
+ position: row.position,
3361
+ id: asMemoryId(row.node_id)
3362
+ }));
3363
+ },
3364
+ async listPlacards() {
3365
+ return sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
3366
+ },
2572
3367
  async restore(id) {
2573
3368
  if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
2574
3369
  sqlSetStatus.run("active", Date.now(), id);
@@ -2597,7 +3392,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2597
3392
  const where = conds.join(" AND ");
2598
3393
  const total = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE ${where}`).get(...params).n;
2599
3394
  return {
2600
- records: db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(rowToRecord),
3395
+ records: (filter.sort === "tour" ? db.prepare(`SELECT nodes.* FROM nodes LEFT JOIN tour_routes ON tour_routes.node_id = nodes.id
3396
+ 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),
2601
3397
  total
2602
3398
  };
2603
3399
  },
@@ -2696,7 +3492,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2696
3492
  const id = asMemoryId(randomUUID());
2697
3493
  const at = Date.now();
2698
3494
  withTransaction(() => {
2699
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
3495
+ const automationFields = applyWriteAutomation(input, id, at, input.imagery);
3496
+ insertRecord(id, {
3497
+ ...input,
3498
+ ...automationFields
3499
+ }, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
2700
3500
  for (const oldId of oldIds) {
2701
3501
  sqlSetStatus.run("archived", at, oldId);
2702
3502
  sqlEdgeUpsert.run(id, oldId, "supersedes", at);
@@ -2722,6 +3522,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
2722
3522
  sqlPurgeEdges.run();
2723
3523
  sqlPurgeFts.run();
2724
3524
  sqlPurgeLog.run();
3525
+ sqlPurgeRoutes.run();
2725
3526
  });
2726
3527
  },
2727
3528
  async close() {
@@ -2931,7 +3732,7 @@ function enforceBudget(lines, totalBudget = RECALL_TOTAL_CHARS) {
2931
3732
  //#endregion
2932
3733
  //#region src/tools/create.ts
2933
3734
  /**
2934
- * 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
3735
+ * 15 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
2935
3736
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
2936
3737
  * @module @kenz1117/dsh-engram/tools/create
2937
3738
  */
@@ -2942,6 +3743,33 @@ const KINDS = [
2942
3743
  "episode",
2943
3744
  "skill"
2944
3745
  ];
3746
+ /** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
3747
+ function renderHistoryEstimate(estimate) {
3748
+ if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
3749
+ const rules = estimate.rules;
3750
+ const window = rules.days === 0 ? "不限" : `${String(rules.days)} 天`;
3751
+ const lines = [
3752
+ `历史回填估算:候选 ${String(estimate.candidates)} 个会话 · 规则内 ${String(estimate.eligibleTurns)} 轮 · 待处理 ${String(estimate.pendingTurns)} 轮(此前已摄取 ${String(estimate.alreadyIngested)} 轮会自动跳过)。`,
3753
+ `规则:时间窗 ${window} · 单会话≤${String(rules.maxTurnsPerSession)} 轮 · 总轮数≤${String(rules.maxTotalTurns)} · 含子代理 ${rules.includeSubagents ? "是" : "否"} · 含种子 ${rules.includeSeeded ? "是" : "否"} · 含无 cwd ${rules.includeNoCwd ? "是" : "否"}`,
3754
+ `已排除:子代理 ${String(estimate.skipped.subagent)} · 种子 ${String(estimate.skipped.seeded)} · 无 cwd ${String(estimate.skipped.noCwd)} · 超时间窗 ${String(estimate.skipped.tooOld)} · 日志不可读 ${String(estimate.skipped.unreadable)}`
3755
+ ];
3756
+ if (estimate.truncated) lines.push("注意:候选超出总轮数上限,本次只会处理最近的一部分会话;可调大 maxTotalTurns 或缩小时间窗分几次跑。");
3757
+ lines.push("实际 LLM 调用次数不超过待处理轮数(低活动/寒暄/显式禁记的轮次会被节流跳过)。确认要真正回填请再传 dryRun=false。");
3758
+ return lines.join("\n");
3759
+ }
3760
+ /** 历史回填执行结果的模型可读文本。 */
3761
+ function renderHistoryRun(result) {
3762
+ const lines = [`历史回填${result.state === "done" ? "完成" : result.state === "cancelled" ? "已中止(可重跑续做)" : "失败"}:处理 ${String(result.sessionsDone)}/${String(result.sessionsTotal)} 个会话 · ${String(result.turnsDone)} 轮 → 写入 ${String(result.memoriesWritten)} 条记忆;跳过 ${String(result.turnsSkipped)} 轮 · 失败 ${String(result.turnsFailed)} 轮。`];
3763
+ const reasons = Object.entries(result.skipReasons).sort(([, a], [, b]) => b - a);
3764
+ if (reasons.length > 0) lines.push(`跳过原因:${reasons.map(([reason, count]) => `${reason} ${String(count)}`).join(" · ")}`);
3765
+ if (result.failures.length > 0) {
3766
+ const shown = result.failures.slice(0, 3).map((failure) => `${failure.sessionId.slice(-12)} 第 ${String(failure.turn)} 轮:${failure.reason}`);
3767
+ lines.push(`失败明细(前 ${String(shown.length)} 条):${shown.join(";")}`);
3768
+ lines.push("若失败原因是历史会话记录的路由在当前环境不可用,可在 cordis.yml 配 provider/model,或让一个会话先用目标模型跑一轮(回填会优先复用当前在用的路由)后重跑——已完成的轮次会自动跳过。");
3769
+ }
3770
+ lines.push("同一批可重复执行:已完成的轮次按幂等键跳过,只补未完成的部分。");
3771
+ return lines.join("\n");
3772
+ }
2945
3773
  /** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
2946
3774
  function scopeOf(raw, fallback) {
2947
3775
  if (raw === "user" || raw === "project" || raw === "shared") return raw;
@@ -3011,7 +3839,8 @@ async function rewriteQueries(deps, exec, query) {
3011
3839
  }
3012
3840
  }
3013
3841
  /**
3014
- * 构造 10 个工具定义(engram_save/search/timeline/update/forget/report/review/stats/export/distill)。
3842
+ * 构造 16 个工具定义(engram_save/search/timeline/update/forget/report/review/review_queue/
3843
+ * stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
3015
3844
  * @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
3016
3845
  * @returns 可直接 register 的工具定义数组。
3017
3846
  */
@@ -3022,7 +3851,10 @@ function createEngramTools(deps) {
3022
3851
  function renderSaveResultText(value) {
3023
3852
  if (value.count !== void 0) {
3024
3853
  const parts = [`已批量保存 ${value.count} 条记忆`];
3025
- for (const item of value.items ?? []) parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance})`);
3854
+ for (const item of value.items ?? []) {
3855
+ const slot = item.slot === void 0 ? "" : `, ${item.slot.room}#${item.slot.index}`;
3856
+ parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance}${slot})`);
3857
+ }
3026
3858
  const failures = value.failed ?? [];
3027
3859
  if (failures.length > 0) parts.push(`${failures.length} 条失败:${failures.map((entry) => `#${entry.index + 1} ${entry.reason}`).join(";")}`);
3028
3860
  parts.push("后续会话可用 engram_search 召回。");
@@ -3040,7 +3872,8 @@ function createEngramTools(deps) {
3040
3872
  content: item.content,
3041
3873
  ...item.importance === void 0 ? {} : { importance: item.importance },
3042
3874
  sourceSessionId: item.sourceSessionId,
3043
- ...embedding === void 0 ? {} : { embedding }
3875
+ ...embedding === void 0 ? {} : { embedding },
3876
+ ...item.imagery === void 0 ? {} : { imagery: item.imagery }
3044
3877
  });
3045
3878
  const candidates = embedding === void 0 ? [] : await store.findContradictions(embedding);
3046
3879
  for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
@@ -3049,6 +3882,18 @@ function createEngramTools(deps) {
3049
3882
  candidates
3050
3883
  };
3051
3884
  }
3885
+ /** 门牌参数收敛:非空字符串转 ImageryLabel(感官/情绪维度留空——AI 不需要人脑补丁),非法返回 undefined。 */
3886
+ function placardOf(raw) {
3887
+ if (typeof raw !== "string") return void 0;
3888
+ const caption = raw.trim();
3889
+ if (caption === "") return void 0;
3890
+ return {
3891
+ caption,
3892
+ sensoryTags: [],
3893
+ emotionalValence: 0,
3894
+ provisional: false
3895
+ };
3896
+ }
3052
3897
  /** 批量保存:统一清洗/校验/批量内去重,一次批量嵌入,逐条写入;单条失败不阻塞其余。 */
3053
3898
  async function saveBatch(sourceSessionId, items, rawScope) {
3054
3899
  if (items.length > MAX_SAVE_BATCH) throw new Error(`engram_save: 单次最多保存 ${MAX_SAVE_BATCH} 条`);
@@ -3120,7 +3965,8 @@ function createEngramTools(deps) {
3120
3965
  saved.push({
3121
3966
  id: record.id,
3122
3967
  kind: record.kind,
3123
- importance: record.importance
3968
+ importance: record.importance,
3969
+ ...record.slot === void 0 ? {} : { slot: record.slot }
3124
3970
  });
3125
3971
  } catch (error) {
3126
3972
  failed.push({
@@ -3184,6 +4030,10 @@ function createEngramTools(deps) {
3184
4030
  importance: {
3185
4031
  type: "number",
3186
4032
  description: "重要性 0-1,默认 0.5(仅单条模式)"
4033
+ },
4034
+ placard: {
4035
+ type: "string",
4036
+ description: "门牌(可选,仅单条模式):4-30 字铭牌。宫殿纪律:唯一 · 差异化 · 带日期锚点(如「2026-09 向量检索选型」),禁止与既有门牌近似到无法区分"
3187
4037
  }
3188
4038
  },
3189
4039
  output: {
@@ -3213,6 +4063,20 @@ function createEngramTools(deps) {
3213
4063
  importance: {
3214
4064
  type: "number",
3215
4065
  required: true
4066
+ },
4067
+ slot: {
4068
+ type: "object",
4069
+ additionalProperties: false,
4070
+ properties: {
4071
+ room: {
4072
+ type: "string",
4073
+ required: true
4074
+ },
4075
+ index: {
4076
+ type: "number",
4077
+ required: true
4078
+ }
4079
+ }
3216
4080
  }
3217
4081
  }
3218
4082
  }
@@ -3256,33 +4120,38 @@ function createEngramTools(deps) {
3256
4120
  const store = await deps.openStore(scope);
3257
4121
  const embedder = await deps.embedder;
3258
4122
  const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
4123
+ const imagery = placardOf(input.placard);
3259
4124
  const { record, candidates } = await writeWithContradictions(store, {
3260
4125
  scope,
3261
4126
  kind: input.kind,
3262
4127
  content,
3263
4128
  ...typeof input.importance === "number" ? { importance: input.importance } : {},
3264
4129
  sourceSessionId,
3265
- ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
4130
+ ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] },
4131
+ ...imagery === void 0 ? {} : { imagery }
3266
4132
  });
4133
+ const placardHint = imagery === void 0 || record.imageryScore === void 0 ? "" : `\n${placardImprovementHint(record.imageryScore) ?? ""}`;
3267
4134
  if (candidates.length > 0) {
3268
4135
  const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
3269
4136
  return {
3270
4137
  id: record.id,
3271
4138
  kind: record.kind,
3272
4139
  importance: record.importance,
3273
- text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
4140
+ text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。${placardHint}`
3274
4141
  };
3275
4142
  }
4143
+ const base = `已保存记忆 ${record.id}(kind=${record.kind}, importance=${record.importance}${record.slot === void 0 ? "" : `, ${record.slot.room}#${record.slot.index}`})。后续会话可用 engram_search 召回。`;
3276
4144
  return {
3277
4145
  id: record.id,
3278
4146
  kind: record.kind,
3279
- importance: record.importance
4147
+ importance: record.importance,
4148
+ text: `${base}${placardHint}`
3280
4149
  };
3281
4150
  }
3282
4151
  });
3283
4152
  const search = defineTool({
3284
4153
  name: "engram_search",
3285
- description: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
4154
+ description: "语义 + 关键词混合检索长期记忆。宫殿纪律:先想进哪个房间——事实厅(fact)/偏好阁(preference)/决策堂(decision)/往事廊(episode)/技法坊(skill),带上 room 参数只查该房间,更快更准;不确定房间时缺省全库检索。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
3286
4155
  parameters: {
3287
4156
  query: {
3288
4157
  type: "string",
@@ -3299,6 +4168,10 @@ function createEngramTools(deps) {
3299
4168
  ],
3300
4169
  description: "作用域,默认 all"
3301
4170
  },
4171
+ room: {
4172
+ type: "string",
4173
+ description: "房间路由:只在指定房间内检索(如「决策堂」)。房间目录见 engram_stats 输出"
4174
+ },
3302
4175
  limit: {
3303
4176
  type: "number",
3304
4177
  description: "返回条数上限,默认 8"
@@ -3327,6 +4200,7 @@ function createEngramTools(deps) {
3327
4200
  async execute(args, exec) {
3328
4201
  const input = args;
3329
4202
  const scopes = scopesOf(input.scope);
4203
+ const rooms = typeof input.room === "string" && input.room.trim() !== "" ? [input.room.trim()] : void 0;
3330
4204
  const limit = input.limit ?? 8;
3331
4205
  const rewrite = await rewriteQueries(deps, exec, input.query);
3332
4206
  const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
@@ -3335,7 +4209,8 @@ function createEngramTools(deps) {
3335
4209
  return (await deps.openStore(scope)).search({
3336
4210
  text: queryText,
3337
4211
  scopes: [scope],
3338
- limit
4212
+ limit,
4213
+ ...rooms === void 0 ? {} : { rooms }
3339
4214
  }, vector);
3340
4215
  }));
3341
4216
  return {
@@ -3346,17 +4221,20 @@ function createEngramTools(deps) {
3346
4221
  const degraded = retrievals.some((retrieval) => retrieval.degraded);
3347
4222
  const lines = enforceBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
3348
4223
  const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
3349
- return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${truncateItem(hit.record.content)}(id=${hit.record.id})${edge}`;
4224
+ const slot = hit.record.slot === void 0 ? "" : ` ${hit.record.slot.room}#${hit.record.slot.index}`;
4225
+ const date = ` 刻于 ${new Date(hit.record.createdAt).toISOString().slice(0, 10)}`;
4226
+ const cues = hit.cues === void 0 ? "" : ` 相邻桩位: ${hit.cues.neighbors.join(", ")}`;
4227
+ return `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id})${edge}${cues}`;
3350
4228
  }));
3351
4229
  return {
3352
4230
  degraded,
3353
- text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
4231
+ text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${rooms === void 0 ? "" : `(房间路由:${rooms.join("、")})\n`}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
3354
4232
  };
3355
4233
  }
3356
4234
  });
3357
4235
  const timeline = defineTool({
3358
4236
  name: "engram_timeline",
3359
- description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
4237
+ description: "按时间范围与主题浏览记忆(默认时间倒序,最近 20 条)。order=tour 时改按固定巡游路线的桩位顺序走(未上路线者排末尾),输出附宫殿坐标——适合按宫殿固定路线复述;多作用域按 user→project→shared 顺序拼接(桩位顺序只在各自库内有意义)。无参数直接列出最近记录。",
3360
4238
  parameters: {
3361
4239
  scope: {
3362
4240
  type: "string",
@@ -3379,6 +4257,11 @@ function createEngramTools(deps) {
3379
4257
  until: {
3380
4258
  type: "string",
3381
4259
  description: "结束时间"
4260
+ },
4261
+ order: {
4262
+ type: "string",
4263
+ enum: ["time", "tour"],
4264
+ description: "排序:缺省 'time' 按创建时间倒序;'tour' 按固定巡游路线桩位顺序"
3382
4265
  }
3383
4266
  },
3384
4267
  output: {
@@ -3398,6 +4281,7 @@ function createEngramTools(deps) {
3398
4281
  async execute(args) {
3399
4282
  const input = args;
3400
4283
  const scopes = scopesOf(input.scope);
4284
+ const order = input.order === "tour" ? "tour" : "time";
3401
4285
  const parseTime = (raw, field) => {
3402
4286
  if (raw === void 0) return void 0;
3403
4287
  const ms = Date.parse(raw);
@@ -3406,15 +4290,22 @@ function createEngramTools(deps) {
3406
4290
  };
3407
4291
  const since = parseTime(input.since, "since");
3408
4292
  const until = parseTime(input.until, "until");
3409
- return { text: renderMemoryPacket(enforceBudget((await Promise.all(scopes.map(async (scope) => {
4293
+ const results = await Promise.all(scopes.map(async (scope) => {
3410
4294
  return (await deps.openStore(scope)).timeline({
3411
4295
  scopes: [scope],
3412
4296
  ...input.topic === void 0 ? {} : { topic: input.topic },
3413
4297
  ...since === void 0 ? {} : { since },
3414
4298
  ...until === void 0 ? {} : { until },
4299
+ order,
3415
4300
  limit: 20
3416
4301
  });
3417
- }))).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 ?? "(对话继续)") };
4302
+ }));
4303
+ const body = enforceBudget((order === "tour" ? results.flat().slice(0, 20) : results.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, 20)).map((record) => {
4304
+ const slot = order === "tour" && record.slot !== void 0 ? ` ${record.slot.room}#${record.slot.index}` : "";
4305
+ return `${new Date(record.createdAt).toISOString()}${slot} [${record.scope}/${record.kind}] ${truncateItem(record.content)}(id=${record.id})`;
4306
+ }));
4307
+ const tail = order === "tour" ? "\n(按固定巡游路线桩位顺序;未上路线者按创建时间排末尾)" : "";
4308
+ return { text: renderMemoryPacket(`${body.join("\n") || "时间线为空"}${tail}`, "tool_timeline", input.topic ?? "(对话继续)") };
3418
4309
  }
3419
4310
  });
3420
4311
  const update = defineTool({
@@ -3444,6 +4335,10 @@ function createEngramTools(deps) {
3444
4335
  type: "string",
3445
4336
  enum: [...KINDS],
3446
4337
  description: "种类,默认继承旧条目"
4338
+ },
4339
+ placard: {
4340
+ type: "string",
4341
+ description: "门牌(可选):4-30 字铭牌,替换旧条目门牌。宫殿纪律:唯一 · 差异化 · 带日期锚点"
3447
4342
  }
3448
4343
  },
3449
4344
  output: {
@@ -3476,13 +4371,15 @@ function createEngramTools(deps) {
3476
4371
  if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
3477
4372
  const embedder = await deps.embedder;
3478
4373
  const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
4374
+ const imagery = placardOf(input.placard);
3479
4375
  return {
3480
4376
  id: (await store.update({
3481
4377
  id: input.id,
3482
4378
  scope,
3483
4379
  kind: input.kind ?? old.kind,
3484
4380
  content,
3485
- ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
4381
+ ...embeddings === void 0 ? {} : { embedding: embeddings[0] },
4382
+ ...imagery === void 0 ? {} : { imagery }
3486
4383
  })).id,
3487
4384
  superseded: input.id
3488
4385
  };
@@ -3533,7 +4430,7 @@ function createEngramTools(deps) {
3533
4430
  },
3534
4431
  render: (_args, value) => [{
3535
4432
  type: "text",
3536
- text: `房间 ${value.id} 已闭馆(软删,可恢复),墓志铭已刻入操作日志。`
4433
+ text: `记忆 ${value.id} 已闭馆(软删,可恢复),墓志铭已刻入操作日志。`
3537
4434
  }]
3538
4435
  },
3539
4436
  async execute(args) {
@@ -3550,6 +4447,73 @@ function createEngramTools(deps) {
3550
4447
  })).id };
3551
4448
  }
3552
4449
  });
4450
+ /**
4451
+ * 历史回填:把 dsh 历史会话逐轮提炼进宫殿(按会话 cwd 分库、已有幂等键的轮次跳过)。
4452
+ * dryRun 默认 true——避免模型顺手触发成百次辅助调用;显式传 false 才真正写入。
4453
+ */
4454
+ const ingestHistory = defineTool({
4455
+ name: "engram_ingest_history",
4456
+ description: "历史会话回填:把 dsh 的历史会话逐轮提炼进记忆宫殿(写进各会话自己 cwd 对应的项目库;此前已摄取的轮次自动跳过,中断后可重跑续做)。dryRun 缺省 true,只返回估算(候选会话数 / 规则内轮数 / 待处理轮数)而不调 LLM、不写库;确认后再传 dryRun=false 执行。大批量回填建议用设置页「历史回填」tab(有规则选择、进度与暂停);本工具适合先估算或小批量执行。",
4457
+ parameters: {
4458
+ dryRun: {
4459
+ type: "boolean",
4460
+ description: "只估算不执行(默认 true;显式 false 才真正回填)"
4461
+ },
4462
+ days: {
4463
+ type: "number",
4464
+ description: "时间窗天数,0 = 不限;缺省用部署配置值"
4465
+ },
4466
+ maxTurnsPerSession: {
4467
+ type: "number",
4468
+ description: "单个会话最多摄取轮数;缺省用部署配置值"
4469
+ },
4470
+ maxTotalTurns: {
4471
+ type: "number",
4472
+ description: "本次最多处理的总轮数(只能调低配置硬上限)"
4473
+ },
4474
+ includeSubagents: {
4475
+ type: "boolean",
4476
+ description: "是否包含子代理会话(默认 false)"
4477
+ },
4478
+ includeSeeded: {
4479
+ type: "boolean",
4480
+ description: "是否包含种子会话(默认 false)"
4481
+ },
4482
+ includeNoCwd: {
4483
+ type: "boolean",
4484
+ description: "是否包含无 cwd 会话(默认 false;这类会话只能写进 user 库)"
4485
+ }
4486
+ },
4487
+ output: {
4488
+ schema: {
4489
+ type: "object",
4490
+ additionalProperties: false,
4491
+ properties: { text: {
4492
+ type: "string",
4493
+ required: true
4494
+ } }
4495
+ },
4496
+ render: (_args, value) => [{
4497
+ type: "text",
4498
+ text: value.text
4499
+ }]
4500
+ },
4501
+ async execute(args, exec) {
4502
+ const input = args;
4503
+ const history = deps.historyBackfill;
4504
+ if (history === void 0) return { text: "历史回填不可用:当前组合未挂载会话持久化服务(会话日志不可读,例如 headless profile)。" };
4505
+ const rules = {
4506
+ ...typeof input.days === "number" ? { days: input.days } : {},
4507
+ ...typeof input.maxTurnsPerSession === "number" ? { maxTurnsPerSession: input.maxTurnsPerSession } : {},
4508
+ ...typeof input.maxTotalTurns === "number" ? { maxTotalTurns: input.maxTotalTurns } : {},
4509
+ ...typeof input.includeSubagents === "boolean" ? { includeSubagents: input.includeSubagents } : {},
4510
+ ...typeof input.includeSeeded === "boolean" ? { includeSeeded: input.includeSeeded } : {},
4511
+ ...typeof input.includeNoCwd === "boolean" ? { includeNoCwd: input.includeNoCwd } : {}
4512
+ };
4513
+ if (input.dryRun !== false) return { text: renderHistoryEstimate(await history.estimate(rules)) };
4514
+ return { text: renderHistoryRun(await history.run(rules, exec.signal)) };
4515
+ }
4516
+ });
3553
4517
  /** P0-3 闭馆考古:返回最近 N 条 forgotten 条目 + 墓志铭。 */
3554
4518
  const auditForgotten = defineTool({
3555
4519
  name: "engram_audit_forgotten",
@@ -3600,6 +4564,61 @@ function createEngramTools(deps) {
3600
4564
  return { text: `闭馆考古(共 ${sliced.length} 条):\n${lines.join("\n")}` };
3601
4565
  }
3602
4566
  });
4567
+ const reviewQueue = defineTool({
4568
+ name: "engram_review_queue",
4569
+ description: "今日待回忆队列:列出已到期间隔重复的记忆,每条只给宫殿坐标与门牌线索(不给正文)。用法:对每条先尝试回忆内容,然后 engram_review 揭示核对,再 engram_report 传 grade(0-5)自评——主动回忆比重复阅读的记忆强化效果强得多。会话开始注入会提示今日是否有待回忆。",
4570
+ parameters: {
4571
+ scope: {
4572
+ type: "string",
4573
+ enum: [
4574
+ "user",
4575
+ "project",
4576
+ "shared",
4577
+ "all"
4578
+ ],
4579
+ description: "作用域,默认 all"
4580
+ },
4581
+ limit: {
4582
+ type: "integer",
4583
+ description: "返回条数上限,默认 10(最逾期在前)"
4584
+ }
4585
+ },
4586
+ output: {
4587
+ schema: {
4588
+ type: "object",
4589
+ additionalProperties: false,
4590
+ properties: { text: {
4591
+ type: "string",
4592
+ required: true
4593
+ } }
4594
+ },
4595
+ render: (_args, value) => [{
4596
+ type: "text",
4597
+ text: value.text
4598
+ }]
4599
+ },
4600
+ async execute(args) {
4601
+ const input = args;
4602
+ const scopes = scopesOf(input.scope);
4603
+ const limit = Number.isInteger(input.limit) ? Math.min(Math.max(1, input.limit), 50) : 10;
4604
+ const now = Date.now();
4605
+ const groups = await Promise.all(scopes.map(async (scope) => ({
4606
+ scope,
4607
+ due: await (await deps.openStore(scope)).dueReviews(now, limit)
4608
+ })));
4609
+ const total = groups.reduce((sum, group) => sum + group.due.length, 0);
4610
+ if (total === 0) return { text: "今日无待回忆条目(队列空)。新记忆保存后次日首次到期。" };
4611
+ const lines = [`今日待回忆 ${total} 段(最逾期在前)。对每段:先回忆 → engram_review 核对 → engram_report 传 grade 自评。`];
4612
+ for (const { scope, due } of groups) for (const [index, record] of due.entries()) {
4613
+ const slot = record.slot === void 0 ? "(未排桩)" : `${record.slot.room} #${record.slot.index}`;
4614
+ const placard = record.imagery?.caption ?? "(无门牌)";
4615
+ const overdueDays = record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5));
4616
+ const overdue = overdueDays === 0 ? "今日到期" : `逾期 ${overdueDays} 天`;
4617
+ lines.push(`${index + 1}. [${scope}] ${slot} · 门牌「${placard}」 · ${overdue} · id=${record.id}`);
4618
+ }
4619
+ return { text: lines.join("\n") };
4620
+ }
4621
+ });
3603
4622
  return [
3604
4623
  save,
3605
4624
  search,
@@ -3608,7 +4627,7 @@ function createEngramTools(deps) {
3608
4627
  forget,
3609
4628
  defineTool({
3610
4629
  name: "engram_report",
3611
- description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。效果影响后续召回排序,长期无效的记忆将被衰减归档。",
4630
+ description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。也可作为复习自评入口:传 grade(0 完全遗忘 … 5 完美回忆)显式报告回忆质量。效果影响后续召回排序与复习排期,长期无效的记忆会被衰减归档。",
3612
4631
  parameters: {
3613
4632
  id: {
3614
4633
  type: "string",
@@ -3618,8 +4637,11 @@ function createEngramTools(deps) {
3618
4637
  outcome: {
3619
4638
  type: "string",
3620
4639
  enum: ["success", "failure"],
3621
- required: true,
3622
- description: "使用效果"
4640
+ description: "使用效果(与 grade 二选一;同传时 grade 优先)"
4641
+ },
4642
+ grade: {
4643
+ type: "integer",
4644
+ description: "回忆质量自评 0-5(复习答题用;0/1 完全遗忘,3 勉强,5 完美)"
3623
4645
  },
3624
4646
  scope: {
3625
4647
  type: "string",
@@ -3647,27 +4669,36 @@ function createEngramTools(deps) {
3647
4669
  confidence: {
3648
4670
  type: "number",
3649
4671
  required: true
3650
- }
4672
+ },
4673
+ nextReviewAt: { type: "number" }
3651
4674
  }
3652
4675
  },
3653
4676
  render: (_args, value) => [{
3654
4677
  type: "text",
3655
- text: value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id} 使用无效(confidence=${value.confidence})。该记忆后续召回排序将下降,持续无效会被衰减归档。`
4678
+ 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)}。`)
3656
4679
  }]
3657
4680
  },
3658
4681
  async execute(args) {
3659
4682
  const input = args;
3660
- if (input.outcome !== "success" && input.outcome !== "failure") throw new Error("engram_report: outcome 必须是 success 或 failure");
4683
+ const hasGrade = Number.isInteger(input.grade) && input.grade >= 0 && input.grade <= 5;
4684
+ if (input.grade !== void 0 && !hasGrade) throw new Error("engram_report: grade 必须是 0-5 的整数");
4685
+ if (input.outcome !== "success" && input.outcome !== "failure" && !hasGrade) throw new Error("engram_report: 需要 outcome(success/failure)或 grade(0-5)参数");
4686
+ const outcome = input.outcome === "success" || input.outcome === "failure" ? input.outcome : input.grade >= 3 ? "success" : "failure";
4687
+ const grade = hasGrade ? input.grade : outcome === "success" ? 5 : 1;
3661
4688
  const scope = scopeOf(input.scope, "project");
3662
- const record = await (await deps.openStore(scope)).reportOutcome(input.id, input.outcome);
4689
+ const store = await deps.openStore(scope);
4690
+ const record = await store.reportOutcome(input.id, outcome);
3663
4691
  if (record === void 0) throw new Error(`engram_report: 条目 ${input.id} 不存在(scope=${scope})`);
4692
+ const scheduled = await store.scheduleReview(input.id, grade);
3664
4693
  return {
3665
4694
  id: record.id,
3666
- outcome: input.outcome,
3667
- confidence: record.confidence
4695
+ outcome,
4696
+ confidence: record.confidence,
4697
+ ...scheduled?.review?.nextReviewAt === null || scheduled?.review?.nextReviewAt === void 0 ? {} : { nextReviewAt: scheduled.review.nextReviewAt }
3668
4698
  };
3669
4699
  }
3670
4700
  }),
4701
+ reviewQueue,
3671
4702
  defineTool({
3672
4703
  name: "engram_review",
3673
4704
  description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
@@ -3723,7 +4754,7 @@ function createEngramTools(deps) {
3723
4754
  }),
3724
4755
  defineTool({
3725
4756
  name: "engram_stats",
3726
- description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量。scope=all 时合并两库。",
4757
+ description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量,以及房间目录(房名/桩位数/最新门牌——检索前先看目录决定进哪个房间,配 engram_search 的 room 参数)。scope=all 时合并两库。",
3727
4758
  parameters: { scope: {
3728
4759
  type: "string",
3729
4760
  enum: [
@@ -3750,19 +4781,38 @@ function createEngramTools(deps) {
3750
4781
  },
3751
4782
  async execute(args) {
3752
4783
  const scopes = scopesOf(args.scope);
3753
- return { text: (await Promise.all(scopes.map(async (scope) => ({
3754
- scope,
3755
- stats: await (await deps.openStore(scope)).stats()
3756
- })))).map(({ scope, stats }) => [
3757
- `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
3758
- `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
3759
- `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`
3760
- ].join("\n")).join("\n\n") };
4784
+ return { text: (await Promise.all(scopes.map(async (scope) => {
4785
+ const store = await deps.openStore(scope);
4786
+ const [storeStats, rooms, placards] = await Promise.all([
4787
+ store.stats(),
4788
+ store.slotCountsByRoom(),
4789
+ store.listPlacards()
4790
+ ]);
4791
+ return {
4792
+ scope,
4793
+ stats: storeStats,
4794
+ rooms,
4795
+ placards
4796
+ };
4797
+ }))).map(({ scope, stats, rooms, placards }) => {
4798
+ const latestPlacardByRoom = /* @__PURE__ */ new Map();
4799
+ for (const row of placards) if (row.room !== null) latestPlacardByRoom.set(row.room, row.caption);
4800
+ const roomLines = Object.entries(rooms).sort(([a], [b]) => a.localeCompare(b, "zh-Hans-CN")).map(([room, state]) => {
4801
+ const placard = latestPlacardByRoom.get(room);
4802
+ return ` ${room}: ${state.count}/${state.maxIndex} 桩${placard === void 0 ? "" : ` · 最新门牌「${placard}」`}`;
4803
+ });
4804
+ return [
4805
+ `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
4806
+ `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
4807
+ `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`,
4808
+ roomLines.length === 0 ? "房间目录: (尚未排桩)" : `房间目录(engram_search 用 room 参数直进):\n${roomLines.join("\n")}`
4809
+ ].join("\n");
4810
+ }).join("\n\n") };
3761
4811
  }
3762
4812
  }),
3763
4813
  defineTool({
3764
4814
  name: "engram_export",
3765
- description: "把记忆库导出为文件(Markdown / JSON / 镜像目录),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。format=markdown-mirror 时每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md,可直接用 Obsidian / git 漫游。",
4815
+ description: "把记忆库导出为文件(Markdown / JSON / 镜像目录),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。format=markdown-mirror 时按房间(kind)分目录、每条记忆一个 .md + frontmatter,附房间清单 _meta.json 与全宫殿入口 _index.md,可直接用 Obsidian / git 漫游。",
3766
4816
  parameters: {
3767
4817
  format: {
3768
4818
  type: "string",
@@ -3771,7 +4821,7 @@ function createEngramTools(deps) {
3771
4821
  "json",
3772
4822
  "markdown-mirror"
3773
4823
  ],
3774
- description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每房间一文件(默认 markdown)"
4824
+ description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每条记忆一文件(按房间分目录,默认 markdown)"
3775
4825
  },
3776
4826
  scope: {
3777
4827
  type: "string",
@@ -3822,7 +4872,7 @@ function createEngramTools(deps) {
3822
4872
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
3823
4873
  const mirrorRoot = join(deps.exportDir, `mirror-${scope}-${stamp}`);
3824
4874
  const report = await writeMirror(mirrorRoot, data);
3825
- written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length} 间房间,${report.floors.length} 个楼层)`);
4875
+ written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length} 条记忆,${report.rooms.length} 个房间)`);
3826
4876
  continue;
3827
4877
  }
3828
4878
  const payload = redactedView ? {
@@ -3905,11 +4955,11 @@ function createEngramTools(deps) {
3905
4955
  }),
3906
4956
  defineTool({
3907
4957
  name: "engram_examine",
3908
- description: "渐进式披露:按 id 批量拉取房间完整铭牌(content + 楼层 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
4958
+ description: "渐进式披露:按 id 批量拉取记忆完整铭牌(content + 房间 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
3909
4959
  parameters: { ids: {
3910
4960
  type: "array",
3911
4961
  items: { type: "string" },
3912
- description: "房间 id 列表"
4962
+ description: "记忆 id 列表"
3913
4963
  } },
3914
4964
  output: {
3915
4965
  schema: {
@@ -3944,11 +4994,11 @@ function createEngramTools(deps) {
3944
4994
  }),
3945
4995
  defineTool({
3946
4996
  name: "engram_neighbors",
3947
- description: "走廊漫步:从某间出发走 1-3 跳内的 related/supersedes/contradicts 边,返回邻居房间简表(仅 id + scope + kind + status + content),便于判断下一站。",
4997
+ description: "走廊漫步:从某条记忆出发走 1-3 跳内的 related/supersedes/contradicts 边,返回邻居记忆简表(仅 id + scope + kind + status + content),便于判断下一站。",
3948
4998
  parameters: {
3949
4999
  id: {
3950
5000
  type: "string",
3951
- description: "起点房间 id"
5001
+ description: "起点记忆 id"
3952
5002
  },
3953
5003
  depth: {
3954
5004
  type: "integer",
@@ -3980,19 +5030,23 @@ function createEngramTools(deps) {
3980
5030
  if (seedRow === void 0) throw new Error(`engram_neighbors: 起点 ${seed} 不存在`);
3981
5031
  const seedScope = seedRow.scope;
3982
5032
  const neighbors = await (seedScope === "user" ? userStore : projectStore).neighbors(seed, depth);
3983
- if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth} 跳内无邻居房间。` };
5033
+ if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth} 跳内无邻居记忆。` };
3984
5034
  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})`);
3985
- return { text: `起点 ${seed}(${seedScope})→ ${depth} 跳走廊共访 ${neighbors.length} 间:\n${lines.join("\n")}` };
5035
+ return { text: `起点 ${seed}(${seedScope})→ ${depth} 跳走廊共访 ${neighbors.length} 条:\n${lines.join("\n")}` };
3986
5036
  }
3987
5037
  }),
3988
5038
  defineTool({
3989
5039
  name: "engram_tour",
3990
- description: "巡游路由:按「同类巩固 → 走廊相邻 → 反差补位」顺序组织 3-7 间房间,每站附入选理由 + 意象铭牌回声。适合在用户问起某主题时直接给出一条可走的导览路线,而不是无序结果集。",
5040
+ description: "巡游路由。mode=fixed:按固定巡游路线走全宫(桩位顺序恒定,骨架长期复用——宫殿的路线永远不变,靠顺序提取);mode=thematic(默认):按主题动态规划 3-7 站(同类巩固 → 走廊相邻 → 反差补位),适合用户问起某主题时给出一条可走的导览路线。",
3991
5041
  parameters: {
3992
5042
  query: {
3993
5043
  type: "string",
3994
- required: true,
3995
- description: "巡游主题(与 engram_search 同义)"
5044
+ description: "巡游主题(thematic 模式必填,与 engram_search 同义)"
5045
+ },
5046
+ mode: {
5047
+ type: "string",
5048
+ enum: ["fixed", "thematic"],
5049
+ description: "fixed 固定路线全宫巡游 / thematic 主题动态路线(默认 thematic)"
3996
5050
  },
3997
5051
  scope: {
3998
5052
  type: "string",
@@ -4006,7 +5060,7 @@ function createEngramTools(deps) {
4006
5060
  },
4007
5061
  maxStops: {
4008
5062
  type: "integer",
4009
- description: "最多站数(默认 6,3-7 之间)"
5063
+ description: "最多站数(默认 6,3-7 之间;fixed 模式默认 20)"
4010
5064
  }
4011
5065
  },
4012
5066
  output: {
@@ -4026,9 +5080,41 @@ function createEngramTools(deps) {
4026
5080
  async execute(args, exec) {
4027
5081
  const input = args;
4028
5082
  const scopes = scopesOf(input.scope);
5083
+ if ((input.mode === "fixed" ? "fixed" : "thematic") === "fixed") {
5084
+ const maxStops = Number.isInteger(input.maxStops) ? Math.max(1, input.maxStops) : 20;
5085
+ const sections = [];
5086
+ let shown = 0;
5087
+ let skipped = 0;
5088
+ for (const scope of scopes) {
5089
+ const store = await deps.openStore(scope);
5090
+ const route = await store.routeList();
5091
+ if (route.length === 0) continue;
5092
+ const records = await store.getMany(route.map((stop) => stop.id));
5093
+ const byId = new Map(records.map((record) => [String(record.id), record]));
5094
+ const lines = [];
5095
+ for (const stop of route) {
5096
+ if (shown >= maxStops) break;
5097
+ const record = byId.get(String(stop.id));
5098
+ if (record === void 0 || record.status !== "active") {
5099
+ skipped += 1;
5100
+ continue;
5101
+ }
5102
+ shown += 1;
5103
+ const slot = record.slot === void 0 ? "" : `${record.slot.room} #${record.slot.index} · `;
5104
+ const placard = record.imagery?.caption;
5105
+ lines.push(`第 ${stop.position + 1} 站 · ${slot}[${record.kind}] ${truncateItem(record.content)}(id=${record.id})${placard === null || placard === void 0 ? "" : ` · 门牌「${placard}」`}`);
5106
+ }
5107
+ if (lines.length > 0) sections.push(`【${scope} 宫殿 · 固定巡游】\n${lines.join("\n")}`);
5108
+ }
5109
+ if (shown === 0) return { text: "巡游路线为空:尚无排桩记忆(保存记忆后自动登记路线)。" };
5110
+ const tail = skipped > 0 ? `\n(另有 ${skipped} 个空桩:原记忆已闭馆或归档,桩位保留不回收)` : "";
5111
+ return { text: `${sections.join("\n\n")}${tail}` };
5112
+ }
5113
+ if (typeof input.query !== "string" || input.query.trim() === "") throw new Error("engram_tour: thematic 模式需要 query 参数(巡游主题)");
5114
+ const tourQuery = input.query;
4029
5115
  const limit = 12;
4030
5116
  const maxStops = Number.isInteger(input.maxStops) ? Math.min(Math.max(3, input.maxStops), 7) : 6;
4031
- const rewrite = await rewriteQueries(deps, exec, input.query);
5117
+ const rewrite = await rewriteQueries(deps, exec, tourQuery);
4032
5118
  const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
4033
5119
  const vector = await queryVectorOf(deps, queryText);
4034
5120
  const results = await Promise.all(scopes.map(async (scope) => {
@@ -4048,17 +5134,18 @@ function createEngramTools(deps) {
4048
5134
  const userStore = await deps.openStore("user");
4049
5135
  const projectStore = await deps.openStore("project");
4050
5136
  const poolLookup = async (id) => await userStore.get(id) ?? await projectStore.get(id);
4051
- const route = await planTour(merged, poolLookup, input.query, maxStops);
5137
+ const route = await planTour(merged, poolLookup, tourQuery, maxStops);
4052
5138
  return { text: `${degraded ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${route.narrative}` };
4053
5139
  }
4054
5140
  }),
4055
- auditForgotten
5141
+ auditForgotten,
5142
+ ingestHistory
4056
5143
  ];
4057
5144
  }
4058
5145
  /**
4059
5146
  * 构造巡游路径:在 search 命中的基础上按路径策略重排。
4060
5147
  * 1) 起点簇:取命中中 kind 出现频次最高的前 N 个同 kind 节点(同类巩固)。
4061
- * 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0 的房间。
5148
+ * 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0 的记忆。
4062
5149
  * 3) 反差收束:从剩余命中挑一个 emotionalValence ≥ 0.7 的做收束(强反差唤醒)。
4063
5150
  * 命中不足时按可用性回退;命中为 0 时返回空 stops。
4064
5151
  */
@@ -4141,13 +5228,13 @@ function renderTourNarrative(stops, query) {
4141
5228
  }
4142
5229
  //#endregion
4143
5230
  //#region src/selection-rationale.ts
4144
- /** 给定一组入选房间,返回归因 XML 字符串;空 rooms 返回空串。 */
5231
+ /** 给定一组入选记忆,返回归因 XML 字符串;空 rooms 返回空串。 */
4145
5232
  function buildSelectionRationale(records) {
4146
5233
  if (records.length === 0) return "";
4147
5234
  const now = Date.now();
4148
5235
  const lines = [
4149
5236
  "<engram_selection_rationale>",
4150
- `本轮画像共 ${String(records.length)} 间房间,挑选理由如下:`,
5237
+ `本轮画像共 ${String(records.length)} 条记忆,挑选理由如下:`,
4151
5238
  ""
4152
5239
  ];
4153
5240
  for (const record of records) {
@@ -4193,7 +5280,7 @@ function reasonsLabel(reasons) {
4193
5280
  /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
4194
5281
  const name = "dsh-engram";
4195
5282
  /** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
4196
- const VERSION = "0.7.0";
5283
+ const VERSION = "0.7.2";
4197
5284
  /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
4198
5285
  const inject = ["tools", "llm"];
4199
5286
  /**
@@ -4206,13 +5293,14 @@ const inject = ["tools", "llm"];
4206
5293
  */
4207
5294
  function renderProfileDetailed(records, tokenBudget) {
4208
5295
  const estimate = (text) => Math.ceil(text.length / 4);
4209
- const header = "User memory profile (dsh-engram, cross-session):";
4210
- const footer = "Use engram_search to recall details; use engram_save to persist new facts.";
5296
+ const header = "User memory profile (dsh-engram, cross-session) — Grand Hall (always present):";
5297
+ const footer = "Use engram_search to recall details (pass room to search inside one room); use engram_save to persist new facts.";
4211
5298
  let remaining = Math.max(0, tokenBudget - estimate(header) - estimate(footer));
4212
5299
  const lines = [];
4213
5300
  const overflow = [];
4214
5301
  for (const record of records) {
4215
- const line = `- [${record.kind}] ${record.content}`;
5302
+ const slot = record.slot === void 0 ? "" : ` ${record.slot.room}#${record.slot.index}`;
5303
+ const line = `- [${record.kind}]${slot} ${record.content}`;
4216
5304
  const cost = estimate(line);
4217
5305
  if (cost <= remaining) {
4218
5306
  lines.push(line);
@@ -4303,6 +5391,10 @@ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, sign
4303
5391
  async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
4304
5392
  const decision = await next();
4305
5393
  if (decision.kind === "reject") return decision;
5394
+ if (step === 1) {
5395
+ const currentRoute = resolved.routeOverride ?? routeFromEvents(agent.session.snapshotEvents());
5396
+ if (currentRoute !== void 0) state.route = currentRoute;
5397
+ }
4306
5398
  const mode = resolved.ingest;
4307
5399
  if (step === 1 && mode !== "off") {
4308
5400
  if (!state.pendingReplayed) {
@@ -4344,6 +5436,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
4344
5436
  if (step !== 1) return decision;
4345
5437
  const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
4346
5438
  if (top.length === 0) return decision;
5439
+ 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;
4347
5440
  const detailed = renderProfileDetailed(top, resolved.injectTokenBudget);
4348
5441
  let text = detailed.text;
4349
5442
  if (detailed.overflow.length > 0) {
@@ -4359,7 +5452,8 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
4359
5452
  }), resolved.injectTokenBudget).text;
4360
5453
  }
4361
5454
  }
4362
- const textWithRationale = wrapWithRationale(text, top);
5455
+ const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
5456
+ const textWithRationale = wrapWithRationale(text, top) + dueLine;
4363
5457
  const hash = createHash("sha256").update(textWithRationale).digest("hex");
4364
5458
  if (state.lastProfileAgent === String(agent.id) && hash === state.lastProfileHash) return decision;
4365
5459
  state.lastProfileAgent = String(agent.id);
@@ -4422,16 +5516,183 @@ function apply(ctx, config = {}) {
4422
5516
  decayAfterDays: resolved.decayAfterDays
4423
5517
  };
4424
5518
  const stores = /* @__PURE__ */ new Map();
4425
- const openStore = (scope) => {
4426
- const existing = stores.get(scope);
5519
+ /** 打开(或复用)一个分库文件;首次打开时幂等补齐存量排桩。 */
5520
+ const openDb = (dbName) => {
5521
+ const existing = stores.get(dbName);
4427
5522
  if (existing !== void 0) return existing;
4428
- const created = openEngramStore(scope === "user" ? join(resolved.dbDir, "user.db") : scope === "shared" ? join(resolved.dbDir, "shared.db") : join(resolved.dbDir, identity.dbName), rankBoost);
4429
- stores.set(scope, created);
5523
+ const path = join(resolved.dbDir, dbName);
5524
+ const created = openEngramStore(path, rankBoost, {
5525
+ autoSlot: resolved.autoSlot,
5526
+ reviewScheduling: resolved.reviewScheduling
5527
+ }).then(async (store) => {
5528
+ const assigned = await store.backfillSlots((room) => {
5529
+ console.warn(`[dsh-engram] 房间已满,自动开新房「${room}」(可在管理面板翻新清单中人工拆分/命名)`);
5530
+ });
5531
+ if (assigned > 0) console.warn(`[dsh-engram] 存量记忆排桩完成:${assigned} 条已钉入宫殿(${path})`);
5532
+ return store;
5533
+ });
5534
+ stores.set(dbName, created);
4430
5535
  return created;
4431
5536
  };
5537
+ const openStore = (scope) => openDb(scope === "user" ? "user.db" : scope === "shared" ? "shared.db" : identity.dbName);
5538
+ /** cwd → 分库文件名(历史回填会对每个历史 cwd 解析一次,避免重复读 git 元数据)。 */
5539
+ const cwdDbNames = /* @__PURE__ */ new Map();
5540
+ /**
5541
+ * 按任意 cwd 的项目标识打开分库(历史回填专用):历史会话写进它自己项目的库,
5542
+ * 而不是当前工作目录的库——否则跨项目内容会串库。
5543
+ * @param cwd - 历史会话 header 里记录的 cwd。
5544
+ * @returns 该 cwd 对应项目分库的连接。
5545
+ */
5546
+ /** cwd → 分库文件名(缓存,避免重复读 git 元数据与重复迁移检查)。 */
5547
+ const dbNameForCwd = (cwd) => {
5548
+ const cached = cwdDbNames.get(cwd);
5549
+ if (cached !== void 0) return cached;
5550
+ const projectIdentity = resolveProjectIdentity(cwd);
5551
+ if (migrateProjectDb(resolved.dbDir, projectIdentity) === "renamed") console.warn(`[dsh-engram] 历史项目库已迁移为 origin 命名(${cwd})`);
5552
+ cwdDbNames.set(cwd, projectIdentity.dbName);
5553
+ return projectIdentity.dbName;
5554
+ };
5555
+ const openStoreForProjectCwd = (cwd) => openDb(dbNameForCwd(cwd));
4432
5556
  const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
4433
5557
  console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
4434
5558
  });
5559
+ const logIngestRequest = (data) => {
5560
+ openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
5561
+ };
5562
+ const preStepState = {
5563
+ pendingReplayed: false,
5564
+ lastProfileAgent: null,
5565
+ lastProfileHash: null,
5566
+ route: void 0
5567
+ };
5568
+ /** 会话持久化服务(可选;缺席时历史回填不可用)。 */
5569
+ const persistenceService = () => {
5570
+ const persistence = ctx.get("sessionPersistence");
5571
+ if (persistence === void 0) return void 0;
5572
+ return {
5573
+ list: (signal) => persistence.list(signal),
5574
+ load: (id) => persistence.load(id)
5575
+ };
5576
+ };
5577
+ /** user 分库若已存在则打开(估算用:不因估算产生空库文件)。 */
5578
+ const openUserStoreIfExists = () => existsSync(join(resolved.dbDir, "user.db")) ? openStore("user") : Promise.resolve(void 0);
5579
+ /** 指定 cwd 的项目库若已存在则打开(估算用:不因估算产生空库文件)。 */
5580
+ const openProjectStoreIfExists = (cwd) => {
5581
+ const dbName = dbNameForCwd(cwd);
5582
+ return existsSync(join(resolved.dbDir, dbName)) ? openDb(dbName) : Promise.resolve(void 0);
5583
+ };
5584
+ /** 组装历史回填依赖;source 每次现取,兼容持久化服务在插件之后挂载的组合。 */
5585
+ const buildHistoryDeps = () => ({
5586
+ source: persistenceService(),
5587
+ resolveStore: openStoreForProjectCwd,
5588
+ resolveExistingStore: openProjectStoreIfExists,
5589
+ openUserStore: () => openStore("user"),
5590
+ resolveExistingUserStore: openUserStoreIfExists,
5591
+ embedder,
5592
+ mode: resolved.ingest === "off" ? "light" : resolved.ingest,
5593
+ routeOverride: resolved.routeOverride ?? preStepState.route,
5594
+ call: (callParams) => streamText(ctx, {
5595
+ ...callParams,
5596
+ sessionId: ""
5597
+ }),
5598
+ logRequest: logIngestRequest
5599
+ });
5600
+ /** 回填任务(进程内单例;面板轮询它的进度,工具同步等待自己的那一次运行)。 */
5601
+ let backfillJob;
5602
+ /** 历史回填对外接口:面板路由(异步 job)与工具(同步运行)共用。 */
5603
+ const historyApi = {
5604
+ /** 已注册的 provider 与模型清单(面板「辅助模型」下拉的数据源)。 */
5605
+ models: async () => {
5606
+ const llm = ctx.get("llm");
5607
+ if (llm === void 0) return {
5608
+ providers: [],
5609
+ failures: []
5610
+ };
5611
+ const failures = [];
5612
+ return {
5613
+ providers: await Promise.all(llm.listProviders().map(async (provider) => {
5614
+ try {
5615
+ const models = await llm.listModels(provider.id);
5616
+ return {
5617
+ id: provider.id,
5618
+ name: provider.name,
5619
+ models: models.map((model) => ({
5620
+ id: model.id,
5621
+ name: model.name
5622
+ }))
5623
+ };
5624
+ } catch (error) {
5625
+ failures.push(`${provider.id}: ${error instanceof Error ? error.message : String(error)}`);
5626
+ return {
5627
+ id: provider.id,
5628
+ name: provider.name,
5629
+ models: []
5630
+ };
5631
+ }
5632
+ })),
5633
+ failures
5634
+ };
5635
+ },
5636
+ estimate: (rules) => estimateHistoryBackfill(buildHistoryDeps(), resolved.historyBackfill, rules),
5637
+ run: (rules, signal) => runHistoryBackfill(buildHistoryDeps(), resolved.historyBackfill, rules, () => {}, signal),
5638
+ /** 启动后台回填;已有任务在跑时拒绝(面板按钮据此禁用)。 */
5639
+ start: (rules) => {
5640
+ if (backfillJob !== void 0 && backfillJob.progress.state === "running") return {
5641
+ ok: false,
5642
+ reason: "已有回填任务正在运行,请先暂停或等待完成"
5643
+ };
5644
+ const controller = new AbortController();
5645
+ const job = {
5646
+ controller,
5647
+ failures: [],
5648
+ progress: {
5649
+ state: "running",
5650
+ sessionsTotal: 0,
5651
+ sessionsDone: 0,
5652
+ turnsPlanned: 0,
5653
+ turnsDone: 0,
5654
+ memoriesWritten: 0,
5655
+ turnsSkipped: 0,
5656
+ turnsFailed: 0,
5657
+ skipReasons: {}
5658
+ }
5659
+ };
5660
+ backfillJob = job;
5661
+ runHistoryBackfill(buildHistoryDeps(), resolved.historyBackfill, rules, (progress) => {
5662
+ job.progress = progress;
5663
+ }, controller.signal).then((result) => {
5664
+ job.progress = result;
5665
+ job.failures = result.failures;
5666
+ }).catch((error) => {
5667
+ console.warn("[dsh-engram] 历史回填失败:", error);
5668
+ job.progress = {
5669
+ ...job.progress,
5670
+ state: "failed"
5671
+ };
5672
+ job.error = error instanceof Error ? error.message : String(error);
5673
+ });
5674
+ return { ok: true };
5675
+ },
5676
+ cancel: () => {
5677
+ backfillJob?.controller.abort();
5678
+ },
5679
+ /** 当前任务快照;从未跑过时返回 idle 占位。 */
5680
+ status: () => ({
5681
+ progress: backfillJob?.progress ?? {
5682
+ state: "done",
5683
+ sessionsTotal: 0,
5684
+ sessionsDone: 0,
5685
+ turnsPlanned: 0,
5686
+ turnsDone: 0,
5687
+ memoriesWritten: 0,
5688
+ turnsSkipped: 0,
5689
+ turnsFailed: 0,
5690
+ skipReasons: {}
5691
+ },
5692
+ failures: backfillJob?.failures ?? [],
5693
+ ...backfillJob?.error === void 0 ? {} : { error: backfillJob.error }
5694
+ })
5695
+ };
4435
5696
  for (const tool of createEngramTools({
4436
5697
  openStore,
4437
5698
  embedder,
@@ -4441,7 +5702,11 @@ function apply(ctx, config = {}) {
4441
5702
  }),
4442
5703
  routeOverride: resolved.routeOverride,
4443
5704
  queryRewrite: resolved.queryRewrite,
4444
- exportDir: `${resolved.dbDir}/exports`
5705
+ exportDir: `${resolved.dbDir}/exports`,
5706
+ historyBackfill: {
5707
+ estimate: historyApi.estimate,
5708
+ run: historyApi.run
5709
+ }
4445
5710
  })) ctx.tools.register(tool);
4446
5711
  ctx.inject(["webServer"], (webCtx) => {
4447
5712
  registerEngramRoutes(webCtx, {
@@ -4450,20 +5715,18 @@ function apply(ctx, config = {}) {
4450
5715
  mirrorDir: `${resolved.dbDir}/palaces`,
4451
5716
  dbDir: resolved.dbDir,
4452
5717
  pluginVersion: VERSION,
4453
- embedder
5718
+ embedder,
5719
+ history: {
5720
+ estimate: historyApi.estimate,
5721
+ start: historyApi.start,
5722
+ cancel: historyApi.cancel,
5723
+ status: historyApi.status,
5724
+ models: historyApi.models,
5725
+ defaults: resolved.historyBackfill
5726
+ }
4454
5727
  });
4455
5728
  });
4456
- const logIngestRequest = (data) => {
4457
- openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
4458
- };
4459
- if (resolved.injectProfile || resolved.ingest !== "off") {
4460
- const state = {
4461
- pendingReplayed: false,
4462
- lastProfileAgent: null,
4463
- lastProfileHash: null
4464
- };
4465
- ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, state, logIngestRequest, payload, next), { prepend: true });
4466
- }
5729
+ if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, preStepState, logIngestRequest, payload, next), { prepend: true });
4467
5730
  if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
4468
5731
  const mode = resolved.ingest;
4469
5732
  if (mode === "off") return;
@@ -4482,6 +5745,8 @@ function apply(ctx, config = {}) {
4482
5745
  }),
4483
5746
  logRequest: logIngestRequest,
4484
5747
  signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
5748
+ }).catch((error) => {
5749
+ console.warn("[dsh-engram] 会话结束的末轮摄取异常(不影响对话):", error);
4485
5750
  });
4486
5751
  });
4487
5752
  const runDecay = async () => {