@kenz1117/dsh-engram 0.7.8 → 0.7.11

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
@@ -28,11 +28,15 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
28
28
  "decayAfterDays",
29
29
  "decayImportanceBelow",
30
30
  "injectTokenBudget",
31
+ "injectItemBudgetStart",
32
+ "injectItemBudgetDecay",
33
+ "injectItemBudgetFloor",
31
34
  "rankRecencyWeight",
32
35
  "rankProofWeight",
33
36
  "queryRewrite",
34
37
  "autoSlot",
35
38
  "reviewScheduling",
39
+ "assessReminder",
36
40
  "historyBackfillDays",
37
41
  "historyBackfillMaxTurnsPerSession",
38
42
  "historyBackfillMaxTotalTurns",
@@ -59,11 +63,15 @@ const Config = z.object({
59
63
  decayAfterDays: z.number().step(1).min(1).max(3650),
60
64
  decayImportanceBelow: z.number().min(0).max(1),
61
65
  injectTokenBudget: z.number().step(1).min(128).max(8192),
66
+ injectItemBudgetStart: z.number().step(1).min(40).max(2e3),
67
+ injectItemBudgetDecay: z.number().min(.5).max(1),
68
+ injectItemBudgetFloor: z.number().step(1).min(8).max(200),
62
69
  rankRecencyWeight: z.number().min(0).max(2),
63
70
  rankProofWeight: z.number().min(0).max(2),
64
71
  queryRewrite: z.boolean(),
65
72
  autoSlot: z.boolean(),
66
73
  reviewScheduling: z.boolean(),
74
+ assessReminder: z.boolean(),
67
75
  historyBackfillDays: z.number().step(1).min(0).max(3650),
68
76
  historyBackfillMaxTurnsPerSession: z.number().step(1).min(1).max(500),
69
77
  historyBackfillMaxTotalTurns: z.number().step(1).min(1).max(5e3),
@@ -88,6 +96,11 @@ function resolveConfig(config = {}) {
88
96
  if (config.decayAfterDays !== void 0 && (!Number.isInteger(config.decayAfterDays) || config.decayAfterDays < 1 || config.decayAfterDays > 3650)) throw new Error("dsh-engram: decayAfterDays must be an integer in [1, 3650]");
89
97
  if (config.decayImportanceBelow !== void 0 && (config.decayImportanceBelow < 0 || config.decayImportanceBelow > 1)) throw new Error("dsh-engram: decayImportanceBelow must be in [0, 1]");
90
98
  if (config.injectTokenBudget !== void 0 && (!Number.isInteger(config.injectTokenBudget) || config.injectTokenBudget < 128 || config.injectTokenBudget > 8192)) throw new Error("dsh-engram: injectTokenBudget must be an integer in [128, 8192]");
99
+ if (config.injectItemBudgetStart !== void 0 && (!Number.isInteger(config.injectItemBudgetStart) || config.injectItemBudgetStart < 40 || config.injectItemBudgetStart > 2e3)) throw new Error("dsh-engram: injectItemBudgetStart must be an integer in [40, 2000]");
100
+ if (config.injectItemBudgetDecay !== void 0 && (config.injectItemBudgetDecay < .5 || config.injectItemBudgetDecay > 1)) throw new Error("dsh-engram: injectItemBudgetDecay must be in [0.5, 1]");
101
+ if (config.injectItemBudgetFloor !== void 0 && (!Number.isInteger(config.injectItemBudgetFloor) || config.injectItemBudgetFloor < 8 || config.injectItemBudgetFloor > 200)) throw new Error("dsh-engram: injectItemBudgetFloor must be an integer in [8, 200]");
102
+ if (config.injectItemBudgetStart !== void 0 && config.injectItemBudgetFloor !== void 0 && config.injectItemBudgetFloor > config.injectItemBudgetStart) throw new Error("dsh-engram: injectItemBudgetFloor must not exceed injectItemBudgetStart");
103
+ if (config.assessReminder !== void 0 && typeof config.assessReminder !== "boolean") throw new Error("dsh-engram: assessReminder must be a boolean");
91
104
  if (config.rankRecencyWeight !== void 0 && (config.rankRecencyWeight < 0 || config.rankRecencyWeight > 2)) throw new Error("dsh-engram: rankRecencyWeight must be in [0, 2]");
92
105
  if (config.rankProofWeight !== void 0 && (config.rankProofWeight < 0 || config.rankProofWeight > 2)) throw new Error("dsh-engram: rankProofWeight must be in [0, 2]");
93
106
  if (config.historyBackfillDays !== void 0 && (!Number.isInteger(config.historyBackfillDays) || config.historyBackfillDays < 0 || config.historyBackfillDays > 3650)) throw new Error("dsh-engram: historyBackfillDays must be an integer in [0, 3650] (0 = unlimited)");
@@ -109,6 +122,10 @@ function resolveConfig(config = {}) {
109
122
  decayAfterDays: config.decayAfterDays ?? 30,
110
123
  decayImportanceBelow: config.decayImportanceBelow ?? .3,
111
124
  injectTokenBudget: config.injectTokenBudget ?? 1024,
125
+ injectItemBudgetStart: config.injectItemBudgetStart ?? 160,
126
+ injectItemBudgetDecay: config.injectItemBudgetDecay ?? .9,
127
+ injectItemBudgetFloor: config.injectItemBudgetFloor ?? 24,
128
+ assessReminder: config.assessReminder ?? true,
112
129
  rankRecencyWeight: config.rankRecencyWeight ?? .2,
113
130
  rankProofWeight: config.rankProofWeight ?? .1,
114
131
  queryRewrite: config.queryRewrite ?? true,
@@ -130,6 +147,8 @@ function resolveConfig(config = {}) {
130
147
  function asMemoryId(raw) {
131
148
  return raw;
132
149
  }
150
+ /** episode 情景时间线的时间邻近扩展缺省窗口:锚点 createdAt ± 60 分钟。 */
151
+ const EPISODE_PROXIMITY_MS_DEFAULT = 36e5;
133
152
  /** dsh-engram 统一错误:加载/使用期的可诊断失败都抛此类型。 */
134
153
  var EngramError = class extends Error {
135
154
  /** 机器可读原因码,如 EMBEDDER_DOWNLOAD_FAILED / SCHEMA_INCOMPATIBLE。 */
@@ -415,7 +434,7 @@ function redacted(kind) {
415
434
  * @returns 脱敏后的文本;无命中时原样返回。
416
435
  */
417
436
  function redactSecrets(text) {
418
- return text.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, redacted("private-key")).replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, `Bearer ${redacted("bearer-token")}`).replace(/\bsk-(?:proj-|cp-|ant-|svcacct-)?[A-Za-z0-9_-]{16,}/g, redacted("api-key")).replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, redacted("github-token")).replace(/\bAKIA[0-9A-Z]{16}\b/g, redacted("aws-access-key")).replace(/\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|auth[_-]?token)\b\s*[=:]\s*("[^"\n]*"|'[^'\n]*'|[^'",;\s)\]}]+)/gi, (_match, key) => `${key}=${redacted("secret-value")}`);
437
+ return text.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, redacted("private-key")).replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, `Bearer ${redacted("bearer-token")}`).replace(/\bsk-(?:proj-|cp-|ant-|svcacct-)?[A-Za-z0-9_-]{16,}/g, redacted("api-key")).replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, redacted("github-token")).replace(/\bAKIA[0-9A-Z]{16}\b/g, redacted("aws-access-key")).replace(/\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|auth[_-]?token)\b\s*[=:]\s*("[^"\n]*"|'[^'\n]*'|[^'",;\s)\]}]+)/gi, (_match, key) => `${key}=${redacted("secret-value")}`).replace(/(密码|口令)[是为::\s]{0,3}([A-Za-z0-9!@#$%^&*()_+=\[\]{}<>/?\\|`~.-]{4,})/g, (_match, key) => `${key}=${redacted("password")}`).replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, redacted("phone")).replace(/\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g, redacted("id-number"));
419
438
  }
420
439
  //#endregion
421
440
  //#region src/security/recall.ts
@@ -484,6 +503,42 @@ function omitRecallToolResults(events) {
484
503
  };
485
504
  });
486
505
  }
506
+ /**
507
+ * 判定一条新内容的处置。
508
+ * 嵌入不可用 / 库内无向量条目 / 最近邻低于 DEFER_COSINE → accept;
509
+ * 最近邻同 kind 且 ≥ MERGE_COSINE → merge;其余达到 DEFER_COSINE 的 → defer。
510
+ * @param store - 目标分库
511
+ * @param kind - 新内容的记忆种类(MERGE 要求同 kind,跨 kind 高度相似按 defer 处理)
512
+ * @param embedding - 新内容的向量;undefined = 嵌入不可用,直接 accept
513
+ */
514
+ async function decideWrite(store, kind, embedding) {
515
+ if (embedding === void 0) return { disposition: "accept" };
516
+ const nearest = await store.nearestNeighbor(embedding);
517
+ if (nearest === void 0 || nearest.similarity < .88) return { disposition: "accept" };
518
+ if (nearest.similarity >= .92 && nearest.record.kind === kind) return {
519
+ disposition: "merge",
520
+ into: nearest.record,
521
+ similarity: nearest.similarity
522
+ };
523
+ return {
524
+ disposition: "defer",
525
+ neighbor: nearest.record,
526
+ similarity: nearest.similarity
527
+ };
528
+ }
529
+ /**
530
+ * MERGE 落地:强化既有条目(accessCount+1 / confidence+0.05)并记 write-merge 审计行。
531
+ * @returns 强化后的既有条目;目标在并发窗口内被物理清除时抛错(loud,调用方按写入失败处理)。
532
+ */
533
+ async function applyMerge(store, decision, content) {
534
+ const note = JSON.stringify({
535
+ content: content.slice(0, 40),
536
+ similarity: Number(decision.similarity.toFixed(4))
537
+ });
538
+ const record = await store.reinforce(decision.into.id, note);
539
+ if (record === void 0) throw new Error(`write-disposition: 并入目标 ${decision.into.id} 不存在`);
540
+ return record;
541
+ }
487
542
  //#endregion
488
543
  //#region src/ingest/hook.ts
489
544
  /** 档位参数(协议内常量,非部署 tunables)。 */
@@ -695,6 +750,9 @@ async function ingestPreviousTurn(deps) {
695
750
  scannedEvents: 0,
696
751
  candidates: 0,
697
752
  written: 0,
753
+ merged: 0,
754
+ deferred: 0,
755
+ dropped: 0,
698
756
  skipped: typeof sliceMode === "number" ? "no-such-turn" : "no-previous-turn"
699
757
  };
700
758
  const store = await deps.openStore();
@@ -704,6 +762,9 @@ async function ingestPreviousTurn(deps) {
704
762
  scannedEvents: slice.length,
705
763
  candidates: 0,
706
764
  written: 0,
765
+ merged: 0,
766
+ deferred: 0,
767
+ dropped: 0,
707
768
  skipped: "already-ingested"
708
769
  };
709
770
  if (sliceMode === "previous" || deps.throttle === true) {
@@ -712,6 +773,9 @@ async function ingestPreviousTurn(deps) {
712
773
  scannedEvents: slice.length,
713
774
  candidates: 0,
714
775
  written: 0,
776
+ merged: 0,
777
+ deferred: 0,
778
+ dropped: 0,
715
779
  skipped: throttled
716
780
  };
717
781
  }
@@ -722,6 +786,9 @@ async function ingestPreviousTurn(deps) {
722
786
  scannedEvents: slice.length,
723
787
  candidates: 0,
724
788
  written: 0,
789
+ merged: 0,
790
+ deferred: 0,
791
+ dropped: 0,
725
792
  skipped: "no-user-content"
726
793
  };
727
794
  const route = deps.routeOverride ?? routeFromEvents(deps.events);
@@ -729,6 +796,9 @@ async function ingestPreviousTurn(deps) {
729
796
  scannedEvents: slice.length,
730
797
  candidates: 0,
731
798
  written: 0,
799
+ merged: 0,
800
+ deferred: 0,
801
+ dropped: 0,
732
802
  skipped: "no-route-in-log"
733
803
  };
734
804
  const recallNote = hasRecallToolCalls(scoped) ? "(注意:上一轮调用过记忆召回工具(engram_search 等),其返回已省略;助手回答中复述的既有记忆不是新信息,不要提取。)" : "";
@@ -752,16 +822,28 @@ async function ingestPreviousTurn(deps) {
752
822
  scannedEvents: slice.length,
753
823
  candidates: 0,
754
824
  written: 0,
825
+ merged: 0,
826
+ deferred: 0,
827
+ dropped: 0,
755
828
  skipped: "unparseable-output"
756
829
  };
757
830
  const embedder = await deps.embedder;
758
831
  const writtenContents = [];
759
832
  let written = 0;
833
+ let merged = 0;
834
+ let deferred = 0;
835
+ let dropped = 0;
760
836
  for (const item of parsed.slice(0, limits.maxCandidates)) {
761
837
  const candidate = item;
762
- if (typeof candidate.content !== "string" || candidate.content.trim() === "") continue;
838
+ if (typeof candidate.content !== "string" || candidate.content.trim() === "") {
839
+ dropped += 1;
840
+ continue;
841
+ }
763
842
  const content = redactSecrets(sanitizeProtocolText(candidate.content.trim()));
764
- if (content === "") continue;
843
+ if (content === "") {
844
+ dropped += 1;
845
+ continue;
846
+ }
765
847
  const kind = typeof candidate.kind === "string" && [
766
848
  "fact",
767
849
  "preference",
@@ -773,12 +855,19 @@ async function ingestPreviousTurn(deps) {
773
855
  const scope = deps.perCandidateScope === true && modelScope !== void 0 ? modelScope : deps.writeScope ?? "user";
774
856
  const target = deps.resolveStore === void 0 ? store : await deps.resolveStore(scope);
775
857
  const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .5;
776
- if (embedder !== void 0) {
777
- const vector = (await embedder.embed([content]))[0];
778
- if (vector !== void 0 && (await target.findContradictions(vector, 1)).length > 0) continue;
858
+ if (writtenContents.includes(content)) {
859
+ dropped += 1;
860
+ continue;
861
+ }
862
+ const vector = embedder === void 0 ? void 0 : (await embedder.embed([content]))[0];
863
+ const decision = await decideWrite(target, kind, vector);
864
+ if (decision.disposition === "merge") {
865
+ await applyMerge(target, decision, content);
866
+ writtenContents.push(content);
867
+ merged += 1;
868
+ continue;
779
869
  }
780
- if (writtenContents.includes(content)) continue;
781
- await target.write({
870
+ const record = await target.write({
782
871
  scope,
783
872
  kind,
784
873
  content,
@@ -787,9 +876,13 @@ async function ingestPreviousTurn(deps) {
787
876
  sourceSessionId: deps.sessionId,
788
877
  sourceRound: round,
789
878
  ...minSeq === null ? {} : { sourceSeq: minSeq },
790
- ...embedder === void 0 ? {} : { embedding: (await embedder.embed([content]))[0] },
879
+ ...vector === void 0 ? {} : { embedding: vector },
791
880
  ...deps.history === true ? { initialReviewAt: null } : {}
792
881
  });
882
+ if (decision.disposition === "defer") {
883
+ await target.linkEdge(record.id, decision.neighbor.id, "contradicts");
884
+ deferred += 1;
885
+ }
793
886
  writtenContents.push(content);
794
887
  written += 1;
795
888
  }
@@ -798,6 +891,9 @@ async function ingestPreviousTurn(deps) {
798
891
  scannedEvents: slice.length,
799
892
  candidates: parsed.length,
800
893
  written,
894
+ merged,
895
+ deferred,
896
+ dropped,
801
897
  skipped: null
802
898
  };
803
899
  }
@@ -836,6 +932,62 @@ async function ingestFinalTurn(deps) {
836
932
  return null;
837
933
  }
838
934
  }
935
+ /** 会话摘要提示词:一句话概括整场会话,供情景时间线的组头展示。 */
936
+ const SESSION_SUMMARY_SYSTEM = ["概括下面这段会话:用一句话(不超过 60 字)说明这场会话主要做了什么、达成了什么结果。", "直接输出摘要本身,不要前缀、引号或任何解释。"].join("\n");
937
+ /** 会话摘要输出 token 上限(一句话足够)。 */
938
+ const SESSION_SUMMARY_MAX_TOKENS = 160;
939
+ /** 会话摘要的输入截断上限(字符):摘要只需主题级信息,超长会话截尾。 */
940
+ const SESSION_SUMMARY_TEXT_LIMIT = 4e3;
941
+ /** 收集整场会话的对话文本(用户跳过插件注入快照 + 助手),脱敏后拼接并截断。 */
942
+ function collectSessionText(events) {
943
+ const texts = [];
944
+ for (const event of events) if (event.type === "user/message") {
945
+ const data = event.data;
946
+ if (data?.source?.kind === "plugin") continue;
947
+ const segments = (data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
948
+ if (segments.length > 0) texts.push(...segments);
949
+ } else if (event.type === "assistant/message") {
950
+ const segments = (event.data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
951
+ if (segments.length > 0) texts.push(...segments);
952
+ }
953
+ const joined = texts.map((text) => redactSecrets(sanitizeProtocolText(text))).filter((text) => text !== "").join("\n");
954
+ return joined.length > SESSION_SUMMARY_TEXT_LIMIT ? `${joined.slice(0, SESSION_SUMMARY_TEXT_LIMIT)}…` : joined;
955
+ }
956
+ /**
957
+ * 会话循环结束处生成一句话摘要(情景时间线组头的增强信息):
958
+ * 已有摘要直接跳过(重跑幂等,不再起 LLM);无可用路由 / 已中止 / 无对话文本同样跳过。
959
+ * LLM 失败静默:摘要是增强信息,实时路径挂在 dispose 观察器、回填路径在批次循环里,
960
+ * 一次限流/超时都不值得升级为告警或失败。
961
+ */
962
+ async function ensureSessionSummary(deps) {
963
+ if (deps.signal.aborted) return;
964
+ const route = deps.routeOverride ?? routeFromEvents(deps.events);
965
+ if (route === void 0) return;
966
+ const dialog = collectSessionText(deps.events);
967
+ if (dialog === "") return;
968
+ const store = await deps.openStore();
969
+ if (await store.getSessionSummary(deps.sessionId) !== void 0) return;
970
+ const userText = `概括下面这段会话:\n${dialog}`;
971
+ deps.logRequest({
972
+ route,
973
+ round: deps.round ?? 0,
974
+ userText,
975
+ maxTokens: SESSION_SUMMARY_MAX_TOKENS,
976
+ mode: deps.mode
977
+ });
978
+ try {
979
+ const summary = (await deps.call({
980
+ route,
981
+ system: SESSION_SUMMARY_SYSTEM,
982
+ userText,
983
+ maxTokens: SESSION_SUMMARY_MAX_TOKENS,
984
+ purpose: "engram-session-summary",
985
+ signal: deps.signal
986
+ })).trim();
987
+ if (summary === "") return;
988
+ await store.setSessionSummary(deps.sessionId, summary);
989
+ } catch {}
990
+ }
839
991
  /**
840
992
  * 重放待补做的末轮摄取(下次会话首次 pre-step 调用)。已有 done 标记或键损坏的
841
993
  * pending 直接出队;事件源不可得的保留到下次。单个键失败抛出,剩余键留待下次。
@@ -1147,6 +1299,17 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
1147
1299
  }
1148
1300
  progress.turnsDone += 1;
1149
1301
  }
1302
+ await ensureSessionSummary({
1303
+ events: candidate.events,
1304
+ sessionId: candidate.header.id,
1305
+ openStore: () => candidate.openStore(),
1306
+ call: deps.call,
1307
+ logRequest: deps.logRequest,
1308
+ mode: deps.mode,
1309
+ routeOverride,
1310
+ signal,
1311
+ round: candidate.turns[candidate.turns.length - 1]
1312
+ });
1150
1313
  progress.sessionsDone += 1;
1151
1314
  onProgress({
1152
1315
  ...progress,
@@ -2104,6 +2267,58 @@ function registerEngramRoutes(ctx, deps) {
2104
2267
  });
2105
2268
  return;
2106
2269
  }
2270
+ if (req.method === "GET" && route === "episode-timeline") {
2271
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2272
+ const since = toNumber(url.searchParams.get("since"));
2273
+ const until = toNumber(url.searchParams.get("until"));
2274
+ const sessionId = url.searchParams.get("sessionId") ?? void 0;
2275
+ const around = url.searchParams.get("around") ?? void 0;
2276
+ const windowMinutes = Math.min(1440, Math.max(1, toNumber(url.searchParams.get("windowMinutes")) ?? 60));
2277
+ const limit = Math.min(200, Math.max(1, toNumber(url.searchParams.get("limit")) ?? 100));
2278
+ const store = await storeFor(scope);
2279
+ if (around !== void 0 && around !== "") {
2280
+ const view = (await store.episodeTimeline({
2281
+ scopes: [scope],
2282
+ around,
2283
+ proximityMs: windowMinutes * 6e4,
2284
+ limit
2285
+ })).around;
2286
+ json(res, 200, { around: {
2287
+ anchor: {
2288
+ id: view.anchor.id,
2289
+ kind: view.anchor.kind,
2290
+ content: view.anchor.content,
2291
+ createdAt: view.anchor.createdAt
2292
+ },
2293
+ neighbors: view.neighbors.map((record) => ({
2294
+ id: record.id,
2295
+ kind: record.kind,
2296
+ content: record.content,
2297
+ createdAt: record.createdAt
2298
+ }))
2299
+ } });
2300
+ return;
2301
+ }
2302
+ json(res, 200, { groups: (await store.episodeTimeline({
2303
+ scopes: [scope],
2304
+ ...since === void 0 ? {} : { since },
2305
+ ...until === void 0 ? {} : { until },
2306
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessionId },
2307
+ limit
2308
+ })).groups.map((group) => ({
2309
+ sessionId: group.sessionId,
2310
+ ...group.summary === void 0 ? {} : { summary: group.summary },
2311
+ startedAt: group.startedAt,
2312
+ endedAt: group.endedAt,
2313
+ episodes: group.episodes.map((record) => ({
2314
+ id: record.id,
2315
+ kind: record.kind,
2316
+ content: record.content,
2317
+ createdAt: record.createdAt
2318
+ }))
2319
+ })) });
2320
+ return;
2321
+ }
2107
2322
  if (req.method === "GET" && route === "activity") {
2108
2323
  const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
2109
2324
  json(res, 200, { operations: (await Promise.all(["user", "project"].map(async (scope) => {
@@ -2378,6 +2593,18 @@ function registerEngramRoutes(ctx, deps) {
2378
2593
  json(res, 200, await deps.history.models());
2379
2594
  return;
2380
2595
  }
2596
+ if (req.method === "GET" && route === "profile-block") {
2597
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2598
+ const store = await storeFor(scope);
2599
+ const block = await store.getProfileBlock(scope);
2600
+ const versions = await store.listProfileBlockVersions(scope, 20);
2601
+ json(res, 200, {
2602
+ scope,
2603
+ block: block ?? null,
2604
+ versions
2605
+ });
2606
+ return;
2607
+ }
2381
2608
  if (req.method === "GET" && route === "history-backfill") {
2382
2609
  json(res, 200, {
2383
2610
  defaults: deps.history.defaults,
@@ -2877,14 +3104,20 @@ function placardImprovementHint(score) {
2877
3104
  * @module @kenz1117/dsh-engram/store/sqlite
2878
3105
  */
2879
3106
  /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
2880
- const SCHEMA_VERSION = 6;
3107
+ const SCHEMA_VERSION = 9;
2881
3108
  /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
2882
3109
  * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
2883
3110
  * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
2884
3111
  * v4 → v5:nodes 补 imagery_json 列(意象铭牌:caption + sensoryTags + emotionalValence + provisional)。
2885
3112
  * v5 → v6:桩位(slot_room/slot_index)、意象质量分(imagery_score)、SM-2 调度
2886
3113
  * (next_review_at/ease_factor/interval_days/review_reps)+ 固定巡游路线表 tour_routes。
2887
- * 全部可空或带默认值,存量条目零搬运;排桩由 backfillSlots 幂等补齐。 */
3114
+ * 全部可空或带默认值,存量条目零搬运;排桩由 backfillSlots 幂等补齐。
3115
+ * v6 → v7:画像 curated block 两表(profile_blocks 当前态 + profile_block_versions
3116
+ * 追加式版本链)。scope 作主键,每 scope 至多一个 block;版本链只增不改,rollback 数据源。
3117
+ * v7 → v8:episode 情景时间线两个专用索引(kind+created_at 日期范围扫描、
3118
+ * source_session_id+created_at 会话过滤)。列自 v1 就存在,纯索引迁移,零数据搬运。
3119
+ * v8 → v9:会话摘要表 session_summaries(摄取期 LLM 生成的一句话会话摘要,
3120
+ * 组头展示用;与 nodes 无外键,会话 id 仅作逻辑关联)。 */
2888
3121
  const MIGRATIONS = {
2889
3122
  "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
2890
3123
  "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
@@ -2901,7 +3134,17 @@ const MIGRATIONS = {
2901
3134
  CREATE TABLE IF NOT EXISTS tour_routes (
2902
3135
  position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
2903
3136
  CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
2904
- CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`
3137
+ CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`,
3138
+ "6": `CREATE TABLE IF NOT EXISTS profile_blocks (
3139
+ scope TEXT PRIMARY KEY, content TEXT NOT NULL, version INTEGER NOT NULL,
3140
+ updated_at INTEGER NOT NULL);
3141
+ CREATE TABLE IF NOT EXISTS profile_block_versions (
3142
+ scope TEXT NOT NULL, version INTEGER NOT NULL, content TEXT NOT NULL,
3143
+ source TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (scope, version));`,
3144
+ "7": `CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
3145
+ CREATE INDEX IF NOT EXISTS nodes_session_created ON nodes (source_session_id, created_at);`,
3146
+ "8": `CREATE TABLE IF NOT EXISTS session_summaries (
3147
+ session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, updated_at INTEGER NOT NULL);`
2905
3148
  };
2906
3149
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
2907
3150
  const RRF_K = 60;
@@ -3079,8 +3322,18 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3079
3322
  importance REAL NOT NULL, superseded_at INTEGER NOT NULL);
3080
3323
  CREATE TABLE IF NOT EXISTS tour_routes (
3081
3324
  position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
3325
+ CREATE TABLE IF NOT EXISTS profile_blocks (
3326
+ scope TEXT PRIMARY KEY, content TEXT NOT NULL, version INTEGER NOT NULL,
3327
+ updated_at INTEGER NOT NULL);
3328
+ CREATE TABLE IF NOT EXISTS profile_block_versions (
3329
+ scope TEXT NOT NULL, version INTEGER NOT NULL, content TEXT NOT NULL,
3330
+ source TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (scope, version));
3331
+ CREATE TABLE IF NOT EXISTS session_summaries (
3332
+ session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, updated_at INTEGER NOT NULL);
3082
3333
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
3083
3334
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
3335
+ CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
3336
+ CREATE INDEX IF NOT EXISTS nodes_session_created ON nodes (source_session_id, created_at);
3084
3337
  `);
3085
3338
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
3086
3339
  if (versionRow === void 0) {
@@ -3184,6 +3437,19 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3184
3437
  const sqlPurgeFts = db.prepare("DELETE FROM nodes_fts");
3185
3438
  const sqlPurgeLog = db.prepare("DELETE FROM op_log");
3186
3439
  const sqlPurgeRoutes = db.prepare("DELETE FROM tour_routes");
3440
+ const sqlPurgeProfileBlocks = db.prepare("DELETE FROM profile_blocks");
3441
+ const sqlPurgeProfileVersions = db.prepare("DELETE FROM profile_block_versions");
3442
+ const sqlPurgeSessionSummaries = db.prepare("DELETE FROM session_summaries");
3443
+ const sqlProfileBlockGet = db.prepare("SELECT scope, content, version, updated_at FROM profile_blocks WHERE scope = ?");
3444
+ const sqlProfileBlockUpsert = db.prepare(`INSERT INTO profile_blocks (scope, content, version, updated_at) VALUES (?, ?, ?, ?)
3445
+ ON CONFLICT(scope) DO UPDATE SET content = excluded.content, version = excluded.version, updated_at = excluded.updated_at`);
3446
+ const sqlProfileVersionInsert = db.prepare("INSERT INTO profile_block_versions (scope, version, content, source, at) VALUES (?, ?, ?, ?, ?)");
3447
+ const sqlProfileVersionsList = db.prepare("SELECT scope, version, content, source, at FROM profile_block_versions WHERE scope = ? ORDER BY version DESC LIMIT ?");
3448
+ const sqlProfileVersionGet = db.prepare("SELECT scope, version, content, source, at FROM profile_block_versions WHERE scope = ? AND version = ?");
3449
+ const sqlSessionSummaryUpsert = db.prepare(`INSERT INTO session_summaries (session_id, summary, updated_at) VALUES (?, ?, ?)
3450
+ ON CONFLICT(session_id) DO UPDATE SET summary = excluded.summary, updated_at = excluded.updated_at`);
3451
+ const sqlSessionSummaryGet = db.prepare("SELECT session_id, summary FROM session_summaries WHERE session_id = ?");
3452
+ const sqlSessionSummaryMany = db.prepare("SELECT session_id, summary FROM session_summaries WHERE session_id IN (SELECT value FROM json_each(?))");
3187
3453
  /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
3188
3454
  const ftsSearch = (match, scopes, rooms) => {
3189
3455
  const placeholders = scopes.map(() => "?").join(",");
@@ -3414,6 +3680,78 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3414
3680
  return (query.order === "tour" ? db.prepare(`SELECT nodes.* FROM nodes LEFT JOIN tour_routes ON tour_routes.node_id = nodes.id
3415
3681
  WHERE ${where} ORDER BY tour_routes.position IS NULL, tour_routes.position ASC, nodes.created_at DESC LIMIT ?`).all(...params) : db.prepare(`SELECT nodes.* FROM nodes WHERE ${where} ORDER BY nodes.created_at DESC LIMIT ?`).all(...params)).map(rowToRecord);
3416
3682
  },
3683
+ async episodeTimeline(query) {
3684
+ const limit = Math.max(1, Math.floor(query.limit ?? 100));
3685
+ const placeholders = query.scopes.map(() => "?").join(",");
3686
+ if (query.around !== void 0) {
3687
+ const anchorRow = getRow(query.around);
3688
+ if (anchorRow === void 0) throw new EngramError("NOT_FOUND", `邻近扩展锚点 ${query.around} 不存在`);
3689
+ const window = Math.max(0, Math.floor(query.proximityMs ?? 36e5));
3690
+ const rows = db.prepare(`SELECT * FROM nodes
3691
+ WHERE kind = 'episode' AND status = 'active' AND scope IN (${placeholders})
3692
+ AND id != ? AND created_at >= ? AND created_at <= ?
3693
+ ORDER BY created_at ASC LIMIT ?`).all(...query.scopes, query.around, anchorRow.created_at - window, anchorRow.created_at + window, limit);
3694
+ return {
3695
+ groups: [],
3696
+ around: {
3697
+ anchor: rowToRecord(anchorRow),
3698
+ neighbors: rows.map(rowToRecord)
3699
+ }
3700
+ };
3701
+ }
3702
+ const params = [
3703
+ ...query.scopes,
3704
+ query.since ?? null,
3705
+ query.since ?? null,
3706
+ query.until ?? null,
3707
+ query.until ?? null,
3708
+ query.sessionId ?? null,
3709
+ query.sessionId ?? null,
3710
+ limit
3711
+ ];
3712
+ const rows = db.prepare(`SELECT * FROM nodes
3713
+ WHERE kind = 'episode' AND status = 'active' AND scope IN (${placeholders})
3714
+ AND (? IS NULL OR created_at >= ?) AND (? IS NULL OR created_at <= ?)
3715
+ AND (? IS NULL OR source_session_id = ?)
3716
+ ORDER BY created_at DESC LIMIT ?`).all(...params);
3717
+ const bySession = /* @__PURE__ */ new Map();
3718
+ for (const row of rows) {
3719
+ const record = rowToRecord(row);
3720
+ const bucket = bySession.get(record.sourceSessionId);
3721
+ if (bucket === void 0) bySession.set(record.sourceSessionId, [record]);
3722
+ else bucket.push(record);
3723
+ }
3724
+ const groups = [...bySession.entries()].map(([sessionId, episodesDesc]) => {
3725
+ const episodes = [...episodesDesc].reverse();
3726
+ return {
3727
+ sessionId,
3728
+ episodes,
3729
+ startedAt: episodes[0].createdAt,
3730
+ endedAt: episodes[episodes.length - 1].createdAt
3731
+ };
3732
+ });
3733
+ groups.sort((a, b) => b.startedAt - a.startedAt);
3734
+ const sessionIds = groups.map((g) => g.sessionId).filter((id) => id !== null);
3735
+ const summaries = /* @__PURE__ */ new Map();
3736
+ if (sessionIds.length > 0) {
3737
+ const rows = sqlSessionSummaryMany.all(JSON.stringify(sessionIds));
3738
+ for (const row of rows) summaries.set(row.session_id, row.summary);
3739
+ }
3740
+ return { groups: groups.map((group) => {
3741
+ const summary = group.sessionId === null ? void 0 : summaries.get(group.sessionId);
3742
+ return {
3743
+ ...group,
3744
+ ...summary === void 0 ? {} : { summary }
3745
+ };
3746
+ }) };
3747
+ },
3748
+ async setSessionSummary(sessionId, summary) {
3749
+ sqlSessionSummaryUpsert.run(sessionId, summary, Date.now());
3750
+ sqlLog.run(Date.now(), "session-summary", sessionId, JSON.stringify({ length: summary.length }));
3751
+ },
3752
+ async getSessionSummary(sessionId) {
3753
+ return sqlSessionSummaryGet.get(sessionId)?.summary;
3754
+ },
3417
3755
  async update(input) {
3418
3756
  const old = getRow(input.id);
3419
3757
  if (old === void 0) throw new EngramError("NOT_FOUND", `条目 ${input.id} 不存在`);
@@ -3572,6 +3910,54 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3572
3910
  async listPlacards() {
3573
3911
  return sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
3574
3912
  },
3913
+ async getProfileBlock(scope) {
3914
+ const row = sqlProfileBlockGet.get(scope);
3915
+ if (row === void 0) return void 0;
3916
+ return {
3917
+ scope: row.scope,
3918
+ content: row.content,
3919
+ version: row.version,
3920
+ updatedAt: row.updated_at
3921
+ };
3922
+ },
3923
+ async saveProfileBlock(scope, expectedVersion, content, source) {
3924
+ if (content.trim() === "") throw new EngramError("EMPTY_CONTENT", "画像 block 内容不能为空");
3925
+ const current = sqlProfileBlockGet.get(scope);
3926
+ if (current === void 0 && expectedVersion !== void 0) throw new EngramError("VERSION_CONFLICT", `画像 block 不存在(${scope}),不能带 expectedVersion=${String(expectedVersion)} 编辑;先传 undefined 创建`);
3927
+ if (current !== void 0 && expectedVersion !== current.version) throw new EngramError("VERSION_CONFLICT", `画像 block 版本冲突(${scope}):当前 v${String(current.version)},请求基于 v${String(expectedVersion)};请重读最新版本后再改`);
3928
+ const nextVersion = current === void 0 ? 1 : current.version + 1;
3929
+ const now = Date.now();
3930
+ withTransaction(() => {
3931
+ sqlProfileVersionInsert.run(scope, nextVersion, content, source, now);
3932
+ sqlProfileBlockUpsert.run(scope, content, nextVersion, now);
3933
+ });
3934
+ return {
3935
+ scope,
3936
+ content,
3937
+ version: nextVersion,
3938
+ updatedAt: now
3939
+ };
3940
+ },
3941
+ async listProfileBlockVersions(scope, limit) {
3942
+ return sqlProfileVersionsList.all(scope, Math.max(1, limit)).map((row) => ({
3943
+ scope: row.scope,
3944
+ version: row.version,
3945
+ content: row.content,
3946
+ source: row.source === "rollback" ? "rollback" : "edit",
3947
+ at: row.at
3948
+ }));
3949
+ },
3950
+ async getProfileBlockVersion(scope, version) {
3951
+ const row = sqlProfileVersionGet.get(scope, version);
3952
+ if (row === void 0) return void 0;
3953
+ return {
3954
+ scope: row.scope,
3955
+ version: row.version,
3956
+ content: row.content,
3957
+ source: row.source === "rollback" ? "rollback" : "edit",
3958
+ at: row.at
3959
+ };
3960
+ },
3575
3961
  async restore(id) {
3576
3962
  if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
3577
3963
  sqlSetStatus.run("active", Date.now(), id);
@@ -3694,6 +4080,27 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3694
4080
  async linkEdge(from, to, type) {
3695
4081
  sqlEdgeUpsert.run(from, to, type, Date.now());
3696
4082
  },
4083
+ async nearestNeighbor(embedding) {
4084
+ const pool = db.prepare("SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN ('user','project')").all();
4085
+ let best;
4086
+ for (const row of pool) {
4087
+ const sim = cosine(embedding, blobToVec(row.embedding));
4088
+ if (best === void 0 || sim > best.sim) best = {
4089
+ row,
4090
+ sim
4091
+ };
4092
+ }
4093
+ return best === void 0 ? void 0 : {
4094
+ record: rowToRecord(best.row),
4095
+ similarity: best.sim
4096
+ };
4097
+ },
4098
+ async reinforce(id, note) {
4099
+ if (sqlGet.get(id) === void 0) return void 0;
4100
+ sqlTouch.run(Date.now(), id);
4101
+ sqlLog.run(Date.now(), "write-merge", id, note);
4102
+ return rowToRecord(sqlGet.get(id));
4103
+ },
3697
4104
  async supersedeMany(input, oldIds) {
3698
4105
  const content = input.content.trim();
3699
4106
  if (content === "") throw new EngramError("EMPTY_CONTENT", "content 不能为空");
@@ -3731,6 +4138,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3731
4138
  sqlPurgeFts.run();
3732
4139
  sqlPurgeLog.run();
3733
4140
  sqlPurgeRoutes.run();
4141
+ sqlPurgeProfileBlocks.run();
4142
+ sqlPurgeProfileVersions.run();
4143
+ sqlPurgeSessionSummaries.run();
3734
4144
  });
3735
4145
  },
3736
4146
  async close() {
@@ -3962,6 +4372,18 @@ const NEXT_STRATEGIES = [
3962
4372
  "stop"
3963
4373
  ];
3964
4374
  /**
4375
+ * 构造证据门收尾提醒(agent 面向文本,英文与检索工具输出一致)。
4376
+ * @param pendingCount - 尚未判定的批次数。
4377
+ * @param insufficientStreak - 连续不足判定次数。
4378
+ * @returns 提醒文本;无待判定批次返回 undefined(不需要提醒)。
4379
+ */
4380
+ function buildAssessReminder(pendingCount, insufficientStreak) {
4381
+ if (pendingCount <= 0) return void 0;
4382
+ const base = `Evidence gate: ${pendingCount} search batch(es) from this conversation have not been assessed. Before answering, call engram_assess with the batchId and your evidence refs — or briefly state why assessment is not needed.`;
4383
+ if (insufficientStreak < 2) return base;
4384
+ return `${base} Multiple insufficient verdicts in a row — try a different room (engram_search room=), engram_timeline, or ask the user for the missing details.`;
4385
+ }
4386
+ /**
3965
4387
  * 判断字符串是否为合法策略。
3966
4388
  * @param value - 待判定字符串。
3967
4389
  */
@@ -3981,6 +4403,8 @@ function evidenceRefOf(scope, id, slot) {
3981
4403
  /** 批次注册表:按会话隔离,超出上限丢最旧的批次。 */
3982
4404
  var EvidenceBatches = class {
3983
4405
  bySession = /* @__PURE__ */ new Map();
4406
+ /** 每会话连续不足判定的次数(sufficient 判定即清零;提醒据此升级措辞)。 */
4407
+ insufficientStreaks = /* @__PURE__ */ new Map();
3984
4408
  counter = 0;
3985
4409
  /**
3986
4410
  * 注册一个批次。
@@ -4016,11 +4440,41 @@ var EvidenceBatches = class {
4016
4440
  return this.bySession.get(sessionId)?.get(batchId);
4017
4441
  }
4018
4442
  /**
4019
- * 清理会话的全部批次(会话销毁时调用,避免长驻进程累积)。
4443
+ * 记录批次判定结果,并维护连续不足计数(sufficient 清零)。
4444
+ * @param sessionId - 会话标识。
4445
+ * @param batchId - 批次 id。
4446
+ * @param verdict - 代码校验后的判定结果。
4447
+ */
4448
+ recordVerdict(sessionId, batchId, verdict) {
4449
+ const batch = this.get(sessionId, batchId);
4450
+ if (batch === void 0) return;
4451
+ batch.verdict = verdict;
4452
+ const streak = this.insufficientStreaks.get(sessionId) ?? 0;
4453
+ this.insufficientStreaks.set(sessionId, verdict.sufficient ? 0 : streak + 1);
4454
+ }
4455
+ /**
4456
+ * 会话内尚未判定的批次(注册顺序,最旧在前)。
4457
+ * @param sessionId - 会话标识。
4458
+ */
4459
+ pendingBatches(sessionId) {
4460
+ const batches = this.bySession.get(sessionId);
4461
+ if (batches === void 0) return [];
4462
+ return [...batches.values()].filter((batch) => batch.verdict === void 0);
4463
+ }
4464
+ /**
4465
+ * 连续不足判定次数(sufficient 判定清零;未判定批次不影响计数)。
4466
+ * @param sessionId - 会话标识。
4467
+ */
4468
+ insufficientStreak(sessionId) {
4469
+ return this.insufficientStreaks.get(sessionId) ?? 0;
4470
+ }
4471
+ /**
4472
+ * 清理会话的全部批次与判定计数(会话销毁时调用,避免长驻进程累积)。
4020
4473
  * @param sessionId - 会话标识。
4021
4474
  */
4022
4475
  clear(sessionId) {
4023
4476
  this.bySession.delete(sessionId);
4477
+ this.insufficientStreaks.delete(sessionId);
4024
4478
  }
4025
4479
  };
4026
4480
  /** 进程内批次注册表:插件为单实例,会话销毁时调用 clear 释放该会话的批次。 */
@@ -4065,7 +4519,7 @@ function assessEvidence(batch, request) {
4065
4519
  //#endregion
4066
4520
  //#region src/tools/create.ts
4067
4521
  /**
4068
- * 17 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
4522
+ * 19 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
4069
4523
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
4070
4524
  * @module @kenz1117/dsh-engram/tools/create
4071
4525
  */
@@ -4087,6 +4541,21 @@ function sessionCwdOf(exec) {
4087
4541
  const cwd = exec.agent?.session?.header?.cwd;
4088
4542
  return cwd === void 0 || cwd === "" ? void 0 : cwd;
4089
4543
  }
4544
+ /** 工具入参时间解析:ISO 或可解析日期字符串 → epoch 毫秒;缺省 undefined;不可解析 loud 失败。 */
4545
+ function parseTimeParam(raw, toolName, field) {
4546
+ if (raw === void 0) return void 0;
4547
+ const ms = Date.parse(raw);
4548
+ if (Number.isNaN(ms)) throw new Error(`${toolName}: ${field} 不是可解析时间 ${raw}`);
4549
+ return ms;
4550
+ }
4551
+ /** 工具入参正数解析(分钟/条数等计数):缺省 fallback;非有限数字或越界 loud 失败。 */
4552
+ function parseCountParam(raw, toolName, field, fallback, min, max) {
4553
+ if (raw === void 0) return fallback;
4554
+ if (typeof raw !== "number" || !Number.isFinite(raw)) throw new Error(`${toolName}: ${field} 必须是有限数字,收到 ${String(raw)}`);
4555
+ const value = Math.floor(raw);
4556
+ if (value < min || value > max) throw new Error(`${toolName}: ${field} 需在 ${String(min)}-${String(max)} 之间,收到 ${String(value)}`);
4557
+ return value;
4558
+ }
4090
4559
  /** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
4091
4560
  function renderHistoryEstimate(estimate) {
4092
4561
  if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
@@ -4119,7 +4588,7 @@ const STRATEGY_HINT = {
4119
4588
  answer: "可以据此作答",
4120
4589
  search_keyword: "换关键词再检索(engram_search)",
4121
4590
  search_room: "收窄到具体房间再检索(engram_search 带 room)",
4122
- search_timeline: "按时间线找(engram_timeline)",
4591
+ search_timeline: "按时间线找(engram_timeline 或 engram_episode_timeline 查情景)",
4123
4592
  ask_user: "向用户澄清缺失的信息",
4124
4593
  stop: "不回答,并向用户说明缺少什么"
4125
4594
  };
@@ -4203,8 +4672,8 @@ async function rewriteQueries(deps, exec, query) {
4203
4672
  }
4204
4673
  }
4205
4674
  /**
4206
- * 构造 17 个工具定义(engram_save/search/assess/timeline/update/forget/report/review/review_queue/
4207
- * stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history)。
4675
+ * 构造 19 个工具定义(engram_save/search/assess/timeline/episode_timeline/update/forget/report/review/review_queue/
4676
+ * stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history/profile_edit)。
4208
4677
  * @param baseDeps - 分库打开器、嵌入器、辅助 LLM、导出目录。
4209
4678
  * @returns 可直接 register 的工具定义数组(execute 已绑定会话 cwd 的项目宫殿路由)。
4210
4679
  */
@@ -4222,10 +4691,18 @@ function createEngramTools(baseDeps) {
4222
4691
  /** engram_save 呈现文本:矛盾警告 text 优先;批量输出汇总成功与失败。 */
4223
4692
  function renderSaveResultText(value) {
4224
4693
  if (value.count !== void 0) {
4694
+ const items = value.items ?? [];
4695
+ const merged = items.filter((item) => item.disposition === "merge").length;
4696
+ const deferred = items.filter((item) => item.disposition === "defer").length;
4225
4697
  const parts = [`已批量保存 ${value.count} 条记忆`];
4226
- for (const item of value.items ?? []) {
4698
+ const states = [];
4699
+ if (merged > 0) states.push(`并入强化 ${merged} 条既有记忆`);
4700
+ if (deferred > 0) states.push(`${deferred} 条与现有记忆高度相似待裁决`);
4701
+ if (states.length > 0) parts.push(states.join(","));
4702
+ for (const item of items) {
4227
4703
  const slot = item.slot === void 0 ? "" : `, ${item.slot.room}#${item.slot.index}`;
4228
- parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance}${slot})`);
4704
+ const state = item.disposition === "merge" ? `, 并入 ${item.mergedInto}` : item.disposition === "defer" ? ", 待裁决" : "";
4705
+ parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance}${slot}${state})`);
4229
4706
  }
4230
4707
  const failures = value.failed ?? [];
4231
4708
  if (failures.length > 0) parts.push(`${failures.length} 条失败:${failures.map((entry) => `#${entry.index + 1} ${entry.reason}`).join(";")}`);
@@ -4235,9 +4712,20 @@ function createEngramTools(baseDeps) {
4235
4712
  if (value.text !== void 0) return value.text;
4236
4713
  return `已保存记忆 ${value.id}(kind=${value.kind}, importance=${value.importance})。后续会话可用 engram_search 召回。`;
4237
4714
  }
4238
- /** 写入单条已清洗内容并按嵌入建矛盾边;返回记录与矛盾候选(调用方决定呈现)。 */
4239
- async function writeWithContradictions(store, item) {
4715
+ /**
4716
+ * 写入单条已清洗内容(四态处置):MERGE 不新建条目只强化既有;
4717
+ * DEFER 写入并建 contradicts 边待裁决;ACCEPT 原样写入。
4718
+ */
4719
+ async function writeWithDisposition(store, item) {
4240
4720
  const { embedding } = item;
4721
+ const decision = await decideWrite(store, item.kind, embedding);
4722
+ if (decision.disposition === "merge") return {
4723
+ disposition: "merge",
4724
+ record: await applyMerge(store, decision, item.content),
4725
+ relatedId: decision.into.id,
4726
+ similarity: decision.similarity,
4727
+ candidates: []
4728
+ };
4241
4729
  const record = await store.write({
4242
4730
  scope: item.scope,
4243
4731
  kind: item.kind,
@@ -4247,11 +4735,21 @@ function createEngramTools(baseDeps) {
4247
4735
  ...embedding === void 0 ? {} : { embedding },
4248
4736
  ...item.imagery === void 0 ? {} : { imagery: item.imagery }
4249
4737
  });
4250
- const candidates = embedding === void 0 ? [] : await store.findContradictions(embedding);
4251
- for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
4738
+ if (decision.disposition === "defer") {
4739
+ const candidates = await store.findContradictions(embedding);
4740
+ for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
4741
+ return {
4742
+ disposition: "defer",
4743
+ record,
4744
+ relatedId: decision.neighbor.id,
4745
+ similarity: decision.similarity,
4746
+ candidates
4747
+ };
4748
+ }
4252
4749
  return {
4750
+ disposition: "accept",
4253
4751
  record,
4254
- candidates
4752
+ candidates: []
4255
4753
  };
4256
4754
  }
4257
4755
  /** 门牌参数收敛:非空字符串转 ImageryLabel(感官/情绪维度留空——AI 不需要人脑补丁),非法返回 undefined。 */
@@ -4326,7 +4824,7 @@ function createEngramTools(baseDeps) {
4326
4824
  const saved = [];
4327
4825
  for (const [position, item] of prepared.entries()) try {
4328
4826
  const embedding = vectors?.[position];
4329
- const { record } = await writeWithContradictions(store, {
4827
+ const outcome = await writeWithDisposition(store, {
4330
4828
  scope,
4331
4829
  kind: item.kind,
4332
4830
  content: item.content,
@@ -4335,10 +4833,12 @@ function createEngramTools(baseDeps) {
4335
4833
  ...embedding === void 0 ? {} : { embedding }
4336
4834
  });
4337
4835
  saved.push({
4338
- id: record.id,
4339
- kind: record.kind,
4340
- importance: record.importance,
4341
- ...record.slot === void 0 ? {} : { slot: record.slot }
4836
+ id: outcome.record.id,
4837
+ kind: outcome.record.kind,
4838
+ importance: outcome.record.importance,
4839
+ disposition: outcome.disposition,
4840
+ ...outcome.disposition === "merge" || outcome.record.slot === void 0 ? {} : { slot: outcome.record.slot },
4841
+ ...outcome.disposition === "merge" && outcome.relatedId !== void 0 ? { mergedInto: outcome.relatedId } : {}
4342
4842
  });
4343
4843
  } catch (error) {
4344
4844
  failed.push({
@@ -4416,6 +4916,16 @@ function createEngramTools(baseDeps) {
4416
4916
  id: { type: "string" },
4417
4917
  kind: { type: "string" },
4418
4918
  importance: { type: "number" },
4919
+ disposition: {
4920
+ type: "string",
4921
+ enum: [
4922
+ "accept",
4923
+ "merge",
4924
+ "defer"
4925
+ ]
4926
+ },
4927
+ mergedInto: { type: "string" },
4928
+ similarity: { type: "number" },
4419
4929
  text: { type: "string" },
4420
4930
  count: { type: "number" },
4421
4931
  items: {
@@ -4436,6 +4946,16 @@ function createEngramTools(baseDeps) {
4436
4946
  type: "number",
4437
4947
  required: true
4438
4948
  },
4949
+ disposition: {
4950
+ type: "string",
4951
+ enum: [
4952
+ "accept",
4953
+ "merge",
4954
+ "defer"
4955
+ ],
4956
+ required: true
4957
+ },
4958
+ mergedInto: { type: "string" },
4439
4959
  slot: {
4440
4960
  type: "object",
4441
4961
  additionalProperties: false,
@@ -4493,7 +5013,7 @@ function createEngramTools(baseDeps) {
4493
5013
  const embedder = await deps.embedder;
4494
5014
  const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
4495
5015
  const imagery = placardOf(input.placard);
4496
- const { record, candidates } = await writeWithContradictions(store, {
5016
+ const outcome = await writeWithDisposition(store, {
4497
5017
  scope,
4498
5018
  kind: input.kind,
4499
5019
  content,
@@ -4502,13 +5022,28 @@ function createEngramTools(baseDeps) {
4502
5022
  ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] },
4503
5023
  ...imagery === void 0 ? {} : { imagery }
4504
5024
  });
5025
+ const { record, candidates } = outcome;
5026
+ if (outcome.disposition === "merge") {
5027
+ const sim = (outcome.similarity ?? 0).toFixed(2);
5028
+ return {
5029
+ id: record.id,
5030
+ kind: record.kind,
5031
+ importance: record.importance,
5032
+ disposition: "merge",
5033
+ ...outcome.relatedId === void 0 ? {} : { mergedInto: outcome.relatedId },
5034
+ ...outcome.similarity === void 0 ? {} : { similarity: outcome.similarity },
5035
+ text: `与既有记忆高度重复(相似度 ${sim}),已并入 ${outcome.relatedId} 并强化其置信度与访问计数,未新建条目。若这是修正而非复述,请用 engram_update 归并。`
5036
+ };
5037
+ }
4505
5038
  const placardHint = imagery === void 0 || record.imageryScore === void 0 ? "" : `\n${placardImprovementHint(record.imageryScore) ?? ""}`;
4506
- if (candidates.length > 0) {
5039
+ if (outcome.disposition === "defer") {
4507
5040
  const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
4508
5041
  return {
4509
5042
  id: record.id,
4510
5043
  kind: record.kind,
4511
5044
  importance: record.importance,
5045
+ disposition: "defer",
5046
+ ...outcome.similarity === void 0 ? {} : { similarity: outcome.similarity },
4512
5047
  text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。${placardHint}`
4513
5048
  };
4514
5049
  }
@@ -4517,6 +5052,7 @@ function createEngramTools(baseDeps) {
4517
5052
  id: record.id,
4518
5053
  kind: record.kind,
4519
5054
  importance: record.importance,
5055
+ disposition: "accept",
4520
5056
  text: `${base}${placardHint}`
4521
5057
  };
4522
5058
  }
@@ -4680,6 +5216,7 @@ function createEngramTools(baseDeps) {
4680
5216
  missing: typeof input.missing === "string" ? input.missing : "",
4681
5217
  nextStrategy: typeof input.nextStrategy === "string" ? input.nextStrategy : ""
4682
5218
  });
5219
+ if (sessionId !== void 0) evidenceBatches.recordVerdict(sessionId, batch.batchId, verdict);
4683
5220
  await (await deps.openStore("user")).audit("assess", batch.batchId, JSON.stringify({
4684
5221
  batchId: batch.batchId,
4685
5222
  sufficient: verdict.sufficient,
@@ -4744,14 +5281,8 @@ function createEngramTools(baseDeps) {
4744
5281
  const input = args;
4745
5282
  const scopes = scopesOf(input.scope);
4746
5283
  const order = input.order === "tour" ? "tour" : "time";
4747
- const parseTime = (raw, field) => {
4748
- if (raw === void 0) return void 0;
4749
- const ms = Date.parse(raw);
4750
- if (Number.isNaN(ms)) throw new Error(`engram_timeline: ${field} 不是可解析时间 ${raw}`);
4751
- return ms;
4752
- };
4753
- const since = parseTime(input.since, "since");
4754
- const until = parseTime(input.until, "until");
5284
+ const since = parseTimeParam(input.since, "engram_timeline", "since");
5285
+ const until = parseTimeParam(input.until, "engram_timeline", "until");
4755
5286
  const results = await Promise.all(scopes.map(async (scope) => {
4756
5287
  return (await deps.openStore(scope)).timeline({
4757
5288
  scopes: [scope],
@@ -4770,6 +5301,106 @@ function createEngramTools(baseDeps) {
4770
5301
  return { text: renderMemoryPacket(`${body.join("\n") || "时间线为空"}${tail}`, "tool_timeline", input.topic ?? "(对话继续)") };
4771
5302
  }
4772
5303
  });
5304
+ const episodeTimeline = defineTool({
5305
+ name: "engram_episode_timeline",
5306
+ description: "episode 情景记忆的独立时间线:按日期范围与来源会话浏览经历,输出按会话分组(组间新→旧,组内时间升序),适合回答「那天/那段时间我们做了什么」。around 传锚点记忆 id 时切换为时间邻近扩展——列出锚点创建时刻 ± 窗口内的情景(忽略日期过滤),回答「当时前后还发生了什么」。",
5307
+ parameters: {
5308
+ scope: {
5309
+ type: "string",
5310
+ enum: [
5311
+ "user",
5312
+ "project",
5313
+ "shared",
5314
+ "all"
5315
+ ],
5316
+ description: "作用域,默认 all"
5317
+ },
5318
+ since: {
5319
+ type: "string",
5320
+ description: "起始日期(ISO 或可解析日期)"
5321
+ },
5322
+ until: {
5323
+ type: "string",
5324
+ description: "结束日期"
5325
+ },
5326
+ sessionId: {
5327
+ type: "string",
5328
+ description: "只看该来源会话的情景(id 精确匹配,从本工具输出的「会话 …」组头取得)"
5329
+ },
5330
+ around: {
5331
+ type: "string",
5332
+ description: "锚点记忆 id:切换为时间邻近扩展模式(与 since/until/sessionId 互斥,此时忽略它们)"
5333
+ },
5334
+ windowMinutes: {
5335
+ type: "number",
5336
+ description: `邻近窗口分钟数(around 模式,默认 ${String(EPISODE_PROXIMITY_MS_DEFAULT / 6e4)})`
5337
+ },
5338
+ limit: {
5339
+ type: "number",
5340
+ description: `最多返回条数(默认 ${String(100)})`
5341
+ }
5342
+ },
5343
+ output: {
5344
+ schema: {
5345
+ type: "object",
5346
+ additionalProperties: false,
5347
+ properties: { text: {
5348
+ type: "string",
5349
+ required: true
5350
+ } }
5351
+ },
5352
+ render: (_args, value) => [{
5353
+ type: "text",
5354
+ text: value.text
5355
+ }]
5356
+ },
5357
+ async execute(args) {
5358
+ const input = args;
5359
+ const scopes = scopesOf(input.scope);
5360
+ const since = parseTimeParam(input.since, "engram_episode_timeline", "since");
5361
+ const until = parseTimeParam(input.until, "engram_episode_timeline", "until");
5362
+ const limit = parseCountParam(input.limit, "engram_episode_timeline", "limit", 100, 1, 500);
5363
+ const sessionId = input.sessionId?.trim();
5364
+ const aroundId = input.around?.trim();
5365
+ const renderRow = (record) => `${new Date(record.createdAt).toISOString()} [${record.scope}/${record.kind}] ${truncateItem(record.content)}(id=${record.id})`;
5366
+ if (aroundId !== void 0 && aroundId !== "") {
5367
+ const windowMinutes = parseCountParam(input.windowMinutes, "engram_episode_timeline", "windowMinutes", EPISODE_PROXIMITY_MS_DEFAULT / 6e4, 1, 1440);
5368
+ const anchor = asMemoryId(aroundId);
5369
+ for (const scope of scopes) {
5370
+ const store = await deps.openStore(scope);
5371
+ if (await store.get(anchor) === void 0) continue;
5372
+ const around = (await store.episodeTimeline({
5373
+ scopes: [scope],
5374
+ around: anchor,
5375
+ proximityMs: windowMinutes * 6e4,
5376
+ limit
5377
+ })).around;
5378
+ return { text: renderMemoryPacket(enforceBudget(around.neighbors.length === 0 ? [`锚点:${renderRow(around.anchor)}`, "邻近窗口内没有情景记忆。"] : [
5379
+ `锚点:${renderRow(around.anchor)}`,
5380
+ `邻近情景(±${String(windowMinutes)} 分钟,${String(around.neighbors.length)} 条,按时间正序):`,
5381
+ ...around.neighbors.map(renderRow)
5382
+ ]).join("\n"), "tool_episode_timeline", aroundId) };
5383
+ }
5384
+ throw new Error(`engram_episode_timeline: 锚点 ${aroundId} 不存在`);
5385
+ }
5386
+ const groups = (await Promise.all(scopes.map(async (scope) => {
5387
+ return (await deps.openStore(scope)).episodeTimeline({
5388
+ scopes: [scope],
5389
+ ...since === void 0 ? {} : { since },
5390
+ ...until === void 0 ? {} : { until },
5391
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessionId },
5392
+ limit
5393
+ });
5394
+ }))).flatMap((result) => result.groups).sort((a, b) => b.startedAt - a.startedAt);
5395
+ const lines = [];
5396
+ for (const group of groups) {
5397
+ lines.push(group.sessionId === null ? `[无会话来源] ${new Date(group.startedAt).toISOString()} ~ ${new Date(group.endedAt).toISOString()}(${String(group.episodes.length)} 条)` : `[会话 ${group.sessionId}] ${new Date(group.startedAt).toISOString()} ~ ${new Date(group.endedAt).toISOString()}(${String(group.episodes.length)} 条)`);
5398
+ if (group.summary !== void 0) lines.push(` 摘要:${group.summary}`);
5399
+ for (const record of group.episodes) lines.push(`- ${renderRow(record)}`);
5400
+ }
5401
+ return { text: renderMemoryPacket(enforceBudget(lines).join("\n") || "情景时间线为空", "tool_episode_timeline", sessionId ?? "(对话继续)") };
5402
+ }
5403
+ });
4773
5404
  const update = defineTool({
4774
5405
  name: "engram_update",
4775
5406
  description: "修正一条记忆:写入新条目并把旧条目标记为被取代(链条保留,可审计)。id 来自 engram_search 结果。",
@@ -5086,6 +5717,7 @@ function createEngramTools(baseDeps) {
5086
5717
  search,
5087
5718
  assess,
5088
5719
  timeline,
5720
+ episodeTimeline,
5089
5721
  update,
5090
5722
  forget,
5091
5723
  defineTool({
@@ -5602,7 +6234,109 @@ function createEngramTools(baseDeps) {
5602
6234
  }
5603
6235
  }),
5604
6236
  auditForgotten,
5605
- ingestHistory
6237
+ ingestHistory,
6238
+ defineTool({
6239
+ name: "engram_profile_edit",
6240
+ description: "编辑会话开始注入的用户画像 curated block(注入时位于自动派生画像之前,是画像的最高优先层)。action=view 查看当前内容与版本历史;action=edit 编辑(乐观锁:须带当前 expectedVersion,版本冲突会报错,重读最新版本后再改;首次创建省略 expectedVersion);action=rollback 回滚(把 toVersion 版本的内容作为新版本写入,历史不改写)。内容入库前做协议剥离与密钥脱敏。",
6241
+ parameters: {
6242
+ action: {
6243
+ type: "string",
6244
+ enum: [
6245
+ "view",
6246
+ "edit",
6247
+ "rollback"
6248
+ ],
6249
+ required: true,
6250
+ description: "view 查看 / edit 编辑 / rollback 回滚"
6251
+ },
6252
+ scope: {
6253
+ type: "string",
6254
+ enum: [
6255
+ "user",
6256
+ "project",
6257
+ "shared"
6258
+ ],
6259
+ description: "作用域,默认 user"
6260
+ },
6261
+ content: {
6262
+ type: "string",
6263
+ description: "edit 必填:新的画像内容(多行文本,会话开始按原文注入)"
6264
+ },
6265
+ expectedVersion: {
6266
+ type: "integer",
6267
+ description: "edit 带上=并发保护(乐观锁);省略=仅当尚无 block 时创建 v1"
6268
+ },
6269
+ toVersion: {
6270
+ type: "integer",
6271
+ description: "rollback 必填:要回滚到的历史版本号(用 view 查得)"
6272
+ }
6273
+ },
6274
+ output: {
6275
+ schema: {
6276
+ type: "object",
6277
+ additionalProperties: false,
6278
+ properties: { text: {
6279
+ type: "string",
6280
+ required: true
6281
+ } }
6282
+ },
6283
+ render: (_args, value) => [{
6284
+ type: "text",
6285
+ text: value.text
6286
+ }]
6287
+ },
6288
+ async execute(args) {
6289
+ const input = args;
6290
+ const action = input.action === "edit" || input.action === "rollback" || input.action === "view" ? input.action : void 0;
6291
+ if (action === void 0) throw new Error("engram_profile_edit: action 必须是 view / edit / rollback 之一");
6292
+ const scope = scopeOf(input.scope, "user");
6293
+ const store = await deps.openStore(scope);
6294
+ if (action === "view") {
6295
+ const block = await store.getProfileBlock(scope);
6296
+ if (block === void 0) return { text: `${scope} 库暂无 curated 画像 block。用 action=edit(省略 expectedVersion)即可创建 v1;创建后会话开始时优先于自动派生画像注入。` };
6297
+ const versions = await store.listProfileBlockVersions(scope, 10);
6298
+ return { text: [
6299
+ `当前 curated 画像(${scope},v${block.version}):`,
6300
+ block.content,
6301
+ "",
6302
+ `版本历史(最近 ${versions.length} 条,新→旧;rollback 用 toVersion 指定):`,
6303
+ ...versions.map((version) => `- v${version.version} · ${version.source === "rollback" ? "回滚" : "编辑"} · ${new Date(version.at).toISOString().slice(0, 16).replace("T", " ")} · ${version.content.length} 字`)
6304
+ ].join("\n") };
6305
+ }
6306
+ if (action === "edit") {
6307
+ if (typeof input.content !== "string" || input.content.trim() === "") throw new Error("engram_profile_edit: edit 需要 content 参数");
6308
+ let expectedVersion;
6309
+ if (input.expectedVersion !== void 0) {
6310
+ if (!Number.isInteger(input.expectedVersion)) throw new Error("engram_profile_edit: expectedVersion 必须是整数(用 action=view 查得)");
6311
+ expectedVersion = input.expectedVersion;
6312
+ }
6313
+ const content = redactSecrets(sanitizeProtocolText(input.content));
6314
+ if (content.trim() === "") throw new Error("engram_profile_edit: 清洗后内容为空(原文只含协议标签或密钥)");
6315
+ const block = await store.saveProfileBlock(scope, expectedVersion, content, "edit");
6316
+ await store.audit("profile-edit", scope, JSON.stringify({
6317
+ action: "edit",
6318
+ fromVersion: expectedVersion ?? null,
6319
+ toVersion: block.version,
6320
+ chars: content.length
6321
+ }));
6322
+ return { text: `curated 画像已写入(${scope},v${block.version})。下一轮会话开始时将优先于自动派生画像注入。` };
6323
+ }
6324
+ if (!Number.isInteger(input.toVersion)) throw new Error("engram_profile_edit: rollback 需要 toVersion(目标历史版本号,用 action=view 查得)");
6325
+ const target = await store.getProfileBlockVersion(scope, input.toVersion);
6326
+ if (target === void 0) throw new Error(`engram_profile_edit: ${scope} 库不存在版本 v${input.toVersion}(用 action=view 查看版本历史)`);
6327
+ const current = await store.getProfileBlock(scope);
6328
+ if (current === void 0) throw new Error(`engram_profile_edit: ${scope} 库尚无 curated 画像 block,无从回滚`);
6329
+ const updated = await store.saveProfileBlock(scope, current.version, target.content, "rollback");
6330
+ await store.audit("profile-edit", scope, JSON.stringify({
6331
+ action: "rollback",
6332
+ fromVersion: current.version,
6333
+ toVersion: updated.version,
6334
+ restoredFrom: target.version,
6335
+ chars: target.content.length
6336
+ }));
6337
+ return { text: `已把 v${target.version} 的内容作为 v${updated.version} 写入(${scope};版本链只增不改,可继续回滚)。` };
6338
+ }
6339
+ })
5606
6340
  ].map((tool) => ({
5607
6341
  ...tool,
5608
6342
  execute: (args, exec) => execSessionCwd.run(sessionCwdOf(exec), () => tool.execute(args, exec))
@@ -5771,8 +6505,8 @@ function estimateTokens(text) {
5771
6505
  //#region src/index.ts
5772
6506
  /**
5773
6507
  * dsh-engram:DeepSeek Harness 跨会话长期记忆插件(host 半)。
5774
- * 注册 10 个 engram_ 工具、会话开始注入用户画像、自动摄取上一轮对话、
5775
- * 蒸馏/衰减飞轮与审计能力。
6508
+ * 注册 18 个 engram_ 工具、会话开始注入画像(curated block 优先于派生画像)、
6509
+ * 自动摄取上一轮对话、蒸馏/衰减飞轮与审计能力。
5776
6510
  * @module @kenz1117/dsh-engram
5777
6511
  */
5778
6512
  /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
@@ -5781,31 +6515,41 @@ const name = "dsh-engram";
5781
6515
  const VERSION = "0.7.6";
5782
6516
  /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
5783
6517
  const inject = ["tools", "llm"];
6518
+ /** 分级递减预算默认值(配置缺省时由 resolveConfig 显式落地,同值于此)。 */
6519
+ const DEFAULT_ITEM_BUDGET = {
6520
+ start: 160,
6521
+ decay: .9,
6522
+ floor: 24
6523
+ };
5784
6524
  /**
5785
- * 会话开始注入的画像渲染:按重要性降序在 token 预算内整行装填(估算见
5786
- * estimateTokens:中文按 1.5 token/字、其余按 4 字符/token,超预算的行跳过不截断、
5787
- * 继续试更短行);装不下的条目降级为索引行(#id + 前 40 字),
5788
- * 索引行也装不下的折成末尾 `+N more; use engram_search` 计数行;计数行同样占用预算。
6525
+ * 会话开始注入的画像渲染:按重要性降序装填,单条正文按分级递减预算截断
6526
+ * (首条 160 字、逐条 ×0.9、下限 24 字——排名越靠后单条越短,平滑降级替代
6527
+ * 整条降级索引行);token 预算(估算见 estimateTokens:中文按 1.5 token/字、
6528
+ * 其余按 4 字符/token)是外层硬约束:截断后仍装不下的条目先以 40 字索引行
6529
+ * 兜底,索引行也装不下的折成末尾 `+N more; use engram_search` 计数行;计数行同样占用预算。
5789
6530
  * @param records - 候选条目(调用方已按重要性排序、按条数截断)。
5790
6531
  * @param tokenBudget - 整段画像的 token 预算(含首尾固定行)。
6532
+ * @param itemBudget - 分级递减的单条正文预算(缺省用 DEFAULT_ITEM_BUDGET;配置面在 resolveConfig)。
5791
6533
  * @returns 渲染文本与溢出条目(调用方可用辅助 LLM 压缩后重渲染)。
5792
6534
  */
5793
- function renderProfileDetailed(records, tokenBudget) {
6535
+ function renderProfileDetailed(records, tokenBudget, itemBudget = DEFAULT_ITEM_BUDGET) {
5794
6536
  const estimate = estimateTokens;
5795
6537
  const header = "User memory profile (dsh-engram, cross-session) — Grand Hall (always present):";
5796
6538
  const footer = "Use engram_search to recall details (pass room to search inside one room); use engram_save to persist new facts.";
5797
6539
  let remaining = Math.max(0, tokenBudget - estimate(header) - estimate(footer));
5798
6540
  const lines = [];
5799
6541
  const overflow = [];
5800
- for (const record of records) {
6542
+ records.forEach((record, index) => {
6543
+ const budget = Math.max(itemBudget.floor, Math.round(itemBudget.start * itemBudget.decay ** index));
6544
+ const content = record.content.length > budget ? `${record.content.slice(0, budget)}…` : record.content;
5801
6545
  const slot = record.slot === void 0 ? "" : ` ${record.slot.room}#${record.slot.index}`;
5802
- const line = `- [${record.kind}]${slot} ${record.content}`;
6546
+ const line = `- [${record.kind}]${slot} ${content}`;
5803
6547
  const cost = estimate(line);
5804
6548
  if (cost <= remaining) {
5805
6549
  lines.push(line);
5806
6550
  remaining -= cost;
5807
6551
  } else overflow.push(record);
5808
- }
6552
+ });
5809
6553
  let more = 0;
5810
6554
  remaining -= overflow.length === 0 ? 0 : estimate(`+${overflow.length} more; use engram_search`);
5811
6555
  for (const record of overflow) {
@@ -5835,6 +6579,28 @@ function renderProfileDetailed(records, tokenBudget) {
5835
6579
  function renderProfile(records, tokenBudget) {
5836
6580
  return renderProfileDetailed(records, tokenBudget).text;
5837
6581
  }
6582
+ /** curated block 段的标题行(模型据此知道这一段是人工维护的最高优先画像)。 */
6583
+ const CURATED_HEADER = "Curated profile (user-maintained via engram_profile_edit; authoritative over derived facts):";
6584
+ /**
6585
+ * 画像渲染(curated 优先):有 curated block 时其全文置于派生画像之前(模型可见的
6586
+ * 第一段),并先从 token 预算中扣除自身占用,剩余预算才给派生条目装填;派生段
6587
+ * 照旧走分级递减与索引行/计数行降级。curated 是用户显式维护的意志,即使超出剩余
6588
+ * 预算也完整注入(与 due 行同理);block 缺省时等同 renderProfileDetailed。
6589
+ * @param curated - 当前态 curated block;undefined = 尚未编辑过。
6590
+ * @param records - 派生画像候选条目(调用方已按重要性排序、按条数截断)。
6591
+ * @param tokenBudget - 整段画像的 token 预算(curated 段与派生段共享)。
6592
+ * @param itemBudget - 分级递减的单条正文预算(透传 renderProfileDetailed)。
6593
+ * @returns 渲染文本与派生段的溢出条目。
6594
+ */
6595
+ function renderProfileWithCurated(curated, records, tokenBudget, itemBudget = DEFAULT_ITEM_BUDGET) {
6596
+ if (curated === void 0) return renderProfileDetailed(records, tokenBudget, itemBudget);
6597
+ const curatedSection = `${CURATED_HEADER}\n${curated.content}`;
6598
+ const detailed = renderProfileDetailed(records, Math.max(0, tokenBudget - estimateTokens(curatedSection)), itemBudget);
6599
+ return {
6600
+ text: `${curatedSection}\n${detailed.text}`,
6601
+ overflow: detailed.overflow
6602
+ };
6603
+ }
5838
6604
  /** 压缩辅助调用的输出 token 上限(40 字 × 若干条,短输出足够)。 */
5839
6605
  const COMPRESS_MAX_TOKENS = 800;
5840
6606
  /** 压缩辅助调用超时:压缩在 pre-step 关键路径上,必须限时防阻塞首轮请求。 */
@@ -5937,23 +6703,59 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
5937
6703
  console.warn("[dsh-engram] 本轮自动摄取失败(已跳过,不影响对话):", error);
5938
6704
  });
5939
6705
  }
6706
+ if (step !== 1 && resolved.assessReminder) {
6707
+ const sessionId = String(agent.session.id);
6708
+ const pending = evidenceBatches.pendingBatches(sessionId).length;
6709
+ const reminder = buildAssessReminder(pending, evidenceBatches.insufficientStreak(sessionId));
6710
+ if (reminder !== void 0 && reminder !== state.lastAssessReminder) {
6711
+ state.lastAssessReminder = reminder;
6712
+ return {
6713
+ ...decision,
6714
+ messages: [...decision.messages, createUserMessage({
6715
+ content: [{
6716
+ type: "text",
6717
+ text: reminder
6718
+ }],
6719
+ source: {
6720
+ kind: "plugin",
6721
+ plugin: name,
6722
+ form: "snapshot",
6723
+ sections: [{
6724
+ name,
6725
+ text: reminder
6726
+ }]
6727
+ }
6728
+ })]
6729
+ };
6730
+ }
6731
+ }
5940
6732
  if (step !== 1) return decision;
5941
- const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
5942
- if (top.length === 0) return decision;
6733
+ const store = await openStore("user");
6734
+ const curated = await store.getProfileBlock("user");
6735
+ const top = await store.topActive("user", resolved.profileTopN);
6736
+ if (top.length === 0 && curated === void 0) return decision;
5943
6737
  const dueTotal = resolved.reviewScheduling ? (await Promise.all(["user", "project"].map(async (scope) => (await openStore(scope)).dueReviews(Date.now(), 50)))).reduce((sum, rows) => sum + rows.length, 0) : 0;
5944
- const detailed = renderProfileDetailed(top, resolved.injectTokenBudget);
6738
+ const detailed = renderProfileWithCurated(curated, top, resolved.injectTokenBudget, {
6739
+ start: resolved.injectItemBudgetStart,
6740
+ decay: resolved.injectItemBudgetDecay,
6741
+ floor: resolved.injectItemBudgetFloor
6742
+ });
5945
6743
  let text = detailed.text;
5946
6744
  if (detailed.overflow.length > 0) {
5947
6745
  const compressed = await compressProfileOverflow(ctx, agent, resolved.routeOverride, detailed.overflow, signal);
5948
6746
  if (compressed !== void 0) {
5949
6747
  openStore("user").then((auditStore) => auditStore.audit("compress-request", "AUX", JSON.stringify({ count: detailed.overflow.length }))).catch(() => {});
5950
- text = renderProfileDetailed(top.map((record) => {
6748
+ text = renderProfileWithCurated(curated, top.map((record) => {
5951
6749
  const shorter = compressed.get(record.id);
5952
6750
  return shorter === void 0 ? record : {
5953
6751
  ...record,
5954
6752
  content: shorter
5955
6753
  };
5956
- }), resolved.injectTokenBudget).text;
6754
+ }), resolved.injectTokenBudget, {
6755
+ start: resolved.injectItemBudgetStart,
6756
+ decay: resolved.injectItemBudgetDecay,
6757
+ floor: resolved.injectItemBudgetFloor
6758
+ }).text;
5957
6759
  }
5958
6760
  }
5959
6761
  const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
@@ -6111,6 +6913,7 @@ function apply(ctx, config = {}) {
6111
6913
  pendingReplayed: false,
6112
6914
  lastProfileAgent: null,
6113
6915
  lastProfileHash: null,
6916
+ lastAssessReminder: null,
6114
6917
  route: void 0
6115
6918
  };
6116
6919
  /** 会话持久化服务(可选;缺席时历史回填不可用)。 */
@@ -6354,14 +7157,17 @@ function apply(ctx, config = {}) {
6354
7157
  if (resolved.ingest !== "off") ctx.on("session/disposed", (session) => {
6355
7158
  const mode = resolved.ingest;
6356
7159
  if (mode === "off") return;
7160
+ const events = session.snapshotEvents();
7161
+ const sessionId = String(session.id);
7162
+ const cwd = sessionCwd(session);
6357
7163
  ingestFinalTurn({
6358
- events: session.snapshotEvents(),
6359
- sessionId: String(session.id),
7164
+ events,
7165
+ sessionId,
6360
7166
  turn: 0,
6361
7167
  slice: "last",
6362
7168
  openStore: () => openStore("user"),
6363
7169
  openAuditStore: () => openStore("user"),
6364
- ...ingestWriteRouting(sessionCwd(session), openStoreForProjectCwd, openStore),
7170
+ ...ingestWriteRouting(cwd, openStoreForProjectCwd, openStore),
6365
7171
  embedder,
6366
7172
  mode,
6367
7173
  routeOverride: resolved.routeOverride,
@@ -6371,8 +7177,24 @@ function apply(ctx, config = {}) {
6371
7177
  }),
6372
7178
  logRequest: logIngestRequest,
6373
7179
  signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
7180
+ }).then((outcome) => {
7181
+ if (outcome === null) return void 0;
7182
+ return ensureSessionSummary({
7183
+ events,
7184
+ sessionId,
7185
+ openStore: () => cwd === void 0 ? openStore("user") : openStoreForProjectCwd(cwd),
7186
+ call: (params) => streamText(ctx, {
7187
+ ...params,
7188
+ sessionId: session.id
7189
+ }),
7190
+ logRequest: logIngestRequest,
7191
+ mode,
7192
+ routeOverride: resolved.routeOverride,
7193
+ signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS),
7194
+ round: lastTurnNumber(events)
7195
+ });
6374
7196
  }).catch((error) => {
6375
- console.warn("[dsh-engram] 会话结束的末轮摄取异常(不影响对话):", error);
7197
+ console.warn("[dsh-engram] 会话结束的收尾处理异常(不影响对话):", error);
6376
7198
  });
6377
7199
  });
6378
7200
  const runDecay = async () => {
@@ -6430,4 +7252,4 @@ function apply(ctx, config = {}) {
6430
7252
  }, "dsh-engram: consolidation timer");
6431
7253
  }
6432
7254
  //#endregion
6433
- export { Config, VERSION, apply, inject, name, renderProfile, renderProfileDetailed };
7255
+ export { CURATED_HEADER, Config, DEFAULT_ITEM_BUDGET, VERSION, apply, inject, name, renderProfile, renderProfileDetailed, renderProfileWithCurated };