@kenz1117/dsh-engram 0.7.4 → 0.7.6
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 +9 -3
- package/README.md +18 -11
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +682 -95
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
7
7
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
import { createGzip, gunzip } from "node:zlib";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
11
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
12
|
//#region src/config.ts
|
|
12
13
|
/**
|
|
@@ -519,10 +520,31 @@ function decodeTurnKey(detail) {
|
|
|
519
520
|
}
|
|
520
521
|
const INGEST_SYSTEM = [
|
|
521
522
|
"从对话记录中提取值得跨会话长期记住的用户信息(事实/偏好/决策/经历/做事方法)。",
|
|
522
|
-
"只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"importance\": 0到1的小数}。",
|
|
523
|
+
"只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"scope\": \"project|user\", \"importance\": 0到1的小数}。",
|
|
524
|
+
"scope 决定这条记忆进哪座宫殿:只跟当前项目/仓库有关的(技术选型、项目约定、架构决策、该项目自身的事实)用 project;与具体项目无关、跨项目通用的(个人偏好、习惯、用户本人的经历、通用事实)用 user。拿不准用 project。",
|
|
523
525
|
"只提取明确、可复用的信息;寒暄、临时上下文、你自己的回答不要提取。没有值得记的就输出 []。",
|
|
524
526
|
"不要输出 JSON 以外的任何内容。"
|
|
525
527
|
].join("\n");
|
|
528
|
+
/**
|
|
529
|
+
* 组装一次摄取的写入路由:会话有 cwd(可归属项目)时默认进项目宫殿且采纳模型的逐条判定,
|
|
530
|
+
* 明显跨项目的个人偏好可被标成 user 落私人宫殿;无 cwd 的会话只能进私人宫殿,
|
|
531
|
+
* 故关掉逐条判定(否则 project 标记落进 user 库后检索不可见)。
|
|
532
|
+
* @param cwd - 会话工作目录;空串/undefined = 无法归属项目。
|
|
533
|
+
* @param openProjectStore - 按 cwd 打开项目分库。
|
|
534
|
+
* @param openScopeStore - 按作用域打开分库(user / shared)。
|
|
535
|
+
* @returns 可直接展开进 IngestDeps / ReplayIngestDeps 的路由字段。
|
|
536
|
+
*/
|
|
537
|
+
function ingestWriteRouting(cwd, openProjectStore, openScopeStore) {
|
|
538
|
+
if (cwd === void 0 || cwd === "") return {
|
|
539
|
+
writeScope: "user",
|
|
540
|
+
perCandidateScope: false
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
writeScope: "project",
|
|
544
|
+
perCandidateScope: true,
|
|
545
|
+
resolveStore: (scope) => scope === "project" ? openProjectStore(cwd) : openScopeStore(scope)
|
|
546
|
+
};
|
|
547
|
+
}
|
|
526
548
|
/** 从事件里按类型收集文本块,跳过插件注入的 user 快照(它们不是用户说的话)。 */
|
|
527
549
|
function collectTexts(events, includeAssistant) {
|
|
528
550
|
const texts = [];
|
|
@@ -672,8 +694,9 @@ async function ingestPreviousTurn(deps) {
|
|
|
672
694
|
skipped: typeof sliceMode === "number" ? "no-such-turn" : "no-previous-turn"
|
|
673
695
|
};
|
|
674
696
|
const store = await deps.openStore();
|
|
697
|
+
const auditStore = deps.openAuditStore === void 0 ? store : await deps.openAuditStore();
|
|
675
698
|
const doneKey = encodeTurnKey(deps.sessionId, round);
|
|
676
|
-
if (await
|
|
699
|
+
if (await auditStore.hasAudit("ingest-done", doneKey)) return {
|
|
677
700
|
scannedEvents: slice.length,
|
|
678
701
|
candidates: 0,
|
|
679
702
|
written: 0,
|
|
@@ -742,14 +765,17 @@ async function ingestPreviousTurn(deps) {
|
|
|
742
765
|
"episode",
|
|
743
766
|
"skill"
|
|
744
767
|
].includes(candidate.kind) ? candidate.kind : "fact";
|
|
768
|
+
const modelScope = candidate.scope === "user" || candidate.scope === "project" ? candidate.scope : void 0;
|
|
769
|
+
const scope = deps.perCandidateScope === true && modelScope !== void 0 ? modelScope : deps.writeScope ?? "user";
|
|
770
|
+
const target = deps.resolveStore === void 0 ? store : await deps.resolveStore(scope);
|
|
745
771
|
const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .5;
|
|
746
772
|
if (embedder !== void 0) {
|
|
747
773
|
const vector = (await embedder.embed([content]))[0];
|
|
748
|
-
if (vector !== void 0 && (await
|
|
774
|
+
if (vector !== void 0 && (await target.findContradictions(vector, 1)).length > 0) continue;
|
|
749
775
|
}
|
|
750
776
|
if (writtenContents.includes(content)) continue;
|
|
751
|
-
await
|
|
752
|
-
scope
|
|
777
|
+
await target.write({
|
|
778
|
+
scope,
|
|
753
779
|
kind,
|
|
754
780
|
content,
|
|
755
781
|
importance,
|
|
@@ -763,7 +789,7 @@ async function ingestPreviousTurn(deps) {
|
|
|
763
789
|
writtenContents.push(content);
|
|
764
790
|
written += 1;
|
|
765
791
|
}
|
|
766
|
-
await
|
|
792
|
+
await auditStore.audit(INGEST_DONE_OP, deps.sessionId, doneKey);
|
|
767
793
|
return {
|
|
768
794
|
scannedEvents: slice.length,
|
|
769
795
|
candidates: parsed.length,
|
|
@@ -800,7 +826,7 @@ async function ingestFinalTurn(deps) {
|
|
|
800
826
|
});
|
|
801
827
|
} catch (error) {
|
|
802
828
|
if (round !== void 0) try {
|
|
803
|
-
await markPendingIngest(await deps.openStore(), deps.sessionId, round);
|
|
829
|
+
await markPendingIngest(await (deps.openAuditStore ?? deps.openStore)(), deps.sessionId, round);
|
|
804
830
|
} catch {}
|
|
805
831
|
console.warn("[dsh-engram] 会话结束的末轮摄取失败(已记入待补做队列,不影响对话):", error);
|
|
806
832
|
return null;
|
|
@@ -809,6 +835,7 @@ async function ingestFinalTurn(deps) {
|
|
|
809
835
|
/**
|
|
810
836
|
* 重放待补做的末轮摄取(下次会话首次 pre-step 调用)。已有 done 标记或键损坏的
|
|
811
837
|
* pending 直接出队;事件源不可得的保留到下次。单个键失败抛出,剩余键留待下次。
|
|
838
|
+
* 每条 pending 按它自己会话的 cwd 组装写入路由(补做时同样逐条判宫殿)。
|
|
812
839
|
*/
|
|
813
840
|
async function replayPendingIngests(deps) {
|
|
814
841
|
const store = await deps.openStore();
|
|
@@ -821,17 +848,19 @@ async function replayPendingIngests(deps) {
|
|
|
821
848
|
await store.clearAudit(INGEST_PENDING_OP, detail);
|
|
822
849
|
continue;
|
|
823
850
|
}
|
|
824
|
-
const
|
|
825
|
-
if (
|
|
851
|
+
const session = await deps.resolveSession(key.sessionId);
|
|
852
|
+
if (session === void 0) {
|
|
826
853
|
kept += 1;
|
|
827
854
|
continue;
|
|
828
855
|
}
|
|
829
856
|
await ingestPreviousTurn({
|
|
830
|
-
events,
|
|
857
|
+
events: session.events,
|
|
831
858
|
sessionId: key.sessionId,
|
|
832
859
|
turn: key.turn,
|
|
833
860
|
slice: key.turn,
|
|
834
861
|
openStore: deps.openStore,
|
|
862
|
+
openAuditStore: deps.openStore,
|
|
863
|
+
...ingestWriteRouting(session.cwd, deps.resolveStore, deps.openStore),
|
|
835
864
|
embedder: deps.embedder,
|
|
836
865
|
mode: deps.mode,
|
|
837
866
|
routeOverride: deps.routeOverride,
|
|
@@ -956,20 +985,23 @@ async function selectSessions(deps, rules) {
|
|
|
956
985
|
const turns = all.slice(Math.max(0, all.length - rules.maxTurnsPerSession));
|
|
957
986
|
if (turns.length === 0) return void 0;
|
|
958
987
|
const cwd = header.cwd;
|
|
959
|
-
|
|
988
|
+
if (cwd === void 0 || cwd === "") return {
|
|
960
989
|
header,
|
|
961
990
|
events,
|
|
962
991
|
turns,
|
|
963
992
|
openStore: deps.openUserStore,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
993
|
+
routing: {
|
|
994
|
+
writeScope: "user",
|
|
995
|
+
perCandidateScope: false
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
const projectCwd = cwd;
|
|
999
|
+
return {
|
|
967
1000
|
header,
|
|
968
1001
|
events,
|
|
969
1002
|
turns,
|
|
970
|
-
openStore: () => deps.resolveStore(
|
|
971
|
-
|
|
972
|
-
storeKey: cwd
|
|
1003
|
+
openStore: () => deps.resolveStore(projectCwd),
|
|
1004
|
+
routing: ingestWriteRouting(projectCwd, deps.resolveStore, deps.openUserStore)
|
|
973
1005
|
};
|
|
974
1006
|
}));
|
|
975
1007
|
loaded.push(...settled);
|
|
@@ -1026,13 +1058,13 @@ async function estimateHistoryBackfill(deps, defaults, overrides = {}) {
|
|
|
1026
1058
|
unavailable: UNAVAILABLE_REASON
|
|
1027
1059
|
};
|
|
1028
1060
|
const { candidates, skipped, truncated } = await selectSessions(deps, rules);
|
|
1061
|
+
const auditStore = await deps.resolveExistingUserStore();
|
|
1029
1062
|
let eligibleTurns = 0;
|
|
1030
1063
|
let alreadyIngested = 0;
|
|
1031
1064
|
for (const candidate of candidates) {
|
|
1032
1065
|
eligibleTurns += candidate.turns.length;
|
|
1033
|
-
|
|
1034
|
-
if (
|
|
1035
|
-
for (const turn of candidate.turns) if (await store.hasAudit("ingest-done", encodeTurnKey(candidate.header.id, turn))) alreadyIngested += 1;
|
|
1066
|
+
if (auditStore === void 0) continue;
|
|
1067
|
+
for (const turn of candidate.turns) if (await auditStore.hasAudit("ingest-done", encodeTurnKey(candidate.header.id, turn))) alreadyIngested += 1;
|
|
1036
1068
|
}
|
|
1037
1069
|
return {
|
|
1038
1070
|
rules,
|
|
@@ -1085,6 +1117,8 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
|
|
|
1085
1117
|
turn,
|
|
1086
1118
|
slice: turn,
|
|
1087
1119
|
openStore: () => candidate.openStore(),
|
|
1120
|
+
openAuditStore: deps.openUserStore,
|
|
1121
|
+
...candidate.routing,
|
|
1088
1122
|
embedder: deps.embedder,
|
|
1089
1123
|
mode: deps.mode,
|
|
1090
1124
|
routeOverride,
|
|
@@ -1092,8 +1126,7 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
|
|
|
1092
1126
|
logRequest: deps.logRequest,
|
|
1093
1127
|
signal,
|
|
1094
1128
|
throttle: true,
|
|
1095
|
-
history: true
|
|
1096
|
-
writeScope: candidate.writeScope
|
|
1129
|
+
history: true
|
|
1097
1130
|
});
|
|
1098
1131
|
progress.memoriesWritten += outcome.written;
|
|
1099
1132
|
if (outcome.skipped !== null) {
|
|
@@ -1955,6 +1988,13 @@ function historyRulesOf(raw) {
|
|
|
1955
1988
|
...includeNoCwd === void 0 ? {} : { includeNoCwd }
|
|
1956
1989
|
};
|
|
1957
1990
|
}
|
|
1991
|
+
/** 未知项目选择器(路由统一映射为 404)。 */
|
|
1992
|
+
var UnknownProjectError = class extends Error {
|
|
1993
|
+
constructor(selector) {
|
|
1994
|
+
super(`unknown project: ${selector}`);
|
|
1995
|
+
this.name = "UnknownProjectError";
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1958
1998
|
/**
|
|
1959
1999
|
* 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
|
|
1960
2000
|
* @param ctx - 携带 webServer 服务的宿主上下文。
|
|
@@ -1967,11 +2007,37 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1967
2007
|
if (!guardLoopback(req, res)) return;
|
|
1968
2008
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1969
2009
|
const route = url.pathname.slice(12);
|
|
2010
|
+
const selectorRef = { value: url.searchParams.get("project") ?? void 0 };
|
|
2011
|
+
/** 按作用域取分库:project 走选择器解析,未知选择器抛错 → catch 统一 404。 */
|
|
2012
|
+
const storeFor = async (scope) => {
|
|
2013
|
+
if (scope !== "project") return deps.openStore(scope);
|
|
2014
|
+
const opened = await deps.projects.open(selectorRef.value);
|
|
2015
|
+
if (opened === void 0) throw new UnknownProjectError(selectorRef.value ?? "");
|
|
2016
|
+
return opened.store;
|
|
2017
|
+
};
|
|
2018
|
+
/** POST body 里的项目选择器(面板所有写操作都会带)。 */
|
|
2019
|
+
const adoptBodySelector = (body) => {
|
|
2020
|
+
const raw = body?.["project"];
|
|
2021
|
+
if (typeof raw === "string" && raw !== "") selectorRef.value = raw;
|
|
2022
|
+
};
|
|
1970
2023
|
try {
|
|
2024
|
+
if (req.method === "GET" && route === "workspaces") {
|
|
2025
|
+
const items = await deps.projects.list();
|
|
2026
|
+
const processDefault = items.find((item) => item.kind === "process");
|
|
2027
|
+
json(res, 200, {
|
|
2028
|
+
items,
|
|
2029
|
+
processDefault: processDefault === void 0 ? null : {
|
|
2030
|
+
dbName: processDefault.dbName,
|
|
2031
|
+
path: processDefault.path,
|
|
2032
|
+
title: processDefault.title
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
1971
2037
|
if (req.method === "GET" && route === "stats") {
|
|
1972
2038
|
json(res, 200, { parts: await Promise.all(["user", "project"].map(async (scope) => ({
|
|
1973
2039
|
scope,
|
|
1974
|
-
stats: await (await
|
|
2040
|
+
stats: await (await storeFor(scope)).stats()
|
|
1975
2041
|
}))) });
|
|
1976
2042
|
return;
|
|
1977
2043
|
}
|
|
@@ -1993,7 +2059,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1993
2059
|
limit,
|
|
1994
2060
|
offset
|
|
1995
2061
|
};
|
|
1996
|
-
json(res, 200, await (await
|
|
2062
|
+
json(res, 200, await (await storeFor(scope)).list(filter));
|
|
1997
2063
|
return;
|
|
1998
2064
|
}
|
|
1999
2065
|
if (req.method === "GET" && route === "search-test") {
|
|
@@ -2009,7 +2075,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2009
2075
|
const embedder = deps.embedder === void 0 ? void 0 : await deps.embedder;
|
|
2010
2076
|
const vector = embedder === void 0 ? void 0 : (await embedder.embed([q.trim()]))[0];
|
|
2011
2077
|
const results = await Promise.all(scopes.map(async (scope) => {
|
|
2012
|
-
return (await
|
|
2078
|
+
return (await storeFor(scope)).search({
|
|
2013
2079
|
text: q,
|
|
2014
2080
|
scopes: [scope],
|
|
2015
2081
|
limit
|
|
@@ -2037,7 +2103,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2037
2103
|
if (req.method === "GET" && route === "activity") {
|
|
2038
2104
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
2039
2105
|
json(res, 200, { operations: (await Promise.all(["user", "project"].map(async (scope) => {
|
|
2040
|
-
return (await (await
|
|
2106
|
+
return (await (await storeFor(scope)).recentOps(limit)).map((op) => ({
|
|
2041
2107
|
...op,
|
|
2042
2108
|
scope
|
|
2043
2109
|
}));
|
|
@@ -2050,7 +2116,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2050
2116
|
const now = Date.now();
|
|
2051
2117
|
json(res, 200, {
|
|
2052
2118
|
scope,
|
|
2053
|
-
items: (await (await
|
|
2119
|
+
items: (await (await storeFor(scope)).dueReviews(now, limit)).map((record) => ({
|
|
2054
2120
|
id: record.id,
|
|
2055
2121
|
kind: record.kind,
|
|
2056
2122
|
...record.slot === void 0 ? {} : { slot: record.slot },
|
|
@@ -2074,8 +2140,8 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2074
2140
|
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
2075
2141
|
return;
|
|
2076
2142
|
}
|
|
2077
|
-
|
|
2078
|
-
const record = await (await
|
|
2143
|
+
adoptBodySelector(body);
|
|
2144
|
+
const record = await (await storeFor(scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user"))).scheduleReview(body.id, grade);
|
|
2079
2145
|
if (record === void 0) {
|
|
2080
2146
|
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
2081
2147
|
return;
|
|
@@ -2093,7 +2159,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2093
2159
|
json(res, 400, { error: "id required" });
|
|
2094
2160
|
return;
|
|
2095
2161
|
}
|
|
2096
|
-
const view = await (await
|
|
2162
|
+
const view = await (await storeFor(scope)).review(id);
|
|
2097
2163
|
if (view === void 0) {
|
|
2098
2164
|
json(res, 404, { error: `未找到条目 ${id}` });
|
|
2099
2165
|
return;
|
|
@@ -2105,7 +2171,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2105
2171
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2106
2172
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
2107
2173
|
const now = Date.now();
|
|
2108
|
-
const due = await (await
|
|
2174
|
+
const due = await (await storeFor(scope)).dueReviews(now, limit);
|
|
2109
2175
|
json(res, 200, {
|
|
2110
2176
|
scope,
|
|
2111
2177
|
count: due.length,
|
|
@@ -2139,8 +2205,8 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2139
2205
|
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
2140
2206
|
return;
|
|
2141
2207
|
}
|
|
2142
|
-
|
|
2143
|
-
const record = await (await
|
|
2208
|
+
adoptBodySelector(body);
|
|
2209
|
+
const record = await (await storeFor(scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user"))).scheduleReview(body.id, grade);
|
|
2144
2210
|
if (record === void 0) {
|
|
2145
2211
|
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
2146
2212
|
return;
|
|
@@ -2151,7 +2217,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2151
2217
|
if (req.method === "GET" && route === "export") {
|
|
2152
2218
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2153
2219
|
const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
|
|
2154
|
-
const data = await (await
|
|
2220
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2155
2221
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
|
|
2156
2222
|
const body = format === "json" ? JSON.stringify(data, null, 2) : [
|
|
2157
2223
|
`# dsh-engram 导出(${scope})`,
|
|
@@ -2171,7 +2237,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2171
2237
|
}
|
|
2172
2238
|
if (req.method === "GET" && route === "mirror") {
|
|
2173
2239
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2174
|
-
const data = await (await
|
|
2240
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2175
2241
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
|
|
2176
2242
|
json(res, 200, {
|
|
2177
2243
|
...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
|
|
@@ -2185,7 +2251,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2185
2251
|
const rawScope = url.searchParams.get("scope");
|
|
2186
2252
|
const scopes = rawScope === "user" || rawScope === "project" || rawScope === "shared" ? [rawScope] : ["user", "project"];
|
|
2187
2253
|
const parts = await Promise.all(scopes.map(async (scope) => {
|
|
2188
|
-
const store = await
|
|
2254
|
+
const store = await storeFor(scope);
|
|
2189
2255
|
const s = await store.stats();
|
|
2190
2256
|
const edgeCount = (await store.exportAll()).edges.length;
|
|
2191
2257
|
const total = Math.max(s.total, 1);
|
|
@@ -2225,7 +2291,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2225
2291
|
if (rawStatus !== null && rawStatus !== "" && rawStatus !== "all") {
|
|
2226
2292
|
for (const s of rawStatus.split(",")) if (s === "active" || s === "archived" || s === "forgotten") includeStatuses.add(s);
|
|
2227
2293
|
}
|
|
2228
|
-
const data = await (await
|
|
2294
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2229
2295
|
const nodes = data.records.filter((r) => includeStatuses.has(r.status)).map((r) => ({
|
|
2230
2296
|
id: r.id,
|
|
2231
2297
|
scope: r.scope,
|
|
@@ -2252,14 +2318,13 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2252
2318
|
if (req.method === "GET" && route === "telemetry") {
|
|
2253
2319
|
const windowDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 7) || 7));
|
|
2254
2320
|
const rawScope = url.searchParams.get("scope");
|
|
2255
|
-
|
|
2256
|
-
json(res, 200, await aggregateTelemetry(deps.openStore, windowDays, scopeFilter));
|
|
2321
|
+
json(res, 200, await aggregateTelemetry((scope) => storeFor(scope), windowDays, rawScope === "user" ? "user" : rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : void 0));
|
|
2257
2322
|
return;
|
|
2258
2323
|
}
|
|
2259
2324
|
if (req.method === "GET" && route === "tour-proposal") {
|
|
2260
2325
|
const rawScope = url.searchParams.get("scope");
|
|
2261
2326
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2262
|
-
const filter = await (await
|
|
2327
|
+
const filter = await (await storeFor(scope)).list({
|
|
2263
2328
|
scope,
|
|
2264
2329
|
status: "active",
|
|
2265
2330
|
limit: 200,
|
|
@@ -2285,7 +2350,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2285
2350
|
if (req.method === "GET" && route === "refurb") {
|
|
2286
2351
|
const rawScope = url.searchParams.get("scope");
|
|
2287
2352
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2288
|
-
const suggestions = gatherRefurbSuggestions((await (await
|
|
2353
|
+
const suggestions = gatherRefurbSuggestions((await (await storeFor(scope)).list({
|
|
2289
2354
|
scope,
|
|
2290
2355
|
status: "active",
|
|
2291
2356
|
limit: 500,
|
|
@@ -2349,10 +2414,11 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2349
2414
|
if (req.method === "POST" && route === "consolidate") {
|
|
2350
2415
|
if (!guardWrite(req, res)) return;
|
|
2351
2416
|
const body = await readJsonBody(req);
|
|
2417
|
+
adoptBodySelector(body);
|
|
2352
2418
|
const rawScope = typeof body?.scope === "string" ? body.scope : "user";
|
|
2353
2419
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2354
2420
|
const candidates = Array.isArray(body?.candidates) ? body.candidates.filter((value) => typeof value === "string") : void 0;
|
|
2355
|
-
json(res, 200, await runConsolidation(await
|
|
2421
|
+
json(res, 200, await runConsolidation(await storeFor(scope), deps.embedder, candidates === void 0 ? { scope } : {
|
|
2356
2422
|
scope,
|
|
2357
2423
|
mergeCandidateIds: candidates
|
|
2358
2424
|
}));
|
|
@@ -2399,8 +2465,9 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2399
2465
|
json(res, 400, { error: "id required" });
|
|
2400
2466
|
return;
|
|
2401
2467
|
}
|
|
2468
|
+
adoptBodySelector(body);
|
|
2402
2469
|
const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
|
|
2403
|
-
const store = await
|
|
2470
|
+
const store = await storeFor(scope);
|
|
2404
2471
|
if (route === "update") {
|
|
2405
2472
|
if (typeof body.content !== "string" || body.content.trim() === "") {
|
|
2406
2473
|
json(res, 400, { error: "content required" });
|
|
@@ -2431,6 +2498,10 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2431
2498
|
}
|
|
2432
2499
|
json(res, 404, { error: "unknown route" });
|
|
2433
2500
|
} catch (error) {
|
|
2501
|
+
if (error instanceof UnknownProjectError) {
|
|
2502
|
+
json(res, 404, { error: "unknown project" });
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2434
2505
|
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2435
2506
|
}
|
|
2436
2507
|
}
|
|
@@ -2444,11 +2515,19 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2444
2515
|
* 指针),不起子进程。启动时负责旧库文件向新标识的 rename 迁移。
|
|
2445
2516
|
* @module @kenz1117/dsh-engram/project/identity
|
|
2446
2517
|
*/
|
|
2447
|
-
/** v0.4 旧命名:cwd 的 hex 编码前 24
|
|
2518
|
+
/** v0.4 旧命名:cwd 的 hex 编码前 24 位(只覆盖 cwd 前 12 字符,同前缀目录会撞库)。保留仅供迁移识别。 */
|
|
2448
2519
|
function legacyProjectDbName(cwd) {
|
|
2449
2520
|
return `project-${Buffer.from(cwd).toString("hex").slice(0, 24)}.db`;
|
|
2450
2521
|
}
|
|
2451
2522
|
/**
|
|
2523
|
+
* 无 git 时的 cwd 命名:cwd 全量 sha256 前 24 位。
|
|
2524
|
+
* 旧算法只取 cwd 前 12 字符,`C:\Users\Adm…` 这类同前缀目录会共用同一个库;
|
|
2525
|
+
* 新算法对每个目录唯一,启动时把旧库 rename 迁移到新名(见 migrateProjectDb)。
|
|
2526
|
+
*/
|
|
2527
|
+
function cwdProjectDbName(cwd) {
|
|
2528
|
+
return `project-${createHash("sha256").update(cwd).digest("hex").slice(0, 24)}.db`;
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2452
2531
|
* 归一化 git origin URL:去协议与凭证、host 小写、去尾部 `.git` 与 `/`,
|
|
2453
2532
|
* 使 `git@github.com:a/b.git` 与 `https://github.com/a/b` 等价。无法解析返回 undefined。
|
|
2454
2533
|
*/
|
|
@@ -2524,7 +2603,7 @@ function readOriginUrl(configPath) {
|
|
|
2524
2603
|
}
|
|
2525
2604
|
/**
|
|
2526
2605
|
* 解析项目标识:origin URL 归一化后取 sha256 hex 前 24 位;无 git、无 origin
|
|
2527
|
-
* 或 URL 无法解析时回退 cwd
|
|
2606
|
+
* 或 URL 无法解析时回退 cwd 全量哈希命名。
|
|
2528
2607
|
*/
|
|
2529
2608
|
function resolveProjectIdentity(cwd) {
|
|
2530
2609
|
const legacyDbName = legacyProjectDbName(cwd);
|
|
@@ -2532,7 +2611,7 @@ function resolveProjectIdentity(cwd) {
|
|
|
2532
2611
|
const origin = configPath === void 0 ? void 0 : readOriginUrl(configPath);
|
|
2533
2612
|
const normalized = origin === void 0 ? void 0 : normalizeOriginUrl(origin);
|
|
2534
2613
|
if (normalized === void 0) return {
|
|
2535
|
-
dbName:
|
|
2614
|
+
dbName: cwdProjectDbName(cwd),
|
|
2536
2615
|
source: "cwd",
|
|
2537
2616
|
legacyDbName
|
|
2538
2617
|
};
|
|
@@ -2543,11 +2622,15 @@ function resolveProjectIdentity(cwd) {
|
|
|
2543
2622
|
};
|
|
2544
2623
|
}
|
|
2545
2624
|
/**
|
|
2546
|
-
*
|
|
2547
|
-
*
|
|
2625
|
+
* 旧库迁移:新名库不存在而旧名库存在时同目录 rename(零数据搬运);两者都存在时
|
|
2626
|
+
* 不合并、不动文件,返回 kept-both 由调用方告警。origin 标识(v0.4 cwd 编码 → 仓库
|
|
2627
|
+
* 哈希)与 cwd 兜底(截断命名 → 全量哈希)两种升级都走这里。
|
|
2628
|
+
*
|
|
2629
|
+
* 注意旧 cwd 命名只覆盖前 12 字符:同一前缀下的多个目录可能共用过一个旧库,
|
|
2630
|
+
* 迁移只能把库判给先打开它的那个目录,调用方应对 kept-both 与本次迁移都告警。
|
|
2548
2631
|
*/
|
|
2549
2632
|
function migrateProjectDb(dbDir, identity) {
|
|
2550
|
-
if (identity.
|
|
2633
|
+
if (identity.dbName === identity.legacyDbName) return "none";
|
|
2551
2634
|
const next = join(dbDir, identity.dbName);
|
|
2552
2635
|
const legacy = join(dbDir, identity.legacyDbName);
|
|
2553
2636
|
const hasNext = existsSync(next);
|
|
@@ -2559,6 +2642,81 @@ function migrateProjectDb(dbDir, identity) {
|
|
|
2559
2642
|
}
|
|
2560
2643
|
return "none";
|
|
2561
2644
|
}
|
|
2645
|
+
//#endregion
|
|
2646
|
+
//#region src/project/registry.ts
|
|
2647
|
+
/**
|
|
2648
|
+
* 项目宫殿清单:把 dsh 的工作区(`ctx.workspaceRegistry` 的注册表)与会话 cwd
|
|
2649
|
+
* 映射成记忆分库,供管理面板「切换/显示当前工作区」使用。
|
|
2650
|
+
*
|
|
2651
|
+
* 设计要点:
|
|
2652
|
+
* 1. **纯函数 + 注入式数据源**——不直接依赖宿主服务,index.ts 负责接
|
|
2653
|
+
* `workspaceRegistry` 与 `sessionPersistence`,本模块只做映射与去重,便于单测;
|
|
2654
|
+
* 2. **零状态**——每次现算 `目录 → 分库名`(git origin 哈希 / cwd 全量哈希),
|
|
2655
|
+
* 不维护额外注册表文件:工作区改名/删除后清单自动跟上;
|
|
2656
|
+
* 3. **三级来源**——宿主注册表工作区 > 只从会话 header 见到的 cwd > 插件进程目录兜底;
|
|
2657
|
+
* 同一个分库只出现一次(同仓库的多个 worktree 共用一个项目宫殿)。
|
|
2658
|
+
* @module @kenz1117/dsh-engram/project/registry
|
|
2659
|
+
*/
|
|
2660
|
+
/** 路径末段作为标题(Windows 与 POSIX 分隔符都接受;根目录回退整串)。 */
|
|
2661
|
+
function pathTitle(path) {
|
|
2662
|
+
const trimmed = path.replace(/[\\/]+$/, "");
|
|
2663
|
+
const cut = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
|
2664
|
+
const tail = cut === -1 ? trimmed : trimmed.slice(cut + 1);
|
|
2665
|
+
return tail === "" ? trimmed : tail;
|
|
2666
|
+
}
|
|
2667
|
+
/** 目录 → 项目宫殿(kind 由调用方给出)。 */
|
|
2668
|
+
function palaceFor(path, kind, title, workspaceId) {
|
|
2669
|
+
const identity = resolveProjectIdentity(path);
|
|
2670
|
+
return {
|
|
2671
|
+
dbName: identity.dbName,
|
|
2672
|
+
path,
|
|
2673
|
+
title,
|
|
2674
|
+
source: identity.source,
|
|
2675
|
+
kind,
|
|
2676
|
+
...workspaceId === void 0 ? {} : { workspaceId }
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* 组装项目宫殿清单。
|
|
2681
|
+
*
|
|
2682
|
+
* **不变量**:清单必须恒定包含宿主注册表里的**每一个**工作区(即使它的分库文件还不存在)。
|
|
2683
|
+
* 面板的「跟随当前工作区」按 dbName / 路径在清单里匹配,缺行会静默降级为进程默认库。
|
|
2684
|
+
* @param input - 注册表工作区、会话 cwd 集合与插件进程目录。
|
|
2685
|
+
* @returns 按「注册表 → 会话 cwd → 进程目录」优先、按分库名去重的清单。
|
|
2686
|
+
*/
|
|
2687
|
+
function listProjectPalaces(input) {
|
|
2688
|
+
const palaces = [];
|
|
2689
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2690
|
+
const push = (palace) => {
|
|
2691
|
+
if (seen.has(palace.dbName)) return;
|
|
2692
|
+
seen.add(palace.dbName);
|
|
2693
|
+
palaces.push(palace);
|
|
2694
|
+
};
|
|
2695
|
+
for (const workspace of input.workspaces) {
|
|
2696
|
+
if (workspace.path === "") continue;
|
|
2697
|
+
push(palaceFor(workspace.path, "workspace", workspace.title === "" ? pathTitle(workspace.path) : workspace.title, workspace.id));
|
|
2698
|
+
}
|
|
2699
|
+
for (const cwd of input.sessionCwds) {
|
|
2700
|
+
if (cwd === "") continue;
|
|
2701
|
+
push(palaceFor(cwd, "session", pathTitle(cwd)));
|
|
2702
|
+
}
|
|
2703
|
+
push(palaceFor(input.processCwd, "process", `${pathTitle(input.processCwd)}(进程目录)`));
|
|
2704
|
+
return palaces;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* 按选择器找项目宫殿:`dbName` 优先,其次 `cwd`;都不给时返回进程目录兜底项。
|
|
2708
|
+
* @param palaces - 清单。
|
|
2709
|
+
* @param selector - 面板/API 传来的选择器。
|
|
2710
|
+
* @returns 命中的宫殿;未知选择器返回 undefined(调用方回 404)。
|
|
2711
|
+
*/
|
|
2712
|
+
function findProjectPalace(palaces, selector) {
|
|
2713
|
+
if (selector.dbName !== void 0 && selector.dbName !== "") return palaces.find((palace) => palace.dbName === selector.dbName);
|
|
2714
|
+
if (selector.cwd !== void 0 && selector.cwd !== "") {
|
|
2715
|
+
const dbName = resolveProjectIdentity(selector.cwd).dbName;
|
|
2716
|
+
return palaces.find((palace) => palace.dbName === dbName) ?? palaceFor(selector.cwd, "session", pathTitle(selector.cwd));
|
|
2717
|
+
}
|
|
2718
|
+
return palaces.find((palace) => palace.kind === "process") ?? palaces[0];
|
|
2719
|
+
}
|
|
2562
2720
|
/** kind → 默认房间名。固定映射,保证同主题记忆总是聚在同一间(同类巩固)。 */
|
|
2563
2721
|
const KIND_ROOMS = {
|
|
2564
2722
|
fact: "事实厅",
|
|
@@ -3708,31 +3866,156 @@ function truncateItem(text, maxChars = RECALL_PER_ITEM_CHARS) {
|
|
|
3708
3866
|
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}…`;
|
|
3709
3867
|
}
|
|
3710
3868
|
/**
|
|
3711
|
-
*
|
|
3712
|
-
*
|
|
3713
|
-
* @param
|
|
3714
|
-
* @param totalBudget -
|
|
3715
|
-
* @returns
|
|
3869
|
+
* 预算内的贪心装填(保序):按 cost 逐个判断,装不下的计入 dropped 并继续试后续更短的项。
|
|
3870
|
+
* @param items - 候选(已按相关性排序)。
|
|
3871
|
+
* @param cost - 单项占用预算(字符数等)。
|
|
3872
|
+
* @param totalBudget - 总预算。
|
|
3873
|
+
* @returns 保留项与丢弃数。
|
|
3716
3874
|
*/
|
|
3717
|
-
function
|
|
3875
|
+
function fitWithinBudget(items, cost, totalBudget) {
|
|
3718
3876
|
const kept = [];
|
|
3719
3877
|
let used = 0;
|
|
3720
3878
|
let dropped = 0;
|
|
3721
|
-
for (const
|
|
3722
|
-
|
|
3879
|
+
for (const item of items) {
|
|
3880
|
+
const size = cost(item);
|
|
3881
|
+
if (used + size > totalBudget) {
|
|
3723
3882
|
dropped += 1;
|
|
3724
3883
|
continue;
|
|
3725
3884
|
}
|
|
3726
|
-
kept.push(
|
|
3727
|
-
used +=
|
|
3885
|
+
kept.push(item);
|
|
3886
|
+
used += size;
|
|
3728
3887
|
}
|
|
3888
|
+
return {
|
|
3889
|
+
kept,
|
|
3890
|
+
dropped
|
|
3891
|
+
};
|
|
3892
|
+
}
|
|
3893
|
+
/**
|
|
3894
|
+
* 总量预算内贪心装填行(保序;某行装不下时继续尝试更短的后续行),
|
|
3895
|
+
* 被跳过的行计数并在末尾追加提示行。
|
|
3896
|
+
* @param lines - 候选行(已按相关性排序)。
|
|
3897
|
+
* @param totalBudget - 总字符预算,默认 RECALL_TOTAL_CHARS。
|
|
3898
|
+
* @returns 装填后的行;有丢弃时末尾含「另有 N 条…」提示。
|
|
3899
|
+
*/
|
|
3900
|
+
function enforceBudget(lines, totalBudget = RECALL_TOTAL_CHARS) {
|
|
3901
|
+
const { kept, dropped } = fitWithinBudget(lines, (line) => line.length, totalBudget);
|
|
3729
3902
|
if (dropped > 0) kept.push(`(另有 ${dropped} 条未展示:缩小查询范围或降低 limit 后重试)`);
|
|
3730
3903
|
return kept;
|
|
3731
3904
|
}
|
|
3905
|
+
/** 判定后可选的下一步策略。 */
|
|
3906
|
+
const NEXT_STRATEGIES = [
|
|
3907
|
+
"answer",
|
|
3908
|
+
"search_keyword",
|
|
3909
|
+
"search_room",
|
|
3910
|
+
"search_timeline",
|
|
3911
|
+
"ask_user",
|
|
3912
|
+
"stop"
|
|
3913
|
+
];
|
|
3914
|
+
/**
|
|
3915
|
+
* 判断字符串是否为合法策略。
|
|
3916
|
+
* @param value - 待判定字符串。
|
|
3917
|
+
*/
|
|
3918
|
+
function isNextStrategy(value) {
|
|
3919
|
+
return NEXT_STRATEGIES.includes(value);
|
|
3920
|
+
}
|
|
3921
|
+
/**
|
|
3922
|
+
* 构造批次内唯一的证据 ref:已排桩时用「scope/房间#桩位」(与检索行里的宫殿坐标一致),
|
|
3923
|
+
* 未排桩时用「scope/#id 前 8 位」。带 scope 前缀,跨作用域的同名房间不会撞号。
|
|
3924
|
+
* @param scope - 条目所在作用域。
|
|
3925
|
+
* @param id - 条目 id。
|
|
3926
|
+
* @param slot - 宫殿坐标;未排桩传 undefined。
|
|
3927
|
+
*/
|
|
3928
|
+
function evidenceRefOf(scope, id, slot) {
|
|
3929
|
+
return slot === void 0 ? `${scope}/#${id.slice(0, 8)}` : `${scope}/${slot.room}#${slot.index}`;
|
|
3930
|
+
}
|
|
3931
|
+
/** 批次注册表:按会话隔离,超出上限丢最旧的批次。 */
|
|
3932
|
+
var EvidenceBatches = class {
|
|
3933
|
+
bySession = /* @__PURE__ */ new Map();
|
|
3934
|
+
counter = 0;
|
|
3935
|
+
/**
|
|
3936
|
+
* 注册一个批次。
|
|
3937
|
+
* @param sessionId - 会话标识。
|
|
3938
|
+
* @param refs - 本次输出中可引用的证据 ref(重复项只留一次)。
|
|
3939
|
+
* @param now - 当前时间(测试注入)。
|
|
3940
|
+
* @returns 新批次。
|
|
3941
|
+
*/
|
|
3942
|
+
register(sessionId, refs, now = Date.now()) {
|
|
3943
|
+
this.counter += 1;
|
|
3944
|
+
const batch = {
|
|
3945
|
+
batchId: `batch-${String(this.counter)}`,
|
|
3946
|
+
refs: new Set(refs),
|
|
3947
|
+
createdAt: now
|
|
3948
|
+
};
|
|
3949
|
+
const batches = this.bySession.get(sessionId) ?? /* @__PURE__ */ new Map();
|
|
3950
|
+
batches.set(batch.batchId, batch);
|
|
3951
|
+
while (batches.size > 20) {
|
|
3952
|
+
const oldest = batches.keys().next().value;
|
|
3953
|
+
if (oldest === void 0) break;
|
|
3954
|
+
batches.delete(oldest);
|
|
3955
|
+
}
|
|
3956
|
+
this.bySession.set(sessionId, batches);
|
|
3957
|
+
return batch;
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* 取会话内指定批次。
|
|
3961
|
+
* @param sessionId - 会话标识。
|
|
3962
|
+
* @param batchId - 批次 id。
|
|
3963
|
+
* @returns 批次;不存在或已被上限淘汰时返回 undefined。
|
|
3964
|
+
*/
|
|
3965
|
+
get(sessionId, batchId) {
|
|
3966
|
+
return this.bySession.get(sessionId)?.get(batchId);
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* 清理会话的全部批次(会话销毁时调用,避免长驻进程累积)。
|
|
3970
|
+
* @param sessionId - 会话标识。
|
|
3971
|
+
*/
|
|
3972
|
+
clear(sessionId) {
|
|
3973
|
+
this.bySession.delete(sessionId);
|
|
3974
|
+
}
|
|
3975
|
+
};
|
|
3976
|
+
/** 进程内批次注册表:插件为单实例,会话销毁时调用 clear 释放该会话的批次。 */
|
|
3977
|
+
const evidenceBatches = new EvidenceBatches();
|
|
3978
|
+
/**
|
|
3979
|
+
* 校验判定:ref 必须属于批次,sufficient 由代码强制。
|
|
3980
|
+
* 强制规则:sufficient 需同时满足「模型声称充足」「至少一条本批次有效证据」「策略为 answer」;
|
|
3981
|
+
* 三者缺一即判为不充足,并把策略改写为继续检索(模型声称充足时)或尊重其非作答策略。
|
|
3982
|
+
* @param batch - 被引用的批次。
|
|
3983
|
+
* @param request - 模型提交的判定。
|
|
3984
|
+
* @returns 校验后的判定结果(含被拒绝与超限的 ref 明细)。
|
|
3985
|
+
*/
|
|
3986
|
+
function assessEvidence(batch, request) {
|
|
3987
|
+
const submitted = request.evidenceRefs.filter((ref) => typeof ref === "string").map((ref) => ref.trim()).filter((ref) => ref !== "");
|
|
3988
|
+
const unique = [...new Set(submitted)];
|
|
3989
|
+
const limited = unique.slice(0, 8);
|
|
3990
|
+
const droppedRefs = unique.slice(8);
|
|
3991
|
+
const accepted = [];
|
|
3992
|
+
const rejectedRefs = [];
|
|
3993
|
+
for (const ref of limited) if (batch.refs.has(ref)) accepted.push(ref);
|
|
3994
|
+
else rejectedRefs.push(ref);
|
|
3995
|
+
const rawMissing = typeof request.missing === "string" ? request.missing.trim() : "";
|
|
3996
|
+
const missingTruncated = rawMissing.length > 160;
|
|
3997
|
+
const missing = missingTruncated ? `${rawMissing.slice(0, 160)}…` : rawMissing;
|
|
3998
|
+
const requestedStrategy = typeof request.nextStrategy === "string" ? request.nextStrategy.trim() : "";
|
|
3999
|
+
const strategy = isNextStrategy(requestedStrategy) ? requestedStrategy : "search_keyword";
|
|
4000
|
+
const claimed = request.sufficient === true;
|
|
4001
|
+
const sufficient = claimed && accepted.length > 0 && strategy === "answer";
|
|
4002
|
+
const nextStrategy = sufficient || strategy !== "answer" ? strategy : "search_keyword";
|
|
4003
|
+
return {
|
|
4004
|
+
sufficient,
|
|
4005
|
+
evidenceRefs: accepted,
|
|
4006
|
+
rejectedRefs,
|
|
4007
|
+
droppedRefs,
|
|
4008
|
+
missing,
|
|
4009
|
+
missingTruncated,
|
|
4010
|
+
nextStrategy,
|
|
4011
|
+
requestedStrategy,
|
|
4012
|
+
forced: claimed !== sufficient || strategy !== requestedStrategy || nextStrategy !== strategy
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
3732
4015
|
//#endregion
|
|
3733
4016
|
//#region src/tools/create.ts
|
|
3734
4017
|
/**
|
|
3735
|
-
*
|
|
4018
|
+
* 17 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
|
|
3736
4019
|
* scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
|
|
3737
4020
|
* @module @kenz1117/dsh-engram/tools/create
|
|
3738
4021
|
*/
|
|
@@ -3743,6 +4026,17 @@ const KINDS = [
|
|
|
3743
4026
|
"episode",
|
|
3744
4027
|
"skill"
|
|
3745
4028
|
];
|
|
4029
|
+
/**
|
|
4030
|
+
* 工具执行期的会话 cwd:project scope 的读写据此解析项目宫殿。
|
|
4031
|
+
* 用 AsyncLocalStorage 而不是模块级变量:同一进程里多个会话的工具调用可能交错,
|
|
4032
|
+
* 模块级可变状态会把 A 会话的 cwd 泄漏给 B 会话。
|
|
4033
|
+
*/
|
|
4034
|
+
const execSessionCwd = new AsyncLocalStorage();
|
|
4035
|
+
/** 从工具运行上下文取会话 cwd(缺省 = 无会话信息,调用方回退插件进程目录)。 */
|
|
4036
|
+
function sessionCwdOf(exec) {
|
|
4037
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
4038
|
+
return cwd === void 0 || cwd === "" ? void 0 : cwd;
|
|
4039
|
+
}
|
|
3746
4040
|
/** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
|
|
3747
4041
|
function renderHistoryEstimate(estimate) {
|
|
3748
4042
|
if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
|
|
@@ -3770,6 +4064,26 @@ function renderHistoryRun(result) {
|
|
|
3770
4064
|
lines.push("同一批可重复执行:已完成的轮次按幂等键跳过,只补未完成的部分。");
|
|
3771
4065
|
return lines.join("\n");
|
|
3772
4066
|
}
|
|
4067
|
+
/** 证据门策略的可读提示。 */
|
|
4068
|
+
const STRATEGY_HINT = {
|
|
4069
|
+
answer: "可以据此作答",
|
|
4070
|
+
search_keyword: "换关键词再检索(engram_search)",
|
|
4071
|
+
search_room: "收窄到具体房间再检索(engram_search 带 room)",
|
|
4072
|
+
search_timeline: "按时间线找(engram_timeline)",
|
|
4073
|
+
ask_user: "向用户澄清缺失的信息",
|
|
4074
|
+
stop: "不回答,并向用户说明缺少什么"
|
|
4075
|
+
};
|
|
4076
|
+
/** 证据门判定的模型可读文本(含被拒绝的 ref 与强制改写说明)。 */
|
|
4077
|
+
function renderAssessText(verdict, batchId) {
|
|
4078
|
+
const lines = [verdict.sufficient ? `证据判定:充足(批次 ${batchId},${String(verdict.evidenceRefs.length)} 条有效证据)——可以据此作答。` : `证据判定:不足(批次 ${batchId})。`];
|
|
4079
|
+
if (verdict.evidenceRefs.length > 0) lines.push(`有效证据:${verdict.evidenceRefs.join("、")}`);
|
|
4080
|
+
if (verdict.rejectedRefs.length > 0) lines.push(`无效 ref(不属于该批次,已忽略):${verdict.rejectedRefs.join("、")}`);
|
|
4081
|
+
if (verdict.droppedRefs.length > 0) lines.push(`超出上限被丢弃(单次最多 ${String(8)} 条):${verdict.droppedRefs.join("、")}`);
|
|
4082
|
+
lines.push(`缺口:${verdict.missing === "" ? "(未填写)" : verdict.missing}${verdict.missingTruncated ? "(已截断)" : ""}`);
|
|
4083
|
+
lines.push(`下一步:${verdict.nextStrategy} —— ${STRATEGY_HINT[verdict.nextStrategy]}`);
|
|
4084
|
+
if (verdict.forced) lines.push("注意:判定由代码修正——sufficient 需同时满足「你声称充足」「至少一条属于本批次的有效证据」「nextStrategy=answer」。");
|
|
4085
|
+
return lines.join("\n");
|
|
4086
|
+
}
|
|
3773
4087
|
/** 从模型参数收敛 scope(非法值或缺失回退 fallback)。 */
|
|
3774
4088
|
function scopeOf(raw, fallback) {
|
|
3775
4089
|
if (raw === "user" || raw === "project" || raw === "shared") return raw;
|
|
@@ -3839,12 +4153,20 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
3839
4153
|
}
|
|
3840
4154
|
}
|
|
3841
4155
|
/**
|
|
3842
|
-
* 构造
|
|
4156
|
+
* 构造 17 个工具定义(engram_save/search/assess/timeline/update/forget/report/review/review_queue/
|
|
3843
4157
|
* stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
|
|
3844
|
-
* @param
|
|
3845
|
-
* @returns 可直接 register
|
|
4158
|
+
* @param baseDeps - 分库打开器、嵌入器、辅助 LLM、导出目录。
|
|
4159
|
+
* @returns 可直接 register 的工具定义数组(execute 已绑定会话 cwd 的项目宫殿路由)。
|
|
3846
4160
|
*/
|
|
3847
|
-
function createEngramTools(
|
|
4161
|
+
function createEngramTools(baseDeps) {
|
|
4162
|
+
/**
|
|
4163
|
+
* 分库门面:project scope 一律走 `resolveProjectStore(执行期会话 cwd)`,
|
|
4164
|
+
* 其余 scope 原样透传。所有 handler 都用这个 `deps`,无需逐处改调用点。
|
|
4165
|
+
*/
|
|
4166
|
+
const deps = {
|
|
4167
|
+
...baseDeps,
|
|
4168
|
+
openStore: (scope) => scope === "project" ? baseDeps.resolveProjectStore(execSessionCwd.getStore()) : baseDeps.openStore(scope)
|
|
4169
|
+
};
|
|
3848
4170
|
/** 批量保存上限(协议内常量:与单轮摄取候选量级对齐,防一次灌入过多)。 */
|
|
3849
4171
|
const MAX_SAVE_BATCH = 10;
|
|
3850
4172
|
/** engram_save 呈现文本:矛盾警告 text 优先;批量输出汇总成功与失败。 */
|
|
@@ -4219,16 +4541,106 @@ function createEngramTools(deps) {
|
|
|
4219
4541
|
};
|
|
4220
4542
|
}));
|
|
4221
4543
|
const degraded = retrievals.some((retrieval) => retrieval.degraded);
|
|
4222
|
-
const
|
|
4544
|
+
const { kept, dropped } = fitWithinBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
|
|
4545
|
+
const ref = evidenceRefOf(hit.record.scope, hit.record.id, hit.record.slot);
|
|
4223
4546
|
const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
|
|
4224
4547
|
const slot = hit.record.slot === void 0 ? "" : ` ${hit.record.slot.room}#${hit.record.slot.index}`;
|
|
4225
4548
|
const date = ` 刻于 ${new Date(hit.record.createdAt).toISOString().slice(0, 10)}`;
|
|
4226
4549
|
const cues = hit.cues === void 0 ? "" : ` 相邻桩位: ${hit.cues.neighbors.join(", ")}`;
|
|
4227
|
-
return
|
|
4228
|
-
|
|
4550
|
+
return {
|
|
4551
|
+
ref,
|
|
4552
|
+
line: `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id}, ref=${ref})${edge}${cues}`
|
|
4553
|
+
};
|
|
4554
|
+
}), (entry) => entry.line.length, RECALL_TOTAL_CHARS);
|
|
4555
|
+
const lines = kept.map((entry) => entry.line);
|
|
4556
|
+
if (dropped > 0) lines.push(`(另有 ${dropped} 条未展示:缩小查询范围或降低 limit 后重试)`);
|
|
4557
|
+
const sessionId = exec.agent === void 0 ? void 0 : String(exec.agent.session.id);
|
|
4558
|
+
const batch = sessionId === void 0 || kept.length === 0 ? void 0 : evidenceBatches.register(sessionId, kept.map((entry) => entry.ref));
|
|
4559
|
+
const prefix = degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : "";
|
|
4560
|
+
const roomNote = rooms === void 0 ? "" : `(房间路由:${rooms.join("、")})\n`;
|
|
4561
|
+
const batchNote = batch === void 0 ? "" : `\n批次 ${batch.batchId}(${String(batch.refs.size)} 条可引用证据):作答前用 engram_assess 判定证据是否充分,evidenceRefs 只能引用上面的 ref。`;
|
|
4229
4562
|
return {
|
|
4230
4563
|
degraded,
|
|
4231
|
-
text: renderMemoryPacket(`${
|
|
4564
|
+
text: renderMemoryPacket(`${prefix}${roomNote}${lines.join("\n") || "无命中"}${batchNote}`, "tool_search", input.query)
|
|
4565
|
+
};
|
|
4566
|
+
}
|
|
4567
|
+
});
|
|
4568
|
+
const assess = defineTool({
|
|
4569
|
+
name: "engram_assess",
|
|
4570
|
+
description: "证据门:检索(engram_search)之后、作答之前,判定「检索到的内容是否足以回答当前问题」。提交 batchId、sufficient、最多 8 条 evidenceRefs(只能引用该批次输出里的 ref=…)、缺口说明 missing(≤160 字符)与下一步 nextStrategy。代码强制校验:sufficient 需同时满足「你声称充足」「至少一条属于本批次的有效证据」「nextStrategy=answer」,否则判为不足并把策略改回继续检索;不属于该批次的 ref 会被拒绝并列出。判词与拒绝明细写入审计日志。",
|
|
4571
|
+
parameters: {
|
|
4572
|
+
batchId: {
|
|
4573
|
+
type: "string",
|
|
4574
|
+
required: true,
|
|
4575
|
+
description: "engram_search 输出末尾给出的批次 id(如 batch-3)"
|
|
4576
|
+
},
|
|
4577
|
+
sufficient: {
|
|
4578
|
+
type: "boolean",
|
|
4579
|
+
required: true,
|
|
4580
|
+
description: "证据是否足以回答当前问题(true 时 nextStrategy 必须为 answer)"
|
|
4581
|
+
},
|
|
4582
|
+
evidenceRefs: {
|
|
4583
|
+
type: "array",
|
|
4584
|
+
items: { type: "string" },
|
|
4585
|
+
description: "引用的证据 ref(最多 8 条,只能取该批次输出里的 ref=…)"
|
|
4586
|
+
},
|
|
4587
|
+
missing: {
|
|
4588
|
+
type: "string",
|
|
4589
|
+
description: "缺少什么维度的信息(≤160 字符)"
|
|
4590
|
+
},
|
|
4591
|
+
nextStrategy: {
|
|
4592
|
+
type: "string",
|
|
4593
|
+
enum: [...NEXT_STRATEGIES],
|
|
4594
|
+
description: "下一步:answer 作答 / search_keyword 换关键词 / search_room 换房间 / search_timeline 查时间线 / ask_user 问用户 / stop 不回答"
|
|
4595
|
+
}
|
|
4596
|
+
},
|
|
4597
|
+
output: {
|
|
4598
|
+
schema: {
|
|
4599
|
+
type: "object",
|
|
4600
|
+
additionalProperties: false,
|
|
4601
|
+
properties: {
|
|
4602
|
+
sufficient: {
|
|
4603
|
+
type: "boolean",
|
|
4604
|
+
required: true
|
|
4605
|
+
},
|
|
4606
|
+
nextStrategy: {
|
|
4607
|
+
type: "string",
|
|
4608
|
+
required: true
|
|
4609
|
+
},
|
|
4610
|
+
text: {
|
|
4611
|
+
type: "string",
|
|
4612
|
+
required: true
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
},
|
|
4616
|
+
render: (_args, value) => [{
|
|
4617
|
+
type: "text",
|
|
4618
|
+
text: value.text
|
|
4619
|
+
}]
|
|
4620
|
+
},
|
|
4621
|
+
async execute(args, exec) {
|
|
4622
|
+
const input = args;
|
|
4623
|
+
if (typeof input.batchId !== "string" || input.batchId.trim() === "") throw new Error("engram_assess: batchId 必填(取自 engram_search 输出末尾)");
|
|
4624
|
+
const sessionId = exec.agent === void 0 ? void 0 : String(exec.agent.session.id);
|
|
4625
|
+
const batch = sessionId === void 0 ? void 0 : evidenceBatches.get(sessionId, input.batchId.trim());
|
|
4626
|
+
if (batch === void 0) throw new Error(`engram_assess: 批次 ${input.batchId} 不存在或已过期(每个会话保留最近 ${String(20)} 个批次)——请重新 engram_search 取新批次`);
|
|
4627
|
+
const verdict = assessEvidence(batch, {
|
|
4628
|
+
sufficient: input.sufficient === true,
|
|
4629
|
+
evidenceRefs: Array.isArray(input.evidenceRefs) ? input.evidenceRefs.filter((ref) => typeof ref === "string") : [],
|
|
4630
|
+
missing: typeof input.missing === "string" ? input.missing : "",
|
|
4631
|
+
nextStrategy: typeof input.nextStrategy === "string" ? input.nextStrategy : ""
|
|
4632
|
+
});
|
|
4633
|
+
await (await deps.openStore("user")).audit("assess", batch.batchId, JSON.stringify({
|
|
4634
|
+
batchId: batch.batchId,
|
|
4635
|
+
sufficient: verdict.sufficient,
|
|
4636
|
+
refs: verdict.evidenceRefs,
|
|
4637
|
+
rejected: verdict.rejectedRefs,
|
|
4638
|
+
strategy: verdict.nextStrategy
|
|
4639
|
+
}));
|
|
4640
|
+
return {
|
|
4641
|
+
sufficient: verdict.sufficient,
|
|
4642
|
+
nextStrategy: verdict.nextStrategy,
|
|
4643
|
+
text: renderAssessText(verdict, batch.batchId)
|
|
4232
4644
|
};
|
|
4233
4645
|
}
|
|
4234
4646
|
});
|
|
@@ -4622,6 +5034,7 @@ function createEngramTools(deps) {
|
|
|
4622
5034
|
return [
|
|
4623
5035
|
save,
|
|
4624
5036
|
search,
|
|
5037
|
+
assess,
|
|
4625
5038
|
timeline,
|
|
4626
5039
|
update,
|
|
4627
5040
|
forget,
|
|
@@ -5140,7 +5553,10 @@ function createEngramTools(deps) {
|
|
|
5140
5553
|
}),
|
|
5141
5554
|
auditForgotten,
|
|
5142
5555
|
ingestHistory
|
|
5143
|
-
]
|
|
5556
|
+
].map((tool) => ({
|
|
5557
|
+
...tool,
|
|
5558
|
+
execute: (args, exec) => execSessionCwd.run(sessionCwdOf(exec), () => tool.execute(args, exec))
|
|
5559
|
+
}));
|
|
5144
5560
|
}
|
|
5145
5561
|
/**
|
|
5146
5562
|
* 构造巡游路径:在 search 命中的基础上按路径策略重排。
|
|
@@ -5270,6 +5686,38 @@ function reasonsLabel(reasons) {
|
|
|
5270
5686
|
return reasons.map((r) => LABELS[r]).join(" / ");
|
|
5271
5687
|
}
|
|
5272
5688
|
//#endregion
|
|
5689
|
+
//#region src/token.ts
|
|
5690
|
+
/**
|
|
5691
|
+
* token 估算:CJK 感知的上下文预算口径。
|
|
5692
|
+
*
|
|
5693
|
+
* 汉字、假名、韩文与全角标点按 1.5 token/字计,其余字符按 4 字符/token 计。
|
|
5694
|
+
* 统一的 `长度 / 4` 会把中文低估四倍以上,使按 token 计的注入预算与实际占用脱钩
|
|
5695
|
+
* (中文 1024 token 预算实际会注入四千余 token)。
|
|
5696
|
+
* @module @kenz1117/dsh-engram/token
|
|
5697
|
+
*/
|
|
5698
|
+
/** 单个 CJK 字符的 token 成本。 */
|
|
5699
|
+
const WIDE_TOKENS_PER_CHAR = 1.5;
|
|
5700
|
+
/**
|
|
5701
|
+
* 判断码点是否属于 CJK 文字与全角符号区间(含扩展平面)。
|
|
5702
|
+
* @param codePoint - Unicode 码点。
|
|
5703
|
+
*/
|
|
5704
|
+
function isWideCodePoint(codePoint) {
|
|
5705
|
+
return codePoint >= 12288 && codePoint <= 12351 || codePoint >= 12352 && codePoint <= 12543 || codePoint >= 13312 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 40959 || codePoint >= 43360 && codePoint <= 43391 || codePoint >= 44032 && codePoint <= 55295 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65280 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 131072 && codePoint <= 262143;
|
|
5706
|
+
}
|
|
5707
|
+
/**
|
|
5708
|
+
* 估算文本占用的 token 数(向上取整)。
|
|
5709
|
+
* @param text - 待估算文本。
|
|
5710
|
+
* @returns token 估算值;空串为 0。
|
|
5711
|
+
*/
|
|
5712
|
+
function estimateTokens(text) {
|
|
5713
|
+
if (text === "") return 0;
|
|
5714
|
+
let wide = 0;
|
|
5715
|
+
let narrow = 0;
|
|
5716
|
+
for (const char of text) if (isWideCodePoint(char.codePointAt(0))) wide += 1;
|
|
5717
|
+
else narrow += 1;
|
|
5718
|
+
return Math.ceil(wide * WIDE_TOKENS_PER_CHAR + narrow / 4);
|
|
5719
|
+
}
|
|
5720
|
+
//#endregion
|
|
5273
5721
|
//#region src/index.ts
|
|
5274
5722
|
/**
|
|
5275
5723
|
* dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
|
|
@@ -5280,19 +5728,20 @@ function reasonsLabel(reasons) {
|
|
|
5280
5728
|
/** Cordis 插件名(loader 诊断与注入 source 使用)。 */
|
|
5281
5729
|
const name = "dsh-engram";
|
|
5282
5730
|
/** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
|
|
5283
|
-
const VERSION = "0.7.
|
|
5731
|
+
const VERSION = "0.7.6";
|
|
5284
5732
|
/** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
|
|
5285
5733
|
const inject = ["tools", "llm"];
|
|
5286
5734
|
/**
|
|
5287
|
-
* 会话开始注入的画像渲染:按重要性降序在 token
|
|
5288
|
-
*
|
|
5289
|
-
*
|
|
5735
|
+
* 会话开始注入的画像渲染:按重要性降序在 token 预算内整行装填(估算见
|
|
5736
|
+
* estimateTokens:中文按 1.5 token/字、其余按 4 字符/token,超预算的行跳过不截断、
|
|
5737
|
+
* 继续试更短行);装不下的条目降级为索引行(#id + 前 40 字),
|
|
5738
|
+
* 索引行也装不下的折成末尾 `+N more; use engram_search` 计数行;计数行同样占用预算。
|
|
5290
5739
|
* @param records - 候选条目(调用方已按重要性排序、按条数截断)。
|
|
5291
5740
|
* @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
|
|
5292
5741
|
* @returns 渲染文本与溢出条目(调用方可用辅助 LLM 压缩后重渲染)。
|
|
5293
5742
|
*/
|
|
5294
5743
|
function renderProfileDetailed(records, tokenBudget) {
|
|
5295
|
-
const estimate =
|
|
5744
|
+
const estimate = estimateTokens;
|
|
5296
5745
|
const header = "User memory profile (dsh-engram, cross-session) — Grand Hall (always present):";
|
|
5297
5746
|
const footer = "Use engram_search to recall details (pass room to search inside one room); use engram_save to persist new facts.";
|
|
5298
5747
|
let remaining = Math.max(0, tokenBudget - estimate(header) - estimate(footer));
|
|
@@ -5308,6 +5757,7 @@ function renderProfileDetailed(records, tokenBudget) {
|
|
|
5308
5757
|
} else overflow.push(record);
|
|
5309
5758
|
}
|
|
5310
5759
|
let more = 0;
|
|
5760
|
+
remaining -= overflow.length === 0 ? 0 : estimate(`+${overflow.length} more; use engram_search`);
|
|
5311
5761
|
for (const record of overflow) {
|
|
5312
5762
|
const line = `- [${record.kind}] #${record.id} ${record.content.slice(0, 40)}…`;
|
|
5313
5763
|
const cost = estimate(line);
|
|
@@ -5316,7 +5766,7 @@ function renderProfileDetailed(records, tokenBudget) {
|
|
|
5316
5766
|
remaining -= cost;
|
|
5317
5767
|
} else more += 1;
|
|
5318
5768
|
}
|
|
5319
|
-
if (more > 0) lines.push(`+${more} more; use engram_search`);
|
|
5769
|
+
if (more > 0 && remaining >= 0) lines.push(`+${more} more; use engram_search`);
|
|
5320
5770
|
return {
|
|
5321
5771
|
text: [
|
|
5322
5772
|
header,
|
|
@@ -5388,7 +5838,7 @@ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, sign
|
|
|
5388
5838
|
* 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
|
|
5389
5839
|
* 记忆库为空或非首轮时不追加消息。
|
|
5390
5840
|
*/
|
|
5391
|
-
async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
5841
|
+
async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
5392
5842
|
const decision = await next();
|
|
5393
5843
|
if (decision.kind === "reject") return decision;
|
|
5394
5844
|
if (step === 1) {
|
|
@@ -5396,12 +5846,14 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5396
5846
|
if (currentRoute !== void 0) state.route = currentRoute;
|
|
5397
5847
|
}
|
|
5398
5848
|
const mode = resolved.ingest;
|
|
5849
|
+
const routing = ingestWriteRouting(sessionCwd(agent.session), openStoreForProjectCwd, openStore);
|
|
5399
5850
|
if (step === 1 && mode !== "off") {
|
|
5400
5851
|
if (!state.pendingReplayed) {
|
|
5401
5852
|
state.pendingReplayed = true;
|
|
5402
5853
|
replayPendingIngests({
|
|
5403
5854
|
openStore: () => openStore("user"),
|
|
5404
|
-
|
|
5855
|
+
resolveSession: makeSessionResolver(ctx, agent),
|
|
5856
|
+
resolveStore: openStoreForProjectCwd,
|
|
5405
5857
|
embedder,
|
|
5406
5858
|
mode,
|
|
5407
5859
|
routeOverride: resolved.routeOverride,
|
|
@@ -5420,6 +5872,8 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5420
5872
|
sessionId: String(agent.id),
|
|
5421
5873
|
turn,
|
|
5422
5874
|
openStore: () => openStore("user"),
|
|
5875
|
+
openAuditStore: () => openStore("user"),
|
|
5876
|
+
...routing,
|
|
5423
5877
|
embedder,
|
|
5424
5878
|
mode,
|
|
5425
5879
|
routeOverride: resolved.routeOverride,
|
|
@@ -5479,17 +5933,49 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5479
5933
|
};
|
|
5480
5934
|
}
|
|
5481
5935
|
/**
|
|
5482
|
-
*
|
|
5483
|
-
*
|
|
5484
|
-
*
|
|
5936
|
+
* 会话工作目录(决定它的记忆进哪座项目宫殿)。
|
|
5937
|
+
* 真实 `Session.header` 恒在;测试替身可能省略 header,故按可选形状读取。
|
|
5938
|
+
* @param session - 会话(或替身)对象。
|
|
5939
|
+
* @returns cwd;取不到时 undefined(该会话的回退口径是私人宫殿)。
|
|
5940
|
+
*/
|
|
5941
|
+
function sessionCwd(session) {
|
|
5942
|
+
const cwd = session?.header?.cwd;
|
|
5943
|
+
return cwd === void 0 || cwd === "" ? void 0 : cwd;
|
|
5944
|
+
}
|
|
5945
|
+
/**
|
|
5946
|
+
* 只读打开会话日志 → 交给调用方读取 → 必关闭句柄。
|
|
5947
|
+
* close 失败不掩盖读取结果(读取成功而 close 失败时仍返回成功值)。
|
|
5948
|
+
* @param persistence - 持久化服务视图。
|
|
5949
|
+
* @param sessionId - 目标会话 id。
|
|
5950
|
+
* @param use - 句柄使用回调。
|
|
5951
|
+
* @returns 回调结果。
|
|
5485
5952
|
*/
|
|
5486
|
-
function
|
|
5953
|
+
async function withReadHandle(persistence, sessionId, use) {
|
|
5954
|
+
const handle = await persistence.open(sessionId, "read");
|
|
5955
|
+
try {
|
|
5956
|
+
return await use(handle);
|
|
5957
|
+
} finally {
|
|
5958
|
+
await handle.close().catch(() => {});
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
/**
|
|
5962
|
+
* pending 重放的会话解析器:pending 属于当前会话时直接用其事件快照与 cwd;
|
|
5963
|
+
* 其余会话经可选的 sessionPersistence 服务(只读句柄)读持久化日志与 header.cwd
|
|
5964
|
+
* (服务缺席或读取失败返回 undefined,pending 保留到下次,不报错)。
|
|
5965
|
+
*/
|
|
5966
|
+
function makeSessionResolver(ctx, agent) {
|
|
5487
5967
|
return async (sessionId) => {
|
|
5488
|
-
if (sessionId === String(agent.id)) return
|
|
5968
|
+
if (sessionId === String(agent.id)) return {
|
|
5969
|
+
events: agent.session.snapshotEvents(),
|
|
5970
|
+
cwd: sessionCwd(agent.session)
|
|
5971
|
+
};
|
|
5489
5972
|
const persistence = ctx.get("sessionPersistence");
|
|
5490
5973
|
if (persistence === void 0) return void 0;
|
|
5491
5974
|
try {
|
|
5492
|
-
return
|
|
5975
|
+
return await withReadHandle(persistence, sessionId, async (handle) => ({
|
|
5976
|
+
events: (await handle.read()).events,
|
|
5977
|
+
cwd: sessionCwd(handle)
|
|
5978
|
+
}));
|
|
5493
5979
|
} catch {
|
|
5494
5980
|
return;
|
|
5495
5981
|
}
|
|
@@ -5535,6 +6021,11 @@ function apply(ctx, config = {}) {
|
|
|
5535
6021
|
return created;
|
|
5536
6022
|
};
|
|
5537
6023
|
const openStore = (scope) => openDb(scope === "user" ? "user.db" : scope === "shared" ? "shared.db" : identity.dbName);
|
|
6024
|
+
ctx.effect(() => () => Promise.allSettled([...stores.values()].map(async (pending) => {
|
|
6025
|
+
try {
|
|
6026
|
+
await (await pending).close();
|
|
6027
|
+
} catch {}
|
|
6028
|
+
})), "dsh-engram: close stores");
|
|
5538
6029
|
/** cwd → 分库文件名(历史回填会对每个历史 cwd 解析一次,避免重复读 git 元数据)。 */
|
|
5539
6030
|
const cwdDbNames = /* @__PURE__ */ new Map();
|
|
5540
6031
|
/**
|
|
@@ -5548,11 +6039,18 @@ function apply(ctx, config = {}) {
|
|
|
5548
6039
|
const cached = cwdDbNames.get(cwd);
|
|
5549
6040
|
if (cached !== void 0) return cached;
|
|
5550
6041
|
const projectIdentity = resolveProjectIdentity(cwd);
|
|
5551
|
-
|
|
6042
|
+
const migration = migrateProjectDb(resolved.dbDir, projectIdentity);
|
|
6043
|
+
if (migration === "renamed") console.warn(`[dsh-engram] 项目记忆库已迁移到新标识命名(${cwd} → ${projectIdentity.dbName})`);
|
|
6044
|
+
else if (migration === "kept-both") console.warn(`[dsh-engram] 检测到新旧项目库并存,未合并(保留新库 ${projectIdentity.dbName};旧库 ${projectIdentity.legacyDbName} 请人工处理后删除)`);
|
|
5552
6045
|
cwdDbNames.set(cwd, projectIdentity.dbName);
|
|
5553
6046
|
return projectIdentity.dbName;
|
|
5554
6047
|
};
|
|
5555
6048
|
const openStoreForProjectCwd = (cwd) => openDb(dbNameForCwd(cwd));
|
|
6049
|
+
/**
|
|
6050
|
+
* 工具调用期的项目分库解析:按会话 cwd 归属(缺省回退插件进程目录)。
|
|
6051
|
+
* 面板「项目」scope、工具读写与自动摄取的 project 落点因此同一口径。
|
|
6052
|
+
*/
|
|
6053
|
+
const resolveProjectStore = (cwd) => cwd === void 0 ? openStore("project") : openStoreForProjectCwd(cwd);
|
|
5556
6054
|
const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
|
|
5557
6055
|
console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
|
|
5558
6056
|
});
|
|
@@ -5570,22 +6068,93 @@ function apply(ctx, config = {}) {
|
|
|
5570
6068
|
const persistence = ctx.get("sessionPersistence");
|
|
5571
6069
|
if (persistence === void 0) return void 0;
|
|
5572
6070
|
return {
|
|
5573
|
-
list: (signal) => persistence.list(signal),
|
|
5574
|
-
load: (id) => persistence.
|
|
6071
|
+
list: async (signal) => (await persistence.list(signal === void 0 ? void 0 : { signal })).map((snapshot) => snapshot.header),
|
|
6072
|
+
load: (id) => withReadHandle(persistence, id, async (handle) => ({ events: (await handle.read()).events }))
|
|
5575
6073
|
};
|
|
5576
6074
|
};
|
|
5577
6075
|
/** user 分库若已存在则打开(估算用:不因估算产生空库文件)。 */
|
|
5578
6076
|
const openUserStoreIfExists = () => existsSync(join(resolved.dbDir, "user.db")) ? openStore("user") : Promise.resolve(void 0);
|
|
5579
|
-
/**
|
|
5580
|
-
const
|
|
5581
|
-
const
|
|
5582
|
-
|
|
6077
|
+
/** 宿主机工作区注册表(可选服务:缺席时降级为「会话 cwd + 进程目录」两级清单)。 */
|
|
6078
|
+
const workspaceRefs = () => {
|
|
6079
|
+
const registry = ctx.get("workspaceRegistry");
|
|
6080
|
+
if (registry === void 0) return [];
|
|
6081
|
+
try {
|
|
6082
|
+
return registry.list().filter((workspace) => typeof workspace.path === "string" && workspace.path !== "").map((workspace) => ({
|
|
6083
|
+
id: String(workspace.id),
|
|
6084
|
+
path: workspace.path,
|
|
6085
|
+
title: typeof workspace.title === "string" ? workspace.title : ""
|
|
6086
|
+
}));
|
|
6087
|
+
} catch {
|
|
6088
|
+
return [];
|
|
6089
|
+
}
|
|
6090
|
+
};
|
|
6091
|
+
/** 只按会话 cwd 认领的分库上限(按会话新旧取最近的若干,避免历史 cwd 把清单撑爆)。 */
|
|
6092
|
+
const SESSION_CWD_PALACE_LIMIT = 30;
|
|
6093
|
+
/**
|
|
6094
|
+
* 会话 header 里出现过的 cwd:历史回填建的库也能在面板里被认领。
|
|
6095
|
+
* 按会话创建时间倒序取最近的 N 个(新的在前,去重后截断)。
|
|
6096
|
+
*/
|
|
6097
|
+
const sessionCwds = async () => {
|
|
6098
|
+
const source = persistenceService();
|
|
6099
|
+
if (source === void 0) return [];
|
|
6100
|
+
try {
|
|
6101
|
+
const ordered = [...await source.list()].sort((a, b) => b.createdAt - a.createdAt);
|
|
6102
|
+
const cwds = [];
|
|
6103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6104
|
+
for (const header of ordered) {
|
|
6105
|
+
const cwd = header.cwd;
|
|
6106
|
+
if (cwd === void 0 || cwd === "" || seen.has(cwd)) continue;
|
|
6107
|
+
seen.add(cwd);
|
|
6108
|
+
cwds.push(cwd);
|
|
6109
|
+
if (cwds.length >= SESSION_CWD_PALACE_LIMIT) break;
|
|
6110
|
+
}
|
|
6111
|
+
return cwds;
|
|
6112
|
+
} catch {
|
|
6113
|
+
return [];
|
|
6114
|
+
}
|
|
6115
|
+
};
|
|
6116
|
+
/** 项目宫殿清单(现算、零状态):注册表工作区 → 会话 cwd → 进程目录兜底,按分库名去重。 */
|
|
6117
|
+
const projectPalaces = async () => listProjectPalaces({
|
|
6118
|
+
workspaces: workspaceRefs(),
|
|
6119
|
+
sessionCwds: await sessionCwds(),
|
|
6120
|
+
processCwd: process.cwd()
|
|
6121
|
+
});
|
|
6122
|
+
/** 面板视图:补 exists 与 active 计数(库文件不存在时不打开,避免为工作区建空库)。 */
|
|
6123
|
+
const palaceView = async (palace) => {
|
|
6124
|
+
const exists = existsSync(join(resolved.dbDir, palace.dbName));
|
|
6125
|
+
let memories = null;
|
|
6126
|
+
if (exists) try {
|
|
6127
|
+
memories = (await (await openDb(palace.dbName)).stats()).active;
|
|
6128
|
+
} catch {
|
|
6129
|
+
memories = null;
|
|
6130
|
+
}
|
|
6131
|
+
return {
|
|
6132
|
+
dbName: palace.dbName,
|
|
6133
|
+
title: palace.title,
|
|
6134
|
+
path: palace.path ?? null,
|
|
6135
|
+
kind: palace.kind,
|
|
6136
|
+
source: palace.source,
|
|
6137
|
+
workspaceId: palace.workspaceId ?? null,
|
|
6138
|
+
exists,
|
|
6139
|
+
memories
|
|
6140
|
+
};
|
|
6141
|
+
};
|
|
6142
|
+
const projectsDeps = {
|
|
6143
|
+
list: async () => Promise.all((await projectPalaces()).map(async (palace) => palaceView(palace))),
|
|
6144
|
+
open: async (selector) => {
|
|
6145
|
+
const palaces = await projectPalaces();
|
|
6146
|
+
const palace = selector === void 0 ? findProjectPalace(palaces, {}) : palaces.find((candidate) => candidate.dbName === selector);
|
|
6147
|
+
if (palace === void 0) return void 0;
|
|
6148
|
+
return {
|
|
6149
|
+
store: await openDb(palace.dbName),
|
|
6150
|
+
project: await palaceView(palace)
|
|
6151
|
+
};
|
|
6152
|
+
}
|
|
5583
6153
|
};
|
|
5584
6154
|
/** 组装历史回填依赖;source 每次现取,兼容持久化服务在插件之后挂载的组合。 */
|
|
5585
6155
|
const buildHistoryDeps = () => ({
|
|
5586
6156
|
source: persistenceService(),
|
|
5587
6157
|
resolveStore: openStoreForProjectCwd,
|
|
5588
|
-
resolveExistingStore: openProjectStoreIfExists,
|
|
5589
6158
|
openUserStore: () => openStore("user"),
|
|
5590
6159
|
resolveExistingUserStore: openUserStoreIfExists,
|
|
5591
6160
|
embedder,
|
|
@@ -5695,6 +6264,7 @@ function apply(ctx, config = {}) {
|
|
|
5695
6264
|
};
|
|
5696
6265
|
for (const tool of createEngramTools({
|
|
5697
6266
|
openStore,
|
|
6267
|
+
resolveProjectStore,
|
|
5698
6268
|
embedder,
|
|
5699
6269
|
call: (callParams) => streamText(ctx, {
|
|
5700
6270
|
...callParams,
|
|
@@ -5711,6 +6281,7 @@ function apply(ctx, config = {}) {
|
|
|
5711
6281
|
ctx.inject(["webServer"], (webCtx) => {
|
|
5712
6282
|
registerEngramRoutes(webCtx, {
|
|
5713
6283
|
openStore,
|
|
6284
|
+
projects: projectsDeps,
|
|
5714
6285
|
exportDir: `${resolved.dbDir}/exports`,
|
|
5715
6286
|
mirrorDir: `${resolved.dbDir}/palaces`,
|
|
5716
6287
|
dbDir: resolved.dbDir,
|
|
@@ -5726,7 +6297,10 @@ function apply(ctx, config = {}) {
|
|
|
5726
6297
|
}
|
|
5727
6298
|
});
|
|
5728
6299
|
});
|
|
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 });
|
|
6300
|
+
if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedder, preStepState, logIngestRequest, payload, next), { prepend: true });
|
|
6301
|
+
ctx.on("session/disposed", (session) => {
|
|
6302
|
+
evidenceBatches.clear(String(session.id));
|
|
6303
|
+
});
|
|
5730
6304
|
if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
|
|
5731
6305
|
const mode = resolved.ingest;
|
|
5732
6306
|
if (mode === "off") return;
|
|
@@ -5736,6 +6310,8 @@ function apply(ctx, config = {}) {
|
|
|
5736
6310
|
turn: 0,
|
|
5737
6311
|
slice: "last",
|
|
5738
6312
|
openStore: () => openStore("user"),
|
|
6313
|
+
openAuditStore: () => openStore("user"),
|
|
6314
|
+
...ingestWriteRouting(sessionCwd(session), openStoreForProjectCwd, openStore),
|
|
5739
6315
|
embedder,
|
|
5740
6316
|
mode,
|
|
5741
6317
|
routeOverride: resolved.routeOverride,
|
|
@@ -5750,12 +6326,23 @@ function apply(ctx, config = {}) {
|
|
|
5750
6326
|
});
|
|
5751
6327
|
});
|
|
5752
6328
|
const runDecay = async () => {
|
|
5753
|
-
|
|
5754
|
-
|
|
6329
|
+
const targets = [{
|
|
6330
|
+
label: "user",
|
|
6331
|
+
open: () => openStore("user")
|
|
6332
|
+
}];
|
|
6333
|
+
for (const palace of await projectPalaces()) {
|
|
6334
|
+
if (!existsSync(join(resolved.dbDir, palace.dbName))) continue;
|
|
6335
|
+
targets.push({
|
|
6336
|
+
label: `project「${palace.title}」`,
|
|
6337
|
+
open: () => openDb(palace.dbName)
|
|
6338
|
+
});
|
|
6339
|
+
}
|
|
6340
|
+
for (const target of targets) {
|
|
6341
|
+
const archived = await (await target.open()).decay({
|
|
5755
6342
|
importanceBelow: resolved.decayImportanceBelow,
|
|
5756
6343
|
olderThanDays: resolved.decayAfterDays
|
|
5757
6344
|
});
|
|
5758
|
-
if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${
|
|
6345
|
+
if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${target.label} 库归档 ${archived} 条低价值记忆(可在 engram_review 查证)`);
|
|
5759
6346
|
}
|
|
5760
6347
|
};
|
|
5761
6348
|
runDecay().catch((error) => {
|