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