@kenz1117/dsh-engram 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,10 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
4
5
  import { homedir } from "node:os";
5
6
  import z from "@deepseek-ai/schemastery";
6
- import { mkdir, writeFile } from "node:fs/promises";
7
- import { createHash, randomUUID } from "node:crypto";
7
+ import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { createGzip, gunzip } from "node:zlib";
9
+ import { promisify } from "node:util";
8
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
11
  //#region src/config.ts
10
12
  /**
@@ -257,6 +259,8 @@ async function streamText(ctx, params) {
257
259
  const MEMORY_CONTEXT_TAG = "engram_memory_context";
258
260
  /** 当前用户请求协议标签。 */
259
261
  const CURRENT_USER_REQUEST_TAG = "current_user_request";
262
+ /** 用户显式禁记标签:在对话中用 <no-palace>...</no-palace> 包裹的整段不会被记忆。 */
263
+ const NO_PALACE_TAG = "no-palace";
260
264
  /**
261
265
  * 入库剥离时识别的记忆上下文标签集合:不区分来源——历史正文可能携带
262
266
  * 其他记忆插件(如 memmy/memos)的包裹标签,一律按不可信协议块剥离。
@@ -274,7 +278,7 @@ const MEMORY_CONTEXT_TAGS = [
274
278
  * @returns 可安全入库/复用的正文。
275
279
  */
276
280
  function sanitizeProtocolText(value) {
277
- return normalizeWhitespace(unwrapCurrentUserRequestBlocks(stripMemoryContextBlocks(value)));
281
+ return normalizeWhitespace(unwrapCurrentUserRequestBlocks(stripMemoryContextBlocks(stripNoPalaceBlocks(value))));
278
282
  }
279
283
  /**
280
284
  * 渲染记忆包:内容先清洗再包裹协议标签,附三条使用警告;当前请求独立成段。
@@ -320,6 +324,10 @@ function stripMemoryContextBlocks(value) {
320
324
  for (const tag of MEMORY_CONTEXT_TAGS) text = replaceTaggedBlocks(text, tag, () => "", { removeUnclosedTail: true });
321
325
  return text;
322
326
  }
327
+ /** 剥离 <no-palace>...</no-palace> 整段:用户显式禁记(默认整段移除,不留存任何痕迹)。 */
328
+ function stripNoPalaceBlocks(value) {
329
+ return replaceTaggedBlocks(value, NO_PALACE_TAG, () => "", { removeUnclosedTail: true });
330
+ }
323
331
  /** 解包 current_user_request 块,保留内部文本(当前请求是可信正文,只是去除标签)。 */
324
332
  function unwrapCurrentUserRequestBlocks(value) {
325
333
  return replaceTaggedBlocks(value, CURRENT_USER_REQUEST_TAG, (inner) => inner);
@@ -507,6 +515,66 @@ function collectTexts(events, includeAssistant) {
507
515
  minSeq
508
516
  };
509
517
  }
518
+ /** 寒暄正则:整段用户输入只含问候/感谢等无信息内容时跳过摄取。 */
519
+ const CHITCHAT_RE = /^(?:你好|您好|嗨|哈喽|hello|hi|hey|谢谢|感谢|ok|okay|好的|在吗|收到|辛苦了)[!!,.,。??~\s]*$/iu;
520
+ /** 显式禁记正则:用户明确要求不要记住本轮内容时跳过摄取(窄匹配整句指令,避免误伤)。 */
521
+ const NO_CAPTURE_RE = /(?:不要|别|不用|无需)(?:把这?[个件些条]?|把上一?轮|把刚才)?(?:记|存)(?:住|录|下来|进去|到记忆|进记忆)|don'?t\s+(?:remember|record|save)\s+(?:this|that|it)/i;
522
+ /** 工具多样性得分:无工具 0;1-2 种 1;≥3 种 2。 */
523
+ function toolDiversity(distinct) {
524
+ return distinct === 0 ? 0 : distinct <= 2 ? 1 : 2;
525
+ }
526
+ /**
527
+ * 活动评分(四信号,< ACTIVITY_THRESHOLD 跳过摄取):
528
+ * min(floor(userChars/50), 3) + completedTurns + min(floor(toolResults/5), 2) + toolDiversity。
529
+ * @param signals - 上一轮活动信号。
530
+ * @returns 0-8 的整数评分。
531
+ */
532
+ function activityScore(signals) {
533
+ return Math.min(Math.floor(signals.userChars / 50), 3) + signals.completedTurns + Math.min(Math.floor(signals.toolResults / 5), 2) + toolDiversity(signals.toolNames.size);
534
+ }
535
+ /** 判断用户输入是否为纯寒暄(无信息内容)。 */
536
+ function isChitchat(text) {
537
+ return CHITCHAT_RE.test(text.trim());
538
+ }
539
+ /** 判断用户输入是否显式要求不要记住。 */
540
+ function forbidsCapture(text) {
541
+ return NO_CAPTURE_RE.test(text);
542
+ }
543
+ /** 从事件切片提取活动信号(用户文本排除插件注入的快照消息)。 */
544
+ function turnSignals(events) {
545
+ let userChars = 0;
546
+ let completedTurns = 0;
547
+ let toolResults = 0;
548
+ const toolNames = /* @__PURE__ */ new Set();
549
+ for (const event of events) if (event.type === "user/message") {
550
+ const data = event.data;
551
+ if (data?.source?.kind === "plugin") continue;
552
+ for (const block of data?.content ?? []) if (block?.type === "text" && typeof block.text === "string") userChars += block.text.length;
553
+ } else if (event.type === "assistant/message") {
554
+ if ((event.data?.content ?? []).some((block) => block?.type === "text" && typeof block.text === "string" && block.text !== "")) completedTurns = 1;
555
+ } else if (event.type === "tool/result") toolResults += 1;
556
+ else if (event.type === "tool/call") {
557
+ const name = event.data?.name;
558
+ if (typeof name === "string") toolNames.add(name);
559
+ }
560
+ return {
561
+ userChars,
562
+ completedTurns,
563
+ toolResults,
564
+ toolNames
565
+ };
566
+ }
567
+ /**
568
+ * 上一轮自动摄取的节流判定(只作用于 previous 切片;末轮/pending 重放补做不受限)。
569
+ * @returns 跳过原因;null = 允许摄取。
570
+ */
571
+ function throttleDecision(events) {
572
+ if (activityScore(turnSignals(events)) < 5) return "low-activity";
573
+ const joined = collectTexts(events, false).texts.join(" ");
574
+ if (joined !== "" && isChitchat(joined)) return "chitchat";
575
+ if (forbidsCapture(joined)) return "capture-forbidden";
576
+ return null;
577
+ }
510
578
  /** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。 */
511
579
  function turnStarts(events) {
512
580
  const starts = [];
@@ -579,6 +647,15 @@ async function ingestPreviousTurn(deps) {
579
647
  written: 0,
580
648
  skipped: "already-ingested"
581
649
  };
650
+ if (sliceMode === "previous") {
651
+ const throttled = throttleDecision(slice);
652
+ if (throttled !== null) return {
653
+ scannedEvents: slice.length,
654
+ candidates: 0,
655
+ written: 0,
656
+ skipped: throttled
657
+ };
658
+ }
582
659
  const scoped = omitRecallToolResults(slice);
583
660
  const { texts, minSeq } = collectTexts(scoped, limits.includeAssistant);
584
661
  const cleaned = texts.map((text) => redactSecrets(sanitizeProtocolText(text)));
@@ -734,6 +811,713 @@ async function replayPendingIngests(deps) {
734
811
  };
735
812
  }
736
813
  //#endregion
814
+ //#region src/consolidation/run.ts
815
+ /** 单条整理动作(写入 op_log 的统一标记)。 */
816
+ const CONSOLIDATION_OP = "consolidation";
817
+ /** 默认参数。 */
818
+ const DEFAULTS = {
819
+ olderThanDays: 30,
820
+ importanceBelow: .3,
821
+ mergeThreshold: .92
822
+ };
823
+ /** 闭馆整理主函数:纯异步,不抛错(失败仅记日志 / 落 pending)。 */
824
+ async function runConsolidation(store, embedder, opts = {}) {
825
+ const start = Date.now();
826
+ const options = {
827
+ ...DEFAULTS,
828
+ ...opts
829
+ };
830
+ const scope = options.scope ?? "user";
831
+ const stats = await store.stats();
832
+ const records = await store.topActive(scope, Math.max(stats.active, 0));
833
+ const archivedIds = /* @__PURE__ */ new Set();
834
+ let archived = 0;
835
+ let merged = 0;
836
+ let skipped = 0;
837
+ const now = Date.now();
838
+ const ageCutoff = options.olderThanDays * 864e5;
839
+ for (const record of records) {
840
+ if (record.importance >= options.importanceBelow) continue;
841
+ if (now - record.createdAt < ageCutoff) continue;
842
+ if (record.confidence >= .3) continue;
843
+ try {
844
+ await store.forget(record.id);
845
+ archivedIds.add(record.id);
846
+ archived += 1;
847
+ } catch {
848
+ skipped += 1;
849
+ }
850
+ }
851
+ const emb = await embedder;
852
+ const buckets = /* @__PURE__ */ new Map();
853
+ for (const record of records) {
854
+ if (archivedIds.has(record.id)) continue;
855
+ const list = buckets.get(record.kind) ?? [];
856
+ list.push(record);
857
+ buckets.set(record.kind, list);
858
+ }
859
+ const seedIds = options.mergeCandidateIds !== void 0 ? new Set(options.mergeCandidateIds) : null;
860
+ for (const list of buckets.values()) {
861
+ if (list.length < 2) continue;
862
+ if (seedIds !== null && !list.some((record) => seedIds.has(record.id))) continue;
863
+ if (emb === void 0) {
864
+ const seen = /* @__PURE__ */ new Map();
865
+ for (const record of list) {
866
+ const key = record.content.slice(0, 60);
867
+ const existing = seen.get(key);
868
+ if (existing === void 0) seen.set(key, record);
869
+ else if (record.importance > existing.importance) {
870
+ await store.supersedeMany({
871
+ scope,
872
+ kind: record.kind,
873
+ content: record.content,
874
+ importance: record.importance,
875
+ confidence: record.confidence
876
+ }, [existing.id]).catch(() => {
877
+ skipped += 1;
878
+ });
879
+ merged += 1;
880
+ seen.set(key, record);
881
+ }
882
+ }
883
+ continue;
884
+ }
885
+ const vectors = /* @__PURE__ */ new Map();
886
+ for (const record of list) {
887
+ if (seedIds !== null && !seedIds.has(record.id)) continue;
888
+ try {
889
+ const vec = (await emb.embed([record.content]))[0];
890
+ if (vec !== void 0) vectors.set(record.id, vec);
891
+ } catch {
892
+ skipped += 1;
893
+ }
894
+ }
895
+ const arr = list.filter((record) => vectors.has(record.id));
896
+ const visited = /* @__PURE__ */ new Set();
897
+ for (let i = 0; i < arr.length; i += 1) {
898
+ const a = arr[i];
899
+ if (visited.has(a.id)) continue;
900
+ const dupes = [];
901
+ for (let j = i + 1; j < list.length; j += 1) {
902
+ const b = list[j];
903
+ if (visited.has(b.id)) continue;
904
+ const bVec = vectors.get(b.id);
905
+ if (bVec === void 0) continue;
906
+ if (cosine$1(vectors.get(a.id), bVec) >= options.mergeThreshold) dupes.push(b);
907
+ }
908
+ if (dupes.length === 0) continue;
909
+ const ids = dupes.map((record) => record.id);
910
+ try {
911
+ await store.supersedeMany({
912
+ scope,
913
+ kind: a.kind,
914
+ content: a.content,
915
+ importance: a.importance,
916
+ confidence: a.confidence
917
+ }, ids);
918
+ for (const id of ids) visited.add(id);
919
+ visited.add(a.id);
920
+ merged += 1;
921
+ } catch {
922
+ skipped += 1;
923
+ }
924
+ }
925
+ }
926
+ const report = {
927
+ archived,
928
+ merged,
929
+ skipped,
930
+ tookMs: Date.now() - start
931
+ };
932
+ try {
933
+ await store.audit(CONSOLIDATION_OP, "AUX", JSON.stringify({
934
+ scope,
935
+ archived: report.archived,
936
+ merged: report.merged,
937
+ skipped: report.skipped,
938
+ tookMs: report.tookMs
939
+ }));
940
+ } catch {}
941
+ return report;
942
+ }
943
+ /** cosine 相似度(两个等长非零向量)。 */
944
+ function cosine$1(a, b) {
945
+ let dot = 0;
946
+ let na = 0;
947
+ let nb = 0;
948
+ const n = Math.min(a.length, b.length);
949
+ for (let i = 0; i < n; i += 1) {
950
+ dot += a[i] * b[i];
951
+ na += a[i] * a[i];
952
+ nb += b[i] * b[i];
953
+ }
954
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
955
+ return denom === 0 ? 0 : dot / denom;
956
+ }
957
+ //#endregion
958
+ //#region src/mirror/markdown.ts
959
+ /**
960
+ * Markdown 镜像:把 SQLite 记忆库导出为可被 Obsidian / VS Code / git 直接漫游的
961
+ * 文件树,每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md。
962
+ * 写入过程全部幂等:重复执行只会覆盖同名文件,不会向 SQLite 写任何东西(只读)。
963
+ * @module @kenz1117/dsh-engram/mirror/markdown
964
+ */
965
+ /** 房间铭牌 URL/路径安全的 slug(仅 ASCII、连字符分隔)。 */
966
+ function slugify(input) {
967
+ const stripped = input.toLowerCase().replace(/[\u4e00-\u9fa5]+/g, "记").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
968
+ return stripped === "" ? "untitled" : stripped.slice(0, 32);
969
+ }
970
+ /** frontmatter 字段值序列化(string 原样、数字 / 布尔直接、null 空字符串)。 */
971
+ function yaml(value) {
972
+ if (value === null || value === void 0) return "\"\"";
973
+ if (typeof value === "string") return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", " ")}"`;
974
+ return JSON.stringify(value);
975
+ }
976
+ /** ISO 时间戳(秒级,frontmatter 与 _meta 通用)。 */
977
+ function iso(ms) {
978
+ return new Date(ms).toISOString();
979
+ }
980
+ /** 楼层中文别名(与面板 i18n 的 kindLabel 保持一致;只用做目录名)。 */
981
+ const FLOOR_LABEL = {
982
+ fact: "fact",
983
+ preference: "preference",
984
+ decision: "decision",
985
+ episode: "episode",
986
+ skill: "skill"
987
+ };
988
+ /** 顶层 _index.md 的纯文本模板(楼层卡片 + 房间清单链接)。 */
989
+ function renderIndex(scope, data, floors) {
990
+ const total = data.records.length;
991
+ const active = data.records.filter((r) => r.status === "active").length;
992
+ const edges = data.edges.length;
993
+ return `# 记忆宫殿 · ${scope === "user" ? "私人宫殿" : "项目宫殿"}\n` + [
994
+ "",
995
+ `> 导出时间 ${iso(data.exportedAt)} · 共 **${total}** 间房间(对外开放 ${active}) · 走廊 ${edges} 条`,
996
+ "",
997
+ "## 楼层导览",
998
+ "",
999
+ ...floors.map((f) => `- **${FLOOR_LABEL[f.kind] ?? f.kind} 层** · ${f.roomCount} 间(开放 ${f.active} · 展厅 ${f.archived} · 闭馆 ${f.forgotten})`),
1000
+ "",
1001
+ "## 房间清单",
1002
+ "",
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)}]]`),
1004
+ "",
1005
+ "## 走廊(关系边)",
1006
+ "",
1007
+ ...edges === 0 ? ["(暂无走廊)"] : data.edges.map((edge) => `- \`${edge.from.slice(0, 8)}\` --${edge.type}--> \`${edge.to.slice(0, 8)}\``),
1008
+ ""
1009
+ ].join("\n");
1010
+ }
1011
+ /** 单房间 .md 模板:YAML frontmatter + 铭牌正文 + 走廊列表。 */
1012
+ function renderRoom(record, edges) {
1013
+ const relEdges = edges.filter((edge) => edge.from === record.id || edge.to === record.id);
1014
+ const tags = [];
1015
+ if (record.outcome !== void 0) tags.push(`outcome-${record.outcome}`);
1016
+ if (record.content.includes("[REDACTED:")) tags.push("redacted");
1017
+ const imagery = record.imagery;
1018
+ const caption = imagery?.caption;
1019
+ const sensory = imagery?.sensoryTags ?? [];
1020
+ const frontmatter = [
1021
+ "---",
1022
+ `id: ${record.id}`,
1023
+ `scope: ${record.scope}`,
1024
+ `kind: ${record.kind}`,
1025
+ `status: ${record.status}`,
1026
+ `importance: ${record.importance.toFixed(3)}`,
1027
+ `confidence: ${record.confidence.toFixed(3)}`,
1028
+ `createdAt: "${iso(record.createdAt)}"`,
1029
+ `accessCount: ${record.accessCount}`,
1030
+ `sourceSession: ${record.sourceSessionId === null ? "\"\"" : yaml(record.sourceSessionId)}`,
1031
+ `sourceRound: ${record.sourceRound ?? "\"\""}`,
1032
+ ...tags.length === 0 ? [] : [`tags: [${tags.join(", ")}]`],
1033
+ ...caption !== void 0 && caption !== null ? [`imageryCaption: ${yaml(caption)}`] : [],
1034
+ ...sensory.length > 0 ? [`imagerySensory: [${sensory.map((s) => yaml(s)).join(", ")}]`, `imageryValence: ${(imagery?.emotionalValence ?? 0).toFixed(3)}`] : [],
1035
+ "---"
1036
+ ].join("\n");
1037
+ const body = [
1038
+ `# ${record.content.split("\n")[0]?.slice(0, 80) ?? "房间铭牌"}`,
1039
+ "",
1040
+ record.content,
1041
+ "",
1042
+ "## 走廊",
1043
+ "",
1044
+ ...relEdges.length === 0 ? ["(此房间暂未连接任何走廊)"] : relEdges.map((edge) => {
1045
+ const other = edge.from === record.id ? edge.to : edge.from;
1046
+ return `- ${edge.from === record.id ? "→" : "←"} \`${other.slice(0, 8)}\`(${edge.type})`;
1047
+ }),
1048
+ ""
1049
+ ].join("\n");
1050
+ return frontmatter + "\n" + body;
1051
+ }
1052
+ /** 楼层 _meta.json 模板。 */
1053
+ function renderMeta(scope, data, floors) {
1054
+ const payload = {
1055
+ scope,
1056
+ exportedAt: data.exportedAt,
1057
+ total: data.records.length,
1058
+ floors
1059
+ };
1060
+ return JSON.stringify(payload, null, 2) + "\n";
1061
+ }
1062
+ /** 计算楼层摘要(按 kind 分组统计 active/archived/forgotten)。 */
1063
+ function summarizeFloors(records) {
1064
+ const map = /* @__PURE__ */ new Map();
1065
+ for (const record of records) {
1066
+ const entry = map.get(record.kind) ?? {
1067
+ roomCount: 0,
1068
+ active: 0,
1069
+ archived: 0,
1070
+ forgotten: 0
1071
+ };
1072
+ entry.roomCount += 1;
1073
+ if (record.status === "active") entry.active += 1;
1074
+ else if (record.status === "archived") entry.archived += 1;
1075
+ else if (record.status === "forgotten") entry.forgotten += 1;
1076
+ map.set(record.kind, entry);
1077
+ }
1078
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([kind, count]) => ({
1079
+ kind,
1080
+ ...count
1081
+ }));
1082
+ }
1083
+ /**
1084
+ * 把一份 exportAll 数据写入镜像目录。
1085
+ * @param rootDir - 镜像根目录(通常 `${exportDir}/mirror/<scope>/`,由调用方拼)。
1086
+ * @param data - exportAll 的产物(含 records + edges)。
1087
+ * @returns 写入摘要(rootDir / fileCount / floors)。
1088
+ */
1089
+ async function writeMirror(rootDir, data) {
1090
+ const scope = data.records[0]?.scope ?? "user";
1091
+ await mkdir(rootDir, {
1092
+ recursive: true,
1093
+ mode: 448
1094
+ });
1095
+ const floors = summarizeFloors(data.records);
1096
+ const recordsByKind = /* @__PURE__ */ new Map();
1097
+ for (const record of data.records) {
1098
+ const list = recordsByKind.get(record.kind) ?? [];
1099
+ list.push(record);
1100
+ recordsByKind.set(record.kind, list);
1101
+ }
1102
+ let fileCount = 0;
1103
+ 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, {
1109
+ recursive: true,
1110
+ mode: 448
1111
+ });
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 });
1114
+ fileCount += 1;
1115
+ }
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 });
1118
+ fileCount += 2;
1119
+ if (scope === "shared") {
1120
+ const manifest = buildShareManifest(data);
1121
+ await writeFile(join(rootDir, "share-manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 384 });
1122
+ fileCount += 1;
1123
+ }
1124
+ return {
1125
+ rootDir,
1126
+ fileCount,
1127
+ floors,
1128
+ exportedAt: data.exportedAt
1129
+ };
1130
+ }
1131
+ /**
1132
+ * 从导出数据构造借阅归还清单(仅 shared scope 调用)。
1133
+ * firstLentAt 用 createdAt 替代(数据库没记首次公开时刻,这是务实选择)。
1134
+ */
1135
+ function buildShareManifest(data, now = Date.now()) {
1136
+ const loans = data.records.filter((record) => record.scope === "shared").map((record) => ({
1137
+ id: record.id,
1138
+ kind: record.kind,
1139
+ status: record.status,
1140
+ importance: record.importance,
1141
+ confidence: record.confidence,
1142
+ firstLentAt: record.createdAt,
1143
+ lastAccessedAt: record.lastAccessedAt,
1144
+ accessCount: record.accessCount
1145
+ }));
1146
+ return {
1147
+ generatedAt: now,
1148
+ scope: "shared",
1149
+ roomCount: loans.length,
1150
+ loans
1151
+ };
1152
+ }
1153
+ //#endregion
1154
+ //#region src/backup/tar-stream.ts
1155
+ /**
1156
+ * 极简 tar 流的写入与解包:UStar 格式(POSIX.1-1988),足够本插件「打包几个 .db + _meta.json」场景。
1157
+ * 文件名长度 ≤ 99 字节、文件 ≤ 8 GiB 已覆盖本插件的备份规模;超过则抛错。
1158
+ * 不依赖 npm 包(无第三方 = 无供应链风险);可被测试套件无障碍运行。
1159
+ * @module @kenz1117/dsh-engram/backup/tar-stream
1160
+ */
1161
+ /** UStar 头字段宽度(POSIX.1-1988)。 */
1162
+ const BLOCK_SIZE = 512;
1163
+ const NAME_LEN = 100;
1164
+ const SIZE_LEN = 12;
1165
+ /** 把数字按八进制 + ASCII 写入固定宽度(末尾置空)。 */
1166
+ function writeOctal(buf, offset, length, value) {
1167
+ const str = value.toString(8).padStart(length - 1, "0");
1168
+ buf.write(str, offset, length - 1, "ascii");
1169
+ buf.write("\0", offset + length - 1, 1, "ascii");
1170
+ }
1171
+ /** 校验和:头块 0-511 字节所有字节求和(chksum 字段本身按空格处理)。 */
1172
+ function computeChecksum(header) {
1173
+ let sum = 0;
1174
+ for (let i = 0; i < BLOCK_SIZE; i += 1) sum += header[i];
1175
+ return sum;
1176
+ }
1177
+ /** 写一个文件条目到 tar 流(UStar 头 + 数据 + 填充到 512 边界)。 */
1178
+ function createTarPack(out, name, data) {
1179
+ if (name.length >= NAME_LEN) throw new Error(`tar 文件名过长(${String(name.length)} / ${String(99)}):${name}`);
1180
+ if (data.length >= 2 ** 88 - 1) throw new Error(`tar 文件过大:${name}(${String(data.length)} bytes)`);
1181
+ const header = Buffer.alloc(BLOCK_SIZE);
1182
+ header.write(name, 0, NAME_LEN, "ascii");
1183
+ writeOctal(header, 100, 8, 384);
1184
+ writeOctal(header, 108, 8, 0);
1185
+ writeOctal(header, 116, 8, 0);
1186
+ writeOctal(header, 124, SIZE_LEN, data.length);
1187
+ writeOctal(header, 136, 12, Math.floor(Date.now() / 1e3));
1188
+ header.write("0", 156, 1, "ascii");
1189
+ header.write("ustar\0", 257, 6, "ascii");
1190
+ header.write("00", 263, 2, "ascii");
1191
+ header.write(" ", 148, 8, "ascii");
1192
+ writeOctal(header, 148, 8, computeChecksum(header));
1193
+ out.write(header);
1194
+ out.write(data);
1195
+ const padLen = (BLOCK_SIZE - data.length % BLOCK_SIZE) % BLOCK_SIZE;
1196
+ if (padLen > 0) out.write(Buffer.alloc(padLen));
1197
+ }
1198
+ /** 写两个全 0 块作为 tar 结束标记(EOF)。 */
1199
+ function endTarPack(out) {
1200
+ out.write(Buffer.alloc(BLOCK_SIZE * 2));
1201
+ }
1202
+ /** 解 tar 字节流到目标目录。整段读完再解析(备份场景文件小、可接受)。 */
1203
+ async function extractTar(buffer, destDir) {
1204
+ const { writeFile, mkdir } = await import("node:fs/promises");
1205
+ const { dirname } = await import("node:path");
1206
+ let offset = 0;
1207
+ while (offset + BLOCK_SIZE <= buffer.length) {
1208
+ const header = buffer.subarray(offset, offset + BLOCK_SIZE);
1209
+ if (header.every((b) => b === 0)) return;
1210
+ const name = header.toString("ascii", 0, NAME_LEN).replace(/\0+$/, "");
1211
+ const sizeOct = header.toString("ascii", 124, 136).replace(/\0+$/, "");
1212
+ const size = parseInt(sizeOct, 8);
1213
+ offset += BLOCK_SIZE;
1214
+ if (size > 0) {
1215
+ const data = buffer.subarray(offset, offset + size);
1216
+ const target = join(destDir, name);
1217
+ await mkdir(dirname(target), {
1218
+ recursive: true,
1219
+ mode: 448
1220
+ });
1221
+ await writeFile(target, data, { mode: 384 });
1222
+ const pad = (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
1223
+ offset += size + pad;
1224
+ } else offset += (BLOCK_SIZE - size % BLOCK_SIZE) % BLOCK_SIZE;
1225
+ }
1226
+ }
1227
+ //#endregion
1228
+ //#region src/backup/tar.ts
1229
+ /**
1230
+ * 宫殿备份与恢复:把当前 user/project 两库的 .db + 元信息打包成单一 .tar.gz 文件;
1231
+ * 恢复时校验 tar 内的 _meta.json schema 版本并覆盖原库(恢复前自动备份现状为 .before-restore-<ts>)。
1232
+ * 设计原则:
1233
+ * 1. tar 内文件路径用相对形式 `palace.db` / `palace-<scope>.db` / `_meta.json`,避免暴露绝对路径。
1234
+ * 2. 元信息必须含 schema_version(与插件同构),恢复时若不匹配返回明确的兼容错误而不是默默覆盖。
1235
+ * 3. 整个过程失败原子:恢复前若任何前置校验失败,原库不动。
1236
+ * @module @kenz1117/dsh-engram/backup/tar
1237
+ */
1238
+ const gunzipAsync = promisify(gunzip);
1239
+ /**
1240
+ * 创建备份:把 dbDir 下所有 *.db 与 `_meta.json` 一起打包成 .tar.gz。
1241
+ * @param dbDir - 插件数据目录(含 user.db / project-*.db)。
1242
+ * @param pluginVersion - 写进 _meta.json 的插件版本(与 package.json 同步)。
1243
+ */
1244
+ async function createBackup(dbDir, pluginVersion) {
1245
+ await mkdir(dbDir, {
1246
+ recursive: true,
1247
+ mode: 448
1248
+ });
1249
+ const files = (await readdir(dbDir)).filter((name) => name.endsWith(".db"));
1250
+ const scopes = [];
1251
+ const entries = [];
1252
+ for (const name of files) {
1253
+ const path = join(dbDir, name);
1254
+ if (!(await stat(path)).isFile()) continue;
1255
+ entries.push({
1256
+ name,
1257
+ data: await readFileBuffer(path)
1258
+ });
1259
+ scopes.push({
1260
+ scope: name === "user.db" ? "user" : "project",
1261
+ recordCount: 0,
1262
+ edgeCount: 0
1263
+ });
1264
+ }
1265
+ const meta = {
1266
+ schemaVersion: 1,
1267
+ pluginVersion,
1268
+ exportedAt: Date.now(),
1269
+ scopes
1270
+ };
1271
+ entries.push({
1272
+ name: "_meta.json",
1273
+ data: Buffer.from(JSON.stringify(meta, null, 2), "utf8")
1274
+ });
1275
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1276
+ const archivePath = join(dbDir, `palace-backup-${stamp}.tar.gz`);
1277
+ await writeTarGz(archivePath, entries);
1278
+ return {
1279
+ archivePath,
1280
+ meta,
1281
+ bytes: (await stat(archivePath)).size
1282
+ };
1283
+ }
1284
+ /** 异步读文件为 Buffer(小文件路径,备份场景可接受)。 */
1285
+ async function readFileBuffer(path) {
1286
+ const { readFile } = await import("node:fs/promises");
1287
+ return readFile(path);
1288
+ }
1289
+ /**
1290
+ * 把 entries 写入 .tar.gz。tar 头由本模块自带实现(避免引入 tar 依赖),gzip 用 zlib.createGzip。
1291
+ */
1292
+ async function writeTarGz(outPath, entries) {
1293
+ await new Promise((resolve, reject) => {
1294
+ const sink = createWriteStream(outPath, { mode: 384 });
1295
+ sink.on("error", reject);
1296
+ sink.on("finish", () => resolve());
1297
+ const gz = createGzip();
1298
+ gz.on("error", reject);
1299
+ gz.pipe(sink);
1300
+ for (const entry of entries) createTarPack(gz, entry.name, entry.data);
1301
+ endTarPack(gz);
1302
+ gz.end();
1303
+ });
1304
+ }
1305
+ /**
1306
+ * 从 .tar.gz 恢复:解包到临时目录 → 校验 _meta.json → 把 .db 文件原子搬到 dbDir。
1307
+ * 任何前置校验失败抛错,原 dbDir 不变。
1308
+ */
1309
+ async function restoreBackup(archivePath, dbDir, options = {}) {
1310
+ const tempDir = join(dbDir, `.restore-tmp-${Date.now()}`);
1311
+ await mkdir(tempDir, {
1312
+ recursive: true,
1313
+ mode: 448
1314
+ });
1315
+ try {
1316
+ await untarGz(archivePath, tempDir);
1317
+ const metaRaw = await readFileBuffer(join(tempDir, "_meta.json")).then((buf) => buf.toString("utf8"));
1318
+ const meta = JSON.parse(metaRaw);
1319
+ if (meta.schemaVersion !== 1) throw new Error(`备份 schema 版本 ${meta.schemaVersion} 与当前 1 不兼容(请升级插件或使用旧版恢复)`);
1320
+ if (typeof meta.pluginVersion !== "string" || meta.pluginVersion === "") throw new Error("备份 _meta.json 缺少 pluginVersion 字段,可能已损坏");
1321
+ const restored = [];
1322
+ if (options.keepCurrent !== false) {
1323
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1324
+ for (const name of (await readdir(dbDir)).filter((n) => n.endsWith(".db"))) {
1325
+ const path = join(dbDir, name);
1326
+ await rename(path, `${path}.before-restore-${stamp}`);
1327
+ }
1328
+ }
1329
+ for (const name of (await readdir(tempDir)).filter((n) => n.endsWith(".db"))) {
1330
+ const target = join(dbDir, name);
1331
+ await rename(join(tempDir, name), target);
1332
+ restored.push(target);
1333
+ }
1334
+ return {
1335
+ restored,
1336
+ archiveMeta: meta
1337
+ };
1338
+ } finally {
1339
+ await rm(tempDir, {
1340
+ recursive: true,
1341
+ force: true
1342
+ });
1343
+ }
1344
+ }
1345
+ /** 解 .tar.gz 到目标目录:先解压到 buffer,再 inline 解 tar。 */
1346
+ async function untarGz(archivePath, destDir) {
1347
+ const compressed = await readFileBuffer(archivePath);
1348
+ await extractTar(await gunzipAsync(compressed), destDir);
1349
+ }
1350
+ //#endregion
1351
+ //#region src/telemetry/aggregate.ts
1352
+ /** 聚合窗口(默认 7 天,可由路由 query 覆盖)。 */
1353
+ const DEFAULT_WINDOW_DAYS = 7;
1354
+ /** 从单库 op_log 统计各 op 的计数(窗口内)。 */
1355
+ async function countByOp(store, sinceMs) {
1356
+ const ops = await store.recentOps(1e5);
1357
+ const counts = {};
1358
+ for (const op of ops) {
1359
+ if (op.at < sinceMs) continue;
1360
+ counts[op.op] = (counts[op.op] ?? 0) + 1;
1361
+ }
1362
+ return counts;
1363
+ }
1364
+ /** 聚合两库的指标 + 各 scope 状态。
1365
+ * @param scopeFilter - 限定只聚合指定 scope;undefined = 三库全聚合(向后兼容)。 */
1366
+ async function aggregateTelemetry(openStore, windowDays = DEFAULT_WINDOW_DAYS, scopeFilter) {
1367
+ const untilMs = Date.now();
1368
+ const sinceMs = untilMs - windowDays * 864e5;
1369
+ const scopes = scopeFilter !== void 0 ? [scopeFilter] : [
1370
+ "user",
1371
+ "project",
1372
+ "shared"
1373
+ ];
1374
+ const countsAgg = {
1375
+ ingestRequests: 0,
1376
+ ingestDones: 0,
1377
+ searches: 0,
1378
+ writes: 0,
1379
+ updates: 0,
1380
+ forgets: 0,
1381
+ restores: 0,
1382
+ distillRequests: 0,
1383
+ compressRequests: 0,
1384
+ searchRewrites: 0,
1385
+ consumptions: 0,
1386
+ consolidations: 0
1387
+ };
1388
+ const scopeViews = [];
1389
+ for (const scope of scopes) try {
1390
+ const store = await openStore(scope);
1391
+ const [counts, stats] = await Promise.all([countByOp(store, sinceMs), store.stats()]);
1392
+ countsAgg.ingestRequests += counts["ingest-request"] ?? 0;
1393
+ countsAgg.ingestDones += counts["ingest-done"] ?? 0;
1394
+ countsAgg.searches += counts["search-rewrite-request"] ?? 0;
1395
+ countsAgg.writes += counts["write"] ?? 0;
1396
+ countsAgg.updates += counts["update"] ?? 0;
1397
+ countsAgg.forgets += counts["forget"] ?? 0;
1398
+ countsAgg.restores += counts["restore"] ?? 0;
1399
+ countsAgg.distillRequests += counts["distill-request"] ?? 0;
1400
+ countsAgg.compressRequests += counts["compress-request"] ?? 0;
1401
+ countsAgg.searchRewrites += counts["search-rewrite-request"] ?? 0;
1402
+ countsAgg.consumptions += counts["outcome-report"] ?? 0;
1403
+ countsAgg.consolidations += counts["consolidation"] ?? 0;
1404
+ scopeViews.push({
1405
+ scope,
1406
+ active: stats.active,
1407
+ total: stats.total,
1408
+ signalRatio: stats.signalRatio
1409
+ });
1410
+ } catch {}
1411
+ return {
1412
+ windowDays,
1413
+ sinceMs,
1414
+ untilMs,
1415
+ counts: countsAgg,
1416
+ scopes: scopeViews
1417
+ };
1418
+ }
1419
+ //#endregion
1420
+ //#region src/tour-proposal.ts
1421
+ /**
1422
+ * 构造入殿建议。
1423
+ * @param scope - 作用域(user/project;shared 单独处理)
1424
+ * @param records - 当前 scope 的 active 条目(已按 importance × confidence 倒序)
1425
+ * @param focusKind - 本轮 user 输入的主题(可选;存在时优先选同 kind)
1426
+ */
1427
+ function buildTourProposal(scope, records, focusKind) {
1428
+ const active = records.filter((r) => r.status === "active");
1429
+ const empty = active.length === 0;
1430
+ const limit = Math.min(5, Math.max(1, active.length));
1431
+ const scored = [...active].sort((a, b) => {
1432
+ const focusBoost = (record) => focusKind !== void 0 && record.kind === focusKind ? 1 : 0;
1433
+ const scoreA = focusBoost(a) * 1e3 + a.importance * a.confidence * 100;
1434
+ return focusBoost(b) * 1e3 + b.importance * b.confidence * 100 - scoreA;
1435
+ }).slice(0, limit);
1436
+ 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(";")}。`,
1438
+ suggestedStops: scored,
1439
+ activeCount: active.length,
1440
+ empty
1441
+ };
1442
+ }
1443
+ //#endregion
1444
+ //#region src/refurb.ts
1445
+ /** 缺省参数。 */
1446
+ const DEFAULT_REFURB_OPTIONS = {
1447
+ demoteBelow: .2,
1448
+ staleDays: 60,
1449
+ duplicateTitleWindow: 0
1450
+ };
1451
+ /**
1452
+ * 扫描一组 active 条目,按规则生成翻新建议。
1453
+ * 仅扫描同 scope 内的内容;跨 scope 的相似合并留给上层判定。
1454
+ */
1455
+ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
1456
+ const suggestions = [];
1457
+ const now = Date.now();
1458
+ const active = records.filter((r) => r.status === "active");
1459
+ const byScope = /* @__PURE__ */ new Map();
1460
+ for (const record of active) {
1461
+ const list = byScope.get(record.scope) ?? [];
1462
+ list.push(record);
1463
+ byScope.set(record.scope, list);
1464
+ }
1465
+ for (const record of active) {
1466
+ const daysSinceAccess = (now - record.lastAccessedAt) / 864e5;
1467
+ if (record.importance < options.demoteBelow && daysSinceAccess > options.staleDays) suggestions.push({
1468
+ action: "demote",
1469
+ primaryId: record.id,
1470
+ candidates: [],
1471
+ scope: record.scope,
1472
+ reason: `重要性 ${record.importance.toFixed(2)} < ${options.demoteBelow} 且 ${Math.floor(daysSinceAccess)} 天未访问,建议降级为 archived。`,
1473
+ confidence: .7
1474
+ });
1475
+ }
1476
+ for (const [scope, list] of byScope) {
1477
+ const byKind = /* @__PURE__ */ new Map();
1478
+ for (const record of list) {
1479
+ const bucket = byKind.get(record.kind) ?? [];
1480
+ bucket.push(record);
1481
+ byKind.set(record.kind, bucket);
1482
+ }
1483
+ for (const [, items] of byKind) {
1484
+ if (items.length < 2) continue;
1485
+ const seen = /* @__PURE__ */ new Set();
1486
+ for (const record of items) {
1487
+ const key = record.content.slice(0, 20);
1488
+ if (seen.has(key)) continue;
1489
+ seen.add(key);
1490
+ const dupes = items.filter((other) => other.id !== record.id && other.content.startsWith(key));
1491
+ if (dupes.length > 0) suggestions.push({
1492
+ action: "merge",
1493
+ primaryId: record.id,
1494
+ candidates: dupes.map((d) => d.id),
1495
+ scope,
1496
+ reason: `与 ${dupes.length} 间房间内容前 20 字重复,建议蒸馏合并。`,
1497
+ confidence: .6
1498
+ });
1499
+ }
1500
+ }
1501
+ }
1502
+ for (const record of active) if (record.accessCount === 0 && record.importance >= .7) suggestions.push({
1503
+ action: "review",
1504
+ primaryId: record.id,
1505
+ candidates: [],
1506
+ scope: record.scope,
1507
+ reason: `重要性 ${record.importance.toFixed(2)} 但从未被参观,可能埋没在走廊,建议复习。`,
1508
+ confidence: .5
1509
+ });
1510
+ for (const record of active) if (record.content.length > 400) suggestions.push({
1511
+ action: "split",
1512
+ primaryId: record.id,
1513
+ candidates: [],
1514
+ scope: record.scope,
1515
+ reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立房间。`,
1516
+ confidence: .6
1517
+ });
1518
+ return suggestions;
1519
+ }
1520
+ //#endregion
737
1521
  //#region src/routes.ts
738
1522
  /** 回环 peer:IPv4 127/8、IPv6 ::1、IPv4-mapped IPv6。 */
739
1523
  function isLoopbackPeer(req) {
@@ -804,7 +1588,8 @@ function json(res, status, body) {
804
1588
  }
805
1589
  /** 从 URL searchParams 收敛 scope(缺省 user)。 */
806
1590
  function scopeOf$1(raw, fallback) {
807
- return raw === "user" || raw === "project" ? raw : fallback;
1591
+ if (raw === "user" || raw === "project" || raw === "shared") return raw;
1592
+ return fallback;
808
1593
  }
809
1594
  /**
810
1595
  * 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
@@ -846,6 +1631,54 @@ function registerEngramRoutes(ctx, deps) {
846
1631
  json(res, 200, await (await deps.openStore(scope)).list(filter));
847
1632
  return;
848
1633
  }
1634
+ if (req.method === "GET" && route === "search-test") {
1635
+ const q = url.searchParams.get("q");
1636
+ if (q === null || q.trim() === "") {
1637
+ json(res, 400, { error: "q required" });
1638
+ return;
1639
+ }
1640
+ const scopeParam = url.searchParams.get("scope");
1641
+ const scopes = scopeParam === "all" || scopeParam === null || scopeParam === "" ? ["user", "project"] : [scopeOf$1(scopeParam, "user")];
1642
+ const kind = url.searchParams.get("kind");
1643
+ const limit = Math.min(20, Math.max(1, Number(url.searchParams.get("limit") ?? 10) || 10));
1644
+ const embedder = deps.embedder === void 0 ? void 0 : await deps.embedder;
1645
+ const vector = embedder === void 0 ? void 0 : (await embedder.embed([q.trim()]))[0];
1646
+ const results = await Promise.all(scopes.map(async (scope) => {
1647
+ return (await deps.openStore(scope)).search({
1648
+ text: q,
1649
+ scopes: [scope],
1650
+ limit
1651
+ }, vector);
1652
+ }));
1653
+ const degraded = results.some((result) => result.degraded);
1654
+ let hits = results.flatMap((result) => result.hits);
1655
+ if (kind !== null && kind !== "" && kind !== "all") hits = hits.filter((hit) => hit.record.kind === kind);
1656
+ json(res, 200, {
1657
+ degraded,
1658
+ hits: hits.map((hit) => ({
1659
+ id: hit.record.id,
1660
+ score: hit.score,
1661
+ via: hit.via,
1662
+ ...hit.viaEdge === void 0 ? {} : { viaEdge: hit.viaEdge },
1663
+ scope: hit.record.scope,
1664
+ kind: hit.record.kind,
1665
+ status: hit.record.status,
1666
+ content: hit.record.content,
1667
+ createdAt: hit.record.createdAt
1668
+ }))
1669
+ });
1670
+ return;
1671
+ }
1672
+ if (req.method === "GET" && route === "activity") {
1673
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
1674
+ json(res, 200, { operations: (await Promise.all(["user", "project"].map(async (scope) => {
1675
+ return (await (await deps.openStore(scope)).recentOps(limit)).map((op) => ({
1676
+ ...op,
1677
+ scope
1678
+ }));
1679
+ }))).flat().sort((a, b) => b.at - a.at).slice(0, limit) });
1680
+ return;
1681
+ }
849
1682
  if (req.method === "GET" && route === "review") {
850
1683
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
851
1684
  const id = url.searchParams.get("id");
@@ -854,7 +1687,6 @@ function registerEngramRoutes(ctx, deps) {
854
1687
  return;
855
1688
  }
856
1689
  const view = await (await deps.openStore(scope)).review(id);
857
- view === void 0 || view.operations;
858
1690
  if (view === void 0) {
859
1691
  json(res, 404, { error: `未找到条目 ${id}` });
860
1692
  return;
@@ -883,6 +1715,188 @@ function registerEngramRoutes(ctx, deps) {
883
1715
  res.end(body);
884
1716
  return;
885
1717
  }
1718
+ if (req.method === "GET" && route === "mirror") {
1719
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1720
+ const data = await (await deps.openStore(scope)).exportAll();
1721
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
1722
+ json(res, 200, {
1723
+ ...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
1724
+ scope,
1725
+ roomCount: data.records.length,
1726
+ edgeCount: data.edges.length
1727
+ });
1728
+ return;
1729
+ }
1730
+ if (req.method === "GET" && route === "health") {
1731
+ const rawScope = url.searchParams.get("scope");
1732
+ const scopes = rawScope === "user" || rawScope === "project" || rawScope === "shared" ? [rawScope] : ["user", "project"];
1733
+ const parts = await Promise.all(scopes.map(async (scope) => {
1734
+ const store = await deps.openStore(scope);
1735
+ const s = await store.stats();
1736
+ const edgeCount = (await store.exportAll()).edges.length;
1737
+ const total = Math.max(s.total, 1);
1738
+ const signal = s.signalRatio;
1739
+ const activeRatio = s.active / total;
1740
+ const corridorRatio = Math.min(.5, edgeCount / Math.max(s.active, 1)) / .5;
1741
+ const redactedPenalty = Math.max(0, 1 - s.redacted / total);
1742
+ const archivedRatio = s.archived / total;
1743
+ const isShared = scope === "shared";
1744
+ const targetActive = isShared ? .7 : .85;
1745
+ const decayLow = isShared ? .25 : .15;
1746
+ const decayHigh = isShared ? .5 : .45;
1747
+ const decayWindow = isShared ? .25 : .3;
1748
+ const decayCoverage = archivedRatio >= decayLow && archivedRatio <= decayHigh ? 1 : Math.max(0, 1 - Math.min(Math.abs(archivedRatio - decayLow), Math.abs(archivedRatio - decayHigh)) / decayWindow);
1749
+ const score = Math.round(signal * 40 + (1 - Math.abs(activeRatio - targetActive) / targetActive) * 25 + corridorRatio * 15 + redactedPenalty * 10 + decayCoverage * 10);
1750
+ return {
1751
+ scope,
1752
+ score: Math.min(100, Math.max(0, score)),
1753
+ signal,
1754
+ activeRatio,
1755
+ edgeCount,
1756
+ redacted: s.redacted,
1757
+ archivedRatio
1758
+ };
1759
+ }));
1760
+ json(res, 200, {
1761
+ overall: Math.round(parts.reduce((sum, part) => sum + part.score, 0) / parts.length),
1762
+ parts,
1763
+ evaluatedAt: Date.now()
1764
+ });
1765
+ return;
1766
+ }
1767
+ if (req.method === "GET" && route === "corridor") {
1768
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
1769
+ const includeStatuses = /* @__PURE__ */ new Set(["active"]);
1770
+ const rawStatus = url.searchParams.get("status");
1771
+ if (rawStatus !== null && rawStatus !== "" && rawStatus !== "all") {
1772
+ for (const s of rawStatus.split(",")) if (s === "active" || s === "archived" || s === "forgotten") includeStatuses.add(s);
1773
+ }
1774
+ const data = await (await deps.openStore(scope)).exportAll();
1775
+ const nodes = data.records.filter((r) => includeStatuses.has(r.status)).map((r) => ({
1776
+ id: r.id,
1777
+ scope: r.scope,
1778
+ kind: r.kind,
1779
+ status: r.status,
1780
+ importance: r.importance,
1781
+ confidence: r.confidence,
1782
+ title: r.content.length > 40 ? `${r.content.slice(0, 40)}…` : r.content,
1783
+ content: r.content
1784
+ }));
1785
+ const allowedIds = new Set(nodes.map((n) => n.id));
1786
+ json(res, 200, {
1787
+ scope,
1788
+ nodes,
1789
+ edges: data.edges.filter((e) => allowedIds.has(e.from) && allowedIds.has(e.to)).map((e) => ({
1790
+ id: `${e.from}-${e.to}-${e.type}`,
1791
+ from: e.from,
1792
+ to: e.to,
1793
+ type: e.type
1794
+ }))
1795
+ });
1796
+ return;
1797
+ }
1798
+ if (req.method === "GET" && route === "telemetry") {
1799
+ const windowDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 7) || 7));
1800
+ const rawScope = url.searchParams.get("scope");
1801
+ const scopeFilter = rawScope === "user" ? "user" : rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : void 0;
1802
+ json(res, 200, await aggregateTelemetry(deps.openStore, windowDays, scopeFilter));
1803
+ return;
1804
+ }
1805
+ if (req.method === "GET" && route === "tour-proposal") {
1806
+ const rawScope = url.searchParams.get("scope");
1807
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1808
+ const filter = await (await deps.openStore(scope)).list({
1809
+ scope,
1810
+ status: "active",
1811
+ limit: 200,
1812
+ offset: 0
1813
+ });
1814
+ const focusKind = url.searchParams.get("focusKind");
1815
+ const proposal = buildTourProposal(scope, filter.records, focusKind ?? void 0);
1816
+ json(res, 200, {
1817
+ scope: proposal.empty ? "empty" : scope,
1818
+ greeting: proposal.greeting,
1819
+ activeCount: proposal.activeCount,
1820
+ empty: proposal.empty,
1821
+ suggestedStops: proposal.suggestedStops.map((record) => ({
1822
+ id: record.id,
1823
+ kind: record.kind,
1824
+ content: record.content,
1825
+ importance: record.importance,
1826
+ confidence: record.confidence
1827
+ }))
1828
+ });
1829
+ return;
1830
+ }
1831
+ if (req.method === "GET" && route === "refurb") {
1832
+ const rawScope = url.searchParams.get("scope");
1833
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1834
+ const suggestions = gatherRefurbSuggestions((await (await deps.openStore(scope)).list({
1835
+ scope,
1836
+ status: "active",
1837
+ limit: 500,
1838
+ offset: 0
1839
+ })).records, DEFAULT_REFURB_OPTIONS);
1840
+ json(res, 200, {
1841
+ scope,
1842
+ count: suggestions.length,
1843
+ suggestions: suggestions.map((suggestion) => ({
1844
+ action: suggestion.action,
1845
+ primaryId: suggestion.primaryId,
1846
+ candidates: [...suggestion.candidates],
1847
+ scope: suggestion.scope,
1848
+ reason: suggestion.reason,
1849
+ confidence: suggestion.confidence
1850
+ }))
1851
+ });
1852
+ return;
1853
+ }
1854
+ if (req.method === "POST" && route === "consolidate") {
1855
+ if (!guardWrite(req, res)) return;
1856
+ const body = await readJsonBody(req);
1857
+ const rawScope = typeof body?.scope === "string" ? body.scope : "user";
1858
+ const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
1859
+ const candidates = Array.isArray(body?.candidates) ? body.candidates.filter((value) => typeof value === "string") : void 0;
1860
+ json(res, 200, await runConsolidation(await deps.openStore(scope), deps.embedder, candidates === void 0 ? { scope } : {
1861
+ scope,
1862
+ mergeCandidateIds: candidates
1863
+ }));
1864
+ return;
1865
+ }
1866
+ if (req.method === "POST" && route === "backup") {
1867
+ if (!guardWrite(req, res)) return;
1868
+ const result = await createBackup(deps.dbDir, deps.pluginVersion);
1869
+ json(res, 200, {
1870
+ archivePath: result.archivePath,
1871
+ bytes: result.bytes,
1872
+ meta: result.meta,
1873
+ schemaVersion: 1
1874
+ });
1875
+ return;
1876
+ }
1877
+ if (req.method === "POST" && route === "restore-backup") {
1878
+ if (!guardWrite(req, res)) return;
1879
+ const body = await readJsonBody(req);
1880
+ if (body === null || typeof body.archivePath !== "string" || body.archivePath === "") {
1881
+ json(res, 400, { error: "archivePath required" });
1882
+ return;
1883
+ }
1884
+ const normalized = join(deps.dbDir, body.archivePath.replace(/^\/+/, ""));
1885
+ if (!normalized.startsWith(deps.dbDir + "/") && normalized !== deps.dbDir) {
1886
+ json(res, 400, { error: "archivePath 必须在 dbDir 内" });
1887
+ return;
1888
+ }
1889
+ try {
1890
+ const result = await restoreBackup(normalized, deps.dbDir);
1891
+ json(res, 200, {
1892
+ restored: result.restored,
1893
+ meta: result.archiveMeta
1894
+ });
1895
+ } catch (restoreError) {
1896
+ json(res, 400, { error: `恢复失败:${restoreError instanceof Error ? restoreError.message : String(restoreError)}` });
1897
+ }
1898
+ return;
1899
+ }
886
1900
  if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
887
1901
  if (!guardWrite(req, res)) return;
888
1902
  const body = await readJsonBody(req);
@@ -1059,8 +2073,20 @@ function migrateProjectDb(dbDir, identity) {
1059
2073
  * 本包声明兼容 node ^22.19。
1060
2074
  * @module @kenz1117/dsh-engram/store/sqlite
1061
2075
  */
1062
- /** 当前 schema 版本;结构性变更必须 +1 并拒绝旧库(pre-release 无兼容承诺)。 */
1063
- const SCHEMA_VERSION = 2;
2076
+ /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
2077
+ const SCHEMA_VERSION = 5;
2078
+ /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
2079
+ * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
2080
+ * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
2081
+ * v4 → v5:nodes 补 imagery_json 列(意象铭牌:caption + sensoryTags + emotionalValence + provisional)。
2082
+ * v5 不再升版:闭馆三问用 op_log JSON 详情承载(已有 audit 接口),不改表结构。 */
2083
+ const MIGRATIONS = {
2084
+ "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
2085
+ "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
2086
+ node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2087
+ importance REAL NOT NULL, superseded_at INTEGER NOT NULL);`,
2088
+ "4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT"
2089
+ };
1064
2090
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
1065
2091
  const RRF_K = 60;
1066
2092
  /** 向量道的语义门槛:低于该余弦的条目不参与排序。 */
@@ -1073,9 +2099,37 @@ const RANK_POOL = 64;
1073
2099
  const EXPANSION_LIMIT = 32;
1074
2100
  /** 命中强化:每次检索命中的置信度增量。 */
1075
2101
  const CONFIDENCE_BUMP = .05;
2102
+ /** 效果回报降权:failure 回报的置信度扣减(success 复用 CONFIDENCE_BUMP)。 */
2103
+ const OUTCOME_PENALTY = .1;
1076
2104
  /** 审计视图返回的操作日志条数上限。 */
1077
2105
  const REVIEW_LOG_LIMIT = 20;
1078
- function rowToRecord(row) {
2106
+ /** 把意象铭牌序列化为 JSON(缺省序列化为 null,落库)。 */
2107
+ function imageryToJson(imagery) {
2108
+ if (imagery === void 0) return null;
2109
+ return JSON.stringify({
2110
+ caption: imagery.caption,
2111
+ sensoryTags: [...imagery.sensoryTags],
2112
+ emotionalValence: imagery.emotionalValence,
2113
+ provisional: imagery.provisional
2114
+ });
2115
+ }
2116
+ /** 从 JSON 反序列化意象铭牌;空串或解析失败返回 undefined(视作未铭刻)。 */
2117
+ function jsonToImagery(raw) {
2118
+ if (raw === null || raw === "") return void 0;
2119
+ try {
2120
+ const parsed = JSON.parse(raw);
2121
+ return {
2122
+ caption: typeof parsed.caption === "string" ? parsed.caption : null,
2123
+ sensoryTags: Array.isArray(parsed.sensoryTags) ? parsed.sensoryTags.filter((s) => typeof s === "string") : [],
2124
+ emotionalValence: typeof parsed.emotionalValence === "number" && Number.isFinite(parsed.emotionalValence) ? Math.min(1, Math.max(0, parsed.emotionalValence)) : 0,
2125
+ provisional: parsed.provisional === true
2126
+ };
2127
+ } catch {
2128
+ return;
2129
+ }
2130
+ }
2131
+ function rowToRecord(row) {
2132
+ const imagery = jsonToImagery(row.imagery_json);
1079
2133
  return {
1080
2134
  id: asMemoryId(row.id),
1081
2135
  scope: row.scope,
@@ -1084,12 +2138,14 @@ function rowToRecord(row) {
1084
2138
  importance: row.importance,
1085
2139
  confidence: row.confidence,
1086
2140
  status: row.status,
2141
+ ...row.outcome === "success" || row.outcome === "failure" ? { outcome: row.outcome } : {},
1087
2142
  createdAt: row.created_at,
1088
2143
  lastAccessedAt: row.last_accessed_at,
1089
2144
  accessCount: row.access_count,
1090
2145
  sourceSessionId: row.source_session_id,
1091
2146
  sourceRound: row.source_round,
1092
- sourceSeq: row.source_seq
2147
+ sourceSeq: row.source_seq,
2148
+ ...imagery === void 0 ? {} : { imagery }
1093
2149
  };
1094
2150
  }
1095
2151
  function blobToVec(blob) {
@@ -1173,36 +2229,80 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1173
2229
  id TEXT PRIMARY KEY, scope TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
1174
2230
  importance REAL NOT NULL, confidence REAL NOT NULL, status TEXT NOT NULL,
1175
2231
  created_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, access_count INTEGER NOT NULL,
1176
- source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB);
2232
+ source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB, outcome TEXT,
2233
+ imagery_json TEXT);
1177
2234
  CREATE TABLE IF NOT EXISTS edges (
1178
2235
  from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL,
1179
2236
  PRIMARY KEY (from_id, to_id, type));
1180
2237
  CREATE TABLE IF NOT EXISTS op_log (
1181
2238
  seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, op TEXT NOT NULL,
1182
2239
  target_id TEXT NOT NULL, detail TEXT);
2240
+ CREATE TABLE IF NOT EXISTS nodes_revisions (
2241
+ node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
2242
+ importance REAL NOT NULL, superseded_at INTEGER NOT NULL);
1183
2243
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
1184
2244
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
1185
2245
  `);
1186
2246
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1187
2247
  if (versionRow === void 0) db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
1188
- else if (Number(versionRow.value) !== SCHEMA_VERSION) {
1189
- db.close();
1190
- throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${versionRow.value} 与插件支持的 ${SCHEMA_VERSION} 不一致:请备份并删除旧库文件(${path})后重试`);
2248
+ else {
2249
+ let version = Number(versionRow.value);
2250
+ if (!Number.isInteger(version) || version > SCHEMA_VERSION) {
2251
+ db.close();
2252
+ throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${versionRow.value} 高于插件支持的 ${SCHEMA_VERSION}:请升级插件或备份后删除旧库文件(${path})`);
2253
+ }
2254
+ while (version < SCHEMA_VERSION) {
2255
+ const migration = MIGRATIONS[String(version)];
2256
+ if (migration === void 0) {
2257
+ db.close();
2258
+ throw new EngramError("SCHEMA_INCOMPATIBLE", `engram 数据库 schema 版本 ${version} 无法升到 ${SCHEMA_VERSION}(缺迁移步骤):请备份并删除旧库文件(${path})后重试`);
2259
+ }
2260
+ withTransaction(() => {
2261
+ const safe = migration.split(";").map((stmt) => stmt.trim()).filter((stmt) => stmt !== "").filter((stmt) => {
2262
+ const match = /^ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)/i.exec(stmt);
2263
+ if (match === null) return true;
2264
+ const table = match[1];
2265
+ const column = match[2];
2266
+ return !db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
2267
+ });
2268
+ for (const stmt of safe) db.exec(stmt);
2269
+ version += 1;
2270
+ db.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'").run(String(version));
2271
+ });
2272
+ }
1191
2273
  }
1192
2274
  const sqlGet = db.prepare("SELECT * FROM nodes WHERE id = ?");
1193
2275
  const sqlInsert = db.prepare(`INSERT INTO nodes
1194
2276
  (id, scope, kind, content, importance, confidence, status, created_at, last_accessed_at, access_count,
1195
- source_session_id, source_round, source_seq, embedding)
1196
- VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`);
2277
+ source_session_id, source_round, source_seq, embedding, imagery_json)
2278
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?)`);
1197
2279
  const sqlFtsInsert = db.prepare("INSERT INTO nodes_fts (node_id, content) VALUES (?, ?)");
1198
2280
  const sqlSetStatus = db.prepare("UPDATE nodes SET status = ?, last_accessed_at = ? WHERE id = ?");
2281
+ const sqlSetOutcome = db.prepare(`UPDATE nodes
2282
+ SET outcome = ?,
2283
+ confidence = CASE WHEN ? = 'success' THEN min(1.0, confidence + ${CONFIDENCE_BUMP}) ELSE max(0.0, confidence - ${OUTCOME_PENALTY}) END
2284
+ WHERE id = ?`);
1199
2285
  const sqlTouch = db.prepare(`UPDATE nodes SET access_count = access_count + 1, last_accessed_at = ?,
1200
2286
  confidence = MIN(1, confidence + ${CONFIDENCE_BUMP}) WHERE id = ?`);
1201
2287
  const sqlLog = db.prepare("INSERT INTO op_log (at, op, target_id, detail) VALUES (?, ?, ?, ?)");
1202
2288
  const sqlHasAudit = db.prepare("SELECT 1 AS x FROM op_log WHERE op = ? AND detail = ? LIMIT 1");
1203
2289
  const sqlListAudit = db.prepare("SELECT detail FROM op_log WHERE op = ? AND detail IS NOT NULL ORDER BY seq");
1204
2290
  const sqlClearAudit = db.prepare("DELETE FROM op_log WHERE op = ? AND detail = ?");
1205
- const sqlOpLogById = db.prepare("SELECT at, op, detail FROM op_log WHERE target_id = ? ORDER BY seq DESC LIMIT ?");
2291
+ const sqlOpLogById = db.prepare("SELECT at, op, target_id, detail FROM op_log WHERE target_id = ? ORDER BY seq DESC LIMIT ?");
2292
+ const sqlGetManyByIds = db.prepare("SELECT * FROM nodes WHERE id IN (SELECT value FROM json_each(?))");
2293
+ const sqlNeighborsBounded = db.prepare(`SELECT * FROM nodes WHERE id IN (
2294
+ WITH RECURSIVE walk(id, depth) AS (
2295
+ SELECT ?, 0
2296
+ UNION
2297
+ SELECT CASE WHEN e.from_id = walk.id THEN e.to_id ELSE e.from_id END, walk.depth + 1
2298
+ FROM edges e JOIN walk ON (e.from_id = walk.id OR e.to_id = walk.id)
2299
+ WHERE walk.depth < ? AND e.type IN ('supports','refines','related','supersedes','contradicts')
2300
+ )
2301
+ SELECT id FROM walk WHERE depth > 0
2302
+ )`);
2303
+ const sqlRecentOps = db.prepare("SELECT at, op, target_id, detail FROM op_log ORDER BY seq DESC LIMIT ?");
2304
+ const sqlRevisionInsert = db.prepare("INSERT INTO nodes_revisions (node_id, content, kind, importance, superseded_at) VALUES (?, ?, ?, ?, ?)");
2305
+ const sqlRevisionsById = db.prepare("SELECT content, kind, importance, superseded_at FROM nodes_revisions WHERE node_id = ? ORDER BY superseded_at DESC");
1206
2306
  const sqlTopActive = db.prepare("SELECT * FROM nodes WHERE scope = ? AND status = 'active' ORDER BY importance DESC, confidence DESC LIMIT ?");
1207
2307
  const sqlEdgeUpsert = db.prepare("INSERT OR IGNORE INTO edges (from_id, to_id, type, created_at) VALUES (?, ?, ?, ?)");
1208
2308
  const sqlNeighbors = db.prepare(`SELECT * FROM edges WHERE from_id IN (SELECT value FROM json_each(?))
@@ -1212,6 +2312,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1212
2312
  const sqlCountKind = db.prepare("SELECT kind, COUNT(*) AS n FROM nodes GROUP BY kind");
1213
2313
  const sqlCountEdges = db.prepare("SELECT COUNT(*) AS n FROM edges");
1214
2314
  const sqlCountOpLog = db.prepare("SELECT COUNT(*) AS n FROM op_log");
2315
+ const sqlCountRedacted = db.prepare("SELECT COUNT(*) AS n FROM nodes WHERE content LIKE '%[REDACTED:%'");
1215
2316
  const sqlAllNodes = db.prepare("SELECT * FROM nodes ORDER BY created_at");
1216
2317
  const sqlAllEdges = db.prepare("SELECT * FROM edges");
1217
2318
  const sqlDecay = db.prepare(`UPDATE nodes SET status = 'archived'
@@ -1237,9 +2338,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1237
2338
  * 写入公共体:插入节点 + FTS + 操作日志(不建边、不开事务)。
1238
2339
  * 事务由调用方持有(withTransaction)。
1239
2340
  */
1240
- const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, op) => {
2341
+ const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, imagery, op) => {
1241
2342
  const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
1242
- sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored);
2343
+ sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery));
1243
2344
  sqlFtsInsert.run(id, tokenizeForFts(content));
1244
2345
  sqlLog.run(at, op, id, JSON.stringify({
1245
2346
  kind: input.kind,
@@ -1272,7 +2373,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1272
2373
  const id = asMemoryId(randomUUID());
1273
2374
  const at = Date.now();
1274
2375
  withTransaction(() => {
1275
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "write");
2376
+ insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
1276
2377
  });
1277
2378
  return rowToRecord(sqlGet.get(id));
1278
2379
  },
@@ -1280,6 +2381,31 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1280
2381
  const row = getRow(id);
1281
2382
  return row === void 0 ? void 0 : rowToRecord(row);
1282
2383
  },
2384
+ async getMany(ids) {
2385
+ if (ids.length === 0) return [];
2386
+ const seen = /* @__PURE__ */ new Set();
2387
+ const unique = [];
2388
+ for (const id of ids) if (!seen.has(id)) {
2389
+ seen.add(id);
2390
+ unique.push(id);
2391
+ }
2392
+ const rows = sqlGetManyByIds.all(JSON.stringify(unique.map((id) => String(id))));
2393
+ const map = /* @__PURE__ */ new Map();
2394
+ for (const row of rows) map.set(row.id, rowToRecord(row));
2395
+ return unique.map((id) => map.get(id)).filter((r) => r !== void 0);
2396
+ },
2397
+ async neighbors(id, depth) {
2398
+ const boundedDepth = Math.min(Math.max(1, Math.floor(depth)), 3);
2399
+ const rows = sqlNeighborsBounded.all(String(id), boundedDepth);
2400
+ const seen = /* @__PURE__ */ new Set([String(id)]);
2401
+ const result = [];
2402
+ for (const row of rows) {
2403
+ if (seen.has(row.id)) continue;
2404
+ seen.add(row.id);
2405
+ result.push(rowToRecord(row));
2406
+ }
2407
+ return result;
2408
+ },
1283
2409
  async search(query, queryVector) {
1284
2410
  const limit = query.limit ?? 8;
1285
2411
  const scores = /* @__PURE__ */ new Map();
@@ -1375,13 +2501,14 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1375
2501
  const id = asMemoryId(randomUUID());
1376
2502
  const at = Date.now();
1377
2503
  withTransaction(() => {
2504
+ sqlRevisionInsert.run(input.id, old.content, old.kind, old.importance, at);
1378
2505
  sqlSetStatus.run("archived", at, input.id);
1379
2506
  sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
1380
2507
  insertRecord(id, {
1381
2508
  scope: input.scope,
1382
2509
  kind: input.kind,
1383
2510
  content
1384
- }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, "update");
2511
+ }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, input.imagery ?? jsonToImagery(old.imagery_json), "update");
1385
2512
  sqlEdgeUpsert.run(id, input.id, "supersedes", at);
1386
2513
  });
1387
2514
  return rowToRecord(sqlGet.get(id));
@@ -1392,6 +2519,56 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1392
2519
  sqlLog.run(Date.now(), "forget", id, null);
1393
2520
  return rowToRecord(sqlGet.get(id));
1394
2521
  },
2522
+ async forgetWithTombstone(id, tombstone) {
2523
+ if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
2524
+ const at = Date.now();
2525
+ const cleaned = {
2526
+ reason: tombstone.reason.trim().slice(0, 200),
2527
+ affects: tombstone.affects.trim().slice(0, 200),
2528
+ stillUseful: tombstone.stillUseful.trim().slice(0, 200)
2529
+ };
2530
+ sqlSetStatus.run("forgotten", at, id);
2531
+ sqlLog.run(at, "forget", id, JSON.stringify({ tombstone: cleaned }));
2532
+ return rowToRecord(sqlGet.get(id));
2533
+ },
2534
+ async listForgottenWithTombs(limit) {
2535
+ const boundedLimit = Math.max(1, Math.min(limit, 200));
2536
+ return db.prepare(`SELECT n.*, o.at AS forgotten_at, o.detail AS tombstone_json
2537
+ FROM nodes n
2538
+ INNER JOIN op_log o ON o.target_id = n.id AND o.op = 'forget'
2539
+ WHERE n.status = 'forgotten'
2540
+ ORDER BY o.seq DESC
2541
+ LIMIT ?`).all(boundedLimit).map((row) => {
2542
+ let tombstone = null;
2543
+ if (row.tombstone_json !== null) try {
2544
+ const inner = JSON.parse(row.tombstone_json).tombstone;
2545
+ if (inner !== void 0 && typeof inner.reason === "string") tombstone = {
2546
+ reason: inner.reason,
2547
+ affects: typeof inner.affects === "string" ? inner.affects : "",
2548
+ stillUseful: typeof inner.stillUseful === "string" ? inner.stillUseful : ""
2549
+ };
2550
+ } catch {
2551
+ tombstone = null;
2552
+ }
2553
+ const base = rowToRecord(row);
2554
+ return {
2555
+ id: base.id,
2556
+ scope: base.scope,
2557
+ kind: base.kind,
2558
+ content: base.content,
2559
+ importance: base.importance,
2560
+ lastAccessedAt: base.lastAccessedAt,
2561
+ tombstone,
2562
+ forgottenAt: row.forgotten_at
2563
+ };
2564
+ });
2565
+ },
2566
+ async reportOutcome(id, outcome) {
2567
+ if (getRow(id) === void 0) return void 0;
2568
+ sqlSetOutcome.run(outcome, outcome, id);
2569
+ sqlLog.run(Date.now(), "outcome-report", id, outcome);
2570
+ return rowToRecord(sqlGet.get(id));
2571
+ },
1395
2572
  async restore(id) {
1396
2573
  if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
1397
2574
  sqlSetStatus.run("active", Date.now(), id);
@@ -1428,12 +2605,32 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1428
2605
  const row = getRow(id);
1429
2606
  if (row === void 0) return void 0;
1430
2607
  const operations = sqlOpLogById.all(id, REVIEW_LOG_LIMIT);
2608
+ const revisions = sqlRevisionsById.all(id);
1431
2609
  return {
1432
2610
  record: rowToRecord(row),
1433
2611
  ...edgeGroups(id),
1434
- operations
2612
+ revisions: revisions.map((rev) => ({
2613
+ content: rev.content,
2614
+ kind: rev.kind,
2615
+ importance: rev.importance,
2616
+ supersededAt: rev.superseded_at
2617
+ })),
2618
+ operations: operations.map((op) => ({
2619
+ at: op.at,
2620
+ op: op.op,
2621
+ targetId: op.target_id,
2622
+ detail: op.detail
2623
+ }))
1435
2624
  };
1436
2625
  },
2626
+ async recentOps(limit) {
2627
+ return sqlRecentOps.all(Math.max(1, limit)).map((op) => ({
2628
+ at: op.at,
2629
+ op: op.op,
2630
+ targetId: op.target_id,
2631
+ detail: op.detail
2632
+ }));
2633
+ },
1437
2634
  async stats() {
1438
2635
  const statusRows = sqlCountBy.all();
1439
2636
  const kindRows = sqlCountKind.all();
@@ -1456,6 +2653,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1456
2653
  active,
1457
2654
  archived,
1458
2655
  forgotten,
2656
+ redacted: sqlCountRedacted.get().n,
1459
2657
  byKind,
1460
2658
  edges: edgeCount,
1461
2659
  opLogCount: opCount,
@@ -1498,7 +2696,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
1498
2696
  const id = asMemoryId(randomUUID());
1499
2697
  const at = Date.now();
1500
2698
  withTransaction(() => {
1501
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, "distill");
2699
+ insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
1502
2700
  for (const oldId of oldIds) {
1503
2701
  sqlSetStatus.run("archived", at, oldId);
1504
2702
  sqlEdgeUpsert.run(id, oldId, "supersedes", at);
@@ -1690,6 +2888,47 @@ function mergeQueryResults(retrievals, limit, rrfConstant, minPerQuery) {
1690
2888
  return [...ranked.filter((entry) => reserved.has(entry.hit.record.id)), ...ranked.filter((entry) => !reserved.has(entry.hit.record.id))].slice(0, Math.max(0, limit)).map((entry) => entry.hit);
1691
2889
  }
1692
2890
  //#endregion
2891
+ //#region src/retrieve/budget.ts
2892
+ /**
2893
+ * 召回输出的字符预算:单条与总量双重截断,控制记忆正文占用的模型上下文
2894
+ * (协议内定值,对齐同类记忆插件的单轮召回配额,非部署可变项)。
2895
+ * @module @kenz1117/dsh-engram/retrieve/budget
2896
+ */
2897
+ /** 单条召回行最大字符数(超长截断加省略号)。 */
2898
+ const RECALL_PER_ITEM_CHARS = 1200;
2899
+ /** 单次召回输出正文的总字符预算(含 id 与元信息行)。 */
2900
+ const RECALL_TOTAL_CHARS = 4800;
2901
+ /**
2902
+ * 截断单条文本到 maxChars(超长加省略号)。
2903
+ * @param text - 原文。
2904
+ * @param maxChars - 上限,默认 RECALL_PER_ITEM_CHARS。
2905
+ */
2906
+ function truncateItem(text, maxChars = RECALL_PER_ITEM_CHARS) {
2907
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars)}…`;
2908
+ }
2909
+ /**
2910
+ * 总量预算内贪心装填行(保序;某行装不下时继续尝试更短的后续行),
2911
+ * 被跳过的行计数并在末尾追加提示行。
2912
+ * @param lines - 候选行(已按相关性排序)。
2913
+ * @param totalBudget - 总字符预算,默认 RECALL_TOTAL_CHARS。
2914
+ * @returns 装填后的行;有丢弃时末尾含「另有 N 条…」提示。
2915
+ */
2916
+ function enforceBudget(lines, totalBudget = RECALL_TOTAL_CHARS) {
2917
+ const kept = [];
2918
+ let used = 0;
2919
+ let dropped = 0;
2920
+ for (const line of lines) {
2921
+ if (used + line.length > totalBudget) {
2922
+ dropped += 1;
2923
+ continue;
2924
+ }
2925
+ kept.push(line);
2926
+ used += line.length;
2927
+ }
2928
+ if (dropped > 0) kept.push(`(另有 ${dropped} 条未展示:缩小查询范围或降低 limit 后重试)`);
2929
+ return kept;
2930
+ }
2931
+ //#endregion
1693
2932
  //#region src/tools/create.ts
1694
2933
  /**
1695
2934
  * 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
@@ -1705,13 +2944,19 @@ const KINDS = [
1705
2944
  ];
1706
2945
  /** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
1707
2946
  function scopeOf(raw, fallback) {
1708
- return raw === "user" || raw === "project" ? raw : fallback;
2947
+ if (raw === "user" || raw === "project" || raw === "shared") return raw;
2948
+ return fallback;
1709
2949
  }
1710
2950
  /** 把 search scope 参数收敛为分库集合。 */
1711
2951
  function scopesOf(raw) {
1712
2952
  if (raw === "user") return ["user"];
1713
2953
  if (raw === "project") return ["project"];
1714
- return ["user", "project"];
2954
+ if (raw === "shared") return ["shared"];
2955
+ return [
2956
+ "user",
2957
+ "project",
2958
+ "shared"
2959
+ ];
1715
2960
  }
1716
2961
  /** 查询向量:嵌入可用时返回查询文本的向量,否则 undefined(降级)。 */
1717
2962
  async function queryVectorOf(deps, text) {
@@ -1729,7 +2974,7 @@ async function rewriteQueries(deps, exec, query) {
1729
2974
  queries: [query],
1730
2975
  rewritten: false
1731
2976
  };
1732
- const events = exec.agent?.session?.events ?? [];
2977
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
1733
2978
  const route = deps.routeOverride ?? routeFromEvents(events);
1734
2979
  if (route === void 0) return {
1735
2980
  queries: [query],
@@ -1766,7 +3011,7 @@ async function rewriteQueries(deps, exec, query) {
1766
3011
  }
1767
3012
  }
1768
3013
  /**
1769
- * 构造 9 个工具定义(engram_save/search/timeline/update/forget/review/stats/export/distill)。
3014
+ * 构造 10 个工具定义(engram_save/search/timeline/update/forget/report/review/stats/export/distill)。
1770
3015
  * @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
1771
3016
  * @returns 可直接 register 的工具定义数组。
1772
3017
  */
@@ -1889,307 +3134,501 @@ function createEngramTools(deps) {
1889
3134
  failed
1890
3135
  };
1891
3136
  }
1892
- return [
1893
- defineTool({
1894
- name: "engram_save",
1895
- description: "保存长期记忆(跨会话可用),支持单条(content/kind)或批量(items,最多 10 条,单条失败不影响其余)。kind:fact 事实 / preference 偏好 / decision 决策 / episode 经历 / skill 方法。scope:project 仅当前项目,user 全局。",
1896
- parameters: {
1897
- content: {
1898
- type: "string",
1899
- description: "记忆正文(单条模式必填),一句话完整表达"
1900
- },
1901
- kind: {
1902
- type: "string",
1903
- enum: [...KINDS],
1904
- description: "记忆种类(单条模式必填)"
1905
- },
3137
+ const save = defineTool({
3138
+ name: "engram_save",
3139
+ description: "保存长期记忆(跨会话可用),支持单条(content/kind)或批量(items,最多 10 条,单条失败不影响其余)。kind:fact 事实 / preference 偏好 / decision 决策 / episode 经历 / skill 方法。scope:project 仅当前项目,user 全局。",
3140
+ parameters: {
3141
+ content: {
3142
+ type: "string",
3143
+ description: "记忆正文(单条模式必填),一句话完整表达"
3144
+ },
3145
+ kind: {
3146
+ type: "string",
3147
+ enum: [...KINDS],
3148
+ description: "记忆种类(单条模式必填)"
3149
+ },
3150
+ items: {
3151
+ type: "array",
3152
+ description: "批量保存条目数组,每项 {content, kind, importance?};与 content/kind 二选一",
1906
3153
  items: {
1907
- type: "array",
1908
- description: "批量保存条目数组,每项 {content, kind, importance?};与 content/kind 二选一",
1909
- items: {
1910
- type: "object",
1911
- additionalProperties: false,
1912
- properties: {
1913
- content: {
1914
- type: "string",
1915
- required: true,
1916
- description: "记忆正文"
1917
- },
1918
- kind: {
1919
- type: "string",
1920
- enum: [...KINDS],
1921
- required: true,
1922
- description: "记忆种类"
1923
- },
1924
- importance: {
1925
- type: "number",
1926
- description: "重要性 0-1"
1927
- }
3154
+ type: "object",
3155
+ additionalProperties: false,
3156
+ properties: {
3157
+ content: {
3158
+ type: "string",
3159
+ required: true,
3160
+ description: "记忆正文"
3161
+ },
3162
+ kind: {
3163
+ type: "string",
3164
+ enum: [...KINDS],
3165
+ required: true,
3166
+ description: "记忆种类"
3167
+ },
3168
+ importance: {
3169
+ type: "number",
3170
+ description: "重要性 0-1"
1928
3171
  }
1929
3172
  }
1930
- },
1931
- scope: {
1932
- type: "string",
1933
- enum: ["user", "project"],
1934
- description: "作用域,默认 project"
1935
- },
1936
- importance: {
1937
- type: "number",
1938
- description: "重要性 0-1,默认 0.5(仅单条模式)"
1939
3173
  }
1940
3174
  },
1941
- output: {
1942
- schema: {
1943
- type: "object",
1944
- additionalProperties: false,
1945
- properties: {
1946
- id: { type: "string" },
1947
- kind: { type: "string" },
1948
- importance: { type: "number" },
1949
- text: { type: "string" },
1950
- count: { type: "number" },
3175
+ scope: {
3176
+ type: "string",
3177
+ enum: [
3178
+ "user",
3179
+ "project",
3180
+ "shared"
3181
+ ],
3182
+ description: "作用域,默认 project"
3183
+ },
3184
+ importance: {
3185
+ type: "number",
3186
+ description: "重要性 0-1,默认 0.5(仅单条模式)"
3187
+ }
3188
+ },
3189
+ output: {
3190
+ schema: {
3191
+ type: "object",
3192
+ additionalProperties: false,
3193
+ properties: {
3194
+ id: { type: "string" },
3195
+ kind: { type: "string" },
3196
+ importance: { type: "number" },
3197
+ text: { type: "string" },
3198
+ count: { type: "number" },
3199
+ items: {
3200
+ type: "array",
1951
3201
  items: {
1952
- type: "array",
1953
- items: {
1954
- type: "object",
1955
- additionalProperties: false,
1956
- properties: {
1957
- id: {
1958
- type: "string",
1959
- required: true
1960
- },
1961
- kind: {
1962
- type: "string",
1963
- required: true
1964
- },
1965
- importance: {
1966
- type: "number",
1967
- required: true
1968
- }
3202
+ type: "object",
3203
+ additionalProperties: false,
3204
+ properties: {
3205
+ id: {
3206
+ type: "string",
3207
+ required: true
3208
+ },
3209
+ kind: {
3210
+ type: "string",
3211
+ required: true
3212
+ },
3213
+ importance: {
3214
+ type: "number",
3215
+ required: true
1969
3216
  }
1970
3217
  }
1971
- },
1972
- failed: {
1973
- type: "array",
1974
- items: {
1975
- type: "object",
1976
- additionalProperties: false,
1977
- properties: {
1978
- index: {
1979
- type: "number",
1980
- required: true
1981
- },
1982
- reason: {
1983
- type: "string",
1984
- required: true
1985
- }
3218
+ }
3219
+ },
3220
+ failed: {
3221
+ type: "array",
3222
+ items: {
3223
+ type: "object",
3224
+ additionalProperties: false,
3225
+ properties: {
3226
+ index: {
3227
+ type: "number",
3228
+ required: true
3229
+ },
3230
+ reason: {
3231
+ type: "string",
3232
+ required: true
1986
3233
  }
1987
3234
  }
1988
3235
  }
1989
3236
  }
1990
- },
1991
- render: (_args, value) => [{
1992
- type: "text",
1993
- text: renderSaveResultText(value)
1994
- }]
1995
- },
1996
- async execute(args, exec) {
1997
- const input = args;
1998
- const sourceSessionId = exec.agent?.id ?? null;
1999
- if (input.items !== void 0) {
2000
- if (input.content !== void 0 || input.kind !== void 0) throw new Error("engram_save: items 与 content/kind 参数不能同时使用");
2001
- if (!Array.isArray(input.items) || input.items.length === 0) throw new Error("engram_save: items 必须是非空数组");
2002
- return saveBatch(sourceSessionId, input.items, input.scope);
2003
- }
2004
- if (typeof input.content !== "string" || typeof input.kind !== "string") throw new Error("engram_save: 需要 content/kind(单条)或 items(批量)参数");
2005
- const content = redactSecrets(sanitizeProtocolText(input.content));
2006
- if (content.trim() === "") throw new Error("engram_save: 清洗后内容为空(原文只含协议标签或密钥)");
2007
- const scope = scopeOf(input.scope, "project");
2008
- const store = await deps.openStore(scope);
2009
- const embedder = await deps.embedder;
2010
- const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
2011
- const { record, candidates } = await writeWithContradictions(store, {
2012
- scope,
2013
- kind: input.kind,
2014
- content,
2015
- ...typeof input.importance === "number" ? { importance: input.importance } : {},
2016
- sourceSessionId,
2017
- ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
2018
- });
2019
- if (candidates.length > 0) {
2020
- const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
2021
- return {
2022
- id: record.id,
2023
- kind: record.kind,
2024
- importance: record.importance,
2025
- text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
2026
- };
2027
3237
  }
3238
+ },
3239
+ render: (_args, value) => [{
3240
+ type: "text",
3241
+ text: renderSaveResultText(value)
3242
+ }]
3243
+ },
3244
+ async execute(args, exec) {
3245
+ const input = args;
3246
+ const sourceSessionId = exec.agent?.id ?? null;
3247
+ if (input.items !== void 0) {
3248
+ if (input.content !== void 0 || input.kind !== void 0) throw new Error("engram_save: items 与 content/kind 参数不能同时使用");
3249
+ if (!Array.isArray(input.items) || input.items.length === 0) throw new Error("engram_save: items 必须是非空数组");
3250
+ return saveBatch(sourceSessionId, input.items, input.scope);
3251
+ }
3252
+ if (typeof input.content !== "string" || typeof input.kind !== "string") throw new Error("engram_save: 需要 content/kind(单条)或 items(批量)参数");
3253
+ const content = redactSecrets(sanitizeProtocolText(input.content));
3254
+ if (content.trim() === "") throw new Error("engram_save: 清洗后内容为空(原文只含协议标签或密钥)");
3255
+ const scope = scopeOf(input.scope, "project");
3256
+ const store = await deps.openStore(scope);
3257
+ const embedder = await deps.embedder;
3258
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
3259
+ const { record, candidates } = await writeWithContradictions(store, {
3260
+ scope,
3261
+ kind: input.kind,
3262
+ content,
3263
+ ...typeof input.importance === "number" ? { importance: input.importance } : {},
3264
+ sourceSessionId,
3265
+ ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
3266
+ });
3267
+ if (candidates.length > 0) {
3268
+ const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
2028
3269
  return {
2029
3270
  id: record.id,
2030
3271
  kind: record.kind,
2031
- importance: record.importance
3272
+ importance: record.importance,
3273
+ text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
2032
3274
  };
2033
3275
  }
2034
- }),
3276
+ return {
3277
+ id: record.id,
3278
+ kind: record.kind,
3279
+ importance: record.importance
3280
+ };
3281
+ }
3282
+ });
3283
+ const search = defineTool({
3284
+ name: "engram_search",
3285
+ description: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
3286
+ parameters: {
3287
+ query: {
3288
+ type: "string",
3289
+ required: true,
3290
+ description: "检索文本"
3291
+ },
3292
+ scope: {
3293
+ type: "string",
3294
+ enum: [
3295
+ "user",
3296
+ "project",
3297
+ "shared",
3298
+ "all"
3299
+ ],
3300
+ description: "作用域,默认 all"
3301
+ },
3302
+ limit: {
3303
+ type: "number",
3304
+ description: "返回条数上限,默认 8"
3305
+ }
3306
+ },
3307
+ output: {
3308
+ schema: {
3309
+ type: "object",
3310
+ additionalProperties: false,
3311
+ properties: {
3312
+ degraded: {
3313
+ type: "boolean",
3314
+ required: true
3315
+ },
3316
+ text: {
3317
+ type: "string",
3318
+ required: true
3319
+ }
3320
+ }
3321
+ },
3322
+ render: (_args, value) => [{
3323
+ type: "text",
3324
+ text: value.text
3325
+ }]
3326
+ },
3327
+ async execute(args, exec) {
3328
+ const input = args;
3329
+ const scopes = scopesOf(input.scope);
3330
+ const limit = input.limit ?? 8;
3331
+ const rewrite = await rewriteQueries(deps, exec, input.query);
3332
+ const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
3333
+ const vector = await queryVectorOf(deps, queryText);
3334
+ const results = await Promise.all(scopes.map(async (scope) => {
3335
+ return (await deps.openStore(scope)).search({
3336
+ text: queryText,
3337
+ scopes: [scope],
3338
+ limit
3339
+ }, vector);
3340
+ }));
3341
+ return {
3342
+ hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
3343
+ degraded: results.some((result) => result.degraded)
3344
+ };
3345
+ }));
3346
+ const degraded = retrievals.some((retrieval) => retrieval.degraded);
3347
+ const lines = enforceBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
3348
+ 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}`;
3350
+ }));
3351
+ return {
3352
+ degraded,
3353
+ text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
3354
+ };
3355
+ }
3356
+ });
3357
+ const timeline = defineTool({
3358
+ name: "engram_timeline",
3359
+ description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
3360
+ parameters: {
3361
+ scope: {
3362
+ type: "string",
3363
+ enum: [
3364
+ "user",
3365
+ "project",
3366
+ "shared",
3367
+ "all"
3368
+ ],
3369
+ description: "作用域,默认 all"
3370
+ },
3371
+ topic: {
3372
+ type: "string",
3373
+ description: "主题子串"
3374
+ },
3375
+ since: {
3376
+ type: "string",
3377
+ description: "起始时间(ISO 或可解析日期)"
3378
+ },
3379
+ until: {
3380
+ type: "string",
3381
+ description: "结束时间"
3382
+ }
3383
+ },
3384
+ output: {
3385
+ schema: {
3386
+ type: "object",
3387
+ additionalProperties: false,
3388
+ properties: { text: {
3389
+ type: "string",
3390
+ required: true
3391
+ } }
3392
+ },
3393
+ render: (_args, value) => [{
3394
+ type: "text",
3395
+ text: value.text
3396
+ }]
3397
+ },
3398
+ async execute(args) {
3399
+ const input = args;
3400
+ const scopes = scopesOf(input.scope);
3401
+ const parseTime = (raw, field) => {
3402
+ if (raw === void 0) return void 0;
3403
+ const ms = Date.parse(raw);
3404
+ if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
3405
+ return ms;
3406
+ };
3407
+ const since = parseTime(input.since, "since");
3408
+ const until = parseTime(input.until, "until");
3409
+ return { text: renderMemoryPacket(enforceBudget((await Promise.all(scopes.map(async (scope) => {
3410
+ return (await deps.openStore(scope)).timeline({
3411
+ scopes: [scope],
3412
+ ...input.topic === void 0 ? {} : { topic: input.topic },
3413
+ ...since === void 0 ? {} : { since },
3414
+ ...until === void 0 ? {} : { until },
3415
+ limit: 20
3416
+ });
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 ?? "(对话继续)") };
3418
+ }
3419
+ });
3420
+ const update = defineTool({
3421
+ name: "engram_update",
3422
+ description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
3423
+ parameters: {
3424
+ id: {
3425
+ type: "string",
3426
+ required: true,
3427
+ description: "要修正的旧条目 id"
3428
+ },
3429
+ content: {
3430
+ type: "string",
3431
+ required: true,
3432
+ description: "修正后的正文"
3433
+ },
3434
+ scope: {
3435
+ type: "string",
3436
+ enum: [
3437
+ "user",
3438
+ "project",
3439
+ "shared"
3440
+ ],
3441
+ description: "旧条目作用域,默认 project"
3442
+ },
3443
+ kind: {
3444
+ type: "string",
3445
+ enum: [...KINDS],
3446
+ description: "种类,默认继承旧条目"
3447
+ }
3448
+ },
3449
+ output: {
3450
+ schema: {
3451
+ type: "object",
3452
+ additionalProperties: false,
3453
+ properties: {
3454
+ id: {
3455
+ type: "string",
3456
+ required: true
3457
+ },
3458
+ superseded: {
3459
+ type: "string",
3460
+ required: true
3461
+ }
3462
+ }
3463
+ },
3464
+ render: (_args, value) => [{
3465
+ type: "text",
3466
+ text: `已写入修正记忆 ${value.id};旧条目 ${value.superseded} 已归档并建立取代链。`
3467
+ }]
3468
+ },
3469
+ async execute(args) {
3470
+ const input = args;
3471
+ const content = redactSecrets(sanitizeProtocolText(input.content));
3472
+ if (content.trim() === "") throw new Error("engram_update: 清洗后内容为空(原文只含协议标签或密钥)");
3473
+ const scope = scopeOf(input.scope, "project");
3474
+ const store = await deps.openStore(scope);
3475
+ const old = await store.get(input.id);
3476
+ if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
3477
+ const embedder = await deps.embedder;
3478
+ const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
3479
+ return {
3480
+ id: (await store.update({
3481
+ id: input.id,
3482
+ scope,
3483
+ kind: input.kind ?? old.kind,
3484
+ content,
3485
+ ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
3486
+ })).id,
3487
+ superseded: input.id
3488
+ };
3489
+ }
3490
+ });
3491
+ const forget = defineTool({
3492
+ name: "engram_forget",
3493
+ description: "闭馆仪式(软删,可恢复):遗忘前必须留下「为什么关 / 影响谁 / 还有用吗」三问答案作为墓志铭,便于日后考古。id 与 scope 来自 engram_search 结果。",
3494
+ parameters: {
3495
+ id: {
3496
+ type: "string",
3497
+ required: true,
3498
+ description: "条目 id"
3499
+ },
3500
+ scope: {
3501
+ type: "string",
3502
+ enum: [
3503
+ "user",
3504
+ "project",
3505
+ "shared"
3506
+ ],
3507
+ description: "条目作用域,默认 project"
3508
+ },
3509
+ reason: {
3510
+ type: "string",
3511
+ required: true,
3512
+ description: "闭馆原因:被取代 / 过期 / 与现实不符 / 隐私 等"
3513
+ },
3514
+ affects: {
3515
+ type: "string",
3516
+ required: true,
3517
+ description: "影响哪些条目/人/项目,空串表示不适用"
3518
+ },
3519
+ stillUseful: {
3520
+ type: "string",
3521
+ required: true,
3522
+ description: "遗留价值:可考古 / 可复习 / 回滚时如何理解"
3523
+ }
3524
+ },
3525
+ output: {
3526
+ schema: {
3527
+ type: "object",
3528
+ additionalProperties: false,
3529
+ properties: { id: {
3530
+ type: "string",
3531
+ required: true
3532
+ } }
3533
+ },
3534
+ render: (_args, value) => [{
3535
+ type: "text",
3536
+ text: `房间 ${value.id} 已闭馆(软删,可恢复),墓志铭已刻入操作日志。`
3537
+ }]
3538
+ },
3539
+ async execute(args) {
3540
+ const input = args;
3541
+ const scope = scopeOf(input.scope, "project");
3542
+ const reason = typeof input.reason === "string" ? input.reason.trim() : "";
3543
+ const affects = typeof input.affects === "string" ? input.affects.trim() : "";
3544
+ const stillUseful = typeof input.stillUseful === "string" ? input.stillUseful.trim() : "";
3545
+ if (reason === "" || affects === "" || stillUseful === "") throw new Error("engram_forget: 闭馆三问(reason / affects / stillUseful)都必须填写,方便日后考古");
3546
+ return { id: (await (await deps.openStore(scope)).forgetWithTombstone(input.id, {
3547
+ reason,
3548
+ affects,
3549
+ stillUseful
3550
+ })).id };
3551
+ }
3552
+ });
3553
+ /** P0-3 闭馆考古:返回最近 N 条 forgotten 条目 + 墓志铭。 */
3554
+ const auditForgotten = defineTool({
3555
+ name: "engram_audit_forgotten",
3556
+ description: "闭馆考古:列出最近 N 条已闭馆条目 + 墓志铭(为什么关 / 影响谁 / 还有用吗),便于复核过去的遗忘是否得当。",
3557
+ parameters: {
3558
+ scope: {
3559
+ type: "string",
3560
+ enum: [
3561
+ "user",
3562
+ "project",
3563
+ "shared",
3564
+ "all"
3565
+ ],
3566
+ description: "作用域,默认 all"
3567
+ },
3568
+ limit: {
3569
+ type: "integer",
3570
+ description: "返回条数上限,默认 20"
3571
+ }
3572
+ },
3573
+ output: {
3574
+ schema: {
3575
+ type: "object",
3576
+ additionalProperties: false,
3577
+ properties: { text: {
3578
+ type: "string",
3579
+ required: true
3580
+ } }
3581
+ },
3582
+ render: (_args, value) => [{
3583
+ type: "text",
3584
+ text: value.text
3585
+ }]
3586
+ },
3587
+ async execute(args) {
3588
+ const input = args;
3589
+ const scopes = scopesOf(input.scope);
3590
+ const limit = Number.isInteger(input.limit) ? Math.min(Math.max(1, input.limit), 50) : 20;
3591
+ const all = (await Promise.all(scopes.map(async (scope) => (await deps.openStore(scope)).listForgottenWithTombs(limit)))).flat();
3592
+ all.sort((a, b) => b.forgottenAt - a.forgottenAt);
3593
+ const sliced = all.slice(0, limit);
3594
+ if (sliced.length === 0) return { text: "尚无闭馆条目。" };
3595
+ const lines = sliced.map((row, index) => {
3596
+ const stamp = new Date(row.forgottenAt).toISOString();
3597
+ const tomb = row.tombstone === null ? "(墓志铭缺失:旧版无三问数据)" : `\n - 为什么关:${row.tombstone.reason}\n - 影响谁:${row.tombstone.affects}\n - 还有用吗:${row.tombstone.stillUseful}`;
3598
+ return `${index + 1}. [${row.scope}/${row.kind}] ${row.content.slice(0, 80)}${row.content.length > 80 ? "…" : ""}\n id=${row.id} · importance=${row.importance.toFixed(2)} · 闭馆于 ${stamp}${tomb}`;
3599
+ });
3600
+ return { text: `闭馆考古(共 ${sliced.length} 条):\n${lines.join("\n")}` };
3601
+ }
3602
+ });
3603
+ return [
3604
+ save,
3605
+ search,
3606
+ timeline,
3607
+ update,
3608
+ forget,
2035
3609
  defineTool({
2036
- name: "engram_search",
2037
- description: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
3610
+ name: "engram_report",
3611
+ description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。效果影响后续召回排序,长期无效的记忆将被衰减归档。",
2038
3612
  parameters: {
2039
- query: {
3613
+ id: {
2040
3614
  type: "string",
2041
3615
  required: true,
2042
- description: "检索文本"
3616
+ description: "条目 id"
3617
+ },
3618
+ outcome: {
3619
+ type: "string",
3620
+ enum: ["success", "failure"],
3621
+ required: true,
3622
+ description: "使用效果"
2043
3623
  },
2044
3624
  scope: {
2045
3625
  type: "string",
2046
3626
  enum: [
2047
3627
  "user",
2048
3628
  "project",
2049
- "all"
3629
+ "shared"
2050
3630
  ],
2051
- description: "作用域,默认 all"
2052
- },
2053
- limit: {
2054
- type: "number",
2055
- description: "返回条数上限,默认 8"
2056
- }
2057
- },
2058
- output: {
2059
- schema: {
2060
- type: "object",
2061
- additionalProperties: false,
2062
- properties: {
2063
- degraded: {
2064
- type: "boolean",
2065
- required: true
2066
- },
2067
- text: {
2068
- type: "string",
2069
- required: true
2070
- }
2071
- }
2072
- },
2073
- render: (_args, value) => [{
2074
- type: "text",
2075
- text: value.text
2076
- }]
2077
- },
2078
- async execute(args, exec) {
2079
- const input = args;
2080
- const scopes = scopesOf(input.scope);
2081
- const limit = input.limit ?? 8;
2082
- const rewrite = await rewriteQueries(deps, exec, input.query);
2083
- const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
2084
- const vector = await queryVectorOf(deps, queryText);
2085
- const results = await Promise.all(scopes.map(async (scope) => {
2086
- return (await deps.openStore(scope)).search({
2087
- text: queryText,
2088
- scopes: [scope],
2089
- limit
2090
- }, vector);
2091
- }));
2092
- return {
2093
- hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
2094
- degraded: results.some((result) => result.degraded)
2095
- };
2096
- }));
2097
- const degraded = retrievals.some((retrieval) => retrieval.degraded);
2098
- const lines = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
2099
- const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
2100
- return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${hit.record.content}(id=${hit.record.id})${edge}`;
2101
- });
2102
- return {
2103
- degraded,
2104
- text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
2105
- };
2106
- }
2107
- }),
2108
- defineTool({
2109
- name: "engram_timeline",
2110
- description: "按时间范围与主题浏览记忆(时间倒序,最近 20 条)。无参数直接列出最近记录。",
2111
- parameters: {
2112
- scope: {
2113
- type: "string",
2114
- enum: [
2115
- "user",
2116
- "project",
2117
- "all"
2118
- ],
2119
- description: "作用域,默认 all"
2120
- },
2121
- topic: {
2122
- type: "string",
2123
- description: "主题子串"
2124
- },
2125
- since: {
2126
- type: "string",
2127
- description: "起始时间(ISO 或可解析日期)"
2128
- },
2129
- until: {
2130
- type: "string",
2131
- description: "结束时间"
2132
- }
2133
- },
2134
- output: {
2135
- schema: {
2136
- type: "object",
2137
- additionalProperties: false,
2138
- properties: { text: {
2139
- type: "string",
2140
- required: true
2141
- } }
2142
- },
2143
- render: (_args, value) => [{
2144
- type: "text",
2145
- text: value.text
2146
- }]
2147
- },
2148
- async execute(args) {
2149
- const input = args;
2150
- const scopes = scopesOf(input.scope);
2151
- const parseTime = (raw, field) => {
2152
- if (raw === void 0) return void 0;
2153
- const ms = Date.parse(raw);
2154
- if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
2155
- return ms;
2156
- };
2157
- const since = parseTime(input.since, "since");
2158
- const until = parseTime(input.until, "until");
2159
- return { text: renderMemoryPacket((await Promise.all(scopes.map(async (scope) => {
2160
- return (await deps.openStore(scope)).timeline({
2161
- scopes: [scope],
2162
- ...input.topic === void 0 ? {} : { topic: input.topic },
2163
- ...since === void 0 ? {} : { since },
2164
- ...until === void 0 ? {} : { until },
2165
- limit: 20
2166
- });
2167
- }))).flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, 20).map((record) => `${new Date(record.createdAt).toISOString()} [${record.scope}/${record.kind}] ${record.content}(id=${record.id})`).join("\n") || "时间线为空", "tool_timeline", input.topic ?? "(对话继续)") };
2168
- }
2169
- }),
2170
- defineTool({
2171
- name: "engram_update",
2172
- description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
2173
- parameters: {
2174
- id: {
2175
- type: "string",
2176
- required: true,
2177
- description: "要修正的旧条目 id"
2178
- },
2179
- content: {
2180
- type: "string",
2181
- required: true,
2182
- description: "修正后的正文"
2183
- },
2184
- scope: {
2185
- type: "string",
2186
- enum: ["user", "project"],
2187
- description: "旧条目作用域,默认 project"
2188
- },
2189
- kind: {
2190
- type: "string",
2191
- enum: [...KINDS],
2192
- description: "种类,默认继承旧条目"
3631
+ description: "条目作用域,默认 project"
2193
3632
  }
2194
3633
  },
2195
3634
  output: {
@@ -2201,74 +3640,34 @@ function createEngramTools(deps) {
2201
3640
  type: "string",
2202
3641
  required: true
2203
3642
  },
2204
- superseded: {
3643
+ outcome: {
2205
3644
  type: "string",
2206
3645
  required: true
3646
+ },
3647
+ confidence: {
3648
+ type: "number",
3649
+ required: true
2207
3650
  }
2208
3651
  }
2209
3652
  },
2210
3653
  render: (_args, value) => [{
2211
3654
  type: "text",
2212
- text: `已写入修正记忆 ${value.id};旧条目 ${value.superseded} 已归档并建立取代链。`
3655
+ text: value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id} 使用无效(confidence=${value.confidence})。该记忆后续召回排序将下降,持续无效会被衰减归档。`
2213
3656
  }]
2214
3657
  },
2215
3658
  async execute(args) {
2216
3659
  const input = args;
2217
- const content = redactSecrets(sanitizeProtocolText(input.content));
2218
- if (content.trim() === "") throw new Error("engram_update: 清洗后内容为空(原文只含协议标签或密钥)");
3660
+ if (input.outcome !== "success" && input.outcome !== "failure") throw new Error("engram_report: outcome 必须是 success 或 failure");
2219
3661
  const scope = scopeOf(input.scope, "project");
2220
- const store = await deps.openStore(scope);
2221
- const old = await store.get(input.id);
2222
- if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
2223
- const embedder = await deps.embedder;
2224
- const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
3662
+ const record = await (await deps.openStore(scope)).reportOutcome(input.id, input.outcome);
3663
+ if (record === void 0) throw new Error(`engram_report: 条目 ${input.id} 不存在(scope=${scope})`);
2225
3664
  return {
2226
- id: (await store.update({
2227
- id: input.id,
2228
- scope,
2229
- kind: input.kind ?? old.kind,
2230
- content,
2231
- ...embeddings === void 0 ? {} : { embedding: embeddings[0] }
2232
- })).id,
2233
- superseded: input.id
3665
+ id: record.id,
3666
+ outcome: input.outcome,
3667
+ confidence: record.confidence
2234
3668
  };
2235
3669
  }
2236
3670
  }),
2237
- defineTool({
2238
- name: "engram_forget",
2239
- description: "遗忘一条记忆(软删,用户可从库中恢复)。id 与 scope 来自 engram_search 结果。",
2240
- parameters: {
2241
- id: {
2242
- type: "string",
2243
- required: true,
2244
- description: "条目 id"
2245
- },
2246
- scope: {
2247
- type: "string",
2248
- enum: ["user", "project"],
2249
- description: "条目作用域,默认 project"
2250
- }
2251
- },
2252
- output: {
2253
- schema: {
2254
- type: "object",
2255
- additionalProperties: false,
2256
- properties: { id: {
2257
- type: "string",
2258
- required: true
2259
- } }
2260
- },
2261
- render: (_args, value) => [{
2262
- type: "text",
2263
- text: `记忆 ${value.id} 已遗忘(软删,可恢复)。`
2264
- }]
2265
- },
2266
- async execute(args) {
2267
- const input = args;
2268
- const scope = scopeOf(input.scope, "project");
2269
- return { id: (await (await deps.openStore(scope)).forget(input.id)).id };
2270
- }
2271
- }),
2272
3671
  defineTool({
2273
3672
  name: "engram_review",
2274
3673
  description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
@@ -2280,7 +3679,11 @@ function createEngramTools(deps) {
2280
3679
  },
2281
3680
  scope: {
2282
3681
  type: "string",
2283
- enum: ["user", "project"],
3682
+ enum: [
3683
+ "user",
3684
+ "project",
3685
+ "shared"
3686
+ ],
2284
3687
  description: "条目作用域,默认 project"
2285
3688
  }
2286
3689
  },
@@ -2313,6 +3716,7 @@ function createEngramTools(deps) {
2313
3716
  section("取代了谁", view.supersedes.map(String)),
2314
3717
  section("矛盾候选", view.contradicts.map(String)),
2315
3718
  section("关联", view.related.map(String)),
3719
+ view.revisions.length === 0 ? "" : `\n修订历史:\n${view.revisions.map((rev) => `- ${new Date(rev.supersededAt).toISOString()} [${rev.kind}] ${truncateItem(rev.content)}`).join("\n")}`,
2316
3720
  view.operations.length === 0 ? "" : `\n最近操作:\n${view.operations.map((op) => `- ${new Date(op.at).toISOString()} ${op.op}${op.detail === null ? "" : ` ${op.detail}`}`).join("\n")}`
2317
3721
  ].filter((part) => part !== "").join("\n"), "tool_review", "(对话继续)") };
2318
3722
  }
@@ -2325,6 +3729,7 @@ function createEngramTools(deps) {
2325
3729
  enum: [
2326
3730
  "user",
2327
3731
  "project",
3732
+ "shared",
2328
3733
  "all"
2329
3734
  ],
2330
3735
  description: "作用域,默认 all"
@@ -2357,21 +3762,30 @@ function createEngramTools(deps) {
2357
3762
  }),
2358
3763
  defineTool({
2359
3764
  name: "engram_export",
2360
- description: "把记忆库导出为文件(Markdown 或 JSON,含全部状态与关系边),返回文件路径。数据可携带。",
3765
+ description: "把记忆库导出为文件(Markdown / JSON / 镜像目录),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。format=markdown-mirror 时每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md,可直接用 Obsidian / git 漫游。",
2361
3766
  parameters: {
2362
3767
  format: {
2363
3768
  type: "string",
2364
- enum: ["markdown", "json"],
2365
- description: "导出格式,默认 markdown"
3769
+ enum: [
3770
+ "markdown",
3771
+ "json",
3772
+ "markdown-mirror"
3773
+ ],
3774
+ description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每房间一文件(默认 markdown)"
2366
3775
  },
2367
3776
  scope: {
2368
3777
  type: "string",
2369
3778
  enum: [
2370
3779
  "user",
2371
3780
  "project",
3781
+ "shared",
2372
3782
  "all"
2373
3783
  ],
2374
3784
  description: "作用域,默认 all"
3785
+ },
3786
+ redactedView: {
3787
+ type: "boolean",
3788
+ description: "脱敏视图:内容二次脱敏并截断为预览(默认 false 完整导出;镜像模式忽略此参数)"
2375
3789
  }
2376
3790
  },
2377
3791
  output: {
@@ -2390,8 +3804,13 @@ function createEngramTools(deps) {
2390
3804
  },
2391
3805
  async execute(args) {
2392
3806
  const input = args;
2393
- const format = input.format === "json" ? "json" : "markdown";
3807
+ const format = input.format === "json" ? "json" : input.format === "markdown-mirror" ? "markdown-mirror" : "markdown";
3808
+ const redactedView = input.redactedView === true;
2394
3809
  const scopes = scopesOf(input.scope);
3810
+ const preview = (content) => {
3811
+ const cleaned = redactSecrets(content);
3812
+ return cleaned.length > 40 ? `${cleaned.slice(0, 40)}…` : cleaned;
3813
+ };
2395
3814
  await mkdir(deps.exportDir, {
2396
3815
  recursive: true,
2397
3816
  mode: 448
@@ -2399,21 +3818,36 @@ function createEngramTools(deps) {
2399
3818
  const written = [];
2400
3819
  for (const scope of scopes) {
2401
3820
  const data = await (await deps.openStore(scope)).exportAll();
3821
+ if (format === "markdown-mirror") {
3822
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
3823
+ const mirrorRoot = join(deps.exportDir, `mirror-${scope}-${stamp}`);
3824
+ const report = await writeMirror(mirrorRoot, data);
3825
+ written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length} 间房间,${report.floors.length} 个楼层)`);
3826
+ continue;
3827
+ }
3828
+ const payload = redactedView ? {
3829
+ ...data,
3830
+ records: data.records.map((record) => ({
3831
+ ...record,
3832
+ content: preview(record.content)
3833
+ }))
3834
+ } : data;
2402
3835
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
2403
- const path = join(deps.exportDir, `engram-${scope}-${stamp}.${format === "json" ? "json" : "md"}`);
2404
- const body = format === "json" ? JSON.stringify(data, null, 2) : [
2405
- `# dsh-engram 导出(${scope})`,
3836
+ const suffix = redactedView ? "-redacted" : "";
3837
+ const path = join(deps.exportDir, `engram-${scope}${suffix}-${stamp}.${format === "json" ? "json" : "md"}`);
3838
+ const body = format === "json" ? JSON.stringify(payload, null, 2) : [
3839
+ `# dsh-engram 导出(${scope}${redactedView ? ",脱敏视图" : ""})`,
2406
3840
  "",
2407
- ...data.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
3841
+ ...payload.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
2408
3842
  "",
2409
3843
  "## 关系边",
2410
- ...data.edges.map((edge) => `- ${edge.from} --${edge.type}--> ${edge.to}`),
3844
+ ...payload.edges.map((edge) => `- ${edge.from} --${edge.type}--> ${edge.to}`),
2411
3845
  ""
2412
3846
  ].join("\n");
2413
3847
  await writeFile(path, body, { mode: 384 });
2414
- written.push(`${path}(${data.records.length} 条记忆,${data.edges.length} 条边)`);
3848
+ written.push(`${path}(${payload.records.length} 条记忆,${payload.edges.length} 条边${redactedView ? ",脱敏视图" : ""})`);
2415
3849
  }
2416
- return { text: `已导出:\n${written.join("\n")}` };
3850
+ return { text: `已导出${redactedView ? "(脱敏视图)" : ""}:\n${written.join("\n")}` };
2417
3851
  }
2418
3852
  }),
2419
3853
  defineTool({
@@ -2421,7 +3855,11 @@ function createEngramTools(deps) {
2421
3855
  description: "蒸馏整理:把同主题的记忆簇合并提炼为更高层的规律(旧条目归档、supersedes 链保留)。建议记忆较多时周期性执行。",
2422
3856
  parameters: { scope: {
2423
3857
  type: "string",
2424
- enum: ["user", "project"],
3858
+ enum: [
3859
+ "user",
3860
+ "project",
3861
+ "shared"
3862
+ ],
2425
3863
  description: "作用域,默认 user"
2426
3864
  } },
2427
3865
  output: {
@@ -2442,7 +3880,7 @@ function createEngramTools(deps) {
2442
3880
  const scope = scopeOf(args.scope, "user");
2443
3881
  if (deps.call === void 0) throw new Error("engram_distill: 辅助 LLM 不可用(宿主未提供 llm 服务),无法蒸馏");
2444
3882
  const call = deps.call;
2445
- const events = exec.agent?.session?.events ?? [];
3883
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
2446
3884
  const route = deps.routeOverride ?? routeFromEvents(events);
2447
3885
  if (route === void 0) throw new Error("engram_distill: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
2448
3886
  const embedder = await deps.embedder;
@@ -2464,19 +3902,298 @@ function createEngramTools(deps) {
2464
3902
  });
2465
3903
  return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
2466
3904
  }
2467
- })
3905
+ }),
3906
+ defineTool({
3907
+ name: "engram_examine",
3908
+ description: "渐进式披露:按 id 批量拉取房间完整铭牌(content + 楼层 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
3909
+ parameters: { ids: {
3910
+ type: "array",
3911
+ items: { type: "string" },
3912
+ description: "房间 id 列表"
3913
+ } },
3914
+ output: {
3915
+ schema: {
3916
+ type: "object",
3917
+ additionalProperties: false,
3918
+ properties: { text: {
3919
+ type: "string",
3920
+ required: true
3921
+ } }
3922
+ },
3923
+ render: (_args, value) => [{
3924
+ type: "text",
3925
+ text: value.text
3926
+ }]
3927
+ },
3928
+ async execute(args) {
3929
+ const input = args;
3930
+ const rawIds = Array.isArray(input.ids) ? input.ids.filter((x) => typeof x === "string") : [];
3931
+ if (rawIds.length === 0) throw new Error("engram_examine: ids 必填且至少含 1 条");
3932
+ const ids = rawIds.slice(0, 16).map((id) => asMemoryId(id));
3933
+ const store = await deps.openStore("user");
3934
+ const projectStore = await deps.openStore("project");
3935
+ const [fromUser, fromProject] = await Promise.all([store.getMany(ids), projectStore.getMany(ids)]);
3936
+ const records = [...fromUser, ...fromProject];
3937
+ if (records.length === 0) throw new Error(`engram_examine: 全部 ${ids.length} 个 id 都找不到`);
3938
+ return { text: records.map((record, index) => [
3939
+ `### ${index + 1}. [${record.scope}/${record.kind}/${record.status}] id=${record.id}`,
3940
+ `铭牌: ${record.content}`,
3941
+ `地标亮度=${record.importance.toFixed(2)} · 考据可靠度=${record.confidence.toFixed(2)} · 参观=${record.accessCount}人次`
3942
+ ].join("\n")).join("\n\n") };
3943
+ }
3944
+ }),
3945
+ defineTool({
3946
+ name: "engram_neighbors",
3947
+ description: "走廊漫步:从某间出发走 1-3 跳内的 related/supersedes/contradicts 边,返回邻居房间简表(仅 id + scope + kind + status + content),便于判断下一站。",
3948
+ parameters: {
3949
+ id: {
3950
+ type: "string",
3951
+ description: "起点房间 id"
3952
+ },
3953
+ depth: {
3954
+ type: "integer",
3955
+ description: "跳数(默认 1,最多 3,由执行器夹逼)"
3956
+ }
3957
+ },
3958
+ output: {
3959
+ schema: {
3960
+ type: "object",
3961
+ additionalProperties: false,
3962
+ properties: { text: {
3963
+ type: "string",
3964
+ required: true
3965
+ } }
3966
+ },
3967
+ render: (_args, value) => [{
3968
+ type: "text",
3969
+ text: value.text
3970
+ }]
3971
+ },
3972
+ async execute(args) {
3973
+ const input = args;
3974
+ if (typeof input.id !== "string" || input.id === "") throw new Error("engram_neighbors: id 必填");
3975
+ const depth = Number.isInteger(input.depth) ? Math.min(Math.max(1, input.depth), 3) : 1;
3976
+ const seed = asMemoryId(input.id);
3977
+ const userStore = await deps.openStore("user");
3978
+ const projectStore = await deps.openStore("project");
3979
+ const seedRow = await userStore.get(seed) ?? await projectStore.get(seed);
3980
+ if (seedRow === void 0) throw new Error(`engram_neighbors: 起点 ${seed} 不存在`);
3981
+ const seedScope = seedRow.scope;
3982
+ const neighbors = await (seedScope === "user" ? userStore : projectStore).neighbors(seed, depth);
3983
+ if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth} 跳内无邻居房间。` };
3984
+ 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")}` };
3986
+ }
3987
+ }),
3988
+ defineTool({
3989
+ name: "engram_tour",
3990
+ description: "巡游路由:按「同类巩固 → 走廊相邻 → 反差补位」顺序组织 3-7 间房间,每站附入选理由 + 意象铭牌回声。适合在用户问起某主题时直接给出一条可走的导览路线,而不是无序结果集。",
3991
+ parameters: {
3992
+ query: {
3993
+ type: "string",
3994
+ required: true,
3995
+ description: "巡游主题(与 engram_search 同义)"
3996
+ },
3997
+ scope: {
3998
+ type: "string",
3999
+ enum: [
4000
+ "user",
4001
+ "project",
4002
+ "shared",
4003
+ "all"
4004
+ ],
4005
+ description: "作用域,默认 all"
4006
+ },
4007
+ maxStops: {
4008
+ type: "integer",
4009
+ description: "最多站数(默认 6,3-7 之间)"
4010
+ }
4011
+ },
4012
+ output: {
4013
+ schema: {
4014
+ type: "object",
4015
+ additionalProperties: false,
4016
+ properties: { text: {
4017
+ type: "string",
4018
+ required: true
4019
+ } }
4020
+ },
4021
+ render: (_args, value) => [{
4022
+ type: "text",
4023
+ text: value.text
4024
+ }]
4025
+ },
4026
+ async execute(args, exec) {
4027
+ const input = args;
4028
+ const scopes = scopesOf(input.scope);
4029
+ const limit = 12;
4030
+ const maxStops = Number.isInteger(input.maxStops) ? Math.min(Math.max(3, input.maxStops), 7) : 6;
4031
+ const rewrite = await rewriteQueries(deps, exec, input.query);
4032
+ const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
4033
+ const vector = await queryVectorOf(deps, queryText);
4034
+ const results = await Promise.all(scopes.map(async (scope) => {
4035
+ return (await deps.openStore(scope)).search({
4036
+ text: queryText,
4037
+ scopes: [scope],
4038
+ limit
4039
+ }, vector);
4040
+ }));
4041
+ return {
4042
+ hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
4043
+ degraded: results.some((result) => result.degraded)
4044
+ };
4045
+ }));
4046
+ const degraded = retrievals.some((retrieval) => retrieval.degraded);
4047
+ const merged = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length)));
4048
+ const userStore = await deps.openStore("user");
4049
+ const projectStore = await deps.openStore("project");
4050
+ const poolLookup = async (id) => await userStore.get(id) ?? await projectStore.get(id);
4051
+ const route = await planTour(merged, poolLookup, input.query, maxStops);
4052
+ return { text: `${degraded ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${route.narrative}` };
4053
+ }
4054
+ }),
4055
+ auditForgotten
2468
4056
  ];
2469
4057
  }
4058
+ /**
4059
+ * 构造巡游路径:在 search 命中的基础上按路径策略重排。
4060
+ * 1) 起点簇:取命中中 kind 出现频次最高的前 N 个同 kind 节点(同类巩固)。
4061
+ * 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0 的房间。
4062
+ * 3) 反差收束:从剩余命中挑一个 emotionalValence ≥ 0.7 的做收束(强反差唤醒)。
4063
+ * 命中不足时按可用性回退;命中为 0 时返回空 stops。
4064
+ */
4065
+ async function planTour(hits, poolLookup, query, maxStops = 6) {
4066
+ if (hits.length === 0) return {
4067
+ stops: [],
4068
+ narrative: "无命中,无巡游路径可规划。",
4069
+ query
4070
+ };
4071
+ const used = /* @__PURE__ */ new Set();
4072
+ const stops = [];
4073
+ const kindCounts = /* @__PURE__ */ new Map();
4074
+ for (const hit of hits) kindCounts.set(hit.record.kind, (kindCounts.get(hit.record.kind) ?? 0) + 1);
4075
+ const topKinds = [...kindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([kind]) => kind);
4076
+ for (const kind of topKinds) for (const hit of hits) {
4077
+ if (stops.length >= maxStops) break;
4078
+ if (hit.record.kind !== kind || used.has(hit.record.id)) continue;
4079
+ stops.push({
4080
+ record: hit.record,
4081
+ reason: "same-kind"
4082
+ });
4083
+ used.add(hit.record.id);
4084
+ }
4085
+ for (const stop of [...stops]) {
4086
+ if (stops.length >= maxStops - 1) break;
4087
+ await poolLookup(stop.record.id);
4088
+ for (const hit of hits) {
4089
+ if (stops.length >= maxStops - 1) break;
4090
+ if (used.has(hit.record.id) || hit.record.id === stop.record.id) continue;
4091
+ if (hit.record.scope !== stop.record.scope) continue;
4092
+ stops.push({
4093
+ record: hit.record,
4094
+ reason: "neighbor"
4095
+ });
4096
+ used.add(hit.record.id);
4097
+ }
4098
+ }
4099
+ const emotionCut = hits.find((hit) => !used.has(hit.record.id) && (hit.record.imagery?.emotionalValence ?? 0) >= .7);
4100
+ if (emotionCut !== void 0 && stops.length < maxStops) {
4101
+ stops.push({
4102
+ record: emotionCut.record,
4103
+ reason: "emotional-peak"
4104
+ });
4105
+ used.add(emotionCut.record.id);
4106
+ }
4107
+ for (const hit of hits) {
4108
+ if (stops.length >= maxStops) break;
4109
+ if (used.has(hit.record.id)) continue;
4110
+ stops.push({
4111
+ record: hit.record,
4112
+ reason: "contrast"
4113
+ });
4114
+ used.add(hit.record.id);
4115
+ }
4116
+ return {
4117
+ stops,
4118
+ narrative: renderTourNarrative(stops, query),
4119
+ query
4120
+ };
4121
+ }
4122
+ /** 把巡游路径渲染为一段含回声触发器的可读文本(供 engram_tour 的 text 字段)。 */
4123
+ function renderTourNarrative(stops, query) {
4124
+ if (stops.length === 0) return "无巡游路径。";
4125
+ const reasonLabel = (reason) => {
4126
+ switch (reason) {
4127
+ case "same-kind": return "同类巩固";
4128
+ case "neighbor": return "走廊相邻";
4129
+ case "contrast": return "反差补位";
4130
+ case "emotional-peak": return "情绪强反差";
4131
+ }
4132
+ };
4133
+ const lines = [`巡游路径(查询:${query}):按「同类巩固 → 走廊相邻 → 反差补位」顺序组织,共 ${stops.length} 站。`];
4134
+ stops.forEach((stop, index) => {
4135
+ const caption = stop.record.imagery?.caption;
4136
+ const sensory = stop.record.imagery?.sensoryTags ?? [];
4137
+ const echo = caption === null || caption === void 0 ? "(未铭刻意象,请先调用 engram_examine 读铭牌)" : `回声:似曾「${caption}」${sensory.length > 0 ? `(${sensory.slice(0, 3).join("、")})` : ""}`;
4138
+ lines.push(`${index + 1}. [${stop.record.scope}/${stop.record.kind}] ${stop.record.content.slice(0, 60)}${stop.record.content.length > 60 ? "…" : ""}(id=${stop.record.id},理由:${reasonLabel(stop.reason)})— ${echo}`);
4139
+ });
4140
+ return lines.join("\n");
4141
+ }
4142
+ //#endregion
4143
+ //#region src/selection-rationale.ts
4144
+ /** 给定一组入选房间,返回归因 XML 字符串;空 rooms 返回空串。 */
4145
+ function buildSelectionRationale(records) {
4146
+ if (records.length === 0) return "";
4147
+ const now = Date.now();
4148
+ const lines = [
4149
+ "<engram_selection_rationale>",
4150
+ `本轮画像共 ${String(records.length)} 间房间,挑选理由如下:`,
4151
+ ""
4152
+ ];
4153
+ for (const record of records) {
4154
+ const reasons = classifyRecord(record, now);
4155
+ lines.push(`- #${record.id.slice(0, 8)} [${record.kind}/${record.scope}] ${reasonsLabel(reasons)}:${record.content.slice(0, 40)}${record.content.length > 40 ? "…" : ""}`);
4156
+ }
4157
+ lines.push("", "如果某条理由错误,请通过 engram_report 反馈,越准的画像越能帮你。", "</engram_selection_rationale>");
4158
+ return lines.join("\n");
4159
+ }
4160
+ /** 把画像正文 + 归因拼成最终注入文本。 */
4161
+ function wrapWithRationale(profileText, records) {
4162
+ const rationale = buildSelectionRationale(records);
4163
+ return rationale === "" ? profileText : `${rationale}\n\n${profileText}`;
4164
+ }
4165
+ /** 启发式:4 类原因按阈值判定,可叠加。 */
4166
+ function classifyRecord(record, now) {
4167
+ const reasons = [];
4168
+ if (now - record.lastAccessedAt < 6048e5) reasons.push("recent");
4169
+ if (record.importance * record.confidence >= .7) reasons.push("bright");
4170
+ if (record.accessCount >= 5) reasons.push("corridor-hit");
4171
+ if (record.outcome === "success") reasons.push("strong-evidence");
4172
+ return reasons;
4173
+ }
4174
+ /** 中文/英文 reason 标签。 */
4175
+ function reasonsLabel(reasons) {
4176
+ if (reasons.length === 0) return "基础入选";
4177
+ const LABELS = {
4178
+ recent: "近期被参观",
4179
+ bright: "地标明亮",
4180
+ "corridor-hit": "走廊常客",
4181
+ "strong-evidence": "管家验证有效"
4182
+ };
4183
+ return reasons.map((r) => LABELS[r]).join(" / ");
4184
+ }
2470
4185
  //#endregion
2471
4186
  //#region src/index.ts
2472
4187
  /**
2473
4188
  * dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
2474
- * 注册 9 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
4189
+ * 注册 10 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
2475
4190
  * 蒸馏/衰减飞轮与审计能力。
2476
4191
  * @module @kenz1117/dsh-engram
2477
4192
  */
2478
4193
  /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
2479
4194
  const name = "dsh-engram";
4195
+ /** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
4196
+ const VERSION = "0.7.0";
2480
4197
  /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
2481
4198
  const inject = ["tools", "llm"];
2482
4199
  /**
@@ -2485,9 +4202,9 @@ const inject = ["tools", "llm"];
2485
4202
  * 索引行也装不下的折成末尾 `+N more; use engram_search` 计数行。
2486
4203
  * @param records - 候选条目(调用方已按重要性排序、按条数截断)。
2487
4204
  * @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
2488
- * @returns 注入文本。
4205
+ * @returns 渲染文本与溢出条目(调用方可用辅助 LLM 压缩后重渲染)。
2489
4206
  */
2490
- function renderProfile(records, tokenBudget) {
4207
+ function renderProfileDetailed(records, tokenBudget) {
2491
4208
  const estimate = (text) => Math.ceil(text.length / 4);
2492
4209
  const header = "User memory profile (dsh-engram, cross-session):";
2493
4210
  const footer = "Use engram_search to recall details; use engram_save to persist new facts.";
@@ -2512,16 +4229,76 @@ function renderProfile(records, tokenBudget) {
2512
4229
  } else more += 1;
2513
4230
  }
2514
4231
  if (more > 0) lines.push(`+${more} more; use engram_search`);
2515
- return [
2516
- header,
2517
- ...lines,
2518
- footer
2519
- ].join("\n");
4232
+ return {
4233
+ text: [
4234
+ header,
4235
+ ...lines,
4236
+ footer
4237
+ ].join("\n"),
4238
+ overflow
4239
+ };
4240
+ }
4241
+ /**
4242
+ * 会话开始注入的画像渲染(只取文本;溢出明细见 renderProfileDetailed)。
4243
+ * @param records - 候选条目(调用方已按重要性排序、按条数截断)。
4244
+ * @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
4245
+ * @returns 注入文本。
4246
+ */
4247
+ function renderProfile(records, tokenBudget) {
4248
+ return renderProfileDetailed(records, tokenBudget).text;
2520
4249
  }
4250
+ /** 压缩辅助调用的输出 token 上限(40 字 × 若干条,短输出足够)。 */
4251
+ const COMPRESS_MAX_TOKENS = 800;
4252
+ /** 压缩辅助调用超时:压缩在 pre-step 关键路径上,必须限时防阻塞首轮请求。 */
4253
+ const COMPRESS_TIMEOUT_MS = 8e3;
4254
+ const COMPRESS_SYSTEM = [
4255
+ "把记忆条目压缩为更短的一句话表述(每条不超过 40 个字符),保留可跨会话复用的关键信息(事实、偏好、决策、方法)。",
4256
+ "只输出一个 JSON 数组,每项形如 {\"id\": \"原样返回的id\", \"content\": \"压缩后表述\"},条目数量与 id 必须与输入一一对应。",
4257
+ "不要输出 JSON 以外的任何内容。"
4258
+ ].join("\n");
2521
4259
  /**
2522
- * agent/pre-step waterfall:每轮第一步注入画像;同时 fire-and-forget 触发
2523
- * 上一轮的自动摄取(不阻塞请求);进程内首次第一步重放待补做的末轮摄取。
2524
- * 必须调用 next() 委托链路;reject 决策原样透传,记忆库为空或非首轮时不追加消息。
4260
+ * 画像超预算的辅助压缩:把装不下的条目交给辅助 LLM 压短,返回 id → 压缩文本。
4261
+ * 任何失败(无路由、输出不可解析、超时、调用异常)返回 undefined,调用方
4262
+ * 降级回索引行装填。压缩请求审计到 user 库 op_log(model-visible ⟺ logged:
4263
+ * 压缩产物本身会随注入消息进会话日志)。
4264
+ */
4265
+ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, signal) {
4266
+ try {
4267
+ const events = agent.session.snapshotEvents();
4268
+ const route = routeOverride ?? routeFromEvents(events);
4269
+ if (route === void 0) return void 0;
4270
+ const userText = JSON.stringify(overflow.map((record) => ({
4271
+ id: record.id,
4272
+ content: record.content
4273
+ })));
4274
+ const parsed = parseJsonArray(await streamText(ctx, {
4275
+ route,
4276
+ system: COMPRESS_SYSTEM,
4277
+ userText,
4278
+ maxTokens: COMPRESS_MAX_TOKENS,
4279
+ purpose: "engram-compress",
4280
+ sessionId: agent.session.id,
4281
+ signal: AbortSignal.any([signal, AbortSignal.timeout(COMPRESS_TIMEOUT_MS)])
4282
+ }));
4283
+ if (parsed === void 0) return void 0;
4284
+ const ids = new Set(overflow.map((record) => record.id));
4285
+ const compressed = /* @__PURE__ */ new Map();
4286
+ for (const item of parsed) {
4287
+ const entry = item;
4288
+ if (typeof entry.id !== "string" || typeof entry.content !== "string") continue;
4289
+ if (!ids.has(entry.id) || entry.content.trim() === "" || entry.content.length >= overflow.find((record) => record.id === entry.id).content.length) continue;
4290
+ compressed.set(entry.id, entry.content.trim());
4291
+ }
4292
+ return compressed.size > 0 ? compressed : void 0;
4293
+ } catch {
4294
+ return;
4295
+ }
4296
+ }
4297
+ /**
4298
+ * agent/pre-step waterfall:每轮第一步注入画像(内容与上次相同则跳过重复注入);
4299
+ * 同时 fire-and-forget 触发上一轮的自动摄取(不阻塞请求);进程内首次第一步
4300
+ * 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
4301
+ * 记忆库为空或非首轮时不追加消息。
2525
4302
  */
2526
4303
  async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
2527
4304
  const decision = await next();
@@ -2547,7 +4324,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2547
4324
  });
2548
4325
  }
2549
4326
  if (turn > 1) ingestPreviousTurn({
2550
- events: agent.session.events,
4327
+ events: agent.session.snapshotEvents(),
2551
4328
  sessionId: String(agent.id),
2552
4329
  turn,
2553
4330
  openStore: () => openStore("user"),
@@ -2567,7 +4344,27 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2567
4344
  if (step !== 1) return decision;
2568
4345
  const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
2569
4346
  if (top.length === 0) return decision;
2570
- const packet = renderMemoryPacket(renderProfile(top, resolved.injectTokenBudget), "turn_start", currentUserRequestText(decision.messages));
4347
+ const detailed = renderProfileDetailed(top, resolved.injectTokenBudget);
4348
+ let text = detailed.text;
4349
+ if (detailed.overflow.length > 0) {
4350
+ const compressed = await compressProfileOverflow(ctx, agent, resolved.routeOverride, detailed.overflow, signal);
4351
+ if (compressed !== void 0) {
4352
+ openStore("user").then((auditStore) => auditStore.audit("compress-request", "AUX", JSON.stringify({ count: detailed.overflow.length }))).catch(() => {});
4353
+ text = renderProfileDetailed(top.map((record) => {
4354
+ const shorter = compressed.get(record.id);
4355
+ return shorter === void 0 ? record : {
4356
+ ...record,
4357
+ content: shorter
4358
+ };
4359
+ }), resolved.injectTokenBudget).text;
4360
+ }
4361
+ }
4362
+ const textWithRationale = wrapWithRationale(text, top);
4363
+ const hash = createHash("sha256").update(textWithRationale).digest("hex");
4364
+ if (state.lastProfileAgent === String(agent.id) && hash === state.lastProfileHash) return decision;
4365
+ state.lastProfileAgent = String(agent.id);
4366
+ state.lastProfileHash = hash;
4367
+ const packet = renderMemoryPacket(text, "turn_start", currentUserRequestText(decision.messages));
2571
4368
  return {
2572
4369
  ...decision,
2573
4370
  messages: [...decision.messages, createUserMessage({
@@ -2594,7 +4391,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
2594
4391
  */
2595
4392
  function makeEventResolver(ctx, agent) {
2596
4393
  return async (sessionId) => {
2597
- if (sessionId === String(agent.id)) return agent.session.events;
4394
+ if (sessionId === String(agent.id)) return agent.session.snapshotEvents();
2598
4395
  const persistence = ctx.get("sessionPersistence");
2599
4396
  if (persistence === void 0) return void 0;
2600
4397
  try {
@@ -2605,7 +4402,7 @@ function makeEventResolver(ctx, agent) {
2605
4402
  };
2606
4403
  }
2607
4404
  /**
2608
- * 插件体:预热分库与嵌入器,注册 9 个工具、画像注入、自动摄取与衰减调度。
4405
+ * 插件体:预热分库与嵌入器,注册 10 个工具、画像注入、自动摄取与衰减调度。
2609
4406
  * @param ctx - host 上下文。
2610
4407
  * @param config - cordis.yml 传入的可选配置;非法值在加载时 loud 失败。
2611
4408
  */
@@ -2628,7 +4425,7 @@ function apply(ctx, config = {}) {
2628
4425
  const openStore = (scope) => {
2629
4426
  const existing = stores.get(scope);
2630
4427
  if (existing !== void 0) return existing;
2631
- const created = openEngramStore(scope === "user" ? join(resolved.dbDir, "user.db") : join(resolved.dbDir, identity.dbName), rankBoost);
4428
+ const created = openEngramStore(scope === "user" ? join(resolved.dbDir, "user.db") : scope === "shared" ? join(resolved.dbDir, "shared.db") : join(resolved.dbDir, identity.dbName), rankBoost);
2632
4429
  stores.set(scope, created);
2633
4430
  return created;
2634
4431
  };
@@ -2649,21 +4446,29 @@ function apply(ctx, config = {}) {
2649
4446
  ctx.inject(["webServer"], (webCtx) => {
2650
4447
  registerEngramRoutes(webCtx, {
2651
4448
  openStore,
2652
- exportDir: `${resolved.dbDir}/exports`
4449
+ exportDir: `${resolved.dbDir}/exports`,
4450
+ mirrorDir: `${resolved.dbDir}/palaces`,
4451
+ dbDir: resolved.dbDir,
4452
+ pluginVersion: VERSION,
4453
+ embedder
2653
4454
  });
2654
4455
  });
2655
4456
  const logIngestRequest = (data) => {
2656
4457
  openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
2657
4458
  };
2658
4459
  if (resolved.injectProfile || resolved.ingest !== "off") {
2659
- const state = { pendingReplayed: false };
4460
+ const state = {
4461
+ pendingReplayed: false,
4462
+ lastProfileAgent: null,
4463
+ lastProfileHash: null
4464
+ };
2660
4465
  ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, state, logIngestRequest, payload, next), { prepend: true });
2661
4466
  }
2662
4467
  if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
2663
4468
  const mode = resolved.ingest;
2664
4469
  if (mode === "off") return;
2665
4470
  ingestFinalTurn({
2666
- events: session.events,
4471
+ events: session.snapshotEvents(),
2667
4472
  sessionId: String(session.id),
2668
4473
  turn: 0,
2669
4474
  slice: "last",
@@ -2701,6 +4506,26 @@ function apply(ctx, config = {}) {
2701
4506
  clearInterval(timer);
2702
4507
  };
2703
4508
  }, "dsh-engram: decay timer");
4509
+ const runConsolidateOnce = async () => {
4510
+ try {
4511
+ const report = await runConsolidation(await openStore("user"), embedder, {
4512
+ olderThanDays: resolved.decayAfterDays,
4513
+ importanceBelow: resolved.decayImportanceBelow
4514
+ });
4515
+ console.log(`[dsh-engram] 闭馆整理完成:归档 ${String(report.archived)},合并 ${String(report.merged)},跳过 ${String(report.skipped)}(${String(report.tookMs)} ms)`);
4516
+ } catch (error) {
4517
+ console.warn("[dsh-engram] 闭馆整理失败(不影响对话):", error);
4518
+ }
4519
+ };
4520
+ runConsolidateOnce();
4521
+ ctx.effect(() => {
4522
+ const timer = setInterval(() => {
4523
+ runConsolidateOnce();
4524
+ }, 864e5);
4525
+ return () => {
4526
+ clearInterval(timer);
4527
+ };
4528
+ }, "dsh-engram: consolidation timer");
2704
4529
  }
2705
4530
  //#endregion
2706
- export { Config, apply, inject, name, renderProfile };
4531
+ export { Config, VERSION, apply, inject, name, renderProfile, renderProfileDetailed };