@kenz1117/dsh-engram 0.7.2 → 0.7.4
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 +19 -6
- package/README.md +19 -6
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +754 -94
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -30,7 +30,13 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
|
30
30
|
"rankProofWeight",
|
|
31
31
|
"queryRewrite",
|
|
32
32
|
"autoSlot",
|
|
33
|
-
"reviewScheduling"
|
|
33
|
+
"reviewScheduling",
|
|
34
|
+
"historyBackfillDays",
|
|
35
|
+
"historyBackfillMaxTurnsPerSession",
|
|
36
|
+
"historyBackfillMaxTotalTurns",
|
|
37
|
+
"historyBackfillIncludeSubagents",
|
|
38
|
+
"historyBackfillIncludeSeeded",
|
|
39
|
+
"historyBackfillIncludeNoCwd"
|
|
34
40
|
]);
|
|
35
41
|
const INGEST_MODES = /* @__PURE__ */ new Set([
|
|
36
42
|
"off",
|
|
@@ -54,13 +60,19 @@ const Config = z.object({
|
|
|
54
60
|
rankProofWeight: z.number().min(0).max(2),
|
|
55
61
|
queryRewrite: z.boolean(),
|
|
56
62
|
autoSlot: z.boolean(),
|
|
57
|
-
reviewScheduling: 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()
|
|
58
70
|
});
|
|
59
71
|
/**
|
|
60
72
|
* 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
|
|
61
73
|
* @param config - cordis.yml 传入的未校验配置。
|
|
62
74
|
* @returns 完整解析配置。
|
|
63
|
-
* @throws 未知键、ingest 档位非法、provider/model 只给其一、decay
|
|
75
|
+
* @throws 未知键、ingest 档位非法、provider/model 只给其一、decay/预算/排序权重/历史回填规则越界时抛错。
|
|
64
76
|
*/
|
|
65
77
|
function resolveConfig(config = {}) {
|
|
66
78
|
for (const key of Object.keys(config)) if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-engram: unknown config key "${key}"`);
|
|
@@ -74,6 +86,9 @@ function resolveConfig(config = {}) {
|
|
|
74
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]");
|
|
75
87
|
if (config.rankRecencyWeight !== void 0 && (config.rankRecencyWeight < 0 || config.rankRecencyWeight > 2)) throw new Error("dsh-engram: rankRecencyWeight must be in [0, 2]");
|
|
76
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]");
|
|
77
92
|
const dbDir = config.dbDir ?? join(homedir(), ".dsh", "engram");
|
|
78
93
|
return {
|
|
79
94
|
dbDir,
|
|
@@ -93,7 +108,15 @@ function resolveConfig(config = {}) {
|
|
|
93
108
|
rankProofWeight: config.rankProofWeight ?? .1,
|
|
94
109
|
queryRewrite: config.queryRewrite ?? true,
|
|
95
110
|
autoSlot: config.autoSlot ?? true,
|
|
96
|
-
reviewScheduling: config.reviewScheduling ?? 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
|
+
}
|
|
97
120
|
};
|
|
98
121
|
}
|
|
99
122
|
//#endregion
|
|
@@ -656,7 +679,7 @@ async function ingestPreviousTurn(deps) {
|
|
|
656
679
|
written: 0,
|
|
657
680
|
skipped: "already-ingested"
|
|
658
681
|
};
|
|
659
|
-
if (sliceMode === "previous") {
|
|
682
|
+
if (sliceMode === "previous" || deps.throttle === true) {
|
|
660
683
|
const throttled = throttleDecision(slice);
|
|
661
684
|
if (throttled !== null) return {
|
|
662
685
|
scannedEvents: slice.length,
|
|
@@ -726,7 +749,7 @@ async function ingestPreviousTurn(deps) {
|
|
|
726
749
|
}
|
|
727
750
|
if (writtenContents.includes(content)) continue;
|
|
728
751
|
await store.write({
|
|
729
|
-
scope: "user",
|
|
752
|
+
scope: deps.writeScope ?? "user",
|
|
730
753
|
kind,
|
|
731
754
|
content,
|
|
732
755
|
importance,
|
|
@@ -734,7 +757,8 @@ async function ingestPreviousTurn(deps) {
|
|
|
734
757
|
sourceSessionId: deps.sessionId,
|
|
735
758
|
sourceRound: round,
|
|
736
759
|
...minSeq === null ? {} : { sourceSeq: minSeq },
|
|
737
|
-
...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 } : {}
|
|
738
762
|
});
|
|
739
763
|
writtenContents.push(content);
|
|
740
764
|
written += 1;
|
|
@@ -824,6 +848,286 @@ async function replayPendingIngests(deps) {
|
|
|
824
848
|
};
|
|
825
849
|
}
|
|
826
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
|
|
827
1131
|
//#region src/consolidation/run.ts
|
|
828
1132
|
/** 单条整理动作(写入 op_log 的统一标记)。 */
|
|
829
1133
|
const CONSOLIDATION_OP = "consolidation";
|
|
@@ -971,11 +1275,11 @@ function cosine$1(a, b) {
|
|
|
971
1275
|
//#region src/mirror/markdown.ts
|
|
972
1276
|
/**
|
|
973
1277
|
* Markdown 镜像:把 SQLite 记忆库导出为可被 Obsidian / VS Code / git 直接漫游的
|
|
974
|
-
*
|
|
1278
|
+
* 文件树:按房间(kind)分目录,每条记忆一个 .md + frontmatter,附房间清单 _meta.json 与全宫殿入口 _index.md。
|
|
975
1279
|
* 写入过程全部幂等:重复执行只会覆盖同名文件,不会向 SQLite 写任何东西(只读)。
|
|
976
1280
|
* @module @kenz1117/dsh-engram/mirror/markdown
|
|
977
1281
|
*/
|
|
978
|
-
/**
|
|
1282
|
+
/** 记忆铭牌 URL/路径安全的 slug(仅 ASCII、连字符分隔)。 */
|
|
979
1283
|
function slugify(input) {
|
|
980
1284
|
const stripped = input.toLowerCase().replace(/[\u4e00-\u9fa5]+/g, "记").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
981
1285
|
return stripped === "" ? "untitled" : stripped.slice(0, 32);
|
|
@@ -990,30 +1294,31 @@ function yaml(value) {
|
|
|
990
1294
|
function iso(ms) {
|
|
991
1295
|
return new Date(ms).toISOString();
|
|
992
1296
|
}
|
|
993
|
-
/**
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1297
|
+
/** 房间展示名(kind → 中文房间名;与 client 词典的 kindFact… 对应,host 侧不引 client 模块)。
|
|
1298
|
+
* 仅用于 _index.md 的可读标签,目录名仍用 kind 原值以保持路径稳定。 */
|
|
1299
|
+
const ROOM_LABEL = {
|
|
1300
|
+
fact: "事实厅",
|
|
1301
|
+
preference: "偏好阁",
|
|
1302
|
+
decision: "决策堂",
|
|
1303
|
+
episode: "往事廊",
|
|
1304
|
+
skill: "技法坊"
|
|
1000
1305
|
};
|
|
1001
|
-
/** 顶层 _index.md
|
|
1002
|
-
function renderIndex(scope, data,
|
|
1306
|
+
/** 顶层 _index.md 的纯文本模板(房间导览 + 记忆清单链接)。 */
|
|
1307
|
+
function renderIndex(scope, data, rooms) {
|
|
1003
1308
|
const total = data.records.length;
|
|
1004
1309
|
const active = data.records.filter((r) => r.status === "active").length;
|
|
1005
1310
|
const edges = data.edges.length;
|
|
1006
1311
|
return `# 记忆宫殿 · ${scope === "user" ? "私人宫殿" : "项目宫殿"}\n` + [
|
|
1007
1312
|
"",
|
|
1008
|
-
`> 导出时间 ${iso(data.exportedAt)} · 共 **${total}**
|
|
1313
|
+
`> 导出时间 ${iso(data.exportedAt)} · 共 **${total}** 条记忆(对外开放 ${active}) · 走廊 ${edges} 条`,
|
|
1009
1314
|
"",
|
|
1010
|
-
"##
|
|
1315
|
+
"## 房间导览",
|
|
1011
1316
|
"",
|
|
1012
|
-
...
|
|
1317
|
+
...rooms.map((room) => `- **${ROOM_LABEL[room.kind] ?? room.kind}** · ${room.memoryCount} 条记忆(开放 ${room.active} · 展厅 ${room.archived} · 闭馆 ${room.forgotten})`),
|
|
1013
1318
|
"",
|
|
1014
|
-
"##
|
|
1319
|
+
"## 记忆清单",
|
|
1015
1320
|
"",
|
|
1016
|
-
...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)}]]`),
|
|
1017
1322
|
"",
|
|
1018
1323
|
"## 走廊(关系边)",
|
|
1019
1324
|
"",
|
|
@@ -1021,8 +1326,8 @@ function renderIndex(scope, data, floors) {
|
|
|
1021
1326
|
""
|
|
1022
1327
|
].join("\n");
|
|
1023
1328
|
}
|
|
1024
|
-
/**
|
|
1025
|
-
function
|
|
1329
|
+
/** 单条记忆 .md 模板:YAML frontmatter + 铭牌正文 + 走廊列表。 */
|
|
1330
|
+
function renderMemory(record, edges) {
|
|
1026
1331
|
const relEdges = edges.filter((edge) => edge.from === record.id || edge.to === record.id);
|
|
1027
1332
|
const tags = [];
|
|
1028
1333
|
if (record.outcome !== void 0) tags.push(`outcome-${record.outcome}`);
|
|
@@ -1048,13 +1353,13 @@ function renderRoom(record, edges) {
|
|
|
1048
1353
|
"---"
|
|
1049
1354
|
].join("\n");
|
|
1050
1355
|
const body = [
|
|
1051
|
-
`# ${record.content.split("\n")[0]?.slice(0, 80) ?? "
|
|
1356
|
+
`# ${record.content.split("\n")[0]?.slice(0, 80) ?? "记忆铭牌"}`,
|
|
1052
1357
|
"",
|
|
1053
1358
|
record.content,
|
|
1054
1359
|
"",
|
|
1055
1360
|
"## 走廊",
|
|
1056
1361
|
"",
|
|
1057
|
-
...relEdges.length === 0 ? ["
|
|
1362
|
+
...relEdges.length === 0 ? ["(此记忆暂未连接任何走廊)"] : relEdges.map((edge) => {
|
|
1058
1363
|
const other = edge.from === record.id ? edge.to : edge.from;
|
|
1059
1364
|
return `- ${edge.from === record.id ? "→" : "←"} \`${other.slice(0, 8)}\`(${edge.type})`;
|
|
1060
1365
|
}),
|
|
@@ -1062,27 +1367,27 @@ function renderRoom(record, edges) {
|
|
|
1062
1367
|
].join("\n");
|
|
1063
1368
|
return frontmatter + "\n" + body;
|
|
1064
1369
|
}
|
|
1065
|
-
/**
|
|
1066
|
-
function renderMeta(scope, data,
|
|
1370
|
+
/** 房间清单 _meta.json 模板。 */
|
|
1371
|
+
function renderMeta(scope, data, rooms) {
|
|
1067
1372
|
const payload = {
|
|
1068
1373
|
scope,
|
|
1069
1374
|
exportedAt: data.exportedAt,
|
|
1070
1375
|
total: data.records.length,
|
|
1071
|
-
|
|
1376
|
+
rooms
|
|
1072
1377
|
};
|
|
1073
1378
|
return JSON.stringify(payload, null, 2) + "\n";
|
|
1074
1379
|
}
|
|
1075
|
-
/**
|
|
1076
|
-
function
|
|
1380
|
+
/** 计算房间摘要(按 kind 分组统计 active/archived/forgotten 的记忆条数)。 */
|
|
1381
|
+
function summarizeRooms(records) {
|
|
1077
1382
|
const map = /* @__PURE__ */ new Map();
|
|
1078
1383
|
for (const record of records) {
|
|
1079
1384
|
const entry = map.get(record.kind) ?? {
|
|
1080
|
-
|
|
1385
|
+
memoryCount: 0,
|
|
1081
1386
|
active: 0,
|
|
1082
1387
|
archived: 0,
|
|
1083
1388
|
forgotten: 0
|
|
1084
1389
|
};
|
|
1085
|
-
entry.
|
|
1390
|
+
entry.memoryCount += 1;
|
|
1086
1391
|
if (record.status === "active") entry.active += 1;
|
|
1087
1392
|
else if (record.status === "archived") entry.archived += 1;
|
|
1088
1393
|
else if (record.status === "forgotten") entry.forgotten += 1;
|
|
@@ -1097,7 +1402,7 @@ function summarizeFloors(records) {
|
|
|
1097
1402
|
* 把一份 exportAll 数据写入镜像目录。
|
|
1098
1403
|
* @param rootDir - 镜像根目录(通常 `${exportDir}/mirror/<scope>/`,由调用方拼)。
|
|
1099
1404
|
* @param data - exportAll 的产物(含 records + edges)。
|
|
1100
|
-
* @returns 写入摘要(rootDir / fileCount /
|
|
1405
|
+
* @returns 写入摘要(rootDir / fileCount / rooms)。
|
|
1101
1406
|
*/
|
|
1102
1407
|
async function writeMirror(rootDir, data) {
|
|
1103
1408
|
const scope = data.records[0]?.scope ?? "user";
|
|
@@ -1105,7 +1410,7 @@ async function writeMirror(rootDir, data) {
|
|
|
1105
1410
|
recursive: true,
|
|
1106
1411
|
mode: 448
|
|
1107
1412
|
});
|
|
1108
|
-
const
|
|
1413
|
+
const rooms = summarizeRooms(data.records);
|
|
1109
1414
|
const recordsByKind = /* @__PURE__ */ new Map();
|
|
1110
1415
|
for (const record of data.records) {
|
|
1111
1416
|
const list = recordsByKind.get(record.kind) ?? [];
|
|
@@ -1114,20 +1419,20 @@ async function writeMirror(rootDir, data) {
|
|
|
1114
1419
|
}
|
|
1115
1420
|
let fileCount = 0;
|
|
1116
1421
|
for (const record of data.records) {
|
|
1117
|
-
const
|
|
1118
|
-
if (
|
|
1119
|
-
const
|
|
1120
|
-
const
|
|
1121
|
-
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, {
|
|
1122
1427
|
recursive: true,
|
|
1123
1428
|
mode: 448
|
|
1124
1429
|
});
|
|
1125
|
-
const fileName = `${record.id.slice(0, 8)}-${slugify(record.content)}-${String(
|
|
1126
|
-
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 });
|
|
1127
1432
|
fileCount += 1;
|
|
1128
1433
|
}
|
|
1129
|
-
await writeFile(join(rootDir, "_index.md"), renderIndex(scope, data,
|
|
1130
|
-
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 });
|
|
1131
1436
|
fileCount += 2;
|
|
1132
1437
|
if (scope === "shared") {
|
|
1133
1438
|
const manifest = buildShareManifest(data);
|
|
@@ -1137,7 +1442,7 @@ async function writeMirror(rootDir, data) {
|
|
|
1137
1442
|
return {
|
|
1138
1443
|
rootDir,
|
|
1139
1444
|
fileCount,
|
|
1140
|
-
|
|
1445
|
+
rooms,
|
|
1141
1446
|
exportedAt: data.exportedAt
|
|
1142
1447
|
};
|
|
1143
1448
|
}
|
|
@@ -1159,7 +1464,7 @@ function buildShareManifest(data, now = Date.now()) {
|
|
|
1159
1464
|
return {
|
|
1160
1465
|
generatedAt: now,
|
|
1161
1466
|
scope: "shared",
|
|
1162
|
-
|
|
1467
|
+
memoryCount: loans.length,
|
|
1163
1468
|
loans
|
|
1164
1469
|
};
|
|
1165
1470
|
}
|
|
@@ -1447,7 +1752,7 @@ function buildTourProposal(scope, records, focusKind) {
|
|
|
1447
1752
|
return focusBoost(b) * 1e3 + b.importance * b.confidence * 100 - scoreA;
|
|
1448
1753
|
}).slice(0, limit);
|
|
1449
1754
|
return {
|
|
1450
|
-
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(";")}。`,
|
|
1451
1756
|
suggestedStops: scored,
|
|
1452
1757
|
activeCount: active.length,
|
|
1453
1758
|
empty
|
|
@@ -1457,6 +1762,7 @@ function buildTourProposal(scope, records, focusKind) {
|
|
|
1457
1762
|
//#region src/refurb.ts
|
|
1458
1763
|
/** 缺省参数。 */
|
|
1459
1764
|
const DEFAULT_REFURB_OPTIONS = {
|
|
1765
|
+
minActive: 8,
|
|
1460
1766
|
demoteBelow: .2,
|
|
1461
1767
|
staleDays: 60,
|
|
1462
1768
|
duplicateTitleWindow: 0
|
|
@@ -1464,11 +1770,13 @@ const DEFAULT_REFURB_OPTIONS = {
|
|
|
1464
1770
|
/**
|
|
1465
1771
|
* 扫描一组 active 条目,按规则生成翻新建议。
|
|
1466
1772
|
* 仅扫描同 scope 内的内容;跨 scope 的相似合并留给上层判定。
|
|
1773
|
+
* active 条目少于 `options.minActive` 时返回空列表:小库样本太少,逐条命中噪声大于价值。
|
|
1467
1774
|
*/
|
|
1468
1775
|
function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
|
|
1469
1776
|
const suggestions = [];
|
|
1470
1777
|
const now = Date.now();
|
|
1471
1778
|
const active = records.filter((r) => r.status === "active");
|
|
1779
|
+
if (active.length < options.minActive) return [];
|
|
1472
1780
|
const byScope = /* @__PURE__ */ new Map();
|
|
1473
1781
|
for (const record of active) {
|
|
1474
1782
|
const list = byScope.get(record.scope) ?? [];
|
|
@@ -1506,7 +1814,7 @@ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
|
|
|
1506
1814
|
primaryId: record.id,
|
|
1507
1815
|
candidates: dupes.map((d) => d.id),
|
|
1508
1816
|
scope,
|
|
1509
|
-
reason: `与 ${dupes.length}
|
|
1817
|
+
reason: `与 ${dupes.length} 条记忆内容前 20 字重复,建议蒸馏合并。`,
|
|
1510
1818
|
confidence: .6
|
|
1511
1819
|
});
|
|
1512
1820
|
}
|
|
@@ -1525,7 +1833,7 @@ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
|
|
|
1525
1833
|
primaryId: record.id,
|
|
1526
1834
|
candidates: [],
|
|
1527
1835
|
scope: record.scope,
|
|
1528
|
-
reason: `内容长度 ${record.content.length} > 400
|
|
1836
|
+
reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立记忆。`,
|
|
1529
1837
|
confidence: .6
|
|
1530
1838
|
});
|
|
1531
1839
|
for (const record of active) {
|
|
@@ -1617,6 +1925,36 @@ function scopeOf$1(raw, fallback) {
|
|
|
1617
1925
|
if (raw === "user" || raw === "project" || raw === "shared") return raw;
|
|
1618
1926
|
return fallback;
|
|
1619
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
|
+
}
|
|
1620
1958
|
/**
|
|
1621
1959
|
* 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
|
|
1622
1960
|
* @param ctx - 携带 webServer 服务的宿主上下文。
|
|
@@ -1838,7 +2176,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1838
2176
|
json(res, 200, {
|
|
1839
2177
|
...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
|
|
1840
2178
|
scope,
|
|
1841
|
-
|
|
2179
|
+
memoryCount: data.records.length,
|
|
1842
2180
|
edgeCount: data.edges.length
|
|
1843
2181
|
});
|
|
1844
2182
|
return;
|
|
@@ -1967,6 +2305,47 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1967
2305
|
});
|
|
1968
2306
|
return;
|
|
1969
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
|
+
}
|
|
1970
2349
|
if (req.method === "POST" && route === "consolidate") {
|
|
1971
2350
|
if (!guardWrite(req, res)) return;
|
|
1972
2351
|
const body = await readJsonBody(req);
|
|
@@ -2649,7 +3028,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
|
|
|
2649
3028
|
* 写入期自动化(save/批量/摄取/update/蒸馏全部写入路径统一在此落地;调用方须已持事务):
|
|
2650
3029
|
* 1) 排桩——显式 slot 优先,否则按 kind 分房自动分配(满员开新房并记 op_log);
|
|
2651
3030
|
* 2) 门牌评分——有铭牌时按「唯一/差异化/带日期」启发式落库;
|
|
2652
|
-
* 3) 初始排期——reviewScheduling 开启且未显式指定时,1
|
|
3031
|
+
* 3) 初始排期——reviewScheduling 开启且未显式指定时,1 天后首次到期(显式 null = 明确不排期);
|
|
2653
3032
|
* 4) 巡游路线——有桩位的条目登记到固定路线末尾。
|
|
2654
3033
|
* @returns 合入 WriteInput 的自动化字段。
|
|
2655
3034
|
*/
|
|
@@ -2676,7 +3055,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
|
|
|
2676
3055
|
roomCaptions: slot === void 0 ? [] : placards.filter((row) => row.room === slot.room).map((row) => row.caption)
|
|
2677
3056
|
});
|
|
2678
3057
|
}
|
|
2679
|
-
const initialReviewAt = input.initialReviewAt
|
|
3058
|
+
const initialReviewAt = input.initialReviewAt === void 0 ? automation.reviewScheduling ? at + 864e5 : void 0 : input.initialReviewAt ?? void 0;
|
|
2680
3059
|
if (slot !== void 0 && sqlRouteHas.get(id) === void 0) sqlRouteAppend.run(id);
|
|
2681
3060
|
return {
|
|
2682
3061
|
...slot === void 0 ? {} : { slot },
|
|
@@ -2811,11 +3190,21 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
|
|
|
2811
3190
|
},
|
|
2812
3191
|
async timeline(query) {
|
|
2813
3192
|
const limit = query.limit ?? 20;
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
AND (? IS NULL OR
|
|
2817
|
-
|
|
2818
|
-
|
|
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);
|
|
2819
3208
|
},
|
|
2820
3209
|
async update(input) {
|
|
2821
3210
|
const old = getRow(input.id);
|
|
@@ -3354,6 +3743,33 @@ const KINDS = [
|
|
|
3354
3743
|
"episode",
|
|
3355
3744
|
"skill"
|
|
3356
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
|
+
}
|
|
3357
3773
|
/** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
|
|
3358
3774
|
function scopeOf(raw, fallback) {
|
|
3359
3775
|
if (raw === "user" || raw === "project" || raw === "shared") return raw;
|
|
@@ -3423,8 +3839,8 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
3423
3839
|
}
|
|
3424
3840
|
}
|
|
3425
3841
|
/**
|
|
3426
|
-
* 构造
|
|
3427
|
-
* stats/export/distill/examine/neighbors/audit_forgotten/tour)。
|
|
3842
|
+
* 构造 16 个工具定义(engram_save/search/timeline/update/forget/report/review/review_queue/
|
|
3843
|
+
* stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
|
|
3428
3844
|
* @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
|
|
3429
3845
|
* @returns 可直接 register 的工具定义数组。
|
|
3430
3846
|
*/
|
|
@@ -3818,7 +4234,7 @@ function createEngramTools(deps) {
|
|
|
3818
4234
|
});
|
|
3819
4235
|
const timeline = defineTool({
|
|
3820
4236
|
name: "engram_timeline",
|
|
3821
|
-
description: "
|
|
4237
|
+
description: "按时间范围与主题浏览记忆(默认时间倒序,最近 20 条)。order=tour 时改按固定巡游路线的桩位顺序走(未上路线者排末尾),输出附宫殿坐标——适合按宫殿固定路线复述;多作用域按 user→project→shared 顺序拼接(桩位顺序只在各自库内有意义)。无参数直接列出最近记录。",
|
|
3822
4238
|
parameters: {
|
|
3823
4239
|
scope: {
|
|
3824
4240
|
type: "string",
|
|
@@ -3841,6 +4257,11 @@ function createEngramTools(deps) {
|
|
|
3841
4257
|
until: {
|
|
3842
4258
|
type: "string",
|
|
3843
4259
|
description: "结束时间"
|
|
4260
|
+
},
|
|
4261
|
+
order: {
|
|
4262
|
+
type: "string",
|
|
4263
|
+
enum: ["time", "tour"],
|
|
4264
|
+
description: "排序:缺省 'time' 按创建时间倒序;'tour' 按固定巡游路线桩位顺序"
|
|
3844
4265
|
}
|
|
3845
4266
|
},
|
|
3846
4267
|
output: {
|
|
@@ -3860,6 +4281,7 @@ function createEngramTools(deps) {
|
|
|
3860
4281
|
async execute(args) {
|
|
3861
4282
|
const input = args;
|
|
3862
4283
|
const scopes = scopesOf(input.scope);
|
|
4284
|
+
const order = input.order === "tour" ? "tour" : "time";
|
|
3863
4285
|
const parseTime = (raw, field) => {
|
|
3864
4286
|
if (raw === void 0) return void 0;
|
|
3865
4287
|
const ms = Date.parse(raw);
|
|
@@ -3868,15 +4290,22 @@ function createEngramTools(deps) {
|
|
|
3868
4290
|
};
|
|
3869
4291
|
const since = parseTime(input.since, "since");
|
|
3870
4292
|
const until = parseTime(input.until, "until");
|
|
3871
|
-
|
|
4293
|
+
const results = await Promise.all(scopes.map(async (scope) => {
|
|
3872
4294
|
return (await deps.openStore(scope)).timeline({
|
|
3873
4295
|
scopes: [scope],
|
|
3874
4296
|
...input.topic === void 0 ? {} : { topic: input.topic },
|
|
3875
4297
|
...since === void 0 ? {} : { since },
|
|
3876
4298
|
...until === void 0 ? {} : { until },
|
|
4299
|
+
order,
|
|
3877
4300
|
limit: 20
|
|
3878
4301
|
});
|
|
3879
|
-
}))
|
|
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 ?? "(对话继续)") };
|
|
3880
4309
|
}
|
|
3881
4310
|
});
|
|
3882
4311
|
const update = defineTool({
|
|
@@ -4001,7 +4430,7 @@ function createEngramTools(deps) {
|
|
|
4001
4430
|
},
|
|
4002
4431
|
render: (_args, value) => [{
|
|
4003
4432
|
type: "text",
|
|
4004
|
-
text:
|
|
4433
|
+
text: `记忆 ${value.id} 已闭馆(软删,可恢复),墓志铭已刻入操作日志。`
|
|
4005
4434
|
}]
|
|
4006
4435
|
},
|
|
4007
4436
|
async execute(args) {
|
|
@@ -4018,6 +4447,73 @@ function createEngramTools(deps) {
|
|
|
4018
4447
|
})).id };
|
|
4019
4448
|
}
|
|
4020
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
|
+
});
|
|
4021
4517
|
/** P0-3 闭馆考古:返回最近 N 条 forgotten 条目 + 墓志铭。 */
|
|
4022
4518
|
const auditForgotten = defineTool({
|
|
4023
4519
|
name: "engram_audit_forgotten",
|
|
@@ -4316,7 +4812,7 @@ function createEngramTools(deps) {
|
|
|
4316
4812
|
}),
|
|
4317
4813
|
defineTool({
|
|
4318
4814
|
name: "engram_export",
|
|
4319
|
-
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 漫游。",
|
|
4320
4816
|
parameters: {
|
|
4321
4817
|
format: {
|
|
4322
4818
|
type: "string",
|
|
@@ -4325,7 +4821,7 @@ function createEngramTools(deps) {
|
|
|
4325
4821
|
"json",
|
|
4326
4822
|
"markdown-mirror"
|
|
4327
4823
|
],
|
|
4328
|
-
description: "导出格式:markdown 单文件、json 单文件、markdown-mirror
|
|
4824
|
+
description: "导出格式:markdown 单文件、json 单文件、markdown-mirror 每条记忆一文件(按房间分目录,默认 markdown)"
|
|
4329
4825
|
},
|
|
4330
4826
|
scope: {
|
|
4331
4827
|
type: "string",
|
|
@@ -4376,7 +4872,7 @@ function createEngramTools(deps) {
|
|
|
4376
4872
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
|
|
4377
4873
|
const mirrorRoot = join(deps.exportDir, `mirror-${scope}-${stamp}`);
|
|
4378
4874
|
const report = await writeMirror(mirrorRoot, data);
|
|
4379
|
-
written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length}
|
|
4875
|
+
written.push(`${mirrorRoot}(${report.fileCount} 个文件,${data.records.length} 条记忆,${report.rooms.length} 个房间)`);
|
|
4380
4876
|
continue;
|
|
4381
4877
|
}
|
|
4382
4878
|
const payload = redactedView ? {
|
|
@@ -4459,11 +4955,11 @@ function createEngramTools(deps) {
|
|
|
4459
4955
|
}),
|
|
4460
4956
|
defineTool({
|
|
4461
4957
|
name: "engram_examine",
|
|
4462
|
-
description: "渐进式披露:按 id
|
|
4958
|
+
description: "渐进式披露:按 id 批量拉取记忆完整铭牌(content + 房间 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
|
|
4463
4959
|
parameters: { ids: {
|
|
4464
4960
|
type: "array",
|
|
4465
4961
|
items: { type: "string" },
|
|
4466
|
-
description: "
|
|
4962
|
+
description: "记忆 id 列表"
|
|
4467
4963
|
} },
|
|
4468
4964
|
output: {
|
|
4469
4965
|
schema: {
|
|
@@ -4498,11 +4994,11 @@ function createEngramTools(deps) {
|
|
|
4498
4994
|
}),
|
|
4499
4995
|
defineTool({
|
|
4500
4996
|
name: "engram_neighbors",
|
|
4501
|
-
description: "
|
|
4997
|
+
description: "走廊漫步:从某条记忆出发走 1-3 跳内的 related/supersedes/contradicts 边,返回邻居记忆简表(仅 id + scope + kind + status + content),便于判断下一站。",
|
|
4502
4998
|
parameters: {
|
|
4503
4999
|
id: {
|
|
4504
5000
|
type: "string",
|
|
4505
|
-
description: "
|
|
5001
|
+
description: "起点记忆 id"
|
|
4506
5002
|
},
|
|
4507
5003
|
depth: {
|
|
4508
5004
|
type: "integer",
|
|
@@ -4534,9 +5030,9 @@ function createEngramTools(deps) {
|
|
|
4534
5030
|
if (seedRow === void 0) throw new Error(`engram_neighbors: 起点 ${seed} 不存在`);
|
|
4535
5031
|
const seedScope = seedRow.scope;
|
|
4536
5032
|
const neighbors = await (seedScope === "user" ? userStore : projectStore).neighbors(seed, depth);
|
|
4537
|
-
if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth}
|
|
5033
|
+
if (neighbors.length === 0) return { text: `从 ${seed}(${seedScope})出发,${depth} 跳内无邻居记忆。` };
|
|
4538
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})`);
|
|
4539
|
-
return { text: `起点 ${seed}(${seedScope})→ ${depth} 跳走廊共访 ${neighbors.length}
|
|
5035
|
+
return { text: `起点 ${seed}(${seedScope})→ ${depth} 跳走廊共访 ${neighbors.length} 条:\n${lines.join("\n")}` };
|
|
4540
5036
|
}
|
|
4541
5037
|
}),
|
|
4542
5038
|
defineTool({
|
|
@@ -4642,13 +5138,14 @@ function createEngramTools(deps) {
|
|
|
4642
5138
|
return { text: `${degraded ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${route.narrative}` };
|
|
4643
5139
|
}
|
|
4644
5140
|
}),
|
|
4645
|
-
auditForgotten
|
|
5141
|
+
auditForgotten,
|
|
5142
|
+
ingestHistory
|
|
4646
5143
|
];
|
|
4647
5144
|
}
|
|
4648
5145
|
/**
|
|
4649
5146
|
* 构造巡游路径:在 search 命中的基础上按路径策略重排。
|
|
4650
5147
|
* 1) 起点簇:取命中中 kind 出现频次最高的前 N 个同 kind 节点(同类巩固)。
|
|
4651
|
-
* 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0
|
|
5148
|
+
* 2) 走廊扩展:从起点簇每个节点的 1-跳 neighbors 中挑 active 且 score > 0 的记忆。
|
|
4652
5149
|
* 3) 反差收束:从剩余命中挑一个 emotionalValence ≥ 0.7 的做收束(强反差唤醒)。
|
|
4653
5150
|
* 命中不足时按可用性回退;命中为 0 时返回空 stops。
|
|
4654
5151
|
*/
|
|
@@ -4731,13 +5228,13 @@ function renderTourNarrative(stops, query) {
|
|
|
4731
5228
|
}
|
|
4732
5229
|
//#endregion
|
|
4733
5230
|
//#region src/selection-rationale.ts
|
|
4734
|
-
/**
|
|
5231
|
+
/** 给定一组入选记忆,返回归因 XML 字符串;空 rooms 返回空串。 */
|
|
4735
5232
|
function buildSelectionRationale(records) {
|
|
4736
5233
|
if (records.length === 0) return "";
|
|
4737
5234
|
const now = Date.now();
|
|
4738
5235
|
const lines = [
|
|
4739
5236
|
"<engram_selection_rationale>",
|
|
4740
|
-
`本轮画像共 ${String(records.length)}
|
|
5237
|
+
`本轮画像共 ${String(records.length)} 条记忆,挑选理由如下:`,
|
|
4741
5238
|
""
|
|
4742
5239
|
];
|
|
4743
5240
|
for (const record of records) {
|
|
@@ -4894,6 +5391,10 @@ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, sign
|
|
|
4894
5391
|
async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
4895
5392
|
const decision = await next();
|
|
4896
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
|
+
}
|
|
4897
5398
|
const mode = resolved.ingest;
|
|
4898
5399
|
if (step === 1 && mode !== "off") {
|
|
4899
5400
|
if (!state.pendingReplayed) {
|
|
@@ -5015,10 +5516,11 @@ function apply(ctx, config = {}) {
|
|
|
5015
5516
|
decayAfterDays: resolved.decayAfterDays
|
|
5016
5517
|
};
|
|
5017
5518
|
const stores = /* @__PURE__ */ new Map();
|
|
5018
|
-
|
|
5019
|
-
|
|
5519
|
+
/** 打开(或复用)一个分库文件;首次打开时幂等补齐存量排桩。 */
|
|
5520
|
+
const openDb = (dbName) => {
|
|
5521
|
+
const existing = stores.get(dbName);
|
|
5020
5522
|
if (existing !== void 0) return existing;
|
|
5021
|
-
const path =
|
|
5523
|
+
const path = join(resolved.dbDir, dbName);
|
|
5022
5524
|
const created = openEngramStore(path, rankBoost, {
|
|
5023
5525
|
autoSlot: resolved.autoSlot,
|
|
5024
5526
|
reviewScheduling: resolved.reviewScheduling
|
|
@@ -5029,12 +5531,168 @@ function apply(ctx, config = {}) {
|
|
|
5029
5531
|
if (assigned > 0) console.warn(`[dsh-engram] 存量记忆排桩完成:${assigned} 条已钉入宫殿(${path})`);
|
|
5030
5532
|
return store;
|
|
5031
5533
|
});
|
|
5032
|
-
stores.set(
|
|
5534
|
+
stores.set(dbName, created);
|
|
5033
5535
|
return created;
|
|
5034
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));
|
|
5035
5556
|
const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
|
|
5036
5557
|
console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
|
|
5037
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
|
+
};
|
|
5038
5696
|
for (const tool of createEngramTools({
|
|
5039
5697
|
openStore,
|
|
5040
5698
|
embedder,
|
|
@@ -5044,7 +5702,11 @@ function apply(ctx, config = {}) {
|
|
|
5044
5702
|
}),
|
|
5045
5703
|
routeOverride: resolved.routeOverride,
|
|
5046
5704
|
queryRewrite: resolved.queryRewrite,
|
|
5047
|
-
exportDir: `${resolved.dbDir}/exports
|
|
5705
|
+
exportDir: `${resolved.dbDir}/exports`,
|
|
5706
|
+
historyBackfill: {
|
|
5707
|
+
estimate: historyApi.estimate,
|
|
5708
|
+
run: historyApi.run
|
|
5709
|
+
}
|
|
5048
5710
|
})) ctx.tools.register(tool);
|
|
5049
5711
|
ctx.inject(["webServer"], (webCtx) => {
|
|
5050
5712
|
registerEngramRoutes(webCtx, {
|
|
@@ -5053,20 +5715,18 @@ function apply(ctx, config = {}) {
|
|
|
5053
5715
|
mirrorDir: `${resolved.dbDir}/palaces`,
|
|
5054
5716
|
dbDir: resolved.dbDir,
|
|
5055
5717
|
pluginVersion: VERSION,
|
|
5056
|
-
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
|
+
}
|
|
5057
5727
|
});
|
|
5058
5728
|
});
|
|
5059
|
-
|
|
5060
|
-
openStore("user").then((store) => store.audit("ingest-request", "AUX", JSON.stringify(data))).catch(() => {});
|
|
5061
|
-
};
|
|
5062
|
-
if (resolved.injectProfile || resolved.ingest !== "off") {
|
|
5063
|
-
const state = {
|
|
5064
|
-
pendingReplayed: false,
|
|
5065
|
-
lastProfileAgent: null,
|
|
5066
|
-
lastProfileHash: null
|
|
5067
|
-
};
|
|
5068
|
-
ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, state, logIngestRequest, payload, next), { prepend: true });
|
|
5069
|
-
}
|
|
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 });
|
|
5070
5730
|
if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
|
|
5071
5731
|
const mode = resolved.ingest;
|
|
5072
5732
|
if (mode === "off") return;
|