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