@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/README.en.md +4 -3
- package/README.md +7 -5
- package/icon-256.svg +40 -0
- package/icon.svg +29 -0
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +2211 -386
- package/package.json +18 -4
package/lib/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1063
|
-
const SCHEMA_VERSION =
|
|
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
|
-
|
|
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
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
* 构造
|
|
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
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: "
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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: "
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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: "
|
|
2037
|
-
description: "
|
|
3610
|
+
name: "engram_report",
|
|
3611
|
+
description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。效果影响后续召回排序,长期无效的记忆将被衰减归档。",
|
|
2038
3612
|
parameters: {
|
|
2039
|
-
|
|
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
|
-
"
|
|
3629
|
+
"shared"
|
|
2050
3630
|
],
|
|
2051
|
-
description: "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
2221
|
-
|
|
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:
|
|
2227
|
-
|
|
2228
|
-
|
|
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: [
|
|
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
|
|
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: [
|
|
2365
|
-
|
|
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
|
|
2404
|
-
const
|
|
2405
|
-
|
|
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
|
-
...
|
|
3841
|
+
...payload.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
|
|
2408
3842
|
"",
|
|
2409
3843
|
"## 关系边",
|
|
2410
|
-
...
|
|
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}(${
|
|
3848
|
+
written.push(`${path}(${payload.records.length} 条记忆,${payload.edges.length} 条边${redactedView ? ",脱敏视图" : ""})`);
|
|
2415
3849
|
}
|
|
2416
|
-
return { text:
|
|
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: [
|
|
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?.
|
|
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
|
-
* 注册
|
|
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
|
|
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
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
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
|
-
*
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
* 插件体:预热分库与嵌入器,注册
|
|
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 = {
|
|
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.
|
|
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 };
|