@kenz1117/dsh-engram 0.7.5 → 0.7.7
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 +40 -1
- package/README.md +42 -8
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +444 -80
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -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
|
/**
|
|
@@ -15,6 +16,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
|
15
16
|
*/
|
|
16
17
|
/** 合法配置键集合(未知键 loud 失败)。 */
|
|
17
18
|
const CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
19
|
+
"legacyMigration",
|
|
18
20
|
"dbDir",
|
|
19
21
|
"injectProfile",
|
|
20
22
|
"profileTopN",
|
|
@@ -46,6 +48,7 @@ const INGEST_MODES = /* @__PURE__ */ new Set([
|
|
|
46
48
|
/** Schemastery 校验面(cordis.yml 读取时校验)。 */
|
|
47
49
|
const Config = z.object({
|
|
48
50
|
dbDir: z.string(),
|
|
51
|
+
legacyMigration: z.string(),
|
|
49
52
|
injectProfile: z.boolean(),
|
|
50
53
|
profileTopN: z.number().step(1).min(1).max(64),
|
|
51
54
|
modelCacheDir: z.string(),
|
|
@@ -76,6 +79,7 @@ const Config = z.object({
|
|
|
76
79
|
*/
|
|
77
80
|
function resolveConfig(config = {}) {
|
|
78
81
|
for (const key of Object.keys(config)) if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-engram: unknown config key "${key}"`);
|
|
82
|
+
if (config.legacyMigration !== void 0 && config.legacyMigration !== "eager" && config.legacyMigration !== "conservative") throw new Error("dsh-engram: legacyMigration must be one of eager|conservative");
|
|
79
83
|
if (config.ingest !== void 0 && !INGEST_MODES.has(config.ingest)) throw new Error(`dsh-engram: ingest must be one of off|light|eager, got "${String(config.ingest)}"`);
|
|
80
84
|
if (config.profileTopN !== void 0 && (!Number.isInteger(config.profileTopN) || config.profileTopN < 1 || config.profileTopN > 64)) throw new Error("dsh-engram: profileTopN must be an integer in [1, 64]");
|
|
81
85
|
const hasProvider = config.provider !== void 0;
|
|
@@ -92,6 +96,7 @@ function resolveConfig(config = {}) {
|
|
|
92
96
|
const dbDir = config.dbDir ?? join(homedir(), ".dsh", "engram");
|
|
93
97
|
return {
|
|
94
98
|
dbDir,
|
|
99
|
+
legacyMigration: config.legacyMigration ?? "eager",
|
|
95
100
|
injectProfile: config.injectProfile ?? true,
|
|
96
101
|
profileTopN: config.profileTopN ?? 8,
|
|
97
102
|
modelCacheDir: config.modelCacheDir ?? join(dbDir, "models"),
|
|
@@ -519,10 +524,31 @@ function decodeTurnKey(detail) {
|
|
|
519
524
|
}
|
|
520
525
|
const INGEST_SYSTEM = [
|
|
521
526
|
"从对话记录中提取值得跨会话长期记住的用户信息(事实/偏好/决策/经历/做事方法)。",
|
|
522
|
-
"只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"importance\": 0到1的小数}。",
|
|
527
|
+
"只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"scope\": \"project|user\", \"importance\": 0到1的小数}。",
|
|
528
|
+
"scope 决定这条记忆进哪座宫殿:只跟当前项目/仓库有关的(技术选型、项目约定、架构决策、该项目自身的事实)用 project;与具体项目无关、跨项目通用的(个人偏好、习惯、用户本人的经历、通用事实)用 user。拿不准用 project。",
|
|
523
529
|
"只提取明确、可复用的信息;寒暄、临时上下文、你自己的回答不要提取。没有值得记的就输出 []。",
|
|
524
530
|
"不要输出 JSON 以外的任何内容。"
|
|
525
531
|
].join("\n");
|
|
532
|
+
/**
|
|
533
|
+
* 组装一次摄取的写入路由:会话有 cwd(可归属项目)时默认进项目宫殿且采纳模型的逐条判定,
|
|
534
|
+
* 明显跨项目的个人偏好可被标成 user 落私人宫殿;无 cwd 的会话只能进私人宫殿,
|
|
535
|
+
* 故关掉逐条判定(否则 project 标记落进 user 库后检索不可见)。
|
|
536
|
+
* @param cwd - 会话工作目录;空串/undefined = 无法归属项目。
|
|
537
|
+
* @param openProjectStore - 按 cwd 打开项目分库。
|
|
538
|
+
* @param openScopeStore - 按作用域打开分库(user / shared)。
|
|
539
|
+
* @returns 可直接展开进 IngestDeps / ReplayIngestDeps 的路由字段。
|
|
540
|
+
*/
|
|
541
|
+
function ingestWriteRouting(cwd, openProjectStore, openScopeStore) {
|
|
542
|
+
if (cwd === void 0 || cwd === "") return {
|
|
543
|
+
writeScope: "user",
|
|
544
|
+
perCandidateScope: false
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
writeScope: "project",
|
|
548
|
+
perCandidateScope: true,
|
|
549
|
+
resolveStore: (scope) => scope === "project" ? openProjectStore(cwd) : openScopeStore(scope)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
526
552
|
/** 从事件里按类型收集文本块,跳过插件注入的 user 快照(它们不是用户说的话)。 */
|
|
527
553
|
function collectTexts(events, includeAssistant) {
|
|
528
554
|
const texts = [];
|
|
@@ -672,8 +698,9 @@ async function ingestPreviousTurn(deps) {
|
|
|
672
698
|
skipped: typeof sliceMode === "number" ? "no-such-turn" : "no-previous-turn"
|
|
673
699
|
};
|
|
674
700
|
const store = await deps.openStore();
|
|
701
|
+
const auditStore = deps.openAuditStore === void 0 ? store : await deps.openAuditStore();
|
|
675
702
|
const doneKey = encodeTurnKey(deps.sessionId, round);
|
|
676
|
-
if (await
|
|
703
|
+
if (await auditStore.hasAudit("ingest-done", doneKey)) return {
|
|
677
704
|
scannedEvents: slice.length,
|
|
678
705
|
candidates: 0,
|
|
679
706
|
written: 0,
|
|
@@ -742,14 +769,17 @@ async function ingestPreviousTurn(deps) {
|
|
|
742
769
|
"episode",
|
|
743
770
|
"skill"
|
|
744
771
|
].includes(candidate.kind) ? candidate.kind : "fact";
|
|
772
|
+
const modelScope = candidate.scope === "user" || candidate.scope === "project" ? candidate.scope : void 0;
|
|
773
|
+
const scope = deps.perCandidateScope === true && modelScope !== void 0 ? modelScope : deps.writeScope ?? "user";
|
|
774
|
+
const target = deps.resolveStore === void 0 ? store : await deps.resolveStore(scope);
|
|
745
775
|
const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .5;
|
|
746
776
|
if (embedder !== void 0) {
|
|
747
777
|
const vector = (await embedder.embed([content]))[0];
|
|
748
|
-
if (vector !== void 0 && (await
|
|
778
|
+
if (vector !== void 0 && (await target.findContradictions(vector, 1)).length > 0) continue;
|
|
749
779
|
}
|
|
750
780
|
if (writtenContents.includes(content)) continue;
|
|
751
|
-
await
|
|
752
|
-
scope
|
|
781
|
+
await target.write({
|
|
782
|
+
scope,
|
|
753
783
|
kind,
|
|
754
784
|
content,
|
|
755
785
|
importance,
|
|
@@ -763,7 +793,7 @@ async function ingestPreviousTurn(deps) {
|
|
|
763
793
|
writtenContents.push(content);
|
|
764
794
|
written += 1;
|
|
765
795
|
}
|
|
766
|
-
await
|
|
796
|
+
await auditStore.audit(INGEST_DONE_OP, deps.sessionId, doneKey);
|
|
767
797
|
return {
|
|
768
798
|
scannedEvents: slice.length,
|
|
769
799
|
candidates: parsed.length,
|
|
@@ -800,7 +830,7 @@ async function ingestFinalTurn(deps) {
|
|
|
800
830
|
});
|
|
801
831
|
} catch (error) {
|
|
802
832
|
if (round !== void 0) try {
|
|
803
|
-
await markPendingIngest(await deps.openStore(), deps.sessionId, round);
|
|
833
|
+
await markPendingIngest(await (deps.openAuditStore ?? deps.openStore)(), deps.sessionId, round);
|
|
804
834
|
} catch {}
|
|
805
835
|
console.warn("[dsh-engram] 会话结束的末轮摄取失败(已记入待补做队列,不影响对话):", error);
|
|
806
836
|
return null;
|
|
@@ -809,6 +839,7 @@ async function ingestFinalTurn(deps) {
|
|
|
809
839
|
/**
|
|
810
840
|
* 重放待补做的末轮摄取(下次会话首次 pre-step 调用)。已有 done 标记或键损坏的
|
|
811
841
|
* pending 直接出队;事件源不可得的保留到下次。单个键失败抛出,剩余键留待下次。
|
|
842
|
+
* 每条 pending 按它自己会话的 cwd 组装写入路由(补做时同样逐条判宫殿)。
|
|
812
843
|
*/
|
|
813
844
|
async function replayPendingIngests(deps) {
|
|
814
845
|
const store = await deps.openStore();
|
|
@@ -821,17 +852,19 @@ async function replayPendingIngests(deps) {
|
|
|
821
852
|
await store.clearAudit(INGEST_PENDING_OP, detail);
|
|
822
853
|
continue;
|
|
823
854
|
}
|
|
824
|
-
const
|
|
825
|
-
if (
|
|
855
|
+
const session = await deps.resolveSession(key.sessionId);
|
|
856
|
+
if (session === void 0) {
|
|
826
857
|
kept += 1;
|
|
827
858
|
continue;
|
|
828
859
|
}
|
|
829
860
|
await ingestPreviousTurn({
|
|
830
|
-
events,
|
|
861
|
+
events: session.events,
|
|
831
862
|
sessionId: key.sessionId,
|
|
832
863
|
turn: key.turn,
|
|
833
864
|
slice: key.turn,
|
|
834
865
|
openStore: deps.openStore,
|
|
866
|
+
openAuditStore: deps.openStore,
|
|
867
|
+
...ingestWriteRouting(session.cwd, deps.resolveStore, deps.openStore),
|
|
835
868
|
embedder: deps.embedder,
|
|
836
869
|
mode: deps.mode,
|
|
837
870
|
routeOverride: deps.routeOverride,
|
|
@@ -956,20 +989,23 @@ async function selectSessions(deps, rules) {
|
|
|
956
989
|
const turns = all.slice(Math.max(0, all.length - rules.maxTurnsPerSession));
|
|
957
990
|
if (turns.length === 0) return void 0;
|
|
958
991
|
const cwd = header.cwd;
|
|
959
|
-
|
|
992
|
+
if (cwd === void 0 || cwd === "") return {
|
|
960
993
|
header,
|
|
961
994
|
events,
|
|
962
995
|
turns,
|
|
963
996
|
openStore: deps.openUserStore,
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
997
|
+
routing: {
|
|
998
|
+
writeScope: "user",
|
|
999
|
+
perCandidateScope: false
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
const projectCwd = cwd;
|
|
1003
|
+
return {
|
|
967
1004
|
header,
|
|
968
1005
|
events,
|
|
969
1006
|
turns,
|
|
970
|
-
openStore: () => deps.resolveStore(
|
|
971
|
-
|
|
972
|
-
storeKey: cwd
|
|
1007
|
+
openStore: () => deps.resolveStore(projectCwd),
|
|
1008
|
+
routing: ingestWriteRouting(projectCwd, deps.resolveStore, deps.openUserStore)
|
|
973
1009
|
};
|
|
974
1010
|
}));
|
|
975
1011
|
loaded.push(...settled);
|
|
@@ -1026,13 +1062,13 @@ async function estimateHistoryBackfill(deps, defaults, overrides = {}) {
|
|
|
1026
1062
|
unavailable: UNAVAILABLE_REASON
|
|
1027
1063
|
};
|
|
1028
1064
|
const { candidates, skipped, truncated } = await selectSessions(deps, rules);
|
|
1065
|
+
const auditStore = await deps.resolveExistingUserStore();
|
|
1029
1066
|
let eligibleTurns = 0;
|
|
1030
1067
|
let alreadyIngested = 0;
|
|
1031
1068
|
for (const candidate of candidates) {
|
|
1032
1069
|
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;
|
|
1070
|
+
if (auditStore === void 0) continue;
|
|
1071
|
+
for (const turn of candidate.turns) if (await auditStore.hasAudit("ingest-done", encodeTurnKey(candidate.header.id, turn))) alreadyIngested += 1;
|
|
1036
1072
|
}
|
|
1037
1073
|
return {
|
|
1038
1074
|
rules,
|
|
@@ -1085,6 +1121,8 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
|
|
|
1085
1121
|
turn,
|
|
1086
1122
|
slice: turn,
|
|
1087
1123
|
openStore: () => candidate.openStore(),
|
|
1124
|
+
openAuditStore: deps.openUserStore,
|
|
1125
|
+
...candidate.routing,
|
|
1088
1126
|
embedder: deps.embedder,
|
|
1089
1127
|
mode: deps.mode,
|
|
1090
1128
|
routeOverride,
|
|
@@ -1092,8 +1130,7 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
|
|
|
1092
1130
|
logRequest: deps.logRequest,
|
|
1093
1131
|
signal,
|
|
1094
1132
|
throttle: true,
|
|
1095
|
-
history: true
|
|
1096
|
-
writeScope: candidate.writeScope
|
|
1133
|
+
history: true
|
|
1097
1134
|
});
|
|
1098
1135
|
progress.memoriesWritten += outcome.written;
|
|
1099
1136
|
if (outcome.skipped !== null) {
|
|
@@ -1955,6 +1992,13 @@ function historyRulesOf(raw) {
|
|
|
1955
1992
|
...includeNoCwd === void 0 ? {} : { includeNoCwd }
|
|
1956
1993
|
};
|
|
1957
1994
|
}
|
|
1995
|
+
/** 未知项目选择器(路由统一映射为 404)。 */
|
|
1996
|
+
var UnknownProjectError = class extends Error {
|
|
1997
|
+
constructor(selector) {
|
|
1998
|
+
super(`unknown project: ${selector}`);
|
|
1999
|
+
this.name = "UnknownProjectError";
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
1958
2002
|
/**
|
|
1959
2003
|
* 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
|
|
1960
2004
|
* @param ctx - 携带 webServer 服务的宿主上下文。
|
|
@@ -1967,11 +2011,37 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1967
2011
|
if (!guardLoopback(req, res)) return;
|
|
1968
2012
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1969
2013
|
const route = url.pathname.slice(12);
|
|
2014
|
+
const selectorRef = { value: url.searchParams.get("project") ?? void 0 };
|
|
2015
|
+
/** 按作用域取分库:project 走选择器解析,未知选择器抛错 → catch 统一 404。 */
|
|
2016
|
+
const storeFor = async (scope) => {
|
|
2017
|
+
if (scope !== "project") return deps.openStore(scope);
|
|
2018
|
+
const opened = await deps.projects.open(selectorRef.value);
|
|
2019
|
+
if (opened === void 0) throw new UnknownProjectError(selectorRef.value ?? "");
|
|
2020
|
+
return opened.store;
|
|
2021
|
+
};
|
|
2022
|
+
/** POST body 里的项目选择器(面板所有写操作都会带)。 */
|
|
2023
|
+
const adoptBodySelector = (body) => {
|
|
2024
|
+
const raw = body?.["project"];
|
|
2025
|
+
if (typeof raw === "string" && raw !== "") selectorRef.value = raw;
|
|
2026
|
+
};
|
|
1970
2027
|
try {
|
|
2028
|
+
if (req.method === "GET" && route === "workspaces") {
|
|
2029
|
+
const items = await deps.projects.list();
|
|
2030
|
+
const processDefault = items.find((item) => item.kind === "process");
|
|
2031
|
+
json(res, 200, {
|
|
2032
|
+
items,
|
|
2033
|
+
processDefault: processDefault === void 0 ? null : {
|
|
2034
|
+
dbName: processDefault.dbName,
|
|
2035
|
+
path: processDefault.path,
|
|
2036
|
+
title: processDefault.title
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
1971
2041
|
if (req.method === "GET" && route === "stats") {
|
|
1972
2042
|
json(res, 200, { parts: await Promise.all(["user", "project"].map(async (scope) => ({
|
|
1973
2043
|
scope,
|
|
1974
|
-
stats: await (await
|
|
2044
|
+
stats: await (await storeFor(scope)).stats()
|
|
1975
2045
|
}))) });
|
|
1976
2046
|
return;
|
|
1977
2047
|
}
|
|
@@ -1993,7 +2063,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1993
2063
|
limit,
|
|
1994
2064
|
offset
|
|
1995
2065
|
};
|
|
1996
|
-
json(res, 200, await (await
|
|
2066
|
+
json(res, 200, await (await storeFor(scope)).list(filter));
|
|
1997
2067
|
return;
|
|
1998
2068
|
}
|
|
1999
2069
|
if (req.method === "GET" && route === "search-test") {
|
|
@@ -2009,7 +2079,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2009
2079
|
const embedder = deps.embedder === void 0 ? void 0 : await deps.embedder;
|
|
2010
2080
|
const vector = embedder === void 0 ? void 0 : (await embedder.embed([q.trim()]))[0];
|
|
2011
2081
|
const results = await Promise.all(scopes.map(async (scope) => {
|
|
2012
|
-
return (await
|
|
2082
|
+
return (await storeFor(scope)).search({
|
|
2013
2083
|
text: q,
|
|
2014
2084
|
scopes: [scope],
|
|
2015
2085
|
limit
|
|
@@ -2037,7 +2107,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2037
2107
|
if (req.method === "GET" && route === "activity") {
|
|
2038
2108
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
2039
2109
|
json(res, 200, { operations: (await Promise.all(["user", "project"].map(async (scope) => {
|
|
2040
|
-
return (await (await
|
|
2110
|
+
return (await (await storeFor(scope)).recentOps(limit)).map((op) => ({
|
|
2041
2111
|
...op,
|
|
2042
2112
|
scope
|
|
2043
2113
|
}));
|
|
@@ -2050,7 +2120,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2050
2120
|
const now = Date.now();
|
|
2051
2121
|
json(res, 200, {
|
|
2052
2122
|
scope,
|
|
2053
|
-
items: (await (await
|
|
2123
|
+
items: (await (await storeFor(scope)).dueReviews(now, limit)).map((record) => ({
|
|
2054
2124
|
id: record.id,
|
|
2055
2125
|
kind: record.kind,
|
|
2056
2126
|
...record.slot === void 0 ? {} : { slot: record.slot },
|
|
@@ -2074,8 +2144,8 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2074
2144
|
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
2075
2145
|
return;
|
|
2076
2146
|
}
|
|
2077
|
-
|
|
2078
|
-
const record = await (await
|
|
2147
|
+
adoptBodySelector(body);
|
|
2148
|
+
const record = await (await storeFor(scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user"))).scheduleReview(body.id, grade);
|
|
2079
2149
|
if (record === void 0) {
|
|
2080
2150
|
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
2081
2151
|
return;
|
|
@@ -2093,7 +2163,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2093
2163
|
json(res, 400, { error: "id required" });
|
|
2094
2164
|
return;
|
|
2095
2165
|
}
|
|
2096
|
-
const view = await (await
|
|
2166
|
+
const view = await (await storeFor(scope)).review(id);
|
|
2097
2167
|
if (view === void 0) {
|
|
2098
2168
|
json(res, 404, { error: `未找到条目 ${id}` });
|
|
2099
2169
|
return;
|
|
@@ -2105,7 +2175,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2105
2175
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2106
2176
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
2107
2177
|
const now = Date.now();
|
|
2108
|
-
const due = await (await
|
|
2178
|
+
const due = await (await storeFor(scope)).dueReviews(now, limit);
|
|
2109
2179
|
json(res, 200, {
|
|
2110
2180
|
scope,
|
|
2111
2181
|
count: due.length,
|
|
@@ -2139,8 +2209,8 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2139
2209
|
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
2140
2210
|
return;
|
|
2141
2211
|
}
|
|
2142
|
-
|
|
2143
|
-
const record = await (await
|
|
2212
|
+
adoptBodySelector(body);
|
|
2213
|
+
const record = await (await storeFor(scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user"))).scheduleReview(body.id, grade);
|
|
2144
2214
|
if (record === void 0) {
|
|
2145
2215
|
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
2146
2216
|
return;
|
|
@@ -2151,7 +2221,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2151
2221
|
if (req.method === "GET" && route === "export") {
|
|
2152
2222
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2153
2223
|
const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
|
|
2154
|
-
const data = await (await
|
|
2224
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2155
2225
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
|
|
2156
2226
|
const body = format === "json" ? JSON.stringify(data, null, 2) : [
|
|
2157
2227
|
`# dsh-engram 导出(${scope})`,
|
|
@@ -2171,7 +2241,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2171
2241
|
}
|
|
2172
2242
|
if (req.method === "GET" && route === "mirror") {
|
|
2173
2243
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
2174
|
-
const data = await (await
|
|
2244
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2175
2245
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-").slice(0, 19);
|
|
2176
2246
|
json(res, 200, {
|
|
2177
2247
|
...await writeMirror(join(deps.mirrorDir, scope, stamp), data),
|
|
@@ -2185,7 +2255,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2185
2255
|
const rawScope = url.searchParams.get("scope");
|
|
2186
2256
|
const scopes = rawScope === "user" || rawScope === "project" || rawScope === "shared" ? [rawScope] : ["user", "project"];
|
|
2187
2257
|
const parts = await Promise.all(scopes.map(async (scope) => {
|
|
2188
|
-
const store = await
|
|
2258
|
+
const store = await storeFor(scope);
|
|
2189
2259
|
const s = await store.stats();
|
|
2190
2260
|
const edgeCount = (await store.exportAll()).edges.length;
|
|
2191
2261
|
const total = Math.max(s.total, 1);
|
|
@@ -2225,7 +2295,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2225
2295
|
if (rawStatus !== null && rawStatus !== "" && rawStatus !== "all") {
|
|
2226
2296
|
for (const s of rawStatus.split(",")) if (s === "active" || s === "archived" || s === "forgotten") includeStatuses.add(s);
|
|
2227
2297
|
}
|
|
2228
|
-
const data = await (await
|
|
2298
|
+
const data = await (await storeFor(scope)).exportAll();
|
|
2229
2299
|
const nodes = data.records.filter((r) => includeStatuses.has(r.status)).map((r) => ({
|
|
2230
2300
|
id: r.id,
|
|
2231
2301
|
scope: r.scope,
|
|
@@ -2252,14 +2322,13 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2252
2322
|
if (req.method === "GET" && route === "telemetry") {
|
|
2253
2323
|
const windowDays = Math.min(90, Math.max(1, Number(url.searchParams.get("days") ?? 7) || 7));
|
|
2254
2324
|
const rawScope = url.searchParams.get("scope");
|
|
2255
|
-
|
|
2256
|
-
json(res, 200, await aggregateTelemetry(deps.openStore, windowDays, scopeFilter));
|
|
2325
|
+
json(res, 200, await aggregateTelemetry((scope) => storeFor(scope), windowDays, rawScope === "user" ? "user" : rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : void 0));
|
|
2257
2326
|
return;
|
|
2258
2327
|
}
|
|
2259
2328
|
if (req.method === "GET" && route === "tour-proposal") {
|
|
2260
2329
|
const rawScope = url.searchParams.get("scope");
|
|
2261
2330
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2262
|
-
const filter = await (await
|
|
2331
|
+
const filter = await (await storeFor(scope)).list({
|
|
2263
2332
|
scope,
|
|
2264
2333
|
status: "active",
|
|
2265
2334
|
limit: 200,
|
|
@@ -2285,7 +2354,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2285
2354
|
if (req.method === "GET" && route === "refurb") {
|
|
2286
2355
|
const rawScope = url.searchParams.get("scope");
|
|
2287
2356
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2288
|
-
const suggestions = gatherRefurbSuggestions((await (await
|
|
2357
|
+
const suggestions = gatherRefurbSuggestions((await (await storeFor(scope)).list({
|
|
2289
2358
|
scope,
|
|
2290
2359
|
status: "active",
|
|
2291
2360
|
limit: 500,
|
|
@@ -2349,10 +2418,11 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2349
2418
|
if (req.method === "POST" && route === "consolidate") {
|
|
2350
2419
|
if (!guardWrite(req, res)) return;
|
|
2351
2420
|
const body = await readJsonBody(req);
|
|
2421
|
+
adoptBodySelector(body);
|
|
2352
2422
|
const rawScope = typeof body?.scope === "string" ? body.scope : "user";
|
|
2353
2423
|
const scope = rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : "user";
|
|
2354
2424
|
const candidates = Array.isArray(body?.candidates) ? body.candidates.filter((value) => typeof value === "string") : void 0;
|
|
2355
|
-
json(res, 200, await runConsolidation(await
|
|
2425
|
+
json(res, 200, await runConsolidation(await storeFor(scope), deps.embedder, candidates === void 0 ? { scope } : {
|
|
2356
2426
|
scope,
|
|
2357
2427
|
mergeCandidateIds: candidates
|
|
2358
2428
|
}));
|
|
@@ -2399,8 +2469,9 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2399
2469
|
json(res, 400, { error: "id required" });
|
|
2400
2470
|
return;
|
|
2401
2471
|
}
|
|
2472
|
+
adoptBodySelector(body);
|
|
2402
2473
|
const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
|
|
2403
|
-
const store = await
|
|
2474
|
+
const store = await storeFor(scope);
|
|
2404
2475
|
if (route === "update") {
|
|
2405
2476
|
if (typeof body.content !== "string" || body.content.trim() === "") {
|
|
2406
2477
|
json(res, 400, { error: "content required" });
|
|
@@ -2431,6 +2502,10 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2431
2502
|
}
|
|
2432
2503
|
json(res, 404, { error: "unknown route" });
|
|
2433
2504
|
} catch (error) {
|
|
2505
|
+
if (error instanceof UnknownProjectError) {
|
|
2506
|
+
json(res, 404, { error: "unknown project" });
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2434
2509
|
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2435
2510
|
}
|
|
2436
2511
|
}
|
|
@@ -2440,15 +2515,23 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
2440
2515
|
//#region src/project/identity.ts
|
|
2441
2516
|
/**
|
|
2442
2517
|
* 项目标识:git origin URL 归一化 → sha256 短哈希分库名;无 git 或无 origin 时
|
|
2443
|
-
*
|
|
2518
|
+
* 回退完整 cwd 哈希。纯文件读(.git/config、worktree 的 gitdir/commondir
|
|
2444
2519
|
* 指针),不起子进程。启动时负责旧库文件向新标识的 rename 迁移。
|
|
2445
2520
|
* @module @kenz1117/dsh-engram/project/identity
|
|
2446
2521
|
*/
|
|
2447
|
-
/** v0.4 旧命名:cwd 的 hex 编码前 24
|
|
2522
|
+
/** v0.4 旧命名:cwd 的 hex 编码前 24 位(只覆盖 cwd 前 12 UTF-8 字节,同前缀目录会撞库)。保留仅供迁移识别。 */
|
|
2448
2523
|
function legacyProjectDbName(cwd) {
|
|
2449
2524
|
return `project-${Buffer.from(cwd).toString("hex").slice(0, 24)}.db`;
|
|
2450
2525
|
}
|
|
2451
2526
|
/**
|
|
2527
|
+
* 无 git 时的 cwd 命名:cwd 全量 sha256 前 24 位。
|
|
2528
|
+
* 旧算法只取 cwd 前 12 UTF-8 字节,`C:\Users\Adm…` 这类同前缀目录会共用同一个库;
|
|
2529
|
+
* 新算法对每个目录唯一,启动时把旧库 rename 迁移到新名(见 migrateProjectDb)。
|
|
2530
|
+
*/
|
|
2531
|
+
function cwdProjectDbName(cwd) {
|
|
2532
|
+
return `project-${createHash("sha256").update(cwd).digest("hex").slice(0, 24)}.db`;
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2452
2535
|
* 归一化 git origin URL:去协议与凭证、host 小写、去尾部 `.git` 与 `/`,
|
|
2453
2536
|
* 使 `git@github.com:a/b.git` 与 `https://github.com/a/b` 等价。无法解析返回 undefined。
|
|
2454
2537
|
*/
|
|
@@ -2524,7 +2607,7 @@ function readOriginUrl(configPath) {
|
|
|
2524
2607
|
}
|
|
2525
2608
|
/**
|
|
2526
2609
|
* 解析项目标识:origin URL 归一化后取 sha256 hex 前 24 位;无 git、无 origin
|
|
2527
|
-
* 或 URL 无法解析时回退 cwd
|
|
2610
|
+
* 或 URL 无法解析时回退 cwd 全量哈希命名。
|
|
2528
2611
|
*/
|
|
2529
2612
|
function resolveProjectIdentity(cwd) {
|
|
2530
2613
|
const legacyDbName = legacyProjectDbName(cwd);
|
|
@@ -2532,33 +2615,158 @@ function resolveProjectIdentity(cwd) {
|
|
|
2532
2615
|
const origin = configPath === void 0 ? void 0 : readOriginUrl(configPath);
|
|
2533
2616
|
const normalized = origin === void 0 ? void 0 : normalizeOriginUrl(origin);
|
|
2534
2617
|
if (normalized === void 0) return {
|
|
2535
|
-
|
|
2618
|
+
cwd,
|
|
2619
|
+
dbName: cwdProjectDbName(cwd),
|
|
2536
2620
|
source: "cwd",
|
|
2537
2621
|
legacyDbName
|
|
2538
2622
|
};
|
|
2539
2623
|
return {
|
|
2624
|
+
cwd,
|
|
2540
2625
|
dbName: `project-${createHash("sha256").update(normalized).digest("hex").slice(0, 24)}.db`,
|
|
2541
2626
|
source: "origin",
|
|
2542
2627
|
legacyDbName
|
|
2543
2628
|
};
|
|
2544
2629
|
}
|
|
2630
|
+
function readMigrationPointer(dbDir, identity) {
|
|
2631
|
+
const path = join(dbDir, `${identity.legacyDbName}.migrated-to`);
|
|
2632
|
+
let text;
|
|
2633
|
+
try {
|
|
2634
|
+
text = readFileSync(path, "utf8");
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
if (error.code === "ENOENT") return void 0;
|
|
2637
|
+
throw error;
|
|
2638
|
+
}
|
|
2639
|
+
const value = JSON.parse(text);
|
|
2640
|
+
if (typeof value !== "object" || value === null || !("migratedTo" in value) || typeof value.migratedTo !== "string" || !/^project-[0-9a-f]{24}\.db$/.test(value.migratedTo) || !("claimedByCwd" in value) || typeof value.claimedByCwd !== "string" || !("claimedAt" in value) || typeof value.claimedAt !== "string" || !Number.isFinite(Date.parse(value.claimedAt))) throw new Error(`dsh-engram: invalid migration pointer ${path}; inspect it manually`);
|
|
2641
|
+
return value;
|
|
2642
|
+
}
|
|
2545
2643
|
/**
|
|
2546
|
-
*
|
|
2547
|
-
*
|
|
2644
|
+
* Eager keeps the existing rename policy, recording its ambiguous ownership in JSON.
|
|
2645
|
+
* Conservative leaves legacy bytes untouched; the caller may open an empty new store.
|
|
2646
|
+
* Coexisting databases are never merged or overwritten. This is not a cross-process lock
|
|
2647
|
+
* or an atomic transaction across the rename and sidecar write: stop other hosts first.
|
|
2548
2648
|
*/
|
|
2549
|
-
function migrateProjectDb(dbDir, identity) {
|
|
2550
|
-
if (identity.
|
|
2649
|
+
function migrateProjectDb(dbDir, identity, policy = "eager") {
|
|
2650
|
+
if (identity.dbName === identity.legacyDbName) return "none";
|
|
2551
2651
|
const next = join(dbDir, identity.dbName);
|
|
2552
2652
|
const legacy = join(dbDir, identity.legacyDbName);
|
|
2553
2653
|
const hasNext = existsSync(next);
|
|
2554
2654
|
const hasLegacy = existsSync(legacy);
|
|
2555
2655
|
if (hasNext && hasLegacy) return "kept-both";
|
|
2656
|
+
const pointer = readMigrationPointer(dbDir, identity);
|
|
2657
|
+
if (pointer !== void 0) {
|
|
2658
|
+
if (hasLegacy || pointer.migratedTo !== identity.dbName) return "already-migrated";
|
|
2659
|
+
return "none";
|
|
2660
|
+
}
|
|
2556
2661
|
if (!hasNext && hasLegacy) {
|
|
2662
|
+
if (policy === "conservative") return "deferred";
|
|
2557
2663
|
renameSync(legacy, next);
|
|
2664
|
+
try {
|
|
2665
|
+
writeFileSync(`${legacy}.migrated-to`, JSON.stringify({
|
|
2666
|
+
migratedTo: identity.dbName,
|
|
2667
|
+
claimedByCwd: identity.cwd,
|
|
2668
|
+
claimedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2669
|
+
}) + "\n", {
|
|
2670
|
+
flag: "wx",
|
|
2671
|
+
mode: 384
|
|
2672
|
+
});
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
throw new Error(`dsh-engram: renamed ${identity.legacyDbName} to ${identity.dbName}, but could not record migration; inspect both paths before retrying`, { cause: error });
|
|
2675
|
+
}
|
|
2558
2676
|
return "renamed";
|
|
2559
2677
|
}
|
|
2560
2678
|
return "none";
|
|
2561
2679
|
}
|
|
2680
|
+
/** Shared guidance for plugin boot and session-cwd migration checks. */
|
|
2681
|
+
function migrationWarning(dbDir, identity, outcome) {
|
|
2682
|
+
const names = `旧库 ${identity.legacyDbName};新库 ${identity.dbName}`;
|
|
2683
|
+
const manual = "人工处理前请停止相关宿主并备份所有数据库及 sidecar;新库可能已写入记忆,请勿直接覆盖,先用 engram_review / engram_export 审核并人工重新归属";
|
|
2684
|
+
switch (outcome) {
|
|
2685
|
+
case "none": return;
|
|
2686
|
+
case "renamed": return `[dsh-engram] 项目记忆库已迁移(${names}),已记录 .migrated-to;旧库可能包含同前缀目录的记忆。${manual}`;
|
|
2687
|
+
case "kept-both": return `[dsh-engram] 新旧项目记忆库并存,未合并或移动(${names})。${manual}`;
|
|
2688
|
+
case "deferred": return `[dsh-engram] conservative 模式暂缓旧库迁移(${names});旧库保持不变,将打开新的空库。${manual}`;
|
|
2689
|
+
case "already-migrated": {
|
|
2690
|
+
const pointer = readMigrationPointer(dbDir, identity);
|
|
2691
|
+
return `[dsh-engram] 旧库已有迁移记录(${names}):${JSON.stringify(pointer)};先前迁移的库可能含有当前目录的记忆。${manual}`;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
//#endregion
|
|
2696
|
+
//#region src/project/registry.ts
|
|
2697
|
+
/**
|
|
2698
|
+
* 项目宫殿清单:把 dsh 的工作区(`ctx.workspaceRegistry` 的注册表)与会话 cwd
|
|
2699
|
+
* 映射成记忆分库,供管理面板「切换/显示当前工作区」使用。
|
|
2700
|
+
*
|
|
2701
|
+
* 设计要点:
|
|
2702
|
+
* 1. **纯函数 + 注入式数据源**——不直接依赖宿主服务,index.ts 负责接
|
|
2703
|
+
* `workspaceRegistry` 与 `sessionPersistence`,本模块只做映射与去重,便于单测;
|
|
2704
|
+
* 2. **零状态**——每次现算 `目录 → 分库名`(git origin 哈希 / cwd 全量哈希),
|
|
2705
|
+
* 不维护额外注册表文件:工作区改名/删除后清单自动跟上;
|
|
2706
|
+
* 3. **三级来源**——宿主注册表工作区 > 只从会话 header 见到的 cwd > 插件进程目录兜底;
|
|
2707
|
+
* 同一个分库只出现一次(同仓库的多个 worktree 共用一个项目宫殿)。
|
|
2708
|
+
* @module @kenz1117/dsh-engram/project/registry
|
|
2709
|
+
*/
|
|
2710
|
+
/** 路径末段作为标题(Windows 与 POSIX 分隔符都接受;根目录回退整串)。 */
|
|
2711
|
+
function pathTitle(path) {
|
|
2712
|
+
const trimmed = path.replace(/[\\/]+$/, "");
|
|
2713
|
+
const cut = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
|
2714
|
+
const tail = cut === -1 ? trimmed : trimmed.slice(cut + 1);
|
|
2715
|
+
return tail === "" ? trimmed : tail;
|
|
2716
|
+
}
|
|
2717
|
+
/** 目录 → 项目宫殿(kind 由调用方给出)。 */
|
|
2718
|
+
function palaceFor(path, kind, title, workspaceId) {
|
|
2719
|
+
const identity = resolveProjectIdentity(path);
|
|
2720
|
+
return {
|
|
2721
|
+
dbName: identity.dbName,
|
|
2722
|
+
path,
|
|
2723
|
+
title,
|
|
2724
|
+
source: identity.source,
|
|
2725
|
+
kind,
|
|
2726
|
+
...workspaceId === void 0 ? {} : { workspaceId }
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* 组装项目宫殿清单。
|
|
2731
|
+
*
|
|
2732
|
+
* **不变量**:清单必须恒定包含宿主注册表里的**每一个**工作区(即使它的分库文件还不存在)。
|
|
2733
|
+
* 面板的「跟随当前工作区」按 dbName / 路径在清单里匹配,缺行会静默降级为进程默认库。
|
|
2734
|
+
* @param input - 注册表工作区、会话 cwd 集合与插件进程目录。
|
|
2735
|
+
* @returns 按「注册表 → 会话 cwd → 进程目录」优先、按分库名去重的清单。
|
|
2736
|
+
*/
|
|
2737
|
+
function listProjectPalaces(input) {
|
|
2738
|
+
const palaces = [];
|
|
2739
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2740
|
+
const push = (palace) => {
|
|
2741
|
+
if (seen.has(palace.dbName)) return;
|
|
2742
|
+
seen.add(palace.dbName);
|
|
2743
|
+
palaces.push(palace);
|
|
2744
|
+
};
|
|
2745
|
+
for (const workspace of input.workspaces) {
|
|
2746
|
+
if (workspace.path === "") continue;
|
|
2747
|
+
push(palaceFor(workspace.path, "workspace", workspace.title === "" ? pathTitle(workspace.path) : workspace.title, workspace.id));
|
|
2748
|
+
}
|
|
2749
|
+
for (const cwd of input.sessionCwds) {
|
|
2750
|
+
if (cwd === "") continue;
|
|
2751
|
+
push(palaceFor(cwd, "session", pathTitle(cwd)));
|
|
2752
|
+
}
|
|
2753
|
+
push(palaceFor(input.processCwd, "process", `${pathTitle(input.processCwd)}(进程目录)`));
|
|
2754
|
+
return palaces;
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* 按选择器找项目宫殿:`dbName` 优先,其次 `cwd`;都不给时返回进程目录兜底项。
|
|
2758
|
+
* @param palaces - 清单。
|
|
2759
|
+
* @param selector - 面板/API 传来的选择器。
|
|
2760
|
+
* @returns 命中的宫殿;未知选择器返回 undefined(调用方回 404)。
|
|
2761
|
+
*/
|
|
2762
|
+
function findProjectPalace(palaces, selector) {
|
|
2763
|
+
if (selector.dbName !== void 0 && selector.dbName !== "") return palaces.find((palace) => palace.dbName === selector.dbName);
|
|
2764
|
+
if (selector.cwd !== void 0 && selector.cwd !== "") {
|
|
2765
|
+
const dbName = resolveProjectIdentity(selector.cwd).dbName;
|
|
2766
|
+
return palaces.find((palace) => palace.dbName === dbName) ?? palaceFor(selector.cwd, "session", pathTitle(selector.cwd));
|
|
2767
|
+
}
|
|
2768
|
+
return palaces.find((palace) => palace.kind === "process") ?? palaces[0];
|
|
2769
|
+
}
|
|
2562
2770
|
/** kind → 默认房间名。固定映射,保证同主题记忆总是聚在同一间(同类巩固)。 */
|
|
2563
2771
|
const KIND_ROOMS = {
|
|
2564
2772
|
fact: "事实厅",
|
|
@@ -3868,6 +4076,17 @@ const KINDS = [
|
|
|
3868
4076
|
"episode",
|
|
3869
4077
|
"skill"
|
|
3870
4078
|
];
|
|
4079
|
+
/**
|
|
4080
|
+
* 工具执行期的会话 cwd:project scope 的读写据此解析项目宫殿。
|
|
4081
|
+
* 用 AsyncLocalStorage 而不是模块级变量:同一进程里多个会话的工具调用可能交错,
|
|
4082
|
+
* 模块级可变状态会把 A 会话的 cwd 泄漏给 B 会话。
|
|
4083
|
+
*/
|
|
4084
|
+
const execSessionCwd = new AsyncLocalStorage();
|
|
4085
|
+
/** 从工具运行上下文取会话 cwd(缺省 = 无会话信息,调用方回退插件进程目录)。 */
|
|
4086
|
+
function sessionCwdOf(exec) {
|
|
4087
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
4088
|
+
return cwd === void 0 || cwd === "" ? void 0 : cwd;
|
|
4089
|
+
}
|
|
3871
4090
|
/** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
|
|
3872
4091
|
function renderHistoryEstimate(estimate) {
|
|
3873
4092
|
if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
|
|
@@ -3986,10 +4205,18 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
3986
4205
|
/**
|
|
3987
4206
|
* 构造 17 个工具定义(engram_save/search/assess/timeline/update/forget/report/review/review_queue/
|
|
3988
4207
|
* stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
|
|
3989
|
-
* @param
|
|
3990
|
-
* @returns 可直接 register
|
|
4208
|
+
* @param baseDeps - 分库打开器、嵌入器、辅助 LLM、导出目录。
|
|
4209
|
+
* @returns 可直接 register 的工具定义数组(execute 已绑定会话 cwd 的项目宫殿路由)。
|
|
3991
4210
|
*/
|
|
3992
|
-
function createEngramTools(
|
|
4211
|
+
function createEngramTools(baseDeps) {
|
|
4212
|
+
/**
|
|
4213
|
+
* 分库门面:project scope 一律走 `resolveProjectStore(执行期会话 cwd)`,
|
|
4214
|
+
* 其余 scope 原样透传。所有 handler 都用这个 `deps`,无需逐处改调用点。
|
|
4215
|
+
*/
|
|
4216
|
+
const deps = {
|
|
4217
|
+
...baseDeps,
|
|
4218
|
+
openStore: (scope) => scope === "project" ? baseDeps.resolveProjectStore(execSessionCwd.getStore()) : baseDeps.openStore(scope)
|
|
4219
|
+
};
|
|
3993
4220
|
/** 批量保存上限(协议内常量:与单轮摄取候选量级对齐,防一次灌入过多)。 */
|
|
3994
4221
|
const MAX_SAVE_BATCH = 10;
|
|
3995
4222
|
/** engram_save 呈现文本:矛盾警告 text 优先;批量输出汇总成功与失败。 */
|
|
@@ -5376,7 +5603,10 @@ function createEngramTools(deps) {
|
|
|
5376
5603
|
}),
|
|
5377
5604
|
auditForgotten,
|
|
5378
5605
|
ingestHistory
|
|
5379
|
-
]
|
|
5606
|
+
].map((tool) => ({
|
|
5607
|
+
...tool,
|
|
5608
|
+
execute: (args, exec) => execSessionCwd.run(sessionCwdOf(exec), () => tool.execute(args, exec))
|
|
5609
|
+
}));
|
|
5380
5610
|
}
|
|
5381
5611
|
/**
|
|
5382
5612
|
* 构造巡游路径:在 search 命中的基础上按路径策略重排。
|
|
@@ -5548,7 +5778,7 @@ function estimateTokens(text) {
|
|
|
5548
5778
|
/** Cordis 插件名(loader 诊断与注入 source 使用)。 */
|
|
5549
5779
|
const name = "dsh-engram";
|
|
5550
5780
|
/** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
|
|
5551
|
-
const VERSION = "0.7.
|
|
5781
|
+
const VERSION = "0.7.6";
|
|
5552
5782
|
/** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
|
|
5553
5783
|
const inject = ["tools", "llm"];
|
|
5554
5784
|
/**
|
|
@@ -5658,7 +5888,7 @@ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, sign
|
|
|
5658
5888
|
* 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
|
|
5659
5889
|
* 记忆库为空或非首轮时不追加消息。
|
|
5660
5890
|
*/
|
|
5661
|
-
async function preStep(ctx, openStore, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
5891
|
+
async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedder, state, logRequest, { agent, step, turn, signal }, next) {
|
|
5662
5892
|
const decision = await next();
|
|
5663
5893
|
if (decision.kind === "reject") return decision;
|
|
5664
5894
|
if (step === 1) {
|
|
@@ -5666,12 +5896,14 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5666
5896
|
if (currentRoute !== void 0) state.route = currentRoute;
|
|
5667
5897
|
}
|
|
5668
5898
|
const mode = resolved.ingest;
|
|
5899
|
+
const routing = ingestWriteRouting(sessionCwd(agent.session), openStoreForProjectCwd, openStore);
|
|
5669
5900
|
if (step === 1 && mode !== "off") {
|
|
5670
5901
|
if (!state.pendingReplayed) {
|
|
5671
5902
|
state.pendingReplayed = true;
|
|
5672
5903
|
replayPendingIngests({
|
|
5673
5904
|
openStore: () => openStore("user"),
|
|
5674
|
-
|
|
5905
|
+
resolveSession: makeSessionResolver(ctx, agent),
|
|
5906
|
+
resolveStore: openStoreForProjectCwd,
|
|
5675
5907
|
embedder,
|
|
5676
5908
|
mode,
|
|
5677
5909
|
routeOverride: resolved.routeOverride,
|
|
@@ -5690,6 +5922,8 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5690
5922
|
sessionId: String(agent.id),
|
|
5691
5923
|
turn,
|
|
5692
5924
|
openStore: () => openStore("user"),
|
|
5925
|
+
openAuditStore: () => openStore("user"),
|
|
5926
|
+
...routing,
|
|
5693
5927
|
embedder,
|
|
5694
5928
|
mode,
|
|
5695
5929
|
routeOverride: resolved.routeOverride,
|
|
@@ -5749,17 +5983,49 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
5749
5983
|
};
|
|
5750
5984
|
}
|
|
5751
5985
|
/**
|
|
5752
|
-
*
|
|
5753
|
-
*
|
|
5754
|
-
*
|
|
5986
|
+
* 会话工作目录(决定它的记忆进哪座项目宫殿)。
|
|
5987
|
+
* 真实 `Session.header` 恒在;测试替身可能省略 header,故按可选形状读取。
|
|
5988
|
+
* @param session - 会话(或替身)对象。
|
|
5989
|
+
* @returns cwd;取不到时 undefined(该会话的回退口径是私人宫殿)。
|
|
5990
|
+
*/
|
|
5991
|
+
function sessionCwd(session) {
|
|
5992
|
+
const cwd = session?.header?.cwd;
|
|
5993
|
+
return cwd === void 0 || cwd === "" ? void 0 : cwd;
|
|
5994
|
+
}
|
|
5995
|
+
/**
|
|
5996
|
+
* 只读打开会话日志 → 交给调用方读取 → 必关闭句柄。
|
|
5997
|
+
* close 失败不掩盖读取结果(读取成功而 close 失败时仍返回成功值)。
|
|
5998
|
+
* @param persistence - 持久化服务视图。
|
|
5999
|
+
* @param sessionId - 目标会话 id。
|
|
6000
|
+
* @param use - 句柄使用回调。
|
|
6001
|
+
* @returns 回调结果。
|
|
6002
|
+
*/
|
|
6003
|
+
async function withReadHandle(persistence, sessionId, use) {
|
|
6004
|
+
const handle = await persistence.open(sessionId, "read");
|
|
6005
|
+
try {
|
|
6006
|
+
return await use(handle);
|
|
6007
|
+
} finally {
|
|
6008
|
+
await handle.close().catch(() => {});
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
/**
|
|
6012
|
+
* pending 重放的会话解析器:pending 属于当前会话时直接用其事件快照与 cwd;
|
|
6013
|
+
* 其余会话经可选的 sessionPersistence 服务(只读句柄)读持久化日志与 header.cwd
|
|
6014
|
+
* (服务缺席或读取失败返回 undefined,pending 保留到下次,不报错)。
|
|
5755
6015
|
*/
|
|
5756
|
-
function
|
|
6016
|
+
function makeSessionResolver(ctx, agent) {
|
|
5757
6017
|
return async (sessionId) => {
|
|
5758
|
-
if (sessionId === String(agent.id)) return
|
|
6018
|
+
if (sessionId === String(agent.id)) return {
|
|
6019
|
+
events: agent.session.snapshotEvents(),
|
|
6020
|
+
cwd: sessionCwd(agent.session)
|
|
6021
|
+
};
|
|
5759
6022
|
const persistence = ctx.get("sessionPersistence");
|
|
5760
6023
|
if (persistence === void 0) return void 0;
|
|
5761
6024
|
try {
|
|
5762
|
-
return
|
|
6025
|
+
return await withReadHandle(persistence, sessionId, async (handle) => ({
|
|
6026
|
+
events: (await handle.read()).events,
|
|
6027
|
+
cwd: sessionCwd(handle)
|
|
6028
|
+
}));
|
|
5763
6029
|
} catch {
|
|
5764
6030
|
return;
|
|
5765
6031
|
}
|
|
@@ -5777,9 +6043,9 @@ function apply(ctx, config = {}) {
|
|
|
5777
6043
|
mode: 448
|
|
5778
6044
|
});
|
|
5779
6045
|
const identity = resolveProjectIdentity(process.cwd());
|
|
5780
|
-
const migration = migrateProjectDb(resolved.dbDir, identity);
|
|
5781
|
-
|
|
5782
|
-
|
|
6046
|
+
const migration = migrateProjectDb(resolved.dbDir, identity, resolved.legacyMigration);
|
|
6047
|
+
const warning = migrationWarning(resolved.dbDir, identity, migration);
|
|
6048
|
+
if (warning !== void 0) console.warn(warning);
|
|
5783
6049
|
const rankBoost = {
|
|
5784
6050
|
recencyWeight: resolved.rankRecencyWeight,
|
|
5785
6051
|
proofWeight: resolved.rankProofWeight,
|
|
@@ -5805,6 +6071,11 @@ function apply(ctx, config = {}) {
|
|
|
5805
6071
|
return created;
|
|
5806
6072
|
};
|
|
5807
6073
|
const openStore = (scope) => openDb(scope === "user" ? "user.db" : scope === "shared" ? "shared.db" : identity.dbName);
|
|
6074
|
+
ctx.effect(() => () => Promise.allSettled([...stores.values()].map(async (pending) => {
|
|
6075
|
+
try {
|
|
6076
|
+
await (await pending).close();
|
|
6077
|
+
} catch {}
|
|
6078
|
+
})), "dsh-engram: close stores");
|
|
5808
6079
|
/** cwd → 分库文件名(历史回填会对每个历史 cwd 解析一次,避免重复读 git 元数据)。 */
|
|
5809
6080
|
const cwdDbNames = /* @__PURE__ */ new Map();
|
|
5810
6081
|
/**
|
|
@@ -5818,11 +6089,18 @@ function apply(ctx, config = {}) {
|
|
|
5818
6089
|
const cached = cwdDbNames.get(cwd);
|
|
5819
6090
|
if (cached !== void 0) return cached;
|
|
5820
6091
|
const projectIdentity = resolveProjectIdentity(cwd);
|
|
5821
|
-
|
|
6092
|
+
const migration = migrateProjectDb(resolved.dbDir, projectIdentity, resolved.legacyMigration);
|
|
6093
|
+
const warning = migrationWarning(resolved.dbDir, projectIdentity, migration);
|
|
6094
|
+
if (warning !== void 0) console.warn(warning);
|
|
5822
6095
|
cwdDbNames.set(cwd, projectIdentity.dbName);
|
|
5823
6096
|
return projectIdentity.dbName;
|
|
5824
6097
|
};
|
|
5825
6098
|
const openStoreForProjectCwd = (cwd) => openDb(dbNameForCwd(cwd));
|
|
6099
|
+
/**
|
|
6100
|
+
* 工具调用期的项目分库解析:按会话 cwd 归属(缺省回退插件进程目录)。
|
|
6101
|
+
* 面板「项目」scope、工具读写与自动摄取的 project 落点因此同一口径。
|
|
6102
|
+
*/
|
|
6103
|
+
const resolveProjectStore = (cwd) => cwd === void 0 ? openStore("project") : openStoreForProjectCwd(cwd);
|
|
5826
6104
|
const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
|
|
5827
6105
|
console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
|
|
5828
6106
|
});
|
|
@@ -5840,22 +6118,93 @@ function apply(ctx, config = {}) {
|
|
|
5840
6118
|
const persistence = ctx.get("sessionPersistence");
|
|
5841
6119
|
if (persistence === void 0) return void 0;
|
|
5842
6120
|
return {
|
|
5843
|
-
list: (signal) => persistence.list(signal),
|
|
5844
|
-
load: (id) => persistence.
|
|
6121
|
+
list: async (signal) => (await persistence.list(signal === void 0 ? void 0 : { signal })).map((snapshot) => snapshot.header),
|
|
6122
|
+
load: (id) => withReadHandle(persistence, id, async (handle) => ({ events: (await handle.read()).events }))
|
|
5845
6123
|
};
|
|
5846
6124
|
};
|
|
5847
6125
|
/** user 分库若已存在则打开(估算用:不因估算产生空库文件)。 */
|
|
5848
6126
|
const openUserStoreIfExists = () => existsSync(join(resolved.dbDir, "user.db")) ? openStore("user") : Promise.resolve(void 0);
|
|
5849
|
-
/**
|
|
5850
|
-
const
|
|
5851
|
-
const
|
|
5852
|
-
|
|
6127
|
+
/** 宿主机工作区注册表(可选服务:缺席时降级为「会话 cwd + 进程目录」两级清单)。 */
|
|
6128
|
+
const workspaceRefs = () => {
|
|
6129
|
+
const registry = ctx.get("workspaceRegistry");
|
|
6130
|
+
if (registry === void 0) return [];
|
|
6131
|
+
try {
|
|
6132
|
+
return registry.list().filter((workspace) => typeof workspace.path === "string" && workspace.path !== "").map((workspace) => ({
|
|
6133
|
+
id: String(workspace.id),
|
|
6134
|
+
path: workspace.path,
|
|
6135
|
+
title: typeof workspace.title === "string" ? workspace.title : ""
|
|
6136
|
+
}));
|
|
6137
|
+
} catch {
|
|
6138
|
+
return [];
|
|
6139
|
+
}
|
|
6140
|
+
};
|
|
6141
|
+
/** 只按会话 cwd 认领的分库上限(按会话新旧取最近的若干,避免历史 cwd 把清单撑爆)。 */
|
|
6142
|
+
const SESSION_CWD_PALACE_LIMIT = 30;
|
|
6143
|
+
/**
|
|
6144
|
+
* 会话 header 里出现过的 cwd:历史回填建的库也能在面板里被认领。
|
|
6145
|
+
* 按会话创建时间倒序取最近的 N 个(新的在前,去重后截断)。
|
|
6146
|
+
*/
|
|
6147
|
+
const sessionCwds = async () => {
|
|
6148
|
+
const source = persistenceService();
|
|
6149
|
+
if (source === void 0) return [];
|
|
6150
|
+
try {
|
|
6151
|
+
const ordered = [...await source.list()].sort((a, b) => b.createdAt - a.createdAt);
|
|
6152
|
+
const cwds = [];
|
|
6153
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6154
|
+
for (const header of ordered) {
|
|
6155
|
+
const cwd = header.cwd;
|
|
6156
|
+
if (cwd === void 0 || cwd === "" || seen.has(cwd)) continue;
|
|
6157
|
+
seen.add(cwd);
|
|
6158
|
+
cwds.push(cwd);
|
|
6159
|
+
if (cwds.length >= SESSION_CWD_PALACE_LIMIT) break;
|
|
6160
|
+
}
|
|
6161
|
+
return cwds;
|
|
6162
|
+
} catch {
|
|
6163
|
+
return [];
|
|
6164
|
+
}
|
|
6165
|
+
};
|
|
6166
|
+
/** 项目宫殿清单(现算、零状态):注册表工作区 → 会话 cwd → 进程目录兜底,按分库名去重。 */
|
|
6167
|
+
const projectPalaces = async () => listProjectPalaces({
|
|
6168
|
+
workspaces: workspaceRefs(),
|
|
6169
|
+
sessionCwds: await sessionCwds(),
|
|
6170
|
+
processCwd: process.cwd()
|
|
6171
|
+
});
|
|
6172
|
+
/** 面板视图:补 exists 与 active 计数(库文件不存在时不打开,避免为工作区建空库)。 */
|
|
6173
|
+
const palaceView = async (palace) => {
|
|
6174
|
+
const exists = existsSync(join(resolved.dbDir, palace.dbName));
|
|
6175
|
+
let memories = null;
|
|
6176
|
+
if (exists) try {
|
|
6177
|
+
memories = (await (await openDb(palace.dbName)).stats()).active;
|
|
6178
|
+
} catch {
|
|
6179
|
+
memories = null;
|
|
6180
|
+
}
|
|
6181
|
+
return {
|
|
6182
|
+
dbName: palace.dbName,
|
|
6183
|
+
title: palace.title,
|
|
6184
|
+
path: palace.path ?? null,
|
|
6185
|
+
kind: palace.kind,
|
|
6186
|
+
source: palace.source,
|
|
6187
|
+
workspaceId: palace.workspaceId ?? null,
|
|
6188
|
+
exists,
|
|
6189
|
+
memories
|
|
6190
|
+
};
|
|
6191
|
+
};
|
|
6192
|
+
const projectsDeps = {
|
|
6193
|
+
list: async () => Promise.all((await projectPalaces()).map(async (palace) => palaceView(palace))),
|
|
6194
|
+
open: async (selector) => {
|
|
6195
|
+
const palaces = await projectPalaces();
|
|
6196
|
+
const palace = selector === void 0 ? findProjectPalace(palaces, {}) : palaces.find((candidate) => candidate.dbName === selector);
|
|
6197
|
+
if (palace === void 0) return void 0;
|
|
6198
|
+
return {
|
|
6199
|
+
store: await openDb(palace.dbName),
|
|
6200
|
+
project: await palaceView(palace)
|
|
6201
|
+
};
|
|
6202
|
+
}
|
|
5853
6203
|
};
|
|
5854
6204
|
/** 组装历史回填依赖;source 每次现取,兼容持久化服务在插件之后挂载的组合。 */
|
|
5855
6205
|
const buildHistoryDeps = () => ({
|
|
5856
6206
|
source: persistenceService(),
|
|
5857
6207
|
resolveStore: openStoreForProjectCwd,
|
|
5858
|
-
resolveExistingStore: openProjectStoreIfExists,
|
|
5859
6208
|
openUserStore: () => openStore("user"),
|
|
5860
6209
|
resolveExistingUserStore: openUserStoreIfExists,
|
|
5861
6210
|
embedder,
|
|
@@ -5965,6 +6314,7 @@ function apply(ctx, config = {}) {
|
|
|
5965
6314
|
};
|
|
5966
6315
|
for (const tool of createEngramTools({
|
|
5967
6316
|
openStore,
|
|
6317
|
+
resolveProjectStore,
|
|
5968
6318
|
embedder,
|
|
5969
6319
|
call: (callParams) => streamText(ctx, {
|
|
5970
6320
|
...callParams,
|
|
@@ -5981,6 +6331,7 @@ function apply(ctx, config = {}) {
|
|
|
5981
6331
|
ctx.inject(["webServer"], (webCtx) => {
|
|
5982
6332
|
registerEngramRoutes(webCtx, {
|
|
5983
6333
|
openStore,
|
|
6334
|
+
projects: projectsDeps,
|
|
5984
6335
|
exportDir: `${resolved.dbDir}/exports`,
|
|
5985
6336
|
mirrorDir: `${resolved.dbDir}/palaces`,
|
|
5986
6337
|
dbDir: resolved.dbDir,
|
|
@@ -5996,7 +6347,7 @@ function apply(ctx, config = {}) {
|
|
|
5996
6347
|
}
|
|
5997
6348
|
});
|
|
5998
6349
|
});
|
|
5999
|
-
if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, preStepState, logIngestRequest, payload, next), { prepend: true });
|
|
6350
|
+
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 });
|
|
6000
6351
|
ctx.on("session/disposed", (session) => {
|
|
6001
6352
|
evidenceBatches.clear(String(session.id));
|
|
6002
6353
|
});
|
|
@@ -6009,6 +6360,8 @@ function apply(ctx, config = {}) {
|
|
|
6009
6360
|
turn: 0,
|
|
6010
6361
|
slice: "last",
|
|
6011
6362
|
openStore: () => openStore("user"),
|
|
6363
|
+
openAuditStore: () => openStore("user"),
|
|
6364
|
+
...ingestWriteRouting(sessionCwd(session), openStoreForProjectCwd, openStore),
|
|
6012
6365
|
embedder,
|
|
6013
6366
|
mode,
|
|
6014
6367
|
routeOverride: resolved.routeOverride,
|
|
@@ -6023,12 +6376,23 @@ function apply(ctx, config = {}) {
|
|
|
6023
6376
|
});
|
|
6024
6377
|
});
|
|
6025
6378
|
const runDecay = async () => {
|
|
6026
|
-
|
|
6027
|
-
|
|
6379
|
+
const targets = [{
|
|
6380
|
+
label: "user",
|
|
6381
|
+
open: () => openStore("user")
|
|
6382
|
+
}];
|
|
6383
|
+
for (const palace of await projectPalaces()) {
|
|
6384
|
+
if (!existsSync(join(resolved.dbDir, palace.dbName))) continue;
|
|
6385
|
+
targets.push({
|
|
6386
|
+
label: `project「${palace.title}」`,
|
|
6387
|
+
open: () => openDb(palace.dbName)
|
|
6388
|
+
});
|
|
6389
|
+
}
|
|
6390
|
+
for (const target of targets) {
|
|
6391
|
+
const archived = await (await target.open()).decay({
|
|
6028
6392
|
importanceBelow: resolved.decayImportanceBelow,
|
|
6029
6393
|
olderThanDays: resolved.decayAfterDays
|
|
6030
6394
|
});
|
|
6031
|
-
if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${
|
|
6395
|
+
if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${target.label} 库归档 ${archived} 条低价值记忆(可在 engram_review 查证)`);
|
|
6032
6396
|
}
|
|
6033
6397
|
};
|
|
6034
6398
|
runDecay().catch((error) => {
|