@kenz1117/dsh-engram 0.6.1 → 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 +6 -4
- 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 +2207 -401
- 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,45 +1715,227 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
883
1715
|
res.end(body);
|
|
884
1716
|
return;
|
|
885
1717
|
}
|
|
886
|
-
if (req.method === "
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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 {
|
|
907
1751
|
scope,
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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);
|
|
919
1773
|
}
|
|
920
|
-
|
|
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
|
+
});
|
|
921
1796
|
return;
|
|
922
1797
|
}
|
|
923
|
-
|
|
924
|
-
|
|
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
|
+
}
|
|
1900
|
+
if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
|
|
1901
|
+
if (!guardWrite(req, res)) return;
|
|
1902
|
+
const body = await readJsonBody(req);
|
|
1903
|
+
if (body === null || typeof body.id !== "string" || body.id === "") {
|
|
1904
|
+
json(res, 400, { error: "id required" });
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
|
|
1908
|
+
const store = await deps.openStore(scope);
|
|
1909
|
+
if (route === "update") {
|
|
1910
|
+
if (typeof body.content !== "string" || body.content.trim() === "") {
|
|
1911
|
+
json(res, 400, { error: "content required" });
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
const old = await store.get(body.id);
|
|
1915
|
+
if (old === void 0) {
|
|
1916
|
+
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
json(res, 200, { record: await store.update({
|
|
1920
|
+
id: body.id,
|
|
1921
|
+
scope,
|
|
1922
|
+
kind: typeof body.kind === "string" && [
|
|
1923
|
+
"fact",
|
|
1924
|
+
"preference",
|
|
1925
|
+
"decision",
|
|
1926
|
+
"episode",
|
|
1927
|
+
"skill"
|
|
1928
|
+
].includes(body.kind) ? body.kind : old.kind,
|
|
1929
|
+
content: body.content,
|
|
1930
|
+
...typeof body.importance === "number" && Number.isFinite(body.importance) ? { importance: Math.min(1, Math.max(0, body.importance)) } : {}
|
|
1931
|
+
}) });
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
json(res, 200, { record: route === "forget" ? await store.forget(body.id) : await store.restore(body.id) });
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
json(res, 404, { error: "unknown route" });
|
|
1938
|
+
} catch (error) {
|
|
925
1939
|
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
926
1940
|
}
|
|
927
1941
|
}
|
|
@@ -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;
|
|
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
|
+
}
|
|
1078
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(?))
|
|
@@ -1238,9 +2338,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1238
2338
|
* 写入公共体:插入节点 + FTS + 操作日志(不建边、不开事务)。
|
|
1239
2339
|
* 事务由调用方持有(withTransaction)。
|
|
1240
2340
|
*/
|
|
1241
|
-
const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, op) => {
|
|
2341
|
+
const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, imagery, op) => {
|
|
1242
2342
|
const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
|
|
1243
|
-
sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored);
|
|
2343
|
+
sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery));
|
|
1244
2344
|
sqlFtsInsert.run(id, tokenizeForFts(content));
|
|
1245
2345
|
sqlLog.run(at, op, id, JSON.stringify({
|
|
1246
2346
|
kind: input.kind,
|
|
@@ -1273,7 +2373,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1273
2373
|
const id = asMemoryId(randomUUID());
|
|
1274
2374
|
const at = Date.now();
|
|
1275
2375
|
withTransaction(() => {
|
|
1276
|
-
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");
|
|
1277
2377
|
});
|
|
1278
2378
|
return rowToRecord(sqlGet.get(id));
|
|
1279
2379
|
},
|
|
@@ -1281,6 +2381,31 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1281
2381
|
const row = getRow(id);
|
|
1282
2382
|
return row === void 0 ? void 0 : rowToRecord(row);
|
|
1283
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
|
+
},
|
|
1284
2409
|
async search(query, queryVector) {
|
|
1285
2410
|
const limit = query.limit ?? 8;
|
|
1286
2411
|
const scores = /* @__PURE__ */ new Map();
|
|
@@ -1376,13 +2501,14 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1376
2501
|
const id = asMemoryId(randomUUID());
|
|
1377
2502
|
const at = Date.now();
|
|
1378
2503
|
withTransaction(() => {
|
|
2504
|
+
sqlRevisionInsert.run(input.id, old.content, old.kind, old.importance, at);
|
|
1379
2505
|
sqlSetStatus.run("archived", at, input.id);
|
|
1380
2506
|
sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
|
|
1381
2507
|
insertRecord(id, {
|
|
1382
2508
|
scope: input.scope,
|
|
1383
2509
|
kind: input.kind,
|
|
1384
2510
|
content
|
|
1385
|
-
}, 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");
|
|
1386
2512
|
sqlEdgeUpsert.run(id, input.id, "supersedes", at);
|
|
1387
2513
|
});
|
|
1388
2514
|
return rowToRecord(sqlGet.get(id));
|
|
@@ -1393,6 +2519,56 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1393
2519
|
sqlLog.run(Date.now(), "forget", id, null);
|
|
1394
2520
|
return rowToRecord(sqlGet.get(id));
|
|
1395
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
|
+
},
|
|
1396
2572
|
async restore(id) {
|
|
1397
2573
|
if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
|
|
1398
2574
|
sqlSetStatus.run("active", Date.now(), id);
|
|
@@ -1429,12 +2605,32 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1429
2605
|
const row = getRow(id);
|
|
1430
2606
|
if (row === void 0) return void 0;
|
|
1431
2607
|
const operations = sqlOpLogById.all(id, REVIEW_LOG_LIMIT);
|
|
2608
|
+
const revisions = sqlRevisionsById.all(id);
|
|
1432
2609
|
return {
|
|
1433
2610
|
record: rowToRecord(row),
|
|
1434
2611
|
...edgeGroups(id),
|
|
1435
|
-
|
|
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
|
+
}))
|
|
1436
2624
|
};
|
|
1437
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
|
+
},
|
|
1438
2634
|
async stats() {
|
|
1439
2635
|
const statusRows = sqlCountBy.all();
|
|
1440
2636
|
const kindRows = sqlCountKind.all();
|
|
@@ -1500,7 +2696,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
1500
2696
|
const id = asMemoryId(randomUUID());
|
|
1501
2697
|
const at = Date.now();
|
|
1502
2698
|
withTransaction(() => {
|
|
1503
|
-
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");
|
|
1504
2700
|
for (const oldId of oldIds) {
|
|
1505
2701
|
sqlSetStatus.run("archived", at, oldId);
|
|
1506
2702
|
sqlEdgeUpsert.run(id, oldId, "supersedes", at);
|
|
@@ -1692,6 +2888,47 @@ function mergeQueryResults(retrievals, limit, rrfConstant, minPerQuery) {
|
|
|
1692
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);
|
|
1693
2889
|
}
|
|
1694
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
|
|
1695
2932
|
//#region src/tools/create.ts
|
|
1696
2933
|
/**
|
|
1697
2934
|
* 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
|
|
@@ -1707,13 +2944,19 @@ const KINDS = [
|
|
|
1707
2944
|
];
|
|
1708
2945
|
/** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
|
|
1709
2946
|
function scopeOf(raw, fallback) {
|
|
1710
|
-
|
|
2947
|
+
if (raw === "user" || raw === "project" || raw === "shared") return raw;
|
|
2948
|
+
return fallback;
|
|
1711
2949
|
}
|
|
1712
2950
|
/** 把 search scope 参数收敛为分库集合。 */
|
|
1713
2951
|
function scopesOf(raw) {
|
|
1714
2952
|
if (raw === "user") return ["user"];
|
|
1715
2953
|
if (raw === "project") return ["project"];
|
|
1716
|
-
|
|
2954
|
+
if (raw === "shared") return ["shared"];
|
|
2955
|
+
return [
|
|
2956
|
+
"user",
|
|
2957
|
+
"project",
|
|
2958
|
+
"shared"
|
|
2959
|
+
];
|
|
1717
2960
|
}
|
|
1718
2961
|
/** 查询向量:嵌入可用时返回查询文本的向量,否则 undefined(降级)。 */
|
|
1719
2962
|
async function queryVectorOf(deps, text) {
|
|
@@ -1731,7 +2974,7 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
1731
2974
|
queries: [query],
|
|
1732
2975
|
rewritten: false
|
|
1733
2976
|
};
|
|
1734
|
-
const events = exec.agent?.session?.
|
|
2977
|
+
const events = exec.agent?.session?.snapshotEvents() ?? [];
|
|
1735
2978
|
const route = deps.routeOverride ?? routeFromEvents(events);
|
|
1736
2979
|
if (route === void 0) return {
|
|
1737
2980
|
queries: [query],
|
|
@@ -1768,7 +3011,7 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
1768
3011
|
}
|
|
1769
3012
|
}
|
|
1770
3013
|
/**
|
|
1771
|
-
* 构造
|
|
3014
|
+
* 构造 10 个工具定义(engram_save/search/timeline/update/forget/report/review/stats/export/distill)。
|
|
1772
3015
|
* @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
|
|
1773
3016
|
* @returns 可直接 register 的工具定义数组。
|
|
1774
3017
|
*/
|
|
@@ -1891,307 +3134,501 @@ function createEngramTools(deps) {
|
|
|
1891
3134
|
failed
|
|
1892
3135
|
};
|
|
1893
3136
|
}
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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 二选一",
|
|
1908
3153
|
items: {
|
|
1909
|
-
type: "
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
importance: {
|
|
1927
|
-
type: "number",
|
|
1928
|
-
description: "重要性 0-1"
|
|
1929
|
-
}
|
|
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"
|
|
1930
3171
|
}
|
|
1931
3172
|
}
|
|
1932
|
-
},
|
|
1933
|
-
scope: {
|
|
1934
|
-
type: "string",
|
|
1935
|
-
enum: ["user", "project"],
|
|
1936
|
-
description: "作用域,默认 project"
|
|
1937
|
-
},
|
|
1938
|
-
importance: {
|
|
1939
|
-
type: "number",
|
|
1940
|
-
description: "重要性 0-1,默认 0.5(仅单条模式)"
|
|
1941
3173
|
}
|
|
1942
3174
|
},
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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",
|
|
1953
3201
|
items: {
|
|
1954
|
-
type: "
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
type: "number",
|
|
1969
|
-
required: true
|
|
1970
|
-
}
|
|
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
|
|
1971
3216
|
}
|
|
1972
3217
|
}
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
|
1988
3233
|
}
|
|
1989
3234
|
}
|
|
1990
3235
|
}
|
|
1991
3236
|
}
|
|
1992
|
-
},
|
|
1993
|
-
render: (_args, value) => [{
|
|
1994
|
-
type: "text",
|
|
1995
|
-
text: renderSaveResultText(value)
|
|
1996
|
-
}]
|
|
1997
|
-
},
|
|
1998
|
-
async execute(args, exec) {
|
|
1999
|
-
const input = args;
|
|
2000
|
-
const sourceSessionId = exec.agent?.id ?? null;
|
|
2001
|
-
if (input.items !== void 0) {
|
|
2002
|
-
if (input.content !== void 0 || input.kind !== void 0) throw new Error("engram_save: items 与 content/kind 参数不能同时使用");
|
|
2003
|
-
if (!Array.isArray(input.items) || input.items.length === 0) throw new Error("engram_save: items 必须是非空数组");
|
|
2004
|
-
return saveBatch(sourceSessionId, input.items, input.scope);
|
|
2005
|
-
}
|
|
2006
|
-
if (typeof input.content !== "string" || typeof input.kind !== "string") throw new Error("engram_save: 需要 content/kind(单条)或 items(批量)参数");
|
|
2007
|
-
const content = redactSecrets(sanitizeProtocolText(input.content));
|
|
2008
|
-
if (content.trim() === "") throw new Error("engram_save: 清洗后内容为空(原文只含协议标签或密钥)");
|
|
2009
|
-
const scope = scopeOf(input.scope, "project");
|
|
2010
|
-
const store = await deps.openStore(scope);
|
|
2011
|
-
const embedder = await deps.embedder;
|
|
2012
|
-
const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
|
|
2013
|
-
const { record, candidates } = await writeWithContradictions(store, {
|
|
2014
|
-
scope,
|
|
2015
|
-
kind: input.kind,
|
|
2016
|
-
content,
|
|
2017
|
-
...typeof input.importance === "number" ? { importance: input.importance } : {},
|
|
2018
|
-
sourceSessionId,
|
|
2019
|
-
...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
|
|
2020
|
-
});
|
|
2021
|
-
if (candidates.length > 0) {
|
|
2022
|
-
const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
|
|
2023
|
-
return {
|
|
2024
|
-
id: record.id,
|
|
2025
|
-
kind: record.kind,
|
|
2026
|
-
importance: record.importance,
|
|
2027
|
-
text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
|
|
2028
|
-
};
|
|
2029
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(";");
|
|
2030
3269
|
return {
|
|
2031
3270
|
id: record.id,
|
|
2032
3271
|
kind: record.kind,
|
|
2033
|
-
importance: record.importance
|
|
3272
|
+
importance: record.importance,
|
|
3273
|
+
text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。`
|
|
2034
3274
|
};
|
|
2035
3275
|
}
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
"all"
|
|
2052
|
-
],
|
|
2053
|
-
description: "作用域,默认 all"
|
|
2054
|
-
},
|
|
2055
|
-
limit: {
|
|
2056
|
-
type: "number",
|
|
2057
|
-
description: "返回条数上限,默认 8"
|
|
2058
|
-
}
|
|
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: "检索文本"
|
|
2059
3291
|
},
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
|
2073
3319
|
}
|
|
2074
|
-
}
|
|
2075
|
-
render: (_args, value) => [{
|
|
2076
|
-
type: "text",
|
|
2077
|
-
text: value.text
|
|
2078
|
-
}]
|
|
3320
|
+
}
|
|
2079
3321
|
},
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
};
|
|
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);
|
|
2098
3340
|
}));
|
|
2099
|
-
const degraded = retrievals.some((retrieval) => retrieval.degraded);
|
|
2100
|
-
const lines = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
|
|
2101
|
-
const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
|
|
2102
|
-
return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${hit.record.content}(id=${hit.record.id})${edge}`;
|
|
2103
|
-
});
|
|
2104
3341
|
return {
|
|
2105
|
-
|
|
2106
|
-
|
|
3342
|
+
hits: results.flatMap((result) => result.hits).sort((a, b) => b.score - a.score).slice(0, limit),
|
|
3343
|
+
degraded: results.some((result) => result.degraded)
|
|
2107
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: "结束时间"
|
|
2108
3382
|
}
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
type: "string",
|
|
2116
|
-
enum: [
|
|
2117
|
-
"user",
|
|
2118
|
-
"project",
|
|
2119
|
-
"all"
|
|
2120
|
-
],
|
|
2121
|
-
description: "作用域,默认 all"
|
|
2122
|
-
},
|
|
2123
|
-
topic: {
|
|
2124
|
-
type: "string",
|
|
2125
|
-
description: "主题子串"
|
|
2126
|
-
},
|
|
2127
|
-
since: {
|
|
2128
|
-
type: "string",
|
|
2129
|
-
description: "起始时间(ISO 或可解析日期)"
|
|
2130
|
-
},
|
|
2131
|
-
until: {
|
|
3383
|
+
},
|
|
3384
|
+
output: {
|
|
3385
|
+
schema: {
|
|
3386
|
+
type: "object",
|
|
3387
|
+
additionalProperties: false,
|
|
3388
|
+
properties: { text: {
|
|
2132
3389
|
type: "string",
|
|
2133
|
-
|
|
2134
|
-
}
|
|
3390
|
+
required: true
|
|
3391
|
+
} }
|
|
2135
3392
|
},
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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: {
|
|
2141
3455
|
type: "string",
|
|
2142
3456
|
required: true
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
}
|
|
3457
|
+
},
|
|
3458
|
+
superseded: {
|
|
3459
|
+
type: "string",
|
|
3460
|
+
required: true
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
2149
3463
|
},
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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: "遗留价值:可考古 / 可复习 / 回滚时如何理解"
|
|
2170
3523
|
}
|
|
2171
|
-
}
|
|
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,
|
|
2172
3609
|
defineTool({
|
|
2173
|
-
name: "
|
|
2174
|
-
description: "
|
|
3610
|
+
name: "engram_report",
|
|
3611
|
+
description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。效果影响后续召回排序,长期无效的记忆将被衰减归档。",
|
|
2175
3612
|
parameters: {
|
|
2176
3613
|
id: {
|
|
2177
3614
|
type: "string",
|
|
2178
3615
|
required: true,
|
|
2179
|
-
description: "
|
|
3616
|
+
description: "条目 id"
|
|
2180
3617
|
},
|
|
2181
|
-
|
|
3618
|
+
outcome: {
|
|
2182
3619
|
type: "string",
|
|
3620
|
+
enum: ["success", "failure"],
|
|
2183
3621
|
required: true,
|
|
2184
|
-
description: "
|
|
3622
|
+
description: "使用效果"
|
|
2185
3623
|
},
|
|
2186
3624
|
scope: {
|
|
2187
3625
|
type: "string",
|
|
2188
|
-
enum: [
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
description: "种类,默认继承旧条目"
|
|
3626
|
+
enum: [
|
|
3627
|
+
"user",
|
|
3628
|
+
"project",
|
|
3629
|
+
"shared"
|
|
3630
|
+
],
|
|
3631
|
+
description: "条目作用域,默认 project"
|
|
2195
3632
|
}
|
|
2196
3633
|
},
|
|
2197
3634
|
output: {
|
|
@@ -2203,74 +3640,34 @@ function createEngramTools(deps) {
|
|
|
2203
3640
|
type: "string",
|
|
2204
3641
|
required: true
|
|
2205
3642
|
},
|
|
2206
|
-
|
|
3643
|
+
outcome: {
|
|
2207
3644
|
type: "string",
|
|
2208
3645
|
required: true
|
|
3646
|
+
},
|
|
3647
|
+
confidence: {
|
|
3648
|
+
type: "number",
|
|
3649
|
+
required: true
|
|
2209
3650
|
}
|
|
2210
3651
|
}
|
|
2211
3652
|
},
|
|
2212
3653
|
render: (_args, value) => [{
|
|
2213
3654
|
type: "text",
|
|
2214
|
-
text:
|
|
3655
|
+
text: value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id} 使用无效(confidence=${value.confidence})。该记忆后续召回排序将下降,持续无效会被衰减归档。`
|
|
2215
3656
|
}]
|
|
2216
3657
|
},
|
|
2217
3658
|
async execute(args) {
|
|
2218
3659
|
const input = args;
|
|
2219
|
-
|
|
2220
|
-
if (content.trim() === "") throw new Error("engram_update: 清洗后内容为空(原文只含协议标签或密钥)");
|
|
3660
|
+
if (input.outcome !== "success" && input.outcome !== "failure") throw new Error("engram_report: outcome 必须是 success 或 failure");
|
|
2221
3661
|
const scope = scopeOf(input.scope, "project");
|
|
2222
|
-
const
|
|
2223
|
-
|
|
2224
|
-
if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
|
|
2225
|
-
const embedder = await deps.embedder;
|
|
2226
|
-
const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
|
|
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})`);
|
|
2227
3664
|
return {
|
|
2228
|
-
id:
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
kind: input.kind ?? old.kind,
|
|
2232
|
-
content,
|
|
2233
|
-
...embeddings === void 0 ? {} : { embedding: embeddings[0] }
|
|
2234
|
-
})).id,
|
|
2235
|
-
superseded: input.id
|
|
3665
|
+
id: record.id,
|
|
3666
|
+
outcome: input.outcome,
|
|
3667
|
+
confidence: record.confidence
|
|
2236
3668
|
};
|
|
2237
3669
|
}
|
|
2238
3670
|
}),
|
|
2239
|
-
defineTool({
|
|
2240
|
-
name: "engram_forget",
|
|
2241
|
-
description: "遗忘一条记忆(软删,用户可从库中恢复)。id 与 scope 来自 engram_search 结果。",
|
|
2242
|
-
parameters: {
|
|
2243
|
-
id: {
|
|
2244
|
-
type: "string",
|
|
2245
|
-
required: true,
|
|
2246
|
-
description: "条目 id"
|
|
2247
|
-
},
|
|
2248
|
-
scope: {
|
|
2249
|
-
type: "string",
|
|
2250
|
-
enum: ["user", "project"],
|
|
2251
|
-
description: "条目作用域,默认 project"
|
|
2252
|
-
}
|
|
2253
|
-
},
|
|
2254
|
-
output: {
|
|
2255
|
-
schema: {
|
|
2256
|
-
type: "object",
|
|
2257
|
-
additionalProperties: false,
|
|
2258
|
-
properties: { id: {
|
|
2259
|
-
type: "string",
|
|
2260
|
-
required: true
|
|
2261
|
-
} }
|
|
2262
|
-
},
|
|
2263
|
-
render: (_args, value) => [{
|
|
2264
|
-
type: "text",
|
|
2265
|
-
text: `记忆 ${value.id} 已遗忘(软删,可恢复)。`
|
|
2266
|
-
}]
|
|
2267
|
-
},
|
|
2268
|
-
async execute(args) {
|
|
2269
|
-
const input = args;
|
|
2270
|
-
const scope = scopeOf(input.scope, "project");
|
|
2271
|
-
return { id: (await (await deps.openStore(scope)).forget(input.id)).id };
|
|
2272
|
-
}
|
|
2273
|
-
}),
|
|
2274
3671
|
defineTool({
|
|
2275
3672
|
name: "engram_review",
|
|
2276
3673
|
description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
|
|
@@ -2282,7 +3679,11 @@ function createEngramTools(deps) {
|
|
|
2282
3679
|
},
|
|
2283
3680
|
scope: {
|
|
2284
3681
|
type: "string",
|
|
2285
|
-
enum: [
|
|
3682
|
+
enum: [
|
|
3683
|
+
"user",
|
|
3684
|
+
"project",
|
|
3685
|
+
"shared"
|
|
3686
|
+
],
|
|
2286
3687
|
description: "条目作用域,默认 project"
|
|
2287
3688
|
}
|
|
2288
3689
|
},
|
|
@@ -2315,6 +3716,7 @@ function createEngramTools(deps) {
|
|
|
2315
3716
|
section("取代了谁", view.supersedes.map(String)),
|
|
2316
3717
|
section("矛盾候选", view.contradicts.map(String)),
|
|
2317
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")}`,
|
|
2318
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")}`
|
|
2319
3721
|
].filter((part) => part !== "").join("\n"), "tool_review", "(对话继续)") };
|
|
2320
3722
|
}
|
|
@@ -2327,6 +3729,7 @@ function createEngramTools(deps) {
|
|
|
2327
3729
|
enum: [
|
|
2328
3730
|
"user",
|
|
2329
3731
|
"project",
|
|
3732
|
+
"shared",
|
|
2330
3733
|
"all"
|
|
2331
3734
|
],
|
|
2332
3735
|
description: "作用域,默认 all"
|
|
@@ -2359,25 +3762,30 @@ function createEngramTools(deps) {
|
|
|
2359
3762
|
}),
|
|
2360
3763
|
defineTool({
|
|
2361
3764
|
name: "engram_export",
|
|
2362
|
-
description: "把记忆库导出为文件(Markdown
|
|
3765
|
+
description: "把记忆库导出为文件(Markdown / JSON / 镜像目录),返回文件路径。redactedView=true 时输出脱敏视图(内容二次清洗并截断为 40 字预览,可安全分享)。format=markdown-mirror 时每个房间一个 .md + frontmatter,附楼层清单 _meta.json 与全宫殿入口 _index.md,可直接用 Obsidian / git 漫游。",
|
|
2363
3766
|
parameters: {
|
|
2364
3767
|
format: {
|
|
2365
3768
|
type: "string",
|
|
2366
|
-
enum: [
|
|
2367
|
-
|
|
3769
|
+
enum: [
|
|
3770
|
+
"markdown",
|
|
3771
|
+
"json",
|
|
3772
|
+
"markdown-mirror"
|
|
3773
|
+
],
|
|
3774
|
+
description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每房间一文件(默认 markdown)"
|
|
2368
3775
|
},
|
|
2369
3776
|
scope: {
|
|
2370
3777
|
type: "string",
|
|
2371
3778
|
enum: [
|
|
2372
3779
|
"user",
|
|
2373
3780
|
"project",
|
|
3781
|
+
"shared",
|
|
2374
3782
|
"all"
|
|
2375
3783
|
],
|
|
2376
3784
|
description: "作用域,默认 all"
|
|
2377
3785
|
},
|
|
2378
3786
|
redactedView: {
|
|
2379
3787
|
type: "boolean",
|
|
2380
|
-
description: "脱敏视图:内容二次脱敏并截断为预览(默认 false
|
|
3788
|
+
description: "脱敏视图:内容二次脱敏并截断为预览(默认 false 完整导出;镜像模式忽略此参数)"
|
|
2381
3789
|
}
|
|
2382
3790
|
},
|
|
2383
3791
|
output: {
|
|
@@ -2396,7 +3804,7 @@ function createEngramTools(deps) {
|
|
|
2396
3804
|
},
|
|
2397
3805
|
async execute(args) {
|
|
2398
3806
|
const input = args;
|
|
2399
|
-
const format = input.format === "json" ? "json" : "markdown";
|
|
3807
|
+
const format = input.format === "json" ? "json" : input.format === "markdown-mirror" ? "markdown-mirror" : "markdown";
|
|
2400
3808
|
const redactedView = input.redactedView === true;
|
|
2401
3809
|
const scopes = scopesOf(input.scope);
|
|
2402
3810
|
const preview = (content) => {
|
|
@@ -2410,6 +3818,13 @@ function createEngramTools(deps) {
|
|
|
2410
3818
|
const written = [];
|
|
2411
3819
|
for (const scope of scopes) {
|
|
2412
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
|
+
}
|
|
2413
3828
|
const payload = redactedView ? {
|
|
2414
3829
|
...data,
|
|
2415
3830
|
records: data.records.map((record) => ({
|
|
@@ -2440,7 +3855,11 @@ function createEngramTools(deps) {
|
|
|
2440
3855
|
description: "蒸馏整理:把同主题的记忆簇合并提炼为更高层的规律(旧条目归档、supersedes 链保留)。建议记忆较多时周期性执行。",
|
|
2441
3856
|
parameters: { scope: {
|
|
2442
3857
|
type: "string",
|
|
2443
|
-
enum: [
|
|
3858
|
+
enum: [
|
|
3859
|
+
"user",
|
|
3860
|
+
"project",
|
|
3861
|
+
"shared"
|
|
3862
|
+
],
|
|
2444
3863
|
description: "作用域,默认 user"
|
|
2445
3864
|
} },
|
|
2446
3865
|
output: {
|
|
@@ -2461,7 +3880,7 @@ function createEngramTools(deps) {
|
|
|
2461
3880
|
const scope = scopeOf(args.scope, "user");
|
|
2462
3881
|
if (deps.call === void 0) throw new Error("engram_distill: 辅助 LLM 不可用(宿主未提供 llm 服务),无法蒸馏");
|
|
2463
3882
|
const call = deps.call;
|
|
2464
|
-
const events = exec.agent?.session?.
|
|
3883
|
+
const events = exec.agent?.session?.snapshotEvents() ?? [];
|
|
2465
3884
|
const route = deps.routeOverride ?? routeFromEvents(events);
|
|
2466
3885
|
if (route === void 0) throw new Error("engram_distill: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
|
|
2467
3886
|
const embedder = await deps.embedder;
|
|
@@ -2483,19 +3902,298 @@ function createEngramTools(deps) {
|
|
|
2483
3902
|
});
|
|
2484
3903
|
return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
|
|
2485
3904
|
}
|
|
2486
|
-
})
|
|
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
|
|
4056
|
+
];
|
|
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
|
+
""
|
|
2487
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(" / ");
|
|
2488
4184
|
}
|
|
2489
4185
|
//#endregion
|
|
2490
4186
|
//#region src/index.ts
|
|
2491
4187
|
/**
|
|
2492
4188
|
* dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
|
|
2493
|
-
* 注册
|
|
4189
|
+
* 注册 10 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
|
|
2494
4190
|
* 蒸馏/衰减飞轮与审计能力。
|
|
2495
4191
|
* @module @kenz1117/dsh-engram
|
|
2496
4192
|
*/
|
|
2497
4193
|
/** Cordis 插件名(loader 诊断与注入 source 使用)。 */
|
|
2498
4194
|
const name = "dsh-engram";
|
|
4195
|
+
/** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
|
|
4196
|
+
const VERSION = "0.7.0";
|
|
2499
4197
|
/** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
|
|
2500
4198
|
const inject = ["tools", "llm"];
|
|
2501
4199
|
/**
|
|
@@ -2504,9 +4202,9 @@ const inject = ["tools", "llm"];
|
|
|
2504
4202
|
* 索引行也装不下的折成末尾 `+N more; use engram_search` 计数行。
|
|
2505
4203
|
* @param records - 候选条目(调用方已按重要性排序、按条数截断)。
|
|
2506
4204
|
* @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
|
|
2507
|
-
* @returns
|
|
4205
|
+
* @returns 渲染文本与溢出条目(调用方可用辅助 LLM 压缩后重渲染)。
|
|
2508
4206
|
*/
|
|
2509
|
-
function
|
|
4207
|
+
function renderProfileDetailed(records, tokenBudget) {
|
|
2510
4208
|
const estimate = (text) => Math.ceil(text.length / 4);
|
|
2511
4209
|
const header = "User memory profile (dsh-engram, cross-session):";
|
|
2512
4210
|
const footer = "Use engram_search to recall details; use engram_save to persist new facts.";
|
|
@@ -2531,16 +4229,76 @@ function renderProfile(records, tokenBudget) {
|
|
|
2531
4229
|
} else more += 1;
|
|
2532
4230
|
}
|
|
2533
4231
|
if (more > 0) lines.push(`+${more} more; use engram_search`);
|
|
2534
|
-
return
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
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;
|
|
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");
|
|
4259
|
+
/**
|
|
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
|
+
}
|
|
2539
4296
|
}
|
|
2540
4297
|
/**
|
|
2541
|
-
* agent/pre-step waterfall
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
4298
|
+
* agent/pre-step waterfall:每轮第一步注入画像(内容与上次相同则跳过重复注入);
|
|
4299
|
+
* 同时 fire-and-forget 触发上一轮的自动摄取(不阻塞请求);进程内首次第一步
|
|
4300
|
+
* 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
|
|
4301
|
+
* 记忆库为空或非首轮时不追加消息。
|
|
2544
4302
|
*/
|
|
2545
4303
|
async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
2546
4304
|
const decision = await next();
|
|
@@ -2566,7 +4324,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
2566
4324
|
});
|
|
2567
4325
|
}
|
|
2568
4326
|
if (turn > 1) ingestPreviousTurn({
|
|
2569
|
-
events: agent.session.
|
|
4327
|
+
events: agent.session.snapshotEvents(),
|
|
2570
4328
|
sessionId: String(agent.id),
|
|
2571
4329
|
turn,
|
|
2572
4330
|
openStore: () => openStore("user"),
|
|
@@ -2586,7 +4344,27 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
2586
4344
|
if (step !== 1) return decision;
|
|
2587
4345
|
const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
|
|
2588
4346
|
if (top.length === 0) return decision;
|
|
2589
|
-
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));
|
|
2590
4368
|
return {
|
|
2591
4369
|
...decision,
|
|
2592
4370
|
messages: [...decision.messages, createUserMessage({
|
|
@@ -2613,7 +4391,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
2613
4391
|
*/
|
|
2614
4392
|
function makeEventResolver(ctx, agent) {
|
|
2615
4393
|
return async (sessionId) => {
|
|
2616
|
-
if (sessionId === String(agent.id)) return agent.session.
|
|
4394
|
+
if (sessionId === String(agent.id)) return agent.session.snapshotEvents();
|
|
2617
4395
|
const persistence = ctx.get("sessionPersistence");
|
|
2618
4396
|
if (persistence === void 0) return void 0;
|
|
2619
4397
|
try {
|
|
@@ -2624,7 +4402,7 @@ function makeEventResolver(ctx, agent) {
|
|
|
2624
4402
|
};
|
|
2625
4403
|
}
|
|
2626
4404
|
/**
|
|
2627
|
-
* 插件体:预热分库与嵌入器,注册
|
|
4405
|
+
* 插件体:预热分库与嵌入器,注册 10 个工具、画像注入、自动摄取与衰减调度。
|
|
2628
4406
|
* @param ctx - host 上下文。
|
|
2629
4407
|
* @param config - cordis.yml 传入的可选配置;非法值在加载时 loud 失败。
|
|
2630
4408
|
*/
|
|
@@ -2647,7 +4425,7 @@ function apply(ctx, config = {}) {
|
|
|
2647
4425
|
const openStore = (scope) => {
|
|
2648
4426
|
const existing = stores.get(scope);
|
|
2649
4427
|
if (existing !== void 0) return existing;
|
|
2650
|
-
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);
|
|
2651
4429
|
stores.set(scope, created);
|
|
2652
4430
|
return created;
|
|
2653
4431
|
};
|
|
@@ -2668,21 +4446,29 @@ function apply(ctx, config = {}) {
|
|
|
2668
4446
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2669
4447
|
registerEngramRoutes(webCtx, {
|
|
2670
4448
|
openStore,
|
|
2671
|
-
exportDir: `${resolved.dbDir}/exports
|
|
4449
|
+
exportDir: `${resolved.dbDir}/exports`,
|
|
4450
|
+
mirrorDir: `${resolved.dbDir}/palaces`,
|
|
4451
|
+
dbDir: resolved.dbDir,
|
|
4452
|
+
pluginVersion: VERSION,
|
|
4453
|
+
embedder
|
|
2672
4454
|
});
|
|
2673
4455
|
});
|
|
2674
4456
|
const logIngestRequest = (data) => {
|
|
2675
4457
|
openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
|
|
2676
4458
|
};
|
|
2677
4459
|
if (resolved.injectProfile || resolved.ingest !== "off") {
|
|
2678
|
-
const state = {
|
|
4460
|
+
const state = {
|
|
4461
|
+
pendingReplayed: false,
|
|
4462
|
+
lastProfileAgent: null,
|
|
4463
|
+
lastProfileHash: null
|
|
4464
|
+
};
|
|
2679
4465
|
ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, state, logIngestRequest, payload, next), { prepend: true });
|
|
2680
4466
|
}
|
|
2681
4467
|
if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
|
|
2682
4468
|
const mode = resolved.ingest;
|
|
2683
4469
|
if (mode === "off") return;
|
|
2684
4470
|
ingestFinalTurn({
|
|
2685
|
-
events: session.
|
|
4471
|
+
events: session.snapshotEvents(),
|
|
2686
4472
|
sessionId: String(session.id),
|
|
2687
4473
|
turn: 0,
|
|
2688
4474
|
slice: "last",
|
|
@@ -2720,6 +4506,26 @@ function apply(ctx, config = {}) {
|
|
|
2720
4506
|
clearInterval(timer);
|
|
2721
4507
|
};
|
|
2722
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");
|
|
2723
4529
|
}
|
|
2724
4530
|
//#endregion
|
|
2725
|
-
export { Config, apply, inject, name, renderProfile };
|
|
4531
|
+
export { Config, VERSION, apply, inject, name, renderProfile, renderProfileDetailed };
|