@kenz1117/dsh-engram 0.7.5 → 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/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 store.hasAudit("ingest-done", doneKey)) return {
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 store.findContradictions(vector, 1)).length > 0) continue;
774
+ if (vector !== void 0 && (await target.findContradictions(vector, 1)).length > 0) continue;
749
775
  }
750
776
  if (writtenContents.includes(content)) continue;
751
- await store.write({
752
- scope: deps.writeScope ?? "user",
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 store.audit(INGEST_DONE_OP, deps.sessionId, doneKey);
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 events = await deps.resolveEvents(key.sessionId);
825
- if (events === void 0) {
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
- return cwd === void 0 || cwd === "" ? {
988
+ if (cwd === void 0 || cwd === "") return {
960
989
  header,
961
990
  events,
962
991
  turns,
963
992
  openStore: deps.openUserStore,
964
- writeScope: "user",
965
- storeKey: "user"
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(cwd),
971
- writeScope: "project",
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
- const store = candidate.writeScope === "user" ? await deps.resolveExistingUserStore() : await deps.resolveExistingStore(candidate.storeKey);
1034
- if (store === void 0) continue;
1035
- for (const turn of candidate.turns) if (await store.hasAudit("ingest-done", encodeTurnKey(candidate.header.id, turn))) alreadyIngested += 1;
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 deps.openStore(scope)).stats()
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 deps.openStore(scope)).list(filter));
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 deps.openStore(scope)).search({
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 deps.openStore(scope)).recentOps(limit)).map((op) => ({
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 deps.openStore(scope)).dueReviews(now, limit)).map((record) => ({
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
- const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
2078
- const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
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 deps.openStore(scope)).review(id);
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 deps.openStore(scope)).dueReviews(now, limit);
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
- const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
2143
- const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
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 deps.openStore(scope)).exportAll();
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 deps.openStore(scope)).exportAll();
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 deps.openStore(scope);
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 deps.openStore(scope)).exportAll();
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
- const scopeFilter = rawScope === "user" ? "user" : rawScope === "project" ? "project" : rawScope === "shared" ? "shared" : void 0;
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 deps.openStore(scope)).list({
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 deps.openStore(scope)).list({
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 deps.openStore(scope), deps.embedder, candidates === void 0 ? { scope } : {
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 deps.openStore(scope);
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 位(无 git 时仍是兜底命名)。 */
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: legacyDbName,
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
- * 旧库迁移:origin 库不存在而 cwd 旧库存在时同目录 rename(零数据搬运);
2547
- * 两者都存在时不合并、不动文件,返回 kept-both 由调用方告警。仅 origin 标识下有意义。
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.source !== "origin" || identity.dbName === identity.legacyDbName) return "none";
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: "事实厅",
@@ -3868,6 +4026,17 @@ const KINDS = [
3868
4026
  "episode",
3869
4027
  "skill"
3870
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
+ }
3871
4040
  /** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
3872
4041
  function renderHistoryEstimate(estimate) {
3873
4042
  if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
@@ -3986,10 +4155,18 @@ async function rewriteQueries(deps, exec, query) {
3986
4155
  /**
3987
4156
  * 构造 17 个工具定义(engram_save/search/assess/timeline/update/forget/report/review/review_queue/
3988
4157
  * stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
3989
- * @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
3990
- * @returns 可直接 register 的工具定义数组。
4158
+ * @param baseDeps - 分库打开器、嵌入器、辅助 LLM、导出目录。
4159
+ * @returns 可直接 register 的工具定义数组(execute 已绑定会话 cwd 的项目宫殿路由)。
3991
4160
  */
3992
- function createEngramTools(deps) {
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
+ };
3993
4170
  /** 批量保存上限(协议内常量:与单轮摄取候选量级对齐,防一次灌入过多)。 */
3994
4171
  const MAX_SAVE_BATCH = 10;
3995
4172
  /** engram_save 呈现文本:矛盾警告 text 优先;批量输出汇总成功与失败。 */
@@ -5376,7 +5553,10 @@ function createEngramTools(deps) {
5376
5553
  }),
5377
5554
  auditForgotten,
5378
5555
  ingestHistory
5379
- ];
5556
+ ].map((tool) => ({
5557
+ ...tool,
5558
+ execute: (args, exec) => execSessionCwd.run(sessionCwdOf(exec), () => tool.execute(args, exec))
5559
+ }));
5380
5560
  }
5381
5561
  /**
5382
5562
  * 构造巡游路径:在 search 命中的基础上按路径策略重排。
@@ -5548,7 +5728,7 @@ function estimateTokens(text) {
5548
5728
  /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
5549
5729
  const name = "dsh-engram";
5550
5730
  /** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
5551
- const VERSION = "0.7.5";
5731
+ const VERSION = "0.7.6";
5552
5732
  /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
5553
5733
  const inject = ["tools", "llm"];
5554
5734
  /**
@@ -5658,7 +5838,7 @@ async function compressProfileOverflow(ctx, agent, routeOverride, overflow, sign
5658
5838
  * 重放待补做的末轮摄取。必须调用 next() 委托链路;reject 决策原样透传,
5659
5839
  * 记忆库为空或非首轮时不追加消息。
5660
5840
  */
5661
- 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) {
5662
5842
  const decision = await next();
5663
5843
  if (decision.kind === "reject") return decision;
5664
5844
  if (step === 1) {
@@ -5666,12 +5846,14 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
5666
5846
  if (currentRoute !== void 0) state.route = currentRoute;
5667
5847
  }
5668
5848
  const mode = resolved.ingest;
5849
+ const routing = ingestWriteRouting(sessionCwd(agent.session), openStoreForProjectCwd, openStore);
5669
5850
  if (step === 1 && mode !== "off") {
5670
5851
  if (!state.pendingReplayed) {
5671
5852
  state.pendingReplayed = true;
5672
5853
  replayPendingIngests({
5673
5854
  openStore: () => openStore("user"),
5674
- resolveEvents: makeEventResolver(ctx, agent),
5855
+ resolveSession: makeSessionResolver(ctx, agent),
5856
+ resolveStore: openStoreForProjectCwd,
5675
5857
  embedder,
5676
5858
  mode,
5677
5859
  routeOverride: resolved.routeOverride,
@@ -5690,6 +5872,8 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
5690
5872
  sessionId: String(agent.id),
5691
5873
  turn,
5692
5874
  openStore: () => openStore("user"),
5875
+ openAuditStore: () => openStore("user"),
5876
+ ...routing,
5693
5877
  embedder,
5694
5878
  mode,
5695
5879
  routeOverride: resolved.routeOverride,
@@ -5749,17 +5933,49 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
5749
5933
  };
5750
5934
  }
5751
5935
  /**
5752
- * pending 重放的事件源解析器:pending 属于当前会话时直接用其事件快照;
5753
- * 其余会话经可选的 sessionPersistence 服务读持久化日志(服务缺席或读取失败
5754
- * 返回 undefined,pending 保留到下次,不报错)。
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 回调结果。
5755
5952
  */
5756
- function makeEventResolver(ctx, agent) {
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) {
5757
5967
  return async (sessionId) => {
5758
- if (sessionId === String(agent.id)) return agent.session.snapshotEvents();
5968
+ if (sessionId === String(agent.id)) return {
5969
+ events: agent.session.snapshotEvents(),
5970
+ cwd: sessionCwd(agent.session)
5971
+ };
5759
5972
  const persistence = ctx.get("sessionPersistence");
5760
5973
  if (persistence === void 0) return void 0;
5761
5974
  try {
5762
- return (await persistence.load(sessionId)).events;
5975
+ return await withReadHandle(persistence, sessionId, async (handle) => ({
5976
+ events: (await handle.read()).events,
5977
+ cwd: sessionCwd(handle)
5978
+ }));
5763
5979
  } catch {
5764
5980
  return;
5765
5981
  }
@@ -5805,6 +6021,11 @@ function apply(ctx, config = {}) {
5805
6021
  return created;
5806
6022
  };
5807
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");
5808
6029
  /** cwd → 分库文件名(历史回填会对每个历史 cwd 解析一次,避免重复读 git 元数据)。 */
5809
6030
  const cwdDbNames = /* @__PURE__ */ new Map();
5810
6031
  /**
@@ -5818,11 +6039,18 @@ function apply(ctx, config = {}) {
5818
6039
  const cached = cwdDbNames.get(cwd);
5819
6040
  if (cached !== void 0) return cached;
5820
6041
  const projectIdentity = resolveProjectIdentity(cwd);
5821
- if (migrateProjectDb(resolved.dbDir, projectIdentity) === "renamed") console.warn(`[dsh-engram] 历史项目库已迁移为 origin 命名(${cwd})`);
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} 请人工处理后删除)`);
5822
6045
  cwdDbNames.set(cwd, projectIdentity.dbName);
5823
6046
  return projectIdentity.dbName;
5824
6047
  };
5825
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);
5826
6054
  const embedder = createLocalEmbedder(resolved.modelCacheDir, resolved.hfEndpoint).catch((error) => {
5827
6055
  console.warn("[dsh-engram] 嵌入器不可用,检索降级为纯关键词模式:", error);
5828
6056
  });
@@ -5840,22 +6068,93 @@ function apply(ctx, config = {}) {
5840
6068
  const persistence = ctx.get("sessionPersistence");
5841
6069
  if (persistence === void 0) return void 0;
5842
6070
  return {
5843
- list: (signal) => persistence.list(signal),
5844
- load: (id) => persistence.load(id)
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 }))
5845
6073
  };
5846
6074
  };
5847
6075
  /** user 分库若已存在则打开(估算用:不因估算产生空库文件)。 */
5848
6076
  const openUserStoreIfExists = () => existsSync(join(resolved.dbDir, "user.db")) ? openStore("user") : Promise.resolve(void 0);
5849
- /** 指定 cwd 的项目库若已存在则打开(估算用:不因估算产生空库文件)。 */
5850
- const openProjectStoreIfExists = (cwd) => {
5851
- const dbName = dbNameForCwd(cwd);
5852
- return existsSync(join(resolved.dbDir, dbName)) ? openDb(dbName) : Promise.resolve(void 0);
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
+ }
5853
6153
  };
5854
6154
  /** 组装历史回填依赖;source 每次现取,兼容持久化服务在插件之后挂载的组合。 */
5855
6155
  const buildHistoryDeps = () => ({
5856
6156
  source: persistenceService(),
5857
6157
  resolveStore: openStoreForProjectCwd,
5858
- resolveExistingStore: openProjectStoreIfExists,
5859
6158
  openUserStore: () => openStore("user"),
5860
6159
  resolveExistingUserStore: openUserStoreIfExists,
5861
6160
  embedder,
@@ -5965,6 +6264,7 @@ function apply(ctx, config = {}) {
5965
6264
  };
5966
6265
  for (const tool of createEngramTools({
5967
6266
  openStore,
6267
+ resolveProjectStore,
5968
6268
  embedder,
5969
6269
  call: (callParams) => streamText(ctx, {
5970
6270
  ...callParams,
@@ -5981,6 +6281,7 @@ function apply(ctx, config = {}) {
5981
6281
  ctx.inject(["webServer"], (webCtx) => {
5982
6282
  registerEngramRoutes(webCtx, {
5983
6283
  openStore,
6284
+ projects: projectsDeps,
5984
6285
  exportDir: `${resolved.dbDir}/exports`,
5985
6286
  mirrorDir: `${resolved.dbDir}/palaces`,
5986
6287
  dbDir: resolved.dbDir,
@@ -5996,7 +6297,7 @@ function apply(ctx, config = {}) {
5996
6297
  }
5997
6298
  });
5998
6299
  });
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 });
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 });
6000
6301
  ctx.on("session/disposed", (session) => {
6001
6302
  evidenceBatches.clear(String(session.id));
6002
6303
  });
@@ -6009,6 +6310,8 @@ function apply(ctx, config = {}) {
6009
6310
  turn: 0,
6010
6311
  slice: "last",
6011
6312
  openStore: () => openStore("user"),
6313
+ openAuditStore: () => openStore("user"),
6314
+ ...ingestWriteRouting(sessionCwd(session), openStoreForProjectCwd, openStore),
6012
6315
  embedder,
6013
6316
  mode,
6014
6317
  routeOverride: resolved.routeOverride,
@@ -6023,12 +6326,23 @@ function apply(ctx, config = {}) {
6023
6326
  });
6024
6327
  });
6025
6328
  const runDecay = async () => {
6026
- for (const scope of ["user", "project"]) {
6027
- const archived = await (await openStore(scope)).decay({
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({
6028
6342
  importanceBelow: resolved.decayImportanceBelow,
6029
6343
  olderThanDays: resolved.decayAfterDays
6030
6344
  });
6031
- if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${scope} 库归档 ${archived} 条低价值记忆(可在 engram_review 查证)`);
6345
+ if (archived > 0) console.warn(`[dsh-engram] 衰减调度:${target.label} 库归档 ${archived} 条低价值记忆(可在 engram_review 查证)`);
6032
6346
  }
6033
6347
  };
6034
6348
  runDecay().catch((error) => {