@kenz1117/dsh-engram 0.7.11 → 0.7.12

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
@@ -42,7 +42,8 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
42
42
  "historyBackfillMaxTotalTurns",
43
43
  "historyBackfillIncludeSubagents",
44
44
  "historyBackfillIncludeSeeded",
45
- "historyBackfillIncludeNoCwd"
45
+ "historyBackfillIncludeNoCwd",
46
+ "jev"
46
47
  ]);
47
48
  const INGEST_MODES = /* @__PURE__ */ new Set([
48
49
  "off",
@@ -77,7 +78,17 @@ const Config = z.object({
77
78
  historyBackfillMaxTotalTurns: z.number().step(1).min(1).max(5e3),
78
79
  historyBackfillIncludeSubagents: z.boolean(),
79
80
  historyBackfillIncludeSeeded: z.boolean(),
80
- historyBackfillIncludeNoCwd: z.boolean()
81
+ historyBackfillIncludeNoCwd: z.boolean(),
82
+ jev: z.object({
83
+ enabled: z.boolean(),
84
+ apiKey: z.string(),
85
+ baseUrl: z.string(),
86
+ model: z.string(),
87
+ timeoutMs: z.number().step(1).min(1e3).max(6e4),
88
+ deferMergeAbove: z.number().min(.5).max(1),
89
+ deferAcceptBelow: z.number().min(0).max(.5),
90
+ contradictMinProbability: z.number().min(.5).max(1)
91
+ })
81
92
  });
82
93
  /**
83
94
  * 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
@@ -106,6 +117,16 @@ function resolveConfig(config = {}) {
106
117
  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)");
107
118
  if (config.historyBackfillMaxTurnsPerSession !== void 0 && (!Number.isInteger(config.historyBackfillMaxTurnsPerSession) || config.historyBackfillMaxTurnsPerSession < 1 || config.historyBackfillMaxTurnsPerSession > 500)) throw new Error("dsh-engram: historyBackfillMaxTurnsPerSession must be an integer in [1, 500]");
108
119
  if (config.historyBackfillMaxTotalTurns !== void 0 && (!Number.isInteger(config.historyBackfillMaxTotalTurns) || config.historyBackfillMaxTotalTurns < 1 || config.historyBackfillMaxTotalTurns > 5e3)) throw new Error("dsh-engram: historyBackfillMaxTotalTurns must be an integer in [1, 5000]");
120
+ const jev = config.jev;
121
+ if (jev !== void 0) {
122
+ if (jev.enabled !== void 0 && typeof jev.enabled !== "boolean") throw new Error("dsh-engram: jev.enabled must be a boolean");
123
+ if (jev.enabled === true && (jev.apiKey === void 0 || jev.apiKey === "")) throw new Error("dsh-engram: jev.enabled=true requires jev.apiKey");
124
+ if (jev.timeoutMs !== void 0 && (!Number.isInteger(jev.timeoutMs) || jev.timeoutMs < 1e3 || jev.timeoutMs > 6e4)) throw new Error("dsh-engram: jev.timeoutMs must be an integer in [1000, 60000]");
125
+ if (jev.deferMergeAbove !== void 0 && (jev.deferMergeAbove < .5 || jev.deferMergeAbove > 1)) throw new Error("dsh-engram: jev.deferMergeAbove must be in [0.5, 1]");
126
+ if (jev.deferAcceptBelow !== void 0 && (jev.deferAcceptBelow < 0 || jev.deferAcceptBelow > .5)) throw new Error("dsh-engram: jev.deferAcceptBelow must be in [0, 0.5]");
127
+ if (jev.contradictMinProbability !== void 0 && (jev.contradictMinProbability < .5 || jev.contradictMinProbability > 1)) throw new Error("dsh-engram: jev.contradictMinProbability must be in [0.5, 1]");
128
+ if ((jev.deferAcceptBelow ?? .15) >= (jev.deferMergeAbove ?? .85)) throw new Error("dsh-engram: jev.deferAcceptBelow must be less than jev.deferMergeAbove");
129
+ }
109
130
  const dbDir = config.dbDir ?? join(homedir(), ".dsh", "engram");
110
131
  return {
111
132
  dbDir,
@@ -138,6 +159,16 @@ function resolveConfig(config = {}) {
138
159
  includeSubagents: config.historyBackfillIncludeSubagents ?? false,
139
160
  includeSeeded: config.historyBackfillIncludeSeeded ?? false,
140
161
  includeNoCwd: config.historyBackfillIncludeNoCwd ?? false
162
+ },
163
+ jev: {
164
+ enabled: config.jev?.enabled ?? false,
165
+ apiKey: config.jev?.apiKey,
166
+ baseUrl: config.jev?.baseUrl ?? "https://api.typesafe.ai",
167
+ model: config.jev?.model ?? "jev-latest",
168
+ timeoutMs: config.jev?.timeoutMs ?? 3e3,
169
+ deferMergeAbove: config.jev?.deferMergeAbove ?? .85,
170
+ deferAcceptBelow: config.jev?.deferAcceptBelow ?? .15,
171
+ contradictMinProbability: config.jev?.contradictMinProbability ?? .8
141
172
  }
142
173
  };
143
174
  }
@@ -147,6 +178,18 @@ function resolveConfig(config = {}) {
147
178
  function asMemoryId(raw) {
148
179
  return raw;
149
180
  }
181
+ /** 从任意字符串铸造品牌化实体 id(存储层入库前调用)。 */
182
+ function asEntityId(raw) {
183
+ return raw;
184
+ }
185
+ /** 从任意字符串铸造品牌化事实 id(存储层入库前调用)。 */
186
+ function asFactId(raw) {
187
+ return raw;
188
+ }
189
+ /** 归一化比较键:trim + 合并连续空白 + 小写(消解第一版只做精确归一匹配,不做相似合并)。 */
190
+ function normalizeEntityName(raw) {
191
+ return raw.trim().replace(/\s+/g, " ").toLowerCase();
192
+ }
150
193
  /** episode 情景时间线的时间邻近扩展缺省窗口:锚点 createdAt ± 60 分钟。 */
151
194
  const EPISODE_PROXIMITY_MS_DEFAULT = 36e5;
152
195
  /** dsh-engram 统一错误:加载/使用期的可诊断失败都抛此类型。 */
@@ -503,15 +546,75 @@ function omitRecallToolResults(events) {
503
546
  };
504
547
  });
505
548
  }
549
+ //#endregion
550
+ //#region src/jev/observe.ts
551
+ /** 缓冲容量:足够回看一次摄取批次,同时封顶内存占用。 */
552
+ const CAPACITY = 50;
553
+ const buffer = [];
554
+ /** 追加一条观测;超出容量丢弃最旧记录。 */
555
+ function recordJevObservation(observation) {
556
+ buffer.push(observation);
557
+ if (buffer.length > CAPACITY) buffer.shift();
558
+ }
559
+ /** 按时间正序返回全部观测(最新在末尾);返回副本,调用方改动不影响缓冲。 */
560
+ function listJevObservations() {
561
+ return [...buffer];
562
+ }
563
+ /**
564
+ * 模糊带的 Jev 三路裁决。
565
+ * @returns 高置信同 → merge;高置信异 → accept;低置信、答案缺失或 judge 抛错 → undefined(保持 defer)。
566
+ */
567
+ async function resolveBand(judge, neighbor, similarity, kind, content) {
568
+ const startedAt = Date.now();
569
+ try {
570
+ const p = (await judge.ask(`Memory A:\n${neighbor.content}\n\nMemory B:\n${content}`, [{
571
+ id: "same_memory",
572
+ instructions: `Are Memory A and Memory B the same fact, i.e. restatements of one identical statement (both are "${kind}" memories)? Answer true only for genuine restatements.`
573
+ }]))["same_memory"];
574
+ recordJevObservation({
575
+ at: startedAt,
576
+ site: "band",
577
+ question: "same_memory",
578
+ answered: p !== void 0,
579
+ probability: p,
580
+ elapsedMs: Date.now() - startedAt,
581
+ error: void 0,
582
+ verdict: p === void 0 ? "fallback" : p >= judge.deferMergeAbove ? "merge" : p <= judge.deferAcceptBelow ? "accept" : "defer"
583
+ });
584
+ if (p === void 0) return void 0;
585
+ if (p >= judge.deferMergeAbove) return {
586
+ disposition: "merge",
587
+ into: neighbor,
588
+ similarity
589
+ };
590
+ if (p <= judge.deferAcceptBelow) return { disposition: "accept" };
591
+ return;
592
+ } catch (error) {
593
+ recordJevObservation({
594
+ at: startedAt,
595
+ site: "band",
596
+ question: "same_memory",
597
+ answered: false,
598
+ probability: void 0,
599
+ elapsedMs: Date.now() - startedAt,
600
+ error: error instanceof Error ? error.message : String(error),
601
+ verdict: "fallback"
602
+ });
603
+ return;
604
+ }
605
+ }
506
606
  /**
507
607
  * 判定一条新内容的处置。
508
608
  * 嵌入不可用 / 库内无向量条目 / 最近邻低于 DEFER_COSINE → accept;
509
- * 最近邻同 kind 且 ≥ MERGE_COSINE → merge;其余达到 DEFER_COSINE 的 → defer。
609
+ * 最近邻同 kind 且 ≥ MERGE_COSINE → merge;
610
+ * 其余达到 DEFER_COSINE 的进模糊带:注入 judge 时自动裁决(高置信同 → merge、
611
+ * 高置信异 → accept、低置信或失败 → defer);未注入 judge → defer(纯规则原行为)。
510
612
  * @param store - 目标分库
511
613
  * @param kind - 新内容的记忆种类(MERGE 要求同 kind,跨 kind 高度相似按 defer 处理)
512
614
  * @param embedding - 新内容的向量;undefined = 嵌入不可用,直接 accept
615
+ * @param options - 可选 judge 注入;judge 与 content 齐备时模糊带走自动裁决
513
616
  */
514
- async function decideWrite(store, kind, embedding) {
617
+ async function decideWrite(store, kind, embedding, options = {}) {
515
618
  if (embedding === void 0) return { disposition: "accept" };
516
619
  const nearest = await store.nearestNeighbor(embedding);
517
620
  if (nearest === void 0 || nearest.similarity < .88) return { disposition: "accept" };
@@ -520,6 +623,11 @@ async function decideWrite(store, kind, embedding) {
520
623
  into: nearest.record,
521
624
  similarity: nearest.similarity
522
625
  };
626
+ const { judge, content } = options;
627
+ if (judge !== void 0 && content !== void 0) {
628
+ const verdict = await resolveBand(judge, nearest.record, nearest.similarity, kind, content);
629
+ if (verdict !== void 0) return verdict;
630
+ }
523
631
  return {
524
632
  disposition: "defer",
525
633
  neighbor: nearest.record,
@@ -527,6 +635,51 @@ async function decideWrite(store, kind, embedding) {
527
635
  };
528
636
  }
529
637
  /**
638
+ * Jev 矛盾确认:对候选逐个问「真矛盾吗」,只保留概率达标的候选。
639
+ * @param judge - Jev 判断器
640
+ * @param incoming - 新写入内容原文
641
+ * @param candidates - 矛盾候选(摄取链路的 defer 最近邻 / 工具链路的 findContradictions 结果)
642
+ * @returns 达标候选数组;judge 抛错时返回 undefined,调用方按旧行为(全量建边)降级。
643
+ */
644
+ async function confirmContradictions(judge, incoming, candidates) {
645
+ try {
646
+ return (await Promise.all(candidates.map(async (candidate) => {
647
+ const startedAt = Date.now();
648
+ try {
649
+ const p = (await judge.ask(`Memory A:\n${candidate.content}\n\nMemory B:\n${incoming}`, [{
650
+ id: "contradicts",
651
+ instructions: "Do Memory A and Memory B state facts that cannot both be true at the same time (a genuine contradiction)?"
652
+ }]))["contradicts"];
653
+ recordJevObservation({
654
+ at: startedAt,
655
+ site: "contradiction",
656
+ question: "contradicts",
657
+ answered: p !== void 0,
658
+ probability: p,
659
+ elapsedMs: Date.now() - startedAt,
660
+ error: void 0,
661
+ verdict: p === void 0 ? "fallback" : p >= judge.contradictMinProbability ? "confirm" : "reject"
662
+ });
663
+ return p !== void 0 && p >= judge.contradictMinProbability ? candidate : void 0;
664
+ } catch (error) {
665
+ recordJevObservation({
666
+ at: startedAt,
667
+ site: "contradiction",
668
+ question: "contradicts",
669
+ answered: false,
670
+ probability: void 0,
671
+ elapsedMs: Date.now() - startedAt,
672
+ error: error instanceof Error ? error.message : String(error),
673
+ verdict: "fallback"
674
+ });
675
+ throw error;
676
+ }
677
+ }))).filter((c) => c !== void 0);
678
+ } catch {
679
+ return;
680
+ }
681
+ }
682
+ /**
530
683
  * MERGE 落地:强化既有条目(accessCount+1 / confidence+0.05)并记 write-merge 审计行。
531
684
  * @returns 强化后的既有条目;目标在并发窗口内被物理清除时抛错(loud,调用方按写入失败处理)。
532
685
  */
@@ -579,11 +732,71 @@ function decodeTurnKey(detail) {
579
732
  }
580
733
  const INGEST_SYSTEM = [
581
734
  "从对话记录中提取值得跨会话长期记住的用户信息(事实/偏好/决策/经历/做事方法)。",
582
- "只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"scope\": \"project|user\", \"importance\": 0到1的小数}。",
735
+ "只输出一个 JSON 数组,每项形如 {\"content\": \"一句话完整表述\", \"kind\": \"fact|preference|decision|episode|skill\", \"scope\": \"project|user\", \"importance\": 0到1的小数, \"entities\": [{\"name\": \"实体名\", \"kind\": \"person|project|tool|concept|other\", \"aliases\": [\"别名\"]}], \"facts\": [{\"entity\": \"实体名\", \"content\": \"关于该实体的一句话事实\"}]}。",
736
+ "entities 是这条记忆里提到的具体对象(人名/项目名/工具名/概念名),没有就省略该字段;实体名要短(专名本身,不超过 20 字),aliases 列出该实体的其他叫法,没有就省略。",
737
+ "facts 列出这条记忆中关于具体实体的客观事实(状态/归属/关系/数据),entity 必须取该条 entities 里出现过的实体名,一条一个知识点,没有就省略该字段。",
583
738
  "scope 决定这条记忆进哪座宫殿:只跟当前项目/仓库有关的(技术选型、项目约定、架构决策、该项目自身的事实)用 project;与具体项目无关、跨项目通用的(个人偏好、习惯、用户本人的经历、通用事实)用 user。拿不准用 project。",
584
739
  "只提取明确、可复用的信息;寒暄、临时上下文、你自己的回答不要提取。没有值得记的就输出 []。",
585
740
  "不要输出 JSON 以外的任何内容。"
586
741
  ].join("\n");
742
+ /** 实体提及 kind 的合法值集合(模型输出边界校验用)。 */
743
+ const ENTITY_KINDS = [
744
+ "person",
745
+ "project",
746
+ "tool",
747
+ "concept",
748
+ "other"
749
+ ];
750
+ /** 单个实体名的长度上限(专名本身,防止把整句话当实体)。 */
751
+ const ENTITY_NAME_MAX = 80;
752
+ /** 单条事实陈述的长度上限(一句话)。 */
753
+ const FACT_CONTENT_MAX = 500;
754
+ /**
755
+ * 解析模型输出的 entities 字段为合法提及列表。模型输出是不可信边界:
756
+ * 非数组、元素缺 name、name 清洗后为空或超长的条目一律丢弃,kind 非法回退 other。
757
+ */
758
+ function parseEntityMentions(raw) {
759
+ if (!Array.isArray(raw)) return [];
760
+ const mentions = [];
761
+ for (const item of raw) {
762
+ if (typeof item !== "object" || item === null) continue;
763
+ const record = item;
764
+ if (typeof record.name !== "string") continue;
765
+ const name = record.name.trim();
766
+ if (name === "" || name.length > ENTITY_NAME_MAX) continue;
767
+ const kind = ENTITY_KINDS.includes(record.kind) ? record.kind : "other";
768
+ const aliases = Array.isArray(record.aliases) ? record.aliases.filter((a) => typeof a === "string" && a.trim() !== "" && a.trim().length <= ENTITY_NAME_MAX) : [];
769
+ mentions.push({
770
+ name,
771
+ kind,
772
+ ...aliases.length === 0 ? {} : { aliases }
773
+ });
774
+ }
775
+ return mentions;
776
+ }
777
+ /**
778
+ * 解析模型输出的 facts 字段为合法事实提及列表。模型输出是不可信边界:
779
+ * 非数组、元素缺 entity/content、清洗后为空或超长的条目一律丢弃。
780
+ * entity 与同批实体名的对应关系由写入路径按归一化名匹配(匹配不上整条丢弃)。
781
+ */
782
+ function parseFactMentions(raw) {
783
+ if (!Array.isArray(raw)) return [];
784
+ const mentions = [];
785
+ for (const item of raw) {
786
+ if (typeof item !== "object" || item === null) continue;
787
+ const record = item;
788
+ if (typeof record.entity !== "string" || typeof record.content !== "string") continue;
789
+ const entity = record.entity.trim();
790
+ const content = record.content.trim();
791
+ if (entity === "" || entity.length > ENTITY_NAME_MAX) continue;
792
+ if (content === "" || content.length > FACT_CONTENT_MAX) continue;
793
+ mentions.push({
794
+ entity,
795
+ content
796
+ });
797
+ }
798
+ return mentions;
799
+ }
587
800
  /**
588
801
  * 组装一次摄取的写入路由:会话有 cwd(可归属项目)时默认进项目宫殿且采纳模型的逐条判定,
589
802
  * 明显跨项目的个人偏好可被标成 user 落私人宫殿;无 cwd 的会话只能进私人宫殿,
@@ -855,14 +1068,38 @@ async function ingestPreviousTurn(deps) {
855
1068
  const scope = deps.perCandidateScope === true && modelScope !== void 0 ? modelScope : deps.writeScope ?? "user";
856
1069
  const target = deps.resolveStore === void 0 ? store : await deps.resolveStore(scope);
857
1070
  const importance = typeof candidate.importance === "number" && Number.isFinite(candidate.importance) ? Math.min(1, Math.max(0, candidate.importance)) : .5;
1071
+ const linkEntities = async (nodeId) => {
1072
+ const mentions = parseEntityMentions(candidate.entities);
1073
+ if (mentions.length === 0) return;
1074
+ try {
1075
+ const entities = await target.resolveEntities(mentions);
1076
+ await target.linkNodeEntities(nodeId, entities.map((entity) => entity.id));
1077
+ const factInputs = [];
1078
+ const byName = new Map(entities.map((entity) => [normalizeEntityName(entity.name), entity]));
1079
+ for (const mention of parseFactMentions(candidate.facts)) {
1080
+ const entity = byName.get(normalizeEntityName(mention.entity));
1081
+ if (entity === void 0) continue;
1082
+ factInputs.push({
1083
+ entityId: entity.id,
1084
+ content: mention.content,
1085
+ sourceNodeId: nodeId
1086
+ });
1087
+ }
1088
+ if (factInputs.length > 0) await target.writeFacts(factInputs);
1089
+ } catch {}
1090
+ };
858
1091
  if (writtenContents.includes(content)) {
859
1092
  dropped += 1;
860
1093
  continue;
861
1094
  }
862
1095
  const vector = embedder === void 0 ? void 0 : (await embedder.embed([content]))[0];
863
- const decision = await decideWrite(target, kind, vector);
1096
+ const decision = await decideWrite(target, kind, vector, deps.judge === void 0 ? {} : {
1097
+ judge: deps.judge,
1098
+ content
1099
+ });
864
1100
  if (decision.disposition === "merge") {
865
1101
  await applyMerge(target, decision, content);
1102
+ await linkEntities(decision.into.id);
866
1103
  writtenContents.push(content);
867
1104
  merged += 1;
868
1105
  continue;
@@ -880,9 +1117,11 @@ async function ingestPreviousTurn(deps) {
880
1117
  ...deps.history === true ? { initialReviewAt: null } : {}
881
1118
  });
882
1119
  if (decision.disposition === "defer") {
883
- await target.linkEdge(record.id, decision.neighbor.id, "contradicts");
1120
+ const neighbors = deps.judge === void 0 ? [decision.neighbor] : await confirmContradictions(deps.judge, content, [decision.neighbor]) ?? [decision.neighbor];
1121
+ for (const neighbor of neighbors) await target.linkEdge(record.id, neighbor.id, "contradicts");
884
1122
  deferred += 1;
885
1123
  }
1124
+ await linkEntities(record.id);
886
1125
  writtenContents.push(content);
887
1126
  written += 1;
888
1127
  }
@@ -1022,6 +1261,7 @@ async function replayPendingIngests(deps) {
1022
1261
  routeOverride: deps.routeOverride,
1023
1262
  call: deps.call,
1024
1263
  logRequest: deps.logRequest,
1264
+ ...deps.judge === void 0 ? {} : { judge: deps.judge },
1025
1265
  signal: deps.signal
1026
1266
  });
1027
1267
  await store.clearAudit(INGEST_PENDING_OP, detail);
@@ -1280,6 +1520,7 @@ async function runHistoryBackfill(deps, defaults, overrides, onProgress, signal)
1280
1520
  routeOverride,
1281
1521
  call: deps.call,
1282
1522
  logRequest: deps.logRequest,
1523
+ ...deps.judge === void 0 ? {} : { judge: deps.judge },
1283
1524
  signal,
1284
1525
  throttle: true,
1285
1526
  history: true
@@ -1472,6 +1713,217 @@ function cosine$1(a, b) {
1472
1713
  return denom === 0 ? 0 : dot / denom;
1473
1714
  }
1474
1715
  //#endregion
1716
+ //#region src/jev/client.ts
1717
+ /**
1718
+ * 调用 Jev /v1/systemone 端点并行回答 Noul 问题。
1719
+ * @param config - 已解析的 Jev 子配置(调用方先判 enabled)。
1720
+ * @param state - 待判断的状态文本(如两条记忆正文的对照)。
1721
+ * @param questions - Noul 问题列表;空列表直接返回空结果,不发请求。
1722
+ * @param signal - 外部取消信号;与配置超时信号合并监听,超时按配置 timeoutMs 兜底。
1723
+ * @returns 问题 id → Noul 概率(0-1,数字即信念)。
1724
+ * @throws 网络、非 2xx、响应缺 answers、答案缺失或非 0-1 数字时抛错。
1725
+ */
1726
+ async function askNoul(config, state, questions, signal = new AbortController().signal) {
1727
+ if (questions.length === 0) return {};
1728
+ const response = await fetch(new URL("/v1/systemone", config.baseUrl), {
1729
+ method: "POST",
1730
+ headers: {
1731
+ Authorization: `Bearer ${config.apiKey ?? ""}`,
1732
+ "Content-Type": "application/json"
1733
+ },
1734
+ body: JSON.stringify({
1735
+ model: config.model,
1736
+ state,
1737
+ questions: Object.fromEntries(questions.map((question) => [question.id, {
1738
+ type: "noul",
1739
+ instructions: question.instructions
1740
+ }]))
1741
+ }),
1742
+ signal: AbortSignal.any([signal, AbortSignal.timeout(config.timeoutMs)])
1743
+ });
1744
+ if (!response.ok) throw new Error(`jev: HTTP ${response.status} from ${config.baseUrl}`);
1745
+ const payload = await response.json();
1746
+ if (payload.answers === void 0 || payload.answers === null) throw new Error("jev: response missing answers");
1747
+ const result = {};
1748
+ for (const question of questions) {
1749
+ const noul = payload.answers[question.id]?.noul;
1750
+ if (typeof noul !== "number" || !Number.isFinite(noul) || noul < 0 || noul > 1) throw new Error(`jev: answer "${question.id}" is not a noul probability`);
1751
+ result[question.id] = noul;
1752
+ }
1753
+ return result;
1754
+ }
1755
+ /**
1756
+ * 从已解析配置构建可注入摄取与工具链路的判断器。
1757
+ * @param config - 已解析的 Jev 子配置(含三阈值)。
1758
+ * @returns MemoryJudge 实现;ask 不接收外部取消信号,仅受配置超时约束。
1759
+ */
1760
+ function createMemoryJudge(config) {
1761
+ return {
1762
+ deferMergeAbove: config.deferMergeAbove,
1763
+ deferAcceptBelow: config.deferAcceptBelow,
1764
+ contradictMinProbability: config.contradictMinProbability,
1765
+ ask: (state, questions) => askNoul(config, state, questions)
1766
+ };
1767
+ }
1768
+ /**
1769
+ * 连接测试:向端点发一个最小 ping 问题,验证 baseUrl/model/apiKey 三者组合可用。
1770
+ * 不抛错——失败原因以 error 字段返回,供面板直接展示。
1771
+ * @param config - 待测试的生效配置(enabled 不参与判断;apiKey/baseUrl/model 用调用方给的值)。
1772
+ */
1773
+ async function testJevConnection(config) {
1774
+ const startedAt = Date.now();
1775
+ try {
1776
+ const answers = await askNoul(config, "Connectivity test: this state exists.", [{
1777
+ id: "ping",
1778
+ instructions: "This is a connectivity test. Return any probability between 0 and 1."
1779
+ }]);
1780
+ return {
1781
+ ok: true,
1782
+ elapsedMs: Date.now() - startedAt,
1783
+ probability: answers["ping"],
1784
+ error: void 0
1785
+ };
1786
+ } catch (error) {
1787
+ return {
1788
+ ok: false,
1789
+ elapsedMs: Date.now() - startedAt,
1790
+ probability: void 0,
1791
+ error: error instanceof Error ? error.message : String(error)
1792
+ };
1793
+ }
1794
+ }
1795
+ //#endregion
1796
+ //#region src/jev/runtime.ts
1797
+ /**
1798
+ * Jev 连接配置的运行时覆盖:管理面板保存到 `<dbDir>/jev-config.json`(0600),
1799
+ * 字段级覆盖 cordis.yml 的 jev 子配置;装配免缓存——judge 构造是无状态闭包、
1800
+ * 覆盖文件是小读,每个装配点即时解析,面板保存后立即生效。
1801
+ * 覆盖文件损坏按空覆盖处理:不卡启动,面板重新保存即修复。
1802
+ * @module @kenz1117/dsh-engram/jev/runtime
1803
+ */
1804
+ /** 覆盖文件路径(dbDir 已 0700,文件本身 0600 双保险)。 */
1805
+ function jevOverridePath(dbDir) {
1806
+ return `${dbDir}/jev-config.json`;
1807
+ }
1808
+ /** 读覆盖文件:缺失/损坏/非法 JSON 都按空覆盖(面板重新保存即修复)。 */
1809
+ function loadJevOverride(dbDir) {
1810
+ let raw;
1811
+ try {
1812
+ raw = readFileSync(jevOverridePath(dbDir), "utf8");
1813
+ } catch {
1814
+ return {};
1815
+ }
1816
+ try {
1817
+ return collectOverride(JSON.parse(raw));
1818
+ } catch {
1819
+ return {};
1820
+ }
1821
+ }
1822
+ /** 收敛未知 JSON 为覆盖对象:只认五个已知字段,类型不符的丢弃。 */
1823
+ function collectOverride(value) {
1824
+ if (value === null || typeof value !== "object") return {};
1825
+ const raw = value;
1826
+ return {
1827
+ ...typeof raw["enabled"] === "boolean" ? { enabled: raw["enabled"] } : {},
1828
+ ...typeof raw["apiKey"] === "string" ? { apiKey: raw["apiKey"] } : {},
1829
+ ...typeof raw["baseUrl"] === "string" ? { baseUrl: raw["baseUrl"] } : {},
1830
+ ...typeof raw["model"] === "string" ? { model: raw["model"] } : {},
1831
+ ...typeof raw["timeoutMs"] === "number" && Number.isInteger(raw["timeoutMs"]) ? { timeoutMs: raw["timeoutMs"] } : {}
1832
+ };
1833
+ }
1834
+ /** 全量保存覆盖文件(调用方先经 parseJevPatch 校验;内容为面板合并后的完整覆盖)。 */
1835
+ function saveJevOverride(dbDir, override) {
1836
+ writeFileSync(jevOverridePath(dbDir), `${JSON.stringify(override, null, 2)}\n`, { mode: 384 });
1837
+ }
1838
+ /**
1839
+ * 面板 patch 解析:只认已知字段并逐个校验,出现非法值整体拒绝。
1840
+ * `apiKey: ''` 表示清除面板密钥覆盖(回落 yml);缺席的字段保留 current。
1841
+ * @param current - 现有覆盖(合并底座)。
1842
+ * @param patch - POST body 原始对象。
1843
+ * @returns 合法时返回合并后的完整覆盖;非法时返回 `invalid:` 前缀的字段错误文案。
1844
+ */
1845
+ function parseJevPatch(current, patch) {
1846
+ const merged = { ...current };
1847
+ if (patch["enabled"] !== void 0) {
1848
+ if (typeof patch["enabled"] !== "boolean") return "invalid: jev.enabled 必须是布尔值";
1849
+ merged.enabled = patch["enabled"];
1850
+ }
1851
+ if (patch["apiKey"] !== void 0) {
1852
+ if (typeof patch["apiKey"] !== "string") return "invalid: jev.apiKey 必须是字符串";
1853
+ const trimmed = patch["apiKey"].trim();
1854
+ if (trimmed === "") delete merged.apiKey;
1855
+ else merged.apiKey = trimmed;
1856
+ }
1857
+ if (patch["baseUrl"] !== void 0) {
1858
+ if (typeof patch["baseUrl"] !== "string") return "invalid: jev.baseUrl 必须是字符串";
1859
+ const value = patch["baseUrl"].trim();
1860
+ try {
1861
+ const url = new URL(value);
1862
+ if (url.protocol !== "https:" && url.protocol !== "http:") return "invalid: jev.baseUrl 必须是 http(s) URL";
1863
+ } catch {
1864
+ return "invalid: jev.baseUrl 必须是 http(s) URL";
1865
+ }
1866
+ merged.baseUrl = value;
1867
+ }
1868
+ if (patch["model"] !== void 0) {
1869
+ if (typeof patch["model"] !== "string" || patch["model"].trim() === "") return "invalid: jev.model 不能为空";
1870
+ merged.model = patch["model"].trim();
1871
+ }
1872
+ if (patch["timeoutMs"] !== void 0) {
1873
+ const value = patch["timeoutMs"];
1874
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1e3 || value > 6e4) return "invalid: jev.timeoutMs 必须是 1000-60000 的整数毫秒";
1875
+ merged.timeoutMs = value;
1876
+ }
1877
+ return merged;
1878
+ }
1879
+ /**
1880
+ * 面板覆盖与 yml 基线的字段级合并;合并后 enabled=true 但无密钥时强制降级
1881
+ * enabled=false(与 Jev 不可用静默降级语义一致,不卡启动)。
1882
+ */
1883
+ function effectiveJevConfig(base, override) {
1884
+ const apiKey = override.apiKey ?? base.apiKey;
1885
+ const enabled = (override.enabled ?? base.enabled) && apiKey !== void 0 && apiKey !== "";
1886
+ return {
1887
+ enabled,
1888
+ apiKey: enabled ? apiKey : void 0,
1889
+ baseUrl: override.baseUrl ?? base.baseUrl,
1890
+ model: override.model ?? base.model,
1891
+ timeoutMs: override.timeoutMs ?? base.timeoutMs,
1892
+ deferMergeAbove: base.deferMergeAbove,
1893
+ deferAcceptBelow: base.deferAcceptBelow,
1894
+ contradictMinProbability: base.contradictMinProbability
1895
+ };
1896
+ }
1897
+ /** 密钥掩码:长度 > 8 显示尾 4 位,其余全掩码。 */
1898
+ function maskApiKey(key) {
1899
+ return key.length > 8 ? `****${key.slice(-4)}` : "******";
1900
+ }
1901
+ /** 组装面板视图:基于生效配置;掩码取合并后的密钥。 */
1902
+ function jevConfigView(base, override) {
1903
+ const config = effectiveJevConfig(base, override);
1904
+ return {
1905
+ enabled: config.enabled,
1906
+ apiKeySet: config.apiKey !== void 0 && config.apiKey !== "",
1907
+ apiKeyMask: config.apiKey === void 0 || config.apiKey === "" ? null : maskApiKey(config.apiKey),
1908
+ baseUrl: config.baseUrl,
1909
+ model: config.model,
1910
+ timeoutMs: config.timeoutMs,
1911
+ deferMergeAbove: config.deferMergeAbove,
1912
+ deferAcceptBelow: config.deferAcceptBelow,
1913
+ contradictMinProbability: config.contradictMinProbability
1914
+ };
1915
+ }
1916
+ /**
1917
+ * 统一装配函数:读面板覆盖、合并 yml 基线,enabled 时产出 judge,否则返回空对象。
1918
+ * 产出形状可直接条件展开进摄取/工具/末轮/回填四类依赖(`...resolveJevField(resolved.jev, dbDir)`)。
1919
+ * 免缓存:judge 构造是无状态闭包、覆盖文件是小读,每次调用即时解析,面板保存立即生效。
1920
+ */
1921
+ function resolveJevField(base, dbDir) {
1922
+ const config = effectiveJevConfig(base, loadJevOverride(dbDir));
1923
+ if (!config.enabled || config.apiKey === void 0 || config.apiKey === "") return {};
1924
+ return { judge: createMemoryJudge(config) };
1925
+ }
1926
+ //#endregion
1475
1927
  //#region src/mirror/markdown.ts
1476
1928
  /**
1477
1929
  * Markdown 镜像:把 SQLite 记忆库导出为可被 Obsidian / VS Code / git 直接漫游的
@@ -2267,6 +2719,62 @@ function registerEngramRoutes(ctx, deps) {
2267
2719
  });
2268
2720
  return;
2269
2721
  }
2722
+ if (req.method === "GET" && route === "entities") {
2723
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2724
+ const kind = url.searchParams.get("kind");
2725
+ const q = url.searchParams.get("q");
2726
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
2727
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
2728
+ json(res, 200, {
2729
+ scope,
2730
+ ...await (await storeFor(scope)).listEntities({
2731
+ ...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
2732
+ ...q !== null && q !== "" ? { q } : {},
2733
+ limit,
2734
+ offset
2735
+ })
2736
+ });
2737
+ return;
2738
+ }
2739
+ if (req.method === "GET" && route === "entity") {
2740
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2741
+ const id = url.searchParams.get("id");
2742
+ if (id === null || id === "") {
2743
+ json(res, 400, { error: "id required" });
2744
+ return;
2745
+ }
2746
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
2747
+ const detail = await (await storeFor(scope)).entityDetail(id, limit);
2748
+ if (detail === void 0) {
2749
+ json(res, 404, { error: `未找到实体 ${id}` });
2750
+ return;
2751
+ }
2752
+ json(res, 200, detail);
2753
+ return;
2754
+ }
2755
+ if (req.method === "GET" && route === "facts") {
2756
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2757
+ const id = url.searchParams.get("entityId");
2758
+ if (id === null || id === "") {
2759
+ json(res, 400, { error: "entityId required" });
2760
+ return;
2761
+ }
2762
+ const asOf = toNumber(url.searchParams.get("asOf"));
2763
+ const includeInvalid = toBoolean(url.searchParams.get("includeInvalid")) ?? false;
2764
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
2765
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
2766
+ json(res, 200, {
2767
+ scope,
2768
+ ...await (await storeFor(scope)).factsOfEntity({
2769
+ entityId: id,
2770
+ ...asOf === void 0 ? {} : { asOf },
2771
+ ...includeInvalid ? { includeInvalid: true } : {},
2772
+ limit,
2773
+ offset
2774
+ })
2775
+ });
2776
+ return;
2777
+ }
2270
2778
  if (req.method === "GET" && route === "episode-timeline") {
2271
2779
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2272
2780
  const since = toNumber(url.searchParams.get("since"));
@@ -2642,6 +3150,40 @@ function registerEngramRoutes(ctx, deps) {
2642
3150
  });
2643
3151
  return;
2644
3152
  }
3153
+ if (req.method === "GET" && route === "jev-config") {
3154
+ json(res, 200, jevConfigView(deps.jev.base, loadJevOverride(deps.jev.dbDir)));
3155
+ return;
3156
+ }
3157
+ if (req.method === "POST" && route === "jev-config") {
3158
+ if (!guardWrite(req, res)) return;
3159
+ const body = await readJsonBody(req);
3160
+ const parsed = parseJevPatch(loadJevOverride(deps.jev.dbDir), body ?? {});
3161
+ if (typeof parsed === "string") {
3162
+ json(res, 400, { error: parsed });
3163
+ return;
3164
+ }
3165
+ saveJevOverride(deps.jev.dbDir, parsed);
3166
+ json(res, 200, {
3167
+ ok: true,
3168
+ view: jevConfigView(deps.jev.base, parsed)
3169
+ });
3170
+ return;
3171
+ }
3172
+ if (req.method === "POST" && route === "jev-test") {
3173
+ if (!guardWrite(req, res)) return;
3174
+ const body = await readJsonBody(req);
3175
+ json(res, 200, await testJevConnection({
3176
+ ...effectiveJevConfig(deps.jev.base, loadJevOverride(deps.jev.dbDir)),
3177
+ ...typeof body?.baseUrl === "string" && body.baseUrl.trim() !== "" ? { baseUrl: body.baseUrl.trim() } : {},
3178
+ ...typeof body?.model === "string" && body.model.trim() !== "" ? { model: body.model.trim() } : {},
3179
+ ...typeof body?.apiKey === "string" && body.apiKey !== "" ? { apiKey: body.apiKey } : {}
3180
+ }));
3181
+ return;
3182
+ }
3183
+ if (req.method === "GET" && route === "jev-observations") {
3184
+ json(res, 200, { items: [...listJevObservations()].reverse() });
3185
+ return;
3186
+ }
2645
3187
  if (req.method === "POST" && route === "consolidate") {
2646
3188
  if (!guardWrite(req, res)) return;
2647
3189
  const body = await readJsonBody(req);
@@ -3104,7 +3646,7 @@ function placardImprovementHint(score) {
3104
3646
  * @module @kenz1117/dsh-engram/store/sqlite
3105
3647
  */
3106
3648
  /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
3107
- const SCHEMA_VERSION = 9;
3649
+ const SCHEMA_VERSION = 11;
3108
3650
  /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
3109
3651
  * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
3110
3652
  * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
@@ -3117,7 +3659,14 @@ const SCHEMA_VERSION = 9;
3117
3659
  * v7 → v8:episode 情景时间线两个专用索引(kind+created_at 日期范围扫描、
3118
3660
  * source_session_id+created_at 会话过滤)。列自 v1 就存在,纯索引迁移,零数据搬运。
3119
3661
  * v8 → v9:会话摘要表 session_summaries(摄取期 LLM 生成的一句话会话摘要,
3120
- * 组头展示用;与 nodes 无外键,会话 id 仅作逻辑关联)。 */
3662
+ * 组头展示用;与 nodes 无外键,会话 id 仅作逻辑关联)。
3663
+ * v9 → v10:实体层两表——entities 实体词典(name/kind/aliases)与 node_entities
3664
+ * 记忆↔实体关联。实体随来源记忆所在 scope 分库;两表均无外键,删除记忆不级联
3665
+ * (关联残留由查询侧 join status 过滤,见 listEntities 的 orphan 统计)。
3666
+ * v10 → v11:facts 事实表——摄取/工具期抽取的一句话事实,挂在实体上(entity_id
3667
+ * 引用同库 entities.id,无外键)。valid_at/invalid_at 时间窗 + replaced_by 软失效
3668
+ * 链:新事实可声明取代旧事实(旧事实置 invalid_at 并回指 replaced_by),历史链
3669
+ * 保留可审计;asOf 查询按时间窗过滤(见 factsOfEntity)。 */
3121
3670
  const MIGRATIONS = {
3122
3671
  "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
3123
3672
  "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
@@ -3144,7 +3693,21 @@ const MIGRATIONS = {
3144
3693
  "7": `CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
3145
3694
  CREATE INDEX IF NOT EXISTS nodes_session_created ON nodes (source_session_id, created_at);`,
3146
3695
  "8": `CREATE TABLE IF NOT EXISTS session_summaries (
3147
- session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, updated_at INTEGER NOT NULL);`
3696
+ session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, updated_at INTEGER NOT NULL);`,
3697
+ "9": `CREATE TABLE IF NOT EXISTS entities (
3698
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
3699
+ aliases_json TEXT NOT NULL DEFAULT '[]',
3700
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3701
+ CREATE TABLE IF NOT EXISTS node_entities (
3702
+ node_id TEXT NOT NULL, entity_id TEXT NOT NULL,
3703
+ PRIMARY KEY (node_id, entity_id));
3704
+ CREATE INDEX IF NOT EXISTS entities_name ON entities (name);
3705
+ CREATE INDEX IF NOT EXISTS node_entities_entity ON node_entities (entity_id);`,
3706
+ "10": `CREATE TABLE IF NOT EXISTS facts (
3707
+ id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3708
+ valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3709
+ source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3710
+ CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);`
3148
3711
  };
3149
3712
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
3150
3713
  const RRF_K = 60;
@@ -3222,6 +3785,34 @@ function rowToRecord(row) {
3222
3785
  function blobToVec(blob) {
3223
3786
  return new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
3224
3787
  }
3788
+ function entityRowToRecord(row) {
3789
+ let aliases = [];
3790
+ try {
3791
+ const parsed = JSON.parse(row.aliases_json);
3792
+ if (Array.isArray(parsed)) aliases = parsed.filter((a) => typeof a === "string");
3793
+ } catch {}
3794
+ return {
3795
+ id: asEntityId(row.id),
3796
+ name: row.name,
3797
+ kind: row.kind,
3798
+ aliases,
3799
+ createdAt: row.created_at,
3800
+ updatedAt: row.updated_at
3801
+ };
3802
+ }
3803
+ function factRowToRecord(row) {
3804
+ return {
3805
+ id: asFactId(row.id),
3806
+ entityId: asEntityId(row.entity_id),
3807
+ content: row.content,
3808
+ validAt: row.valid_at,
3809
+ invalidAt: row.invalid_at,
3810
+ replacedBy: row.replaced_by === null ? null : asFactId(row.replaced_by),
3811
+ sourceNodeId: row.source_node_id === null ? null : asMemoryId(row.source_node_id),
3812
+ createdAt: row.created_at,
3813
+ updatedAt: row.updated_at
3814
+ };
3815
+ }
3225
3816
  function vecToBlob(vector) {
3226
3817
  return new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
3227
3818
  }
@@ -3330,10 +3921,24 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3330
3921
  source TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (scope, version));
3331
3922
  CREATE TABLE IF NOT EXISTS session_summaries (
3332
3923
  session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, updated_at INTEGER NOT NULL);
3924
+ CREATE TABLE IF NOT EXISTS entities (
3925
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
3926
+ aliases_json TEXT NOT NULL DEFAULT '[]',
3927
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3928
+ CREATE TABLE IF NOT EXISTS node_entities (
3929
+ node_id TEXT NOT NULL, entity_id TEXT NOT NULL,
3930
+ PRIMARY KEY (node_id, entity_id));
3931
+ CREATE TABLE IF NOT EXISTS facts (
3932
+ id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3933
+ valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3934
+ source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3333
3935
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
3334
3936
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
3335
3937
  CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
3336
3938
  CREATE INDEX IF NOT EXISTS nodes_session_created ON nodes (source_session_id, created_at);
3939
+ CREATE INDEX IF NOT EXISTS entities_name ON entities (name);
3940
+ CREATE INDEX IF NOT EXISTS node_entities_entity ON node_entities (entity_id);
3941
+ CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);
3337
3942
  `);
3338
3943
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
3339
3944
  if (versionRow === void 0) {
@@ -3447,9 +4052,28 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3447
4052
  const sqlProfileVersionsList = db.prepare("SELECT scope, version, content, source, at FROM profile_block_versions WHERE scope = ? ORDER BY version DESC LIMIT ?");
3448
4053
  const sqlProfileVersionGet = db.prepare("SELECT scope, version, content, source, at FROM profile_block_versions WHERE scope = ? AND version = ?");
3449
4054
  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`);
4055
+ ON CONFLICT (session_id) DO UPDATE SET summary = excluded.summary, updated_at = excluded.updated_at`);
3451
4056
  const sqlSessionSummaryGet = db.prepare("SELECT session_id, summary FROM session_summaries WHERE session_id = ?");
3452
4057
  const sqlSessionSummaryMany = db.prepare("SELECT session_id, summary FROM session_summaries WHERE session_id IN (SELECT value FROM json_each(?))");
4058
+ const sqlEntityAll = db.prepare("SELECT * FROM entities");
4059
+ const sqlEntityGet = db.prepare("SELECT * FROM entities WHERE id = ?");
4060
+ const sqlEntityInsert = db.prepare("INSERT INTO entities (id, name, kind, aliases_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
4061
+ const sqlEntityTouch = db.prepare("UPDATE entities SET updated_at = ? WHERE id = ?");
4062
+ const sqlNodeEntityInsert = db.prepare("INSERT OR IGNORE INTO node_entities (node_id, entity_id) VALUES (?, ?)");
4063
+ const sqlEntityByNode = db.prepare(`SELECT e.* FROM node_entities ne JOIN entities e ON e.id = ne.entity_id
4064
+ WHERE ne.node_id = ? ORDER BY e.name`);
4065
+ const sqlEntityMemoryCounts = db.prepare(`SELECT ne.entity_id AS id, COUNT(*) AS n FROM node_entities ne
4066
+ JOIN nodes n ON n.id = ne.node_id WHERE n.status = 'active' GROUP BY ne.entity_id`);
4067
+ const sqlEntityMemories = db.prepare(`SELECT n.* FROM node_entities ne JOIN nodes n ON n.id = ne.node_id
4068
+ WHERE ne.entity_id = ? AND n.status = 'active' ORDER BY n.created_at DESC LIMIT ?`);
4069
+ const sqlPurgeEntities = db.prepare("DELETE FROM entities");
4070
+ const sqlPurgeNodeEntities = db.prepare("DELETE FROM node_entities");
4071
+ const sqlFactInsert = db.prepare(`INSERT INTO facts
4072
+ (id, entity_id, content, valid_at, invalid_at, replaced_by, source_node_id, created_at, updated_at)
4073
+ VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?)`);
4074
+ const sqlFactInvalidate = db.prepare("UPDATE facts SET invalid_at = ?, replaced_by = ?, updated_at = ? WHERE id = ?");
4075
+ const sqlFactGet = db.prepare("SELECT * FROM facts WHERE id = ?");
4076
+ const sqlPurgeFacts = db.prepare("DELETE FROM facts");
3453
4077
  /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
3454
4078
  const ftsSearch = (match, scopes, rooms) => {
3455
4079
  const placeholders = scopes.map(() => "?").join(",");
@@ -3648,12 +4272,17 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3648
4272
  if (row === void 0) continue;
3649
4273
  const viaEdge = viaEdgeOf.get(id);
3650
4274
  const cueNeighbors = hits.length < 5 && row.slot_room !== null && row.slot_index !== null ? sqlSlotNeighbors.all(row.slot_room, row.slot_index - 1, row.slot_index + 1, id).map((neighbor) => asMemoryId(neighbor.id)) : [];
4275
+ const entityRows = sqlEntityByNode.all(id);
3651
4276
  hits.push({
3652
4277
  record: rowToRecord(row),
3653
4278
  score: info.score,
3654
4279
  via: info.via,
3655
4280
  ...viaEdge === void 0 ? {} : { viaEdge },
3656
- ...cueNeighbors.length === 0 ? {} : { cues: { neighbors: cueNeighbors } }
4281
+ ...cueNeighbors.length === 0 ? {} : { cues: { neighbors: cueNeighbors } },
4282
+ ...entityRows.length === 0 ? {} : { entities: entityRows.map((r) => ({
4283
+ id: asEntityId(r.id),
4284
+ name: r.name
4285
+ })) }
3657
4286
  });
3658
4287
  sqlTouch.run(Date.now(), id);
3659
4288
  }
@@ -4053,6 +4682,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4053
4682
  async exportAll() {
4054
4683
  const records = sqlAllNodes.all().map(rowToRecord);
4055
4684
  const edgeRows = sqlAllEdges.all();
4685
+ const entities = sqlEntityAll.all().map(entityRowToRecord);
4056
4686
  return {
4057
4687
  exportedAt: Date.now(),
4058
4688
  records,
@@ -4061,7 +4691,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4061
4691
  to: asMemoryId(edge.to_id),
4062
4692
  type: edge.type,
4063
4693
  createdAt: edge.created_at
4064
- }))
4694
+ })),
4695
+ entities
4065
4696
  };
4066
4697
  },
4067
4698
  async decay(options) {
@@ -4119,6 +4750,134 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4119
4750
  });
4120
4751
  return rowToRecord(sqlGet.get(id));
4121
4752
  },
4753
+ async resolveEntities(mentions) {
4754
+ if (mentions.length === 0) return [];
4755
+ const at = Date.now();
4756
+ const result = [];
4757
+ withTransaction(() => {
4758
+ const byKey = /* @__PURE__ */ new Map();
4759
+ for (const row of sqlEntityAll.all()) {
4760
+ const entity = entityRowToRecord(row);
4761
+ byKey.set(normalizeEntityName(entity.name), entity);
4762
+ for (const alias of entity.aliases) {
4763
+ const aliasKey = normalizeEntityName(alias);
4764
+ if (aliasKey !== "" && !byKey.has(aliasKey)) byKey.set(aliasKey, entity);
4765
+ }
4766
+ }
4767
+ for (const mention of mentions) {
4768
+ const name = mention.name.trim();
4769
+ if (name === "") throw new EngramError("EMPTY_ENTITY_NAME", "实体名不能为空(清洗后的提及仍含空名)");
4770
+ const key = normalizeEntityName(name);
4771
+ const hit = byKey.get(key);
4772
+ if (hit !== void 0) {
4773
+ sqlEntityTouch.run(at, hit.id);
4774
+ result.push(hit);
4775
+ continue;
4776
+ }
4777
+ const aliases = [...new Set((mention.aliases ?? []).map((a) => a.trim()).filter((a) => a !== ""))];
4778
+ const entity = {
4779
+ id: asEntityId(randomUUID()),
4780
+ name,
4781
+ kind: mention.kind,
4782
+ aliases,
4783
+ createdAt: at,
4784
+ updatedAt: at
4785
+ };
4786
+ sqlEntityInsert.run(entity.id, entity.name, entity.kind, JSON.stringify(aliases), at, at);
4787
+ byKey.set(key, entity);
4788
+ for (const alias of aliases) {
4789
+ const aliasKey = normalizeEntityName(alias);
4790
+ if (aliasKey !== "" && !byKey.has(aliasKey)) byKey.set(aliasKey, entity);
4791
+ }
4792
+ result.push(entity);
4793
+ }
4794
+ });
4795
+ return result;
4796
+ },
4797
+ async linkNodeEntities(nodeId, entityIds) {
4798
+ withTransaction(() => {
4799
+ for (const entityId of entityIds) sqlNodeEntityInsert.run(String(nodeId), String(entityId));
4800
+ });
4801
+ },
4802
+ async entitiesOfNodes(nodeIds) {
4803
+ const map = /* @__PURE__ */ new Map();
4804
+ for (const nodeId of nodeIds) {
4805
+ const rows = sqlEntityByNode.all(String(nodeId));
4806
+ if (rows.length > 0) map.set(nodeId, rows.map(entityRowToRecord));
4807
+ }
4808
+ return map;
4809
+ },
4810
+ async listEntities(filter) {
4811
+ const conditions = [];
4812
+ const params = [];
4813
+ if (filter.kind !== void 0) {
4814
+ conditions.push("kind = ?");
4815
+ params.push(filter.kind);
4816
+ }
4817
+ if (filter.q !== void 0 && filter.q !== "") {
4818
+ conditions.push("name LIKE ? ESCAPE '\\' OR aliases_json LIKE ? ESCAPE '\\'");
4819
+ const pattern = `%${filter.q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
4820
+ params.push(pattern, pattern);
4821
+ }
4822
+ const where = conditions.length === 0 ? "" : ` WHERE ${conditions.join(" AND ")}`;
4823
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM entities${where}`).get(...params).n;
4824
+ const rows = db.prepare(`SELECT * FROM entities${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset);
4825
+ const counts = new Map(sqlEntityMemoryCounts.all().map((r) => [r.id, r.n]));
4826
+ return {
4827
+ items: rows.map((row) => ({
4828
+ entity: entityRowToRecord(row),
4829
+ memoryCount: counts.get(row.id) ?? 0
4830
+ })),
4831
+ total
4832
+ };
4833
+ },
4834
+ async entityDetail(id, memoryLimit) {
4835
+ const row = sqlEntityGet.get(String(id));
4836
+ if (row === void 0) return void 0;
4837
+ const memories = sqlEntityMemories.all(String(id), memoryLimit).map(rowToRecord);
4838
+ return {
4839
+ entity: entityRowToRecord(row),
4840
+ memories
4841
+ };
4842
+ },
4843
+ async writeFacts(inputs) {
4844
+ const now = Date.now();
4845
+ return inputs.map((input) => {
4846
+ const id = asFactId(randomUUID());
4847
+ const validAt = input.validAt ?? now;
4848
+ withTransaction(() => {
4849
+ if (input.replaces !== void 0 && sqlFactGet.get(String(input.replaces)) !== void 0) sqlFactInvalidate.run(now, id, now, String(input.replaces));
4850
+ sqlFactInsert.run(id, String(input.entityId), input.content, validAt, input.sourceNodeId === void 0 ? null : String(input.sourceNodeId), now, now);
4851
+ });
4852
+ return {
4853
+ id,
4854
+ entityId: input.entityId,
4855
+ content: input.content,
4856
+ validAt,
4857
+ invalidAt: null,
4858
+ replacedBy: null,
4859
+ sourceNodeId: input.sourceNodeId ?? null,
4860
+ createdAt: now,
4861
+ updatedAt: now
4862
+ };
4863
+ });
4864
+ },
4865
+ async factsOfEntity(filter) {
4866
+ const conditions = ["entity_id = ?"];
4867
+ const params = [String(filter.entityId)];
4868
+ if (!filter.includeInvalid) {
4869
+ if (filter.asOf !== void 0) {
4870
+ conditions.push("valid_at <= ? AND (invalid_at IS NULL OR invalid_at > ?)");
4871
+ params.push(filter.asOf, filter.asOf);
4872
+ } else conditions.push("invalid_at IS NULL");
4873
+ }
4874
+ const where = ` WHERE ${conditions.join(" AND ")}`;
4875
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM facts${where}`).get(...params).n;
4876
+ return {
4877
+ items: db.prepare(`SELECT * FROM facts${where} ORDER BY valid_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(factRowToRecord),
4878
+ total
4879
+ };
4880
+ },
4122
4881
  async audit(op, targetId, detail) {
4123
4882
  sqlLog.run(Date.now(), op, targetId, detail);
4124
4883
  },
@@ -4141,6 +4900,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4141
4900
  sqlPurgeProfileBlocks.run();
4142
4901
  sqlPurgeProfileVersions.run();
4143
4902
  sqlPurgeSessionSummaries.run();
4903
+ sqlPurgeNodeEntities.run();
4904
+ sqlPurgeEntities.run();
4905
+ sqlPurgeFacts.run();
4144
4906
  });
4145
4907
  },
4146
4908
  async close() {
@@ -4519,7 +5281,7 @@ function assessEvidence(batch, request) {
4519
5281
  //#endregion
4520
5282
  //#region src/tools/create.ts
4521
5283
  /**
4522
- * 19 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
5284
+ * 20 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
4523
5285
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
4524
5286
  * @module @kenz1117/dsh-engram/tools/create
4525
5287
  */
@@ -4556,6 +5318,12 @@ function parseCountParam(raw, toolName, field, fallback, min, max) {
4556
5318
  if (value < min || value > max) throw new Error(`${toolName}: ${field} 需在 ${String(min)}-${String(max)} 之间,收到 ${String(value)}`);
4557
5319
  return value;
4558
5320
  }
5321
+ /** 事实链行渲染:序号 + 陈述 + id/生效日期;失效事实附失效日期与后继 id(全链视图用)。 */
5322
+ function renderFactLine(index, fact) {
5323
+ const valid = new Date(fact.validAt).toISOString().slice(0, 10);
5324
+ const state = fact.invalidAt === null ? "" : `(已失效 ${new Date(fact.invalidAt).toISOString().slice(0, 10)},被 ${fact.replacedBy ?? "未知事实"} 取代)`;
5325
+ return `${index + 1}. ${fact.content}(id=${fact.id}, 生效 ${valid})${state}`;
5326
+ }
4559
5327
  /** 历史回填估算的模型可读文本(零成本,先看数再决定跑不跑)。 */
4560
5328
  function renderHistoryEstimate(estimate) {
4561
5329
  if (estimate.unavailable !== void 0) return `历史回填不可用:${estimate.unavailable}`;
@@ -4672,7 +5440,7 @@ async function rewriteQueries(deps, exec, query) {
4672
5440
  }
4673
5441
  }
4674
5442
  /**
4675
- * 构造 19 个工具定义(engram_save/search/assess/timeline/episode_timeline/update/forget/report/review/review_queue/
5443
+ * 构造 20 个工具定义(engram_save/search/facts/assess/timeline/episode_timeline/update/forget/report/review/review_queue/
4676
5444
  * stats/export/distill/examine/neighbors/audit_forgotten/tour/ingest_history/profile_edit)。
4677
5445
  * @param baseDeps - 分库打开器、嵌入器、辅助 LLM、导出目录。
4678
5446
  * @returns 可直接 register 的工具定义数组(execute 已绑定会话 cwd 的项目宫殿路由)。
@@ -4718,7 +5486,10 @@ function createEngramTools(baseDeps) {
4718
5486
  */
4719
5487
  async function writeWithDisposition(store, item) {
4720
5488
  const { embedding } = item;
4721
- const decision = await decideWrite(store, item.kind, embedding);
5489
+ const decision = await decideWrite(store, item.kind, embedding, deps.judge === void 0 ? {} : {
5490
+ judge: deps.judge,
5491
+ content: item.content
5492
+ });
4722
5493
  if (decision.disposition === "merge") return {
4723
5494
  disposition: "merge",
4724
5495
  record: await applyMerge(store, decision, item.content),
@@ -4737,13 +5508,14 @@ function createEngramTools(baseDeps) {
4737
5508
  });
4738
5509
  if (decision.disposition === "defer") {
4739
5510
  const candidates = await store.findContradictions(embedding);
4740
- for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
5511
+ const confirmed = deps.judge === void 0 ? candidates : await confirmContradictions(deps.judge, item.content, candidates) ?? candidates;
5512
+ for (const candidate of confirmed) await store.linkEdge(record.id, candidate.id, "contradicts");
4741
5513
  return {
4742
5514
  disposition: "defer",
4743
5515
  record,
4744
5516
  relatedId: decision.neighbor.id,
4745
5517
  similarity: decision.similarity,
4746
- candidates
5518
+ candidates: confirmed
4747
5519
  };
4748
5520
  }
4749
5521
  return {
@@ -4752,6 +5524,17 @@ function createEngramTools(baseDeps) {
4752
5524
  candidates: []
4753
5525
  };
4754
5526
  }
5527
+ /**
5528
+ * 实体关联辅助:消解实体提及并挂到条目。merge 处置挂被并入的既有条目,
5529
+ * accept/defer 挂新条目;失败静默(实体词典是增强数据,不阻塞记忆写入)。
5530
+ */
5531
+ async function linkEntities(store, nodeId, mentions) {
5532
+ if (mentions.length === 0) return;
5533
+ try {
5534
+ const entities = await store.resolveEntities(mentions);
5535
+ await store.linkNodeEntities(nodeId, entities.map((entity) => entity.id));
5536
+ } catch {}
5537
+ }
4755
5538
  /** 门牌参数收敛:非空字符串转 ImageryLabel(感官/情绪维度留空——AI 不需要人脑补丁),非法返回 undefined。 */
4756
5539
  function placardOf(raw) {
4757
5540
  if (typeof raw !== "string") return void 0;
@@ -4817,7 +5600,8 @@ function createEngramTools(baseDeps) {
4817
5600
  index,
4818
5601
  content,
4819
5602
  kind: candidate.kind,
4820
- importance: typeof candidate.importance === "number" ? candidate.importance : void 0
5603
+ importance: typeof candidate.importance === "number" ? candidate.importance : void 0,
5604
+ entities: parseEntityMentions(candidate.entities)
4821
5605
  });
4822
5606
  }
4823
5607
  const vectors = embedder === void 0 || prepared.length === 0 ? void 0 : await embedder.embed(prepared.map((item) => item.content.trim()));
@@ -4832,6 +5616,7 @@ function createEngramTools(baseDeps) {
4832
5616
  sourceSessionId,
4833
5617
  ...embedding === void 0 ? {} : { embedding }
4834
5618
  });
5619
+ await linkEntities(store, outcome.disposition === "merge" && outcome.relatedId !== void 0 ? asMemoryId(outcome.relatedId) : outcome.record.id, item.entities);
4835
5620
  saved.push({
4836
5621
  id: outcome.record.id,
4837
5622
  kind: outcome.record.kind,
@@ -4865,9 +5650,40 @@ function createEngramTools(baseDeps) {
4865
5650
  enum: [...KINDS],
4866
5651
  description: "记忆种类(单条模式必填)"
4867
5652
  },
5653
+ entities: {
5654
+ type: "array",
5655
+ description: "本条记忆提到的实体(可选,仅单条模式):人名/项目名/工具名/概念名,用于按实体检索",
5656
+ items: {
5657
+ type: "object",
5658
+ additionalProperties: false,
5659
+ properties: {
5660
+ name: {
5661
+ type: "string",
5662
+ required: true,
5663
+ description: "实体名(专名本身,不超过 20 字)"
5664
+ },
5665
+ kind: {
5666
+ type: "string",
5667
+ enum: [
5668
+ "person",
5669
+ "project",
5670
+ "tool",
5671
+ "concept",
5672
+ "other"
5673
+ ],
5674
+ description: "实体种类,默认 other"
5675
+ },
5676
+ aliases: {
5677
+ type: "array",
5678
+ items: { type: "string" },
5679
+ description: "该实体的其他叫法"
5680
+ }
5681
+ }
5682
+ }
5683
+ },
4868
5684
  items: {
4869
5685
  type: "array",
4870
- description: "批量保存条目数组,每项 {content, kind, importance?};与 content/kind 二选一",
5686
+ description: "批量保存条目数组,每项 {content, kind, importance?, entities?};与 content/kind 二选一",
4871
5687
  items: {
4872
5688
  type: "object",
4873
5689
  additionalProperties: false,
@@ -4886,6 +5702,37 @@ function createEngramTools(baseDeps) {
4886
5702
  importance: {
4887
5703
  type: "number",
4888
5704
  description: "重要性 0-1"
5705
+ },
5706
+ entities: {
5707
+ type: "array",
5708
+ description: "该条提到的实体",
5709
+ items: {
5710
+ type: "object",
5711
+ additionalProperties: false,
5712
+ properties: {
5713
+ name: {
5714
+ type: "string",
5715
+ required: true,
5716
+ description: "实体名(专名本身,不超过 20 字)"
5717
+ },
5718
+ kind: {
5719
+ type: "string",
5720
+ enum: [
5721
+ "person",
5722
+ "project",
5723
+ "tool",
5724
+ "concept",
5725
+ "other"
5726
+ ],
5727
+ description: "实体种类,默认 other"
5728
+ },
5729
+ aliases: {
5730
+ type: "array",
5731
+ items: { type: "string" },
5732
+ description: "该实体的其他叫法"
5733
+ }
5734
+ }
5735
+ }
4889
5736
  }
4890
5737
  }
4891
5738
  }
@@ -5013,6 +5860,7 @@ function createEngramTools(baseDeps) {
5013
5860
  const embedder = await deps.embedder;
5014
5861
  const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
5015
5862
  const imagery = placardOf(input.placard);
5863
+ const mentions = parseEntityMentions(input.entities);
5016
5864
  const outcome = await writeWithDisposition(store, {
5017
5865
  scope,
5018
5866
  kind: input.kind,
@@ -5022,6 +5870,7 @@ function createEngramTools(baseDeps) {
5022
5870
  ...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] },
5023
5871
  ...imagery === void 0 ? {} : { imagery }
5024
5872
  });
5873
+ if (mentions.length > 0) await linkEntities(store, outcome.disposition === "merge" && outcome.relatedId !== void 0 ? asMemoryId(outcome.relatedId) : outcome.record.id, mentions);
5025
5874
  const { record, candidates } = outcome;
5026
5875
  if (outcome.disposition === "merge") {
5027
5876
  const sim = (outcome.similarity ?? 0).toFixed(2);
@@ -5083,6 +5932,10 @@ function createEngramTools(baseDeps) {
5083
5932
  limit: {
5084
5933
  type: "number",
5085
5934
  description: "返回条数上限,默认 8"
5935
+ },
5936
+ asOf: {
5937
+ type: "string",
5938
+ description: "时点回看(可选,ISO 日期如 2026-03-01):结果行附各记忆关联实体在该时点仍有效的事实快照,回答「当时」类问题时防止过时事实误导"
5086
5939
  }
5087
5940
  },
5088
5941
  output: {
@@ -5110,6 +5963,7 @@ function createEngramTools(baseDeps) {
5110
5963
  const scopes = scopesOf(input.scope);
5111
5964
  const rooms = typeof input.room === "string" && input.room.trim() !== "" ? [input.room.trim()] : void 0;
5112
5965
  const limit = input.limit ?? 8;
5966
+ const asOfMs = parseTimeParam(typeof input.asOf === "string" ? input.asOf : void 0, "engram_search", "asOf");
5113
5967
  const rewrite = await rewriteQueries(deps, exec, input.query);
5114
5968
  const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
5115
5969
  const vector = await queryVectorOf(deps, queryText);
@@ -5127,15 +5981,49 @@ function createEngramTools(baseDeps) {
5127
5981
  };
5128
5982
  }));
5129
5983
  const degraded = retrievals.some((retrieval) => retrieval.degraded);
5130
- const { kept, dropped } = fitWithinBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
5984
+ const merged = mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length)));
5985
+ const factNotes = /* @__PURE__ */ new Map();
5986
+ if (asOfMs !== void 0) {
5987
+ const scopeGroups = /* @__PURE__ */ new Map();
5988
+ for (const hit of merged) {
5989
+ const ids = scopeGroups.get(hit.record.scope);
5990
+ if (ids === void 0) scopeGroups.set(hit.record.scope, [hit.record.id]);
5991
+ else ids.push(hit.record.id);
5992
+ }
5993
+ for (const [scope, ids] of scopeGroups) {
5994
+ const store = await deps.openStore(scope);
5995
+ const entityMap = await store.entitiesOfNodes(ids);
5996
+ const factLists = /* @__PURE__ */ new Map();
5997
+ for (const entities of entityMap.values()) for (const entity of entities) {
5998
+ if (factLists.has(entity.id)) continue;
5999
+ factLists.set(entity.id, (await store.factsOfEntity({
6000
+ entityId: entity.id,
6001
+ asOf: asOfMs,
6002
+ limit: 3,
6003
+ offset: 0
6004
+ })).items);
6005
+ }
6006
+ for (const [nodeId, entities] of entityMap) {
6007
+ const contents = [];
6008
+ for (const entity of entities) for (const fact of factLists.get(entity.id) ?? []) {
6009
+ if (contents.length >= 4) break;
6010
+ contents.push(fact.content);
6011
+ }
6012
+ if (contents.length > 0) factNotes.set(String(nodeId), contents);
6013
+ }
6014
+ }
6015
+ }
6016
+ const { kept, dropped } = fitWithinBudget(merged.map((hit, index) => {
5131
6017
  const ref = evidenceRefOf(hit.record.scope, hit.record.id, hit.record.slot);
5132
6018
  const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
5133
6019
  const slot = hit.record.slot === void 0 ? "" : ` ${hit.record.slot.room}#${hit.record.slot.index}`;
5134
6020
  const date = ` 刻于 ${new Date(hit.record.createdAt).toISOString().slice(0, 10)}`;
5135
6021
  const cues = hit.cues === void 0 ? "" : ` 相邻桩位: ${hit.cues.neighbors.join(", ")}`;
6022
+ const notes = factNotes.get(String(hit.record.id));
6023
+ const facts = notes === void 0 ? "" : ` 时点事实: ${notes.join(";")}`;
5136
6024
  return {
5137
6025
  ref,
5138
- line: `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id}, ref=${ref})${edge}${cues}`
6026
+ line: `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id}, ref=${ref})${edge}${cues}${facts}`
5139
6027
  };
5140
6028
  }), (entry) => entry.line.length, RECALL_TOTAL_CHARS);
5141
6029
  const lines = kept.map((entry) => entry.line);
@@ -5144,10 +6032,284 @@ function createEngramTools(baseDeps) {
5144
6032
  const batch = sessionId === void 0 || kept.length === 0 ? void 0 : evidenceBatches.register(sessionId, kept.map((entry) => entry.ref));
5145
6033
  const prefix = degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : "";
5146
6034
  const roomNote = rooms === void 0 ? "" : `(房间路由:${rooms.join("、")})\n`;
6035
+ const asOfNote = asOfMs === void 0 ? "" : `(时点回看:行尾「时点事实」为关联实体在 ${new Date(asOfMs).toISOString().slice(0, 10)} 仍有效的事实快照)\n`;
5147
6036
  const batchNote = batch === void 0 ? "" : `\n批次 ${batch.batchId}(${String(batch.refs.size)} 条可引用证据):作答前用 engram_assess 判定证据是否充分,evidenceRefs 只能引用上面的 ref。`;
5148
6037
  return {
5149
6038
  degraded,
5150
- text: renderMemoryPacket(`${prefix}${roomNote}${lines.join("\n") || "无命中"}${batchNote}`, "tool_search", input.query)
6039
+ text: renderMemoryPacket(`${prefix}${roomNote}${asOfNote}${lines.join("\n") || "无命中"}${batchNote}`, "tool_search", input.query)
6040
+ };
6041
+ }
6042
+ });
6043
+ const factsTool = defineTool({
6044
+ name: "engram_facts",
6045
+ description: "实体事实链:查询某实体的时序事实(asOf 时点回看 / includeInvalid 全链展开),或写入与修正事实(facts 数组)。事实是关于具体实体的一条客观陈述(状态/归属/关系/数据)。修正过时事实时传 replaces=旧事实 id:旧事实软失效、新事实接棒,历史链保留——不要反复保存互相矛盾的事实,用取代链表达演变。",
6046
+ parameters: {
6047
+ scope: {
6048
+ type: "string",
6049
+ enum: [
6050
+ "user",
6051
+ "project",
6052
+ "shared"
6053
+ ],
6054
+ description: "作用域,默认 project"
6055
+ },
6056
+ entityId: {
6057
+ type: "string",
6058
+ description: "查询模式:实体 id(engram_search/engram_entities 输出中获取;与 entity 二选一,优先)"
6059
+ },
6060
+ entity: {
6061
+ type: "string",
6062
+ description: "查询模式:实体名(按名称与别名精确匹配;未命中不新建,返回近似候选)"
6063
+ },
6064
+ asOf: {
6065
+ type: "string",
6066
+ description: "时点回看(ISO 日期或毫秒时间戳):只看该时点仍有效的事实"
6067
+ },
6068
+ includeInvalid: {
6069
+ type: "boolean",
6070
+ description: "true 展开全链(含已失效事实,查看事实演变历史)"
6071
+ },
6072
+ limit: {
6073
+ type: "number",
6074
+ description: "返回条数上限,默认 20"
6075
+ },
6076
+ offset: {
6077
+ type: "number",
6078
+ description: "分页偏移,默认 0"
6079
+ },
6080
+ facts: {
6081
+ type: "array",
6082
+ description: "写入模式:事实数组(与查询参数二选一),每项 {entity 或 entityId, content, replaces?}",
6083
+ items: {
6084
+ type: "object",
6085
+ additionalProperties: false,
6086
+ properties: {
6087
+ entity: {
6088
+ type: "string",
6089
+ description: "实体名(与 entityId 二选一;实体不存在时新建)"
6090
+ },
6091
+ entityId: {
6092
+ type: "string",
6093
+ description: "实体 id(优先于 entity)"
6094
+ },
6095
+ content: {
6096
+ type: "string",
6097
+ required: true,
6098
+ description: "事实陈述(一句话,不超过 500 字符)"
6099
+ },
6100
+ replaces: {
6101
+ type: "string",
6102
+ description: "被取代的旧事实 id(旧事实软失效;id 不存在时按无取代写入)"
6103
+ }
6104
+ }
6105
+ }
6106
+ }
6107
+ },
6108
+ output: {
6109
+ schema: {
6110
+ type: "object",
6111
+ additionalProperties: false,
6112
+ properties: {
6113
+ total: { type: "number" },
6114
+ count: { type: "number" },
6115
+ items: {
6116
+ type: "array",
6117
+ items: {
6118
+ type: "object",
6119
+ additionalProperties: false,
6120
+ properties: {
6121
+ id: {
6122
+ type: "string",
6123
+ required: true
6124
+ },
6125
+ entityId: {
6126
+ type: "string",
6127
+ required: true
6128
+ },
6129
+ replaced: { type: "string" }
6130
+ }
6131
+ }
6132
+ },
6133
+ failed: {
6134
+ type: "array",
6135
+ items: {
6136
+ type: "object",
6137
+ additionalProperties: false,
6138
+ properties: {
6139
+ index: {
6140
+ type: "number",
6141
+ required: true
6142
+ },
6143
+ reason: {
6144
+ type: "string",
6145
+ required: true
6146
+ }
6147
+ }
6148
+ }
6149
+ },
6150
+ text: {
6151
+ type: "string",
6152
+ required: true
6153
+ }
6154
+ }
6155
+ },
6156
+ render: (_args, value) => [{
6157
+ type: "text",
6158
+ text: value.text
6159
+ }]
6160
+ },
6161
+ async execute(args) {
6162
+ const input = args;
6163
+ const scope = scopeOf(input.scope, "project");
6164
+ const store = await deps.openStore(scope);
6165
+ const asOfMs = parseTimeParam(typeof input.asOf === "string" ? input.asOf : void 0, "engram_facts", "asOf");
6166
+ if (input.facts !== void 0) {
6167
+ if (input.entityId !== void 0 || input.entity !== void 0 || input.asOf !== void 0 || input.includeInvalid !== void 0) throw new Error("engram_facts: facts(写入)与 entityId/entity/asOf/includeInvalid(查询)参数不能同时使用");
6168
+ if (!Array.isArray(input.facts) || input.facts.length === 0) throw new Error("engram_facts: facts 必须是非空数组");
6169
+ if (input.facts.length > MAX_SAVE_BATCH) throw new Error(`engram_facts: 单次最多写入 ${MAX_SAVE_BATCH} 条事实`);
6170
+ const failed = [];
6171
+ const prepared = [];
6172
+ for (const [index, raw] of input.facts.entries()) {
6173
+ if (raw === null || typeof raw !== "object") {
6174
+ failed.push({
6175
+ index,
6176
+ reason: "条目必须是对象"
6177
+ });
6178
+ continue;
6179
+ }
6180
+ const candidate = raw;
6181
+ if (typeof candidate.content !== "string" || candidate.content.trim() === "") {
6182
+ failed.push({
6183
+ index,
6184
+ reason: "content 缺失或为空"
6185
+ });
6186
+ continue;
6187
+ }
6188
+ const content = redactSecrets(sanitizeProtocolText(candidate.content));
6189
+ if (content.trim() === "") {
6190
+ failed.push({
6191
+ index,
6192
+ reason: "清洗后内容为空(只含协议标签或密钥)"
6193
+ });
6194
+ continue;
6195
+ }
6196
+ try {
6197
+ let entityId;
6198
+ if (typeof candidate.entityId === "string" && candidate.entityId.trim() !== "") entityId = asEntityId(candidate.entityId.trim());
6199
+ else if (typeof candidate.entity === "string" && candidate.entity.trim() !== "") {
6200
+ const resolvedEntity = (await store.resolveEntities([{
6201
+ name: candidate.entity.trim(),
6202
+ kind: "other"
6203
+ }]))[0];
6204
+ if (resolvedEntity === void 0) {
6205
+ failed.push({
6206
+ index,
6207
+ reason: "实体消解未返回结果"
6208
+ });
6209
+ continue;
6210
+ }
6211
+ entityId = resolvedEntity.id;
6212
+ } else {
6213
+ failed.push({
6214
+ index,
6215
+ reason: "需要 entity 或 entityId"
6216
+ });
6217
+ continue;
6218
+ }
6219
+ prepared.push({
6220
+ index,
6221
+ input: {
6222
+ entityId,
6223
+ content,
6224
+ ...typeof candidate.replaces === "string" && candidate.replaces.trim() !== "" ? { replaces: asFactId(candidate.replaces.trim()) } : {}
6225
+ }
6226
+ });
6227
+ } catch (error) {
6228
+ failed.push({
6229
+ index,
6230
+ reason: error instanceof Error ? error.message : String(error)
6231
+ });
6232
+ }
6233
+ }
6234
+ const records = prepared.length === 0 ? [] : await store.writeFacts(prepared.map((entry) => entry.input));
6235
+ const items = [];
6236
+ for (const [position, record] of records.entries()) {
6237
+ const entry = prepared[position];
6238
+ if (entry === void 0) continue;
6239
+ items.push({
6240
+ id: record.id,
6241
+ entityId: record.entityId,
6242
+ ...entry.input.replaces === void 0 ? {} : { replaced: entry.input.replaces }
6243
+ });
6244
+ }
6245
+ const parts = [`已写入 ${String(records.length)} 条事实`];
6246
+ const replacedCount = prepared.filter((entry) => entry.input.replaces !== void 0).length;
6247
+ if (replacedCount > 0) parts.push(`其中 ${String(replacedCount)} 条声明取代旧事实(旧事实已软失效,历史链保留;id 不存在时按无取代写入)`);
6248
+ for (const item of items) parts.push(`${item.id} → 实体 ${item.entityId}${item.replaced === void 0 ? "" : `(取代 ${item.replaced})`}`);
6249
+ if (failed.length > 0) parts.push(`${String(failed.length)} 条失败:${failed.map((entry) => `#${String(entry.index + 1)} ${entry.reason}`).join(";")}`);
6250
+ return {
6251
+ count: records.length,
6252
+ items,
6253
+ ...failed.length > 0 ? { failed } : {},
6254
+ text: parts.join(";")
6255
+ };
6256
+ }
6257
+ const hasEntityId = typeof input.entityId === "string" && input.entityId.trim() !== "";
6258
+ const hasEntityName = typeof input.entity === "string" && input.entity.trim() !== "";
6259
+ if (!hasEntityId && !hasEntityName) throw new Error("engram_facts: 需要 entityId/entity(查询)或 facts(写入)参数");
6260
+ const limit = parseCountParam(typeof input.limit === "number" ? input.limit : void 0, "engram_facts", "limit", 20, 1, 100);
6261
+ const offset = parseCountParam(typeof input.offset === "number" ? input.offset : void 0, "engram_facts", "offset", 0, 0, 1e5);
6262
+ const includeInvalid = input.includeInvalid === true;
6263
+ let entityId;
6264
+ let entityName;
6265
+ if (hasEntityId) {
6266
+ entityId = asEntityId(input.entityId.trim());
6267
+ const detail = await store.entityDetail(entityId, 0);
6268
+ if (detail === void 0) throw new Error(`engram_facts: 实体 ${input.entityId} 不存在`);
6269
+ entityName = detail.entity.name;
6270
+ } else {
6271
+ const name = input.entity.trim();
6272
+ const { items: candidates } = await store.listEntities({
6273
+ q: name,
6274
+ limit: 10,
6275
+ offset: 0
6276
+ });
6277
+ const normalized = normalizeEntityName(name);
6278
+ const matches = candidates.filter((item) => normalizeEntityName(item.entity.name) === normalized || item.entity.aliases.some((alias) => normalizeEntityName(alias) === normalized));
6279
+ if (matches.length === 0) {
6280
+ const near = candidates.map((item) => `${item.entity.name}(id=${item.entity.id})`).join(";");
6281
+ return {
6282
+ total: 0,
6283
+ text: `未找到实体「${name}」(按名称与别名精确匹配,不自动新建)。${near === "" ? "词典中没有近似名称。" : `近似候选:${near}。可用 entityId 精确查询。`}`
6284
+ };
6285
+ }
6286
+ if (matches.length > 1) {
6287
+ const listed = matches.map((item) => `${item.entity.name}(id=${item.entity.id})`).join(";");
6288
+ return {
6289
+ total: 0,
6290
+ text: `名称「${name}」匹配到 ${String(matches.length)} 个实体:${listed}。请用 entityId 指定。`
6291
+ };
6292
+ }
6293
+ const match = matches[0];
6294
+ if (match === void 0) return {
6295
+ total: 0,
6296
+ text: `未找到实体「${name}」。`
6297
+ };
6298
+ entityId = match.entity.id;
6299
+ entityName = match.entity.name;
6300
+ }
6301
+ const { items: facts, total } = await store.factsOfEntity({
6302
+ entityId,
6303
+ ...asOfMs === void 0 ? {} : { asOf: asOfMs },
6304
+ ...includeInvalid ? { includeInvalid: true } : {},
6305
+ limit,
6306
+ offset
6307
+ });
6308
+ const header = `实体「${entityName}」的事实(共 ${String(total)} 条${asOfMs === void 0 ? "" : `,时点 ${new Date(asOfMs).toISOString().slice(0, 10)}`}${includeInvalid ? ",全链含已失效" : ""})`;
6309
+ const lines = facts.map((fact, index) => renderFactLine(index, fact));
6310
+ return {
6311
+ total,
6312
+ text: renderMemoryPacket(`${header}\n${lines.length === 0 ? "没有符合条件的事实。" : lines.join("\n")}`, "tool_facts", hasEntityName ? input.entity : entityId)
5151
6313
  };
5152
6314
  }
5153
6315
  });
@@ -5715,6 +6877,7 @@ function createEngramTools(baseDeps) {
5715
6877
  return [
5716
6878
  save,
5717
6879
  search,
6880
+ factsTool,
5718
6881
  assess,
5719
6882
  timeline,
5720
6883
  episodeTimeline,
@@ -6673,6 +7836,7 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
6673
7836
  embedder,
6674
7837
  mode,
6675
7838
  routeOverride: resolved.routeOverride,
7839
+ ...resolveJevField(resolved.jev, resolved.dbDir),
6676
7840
  call: (params) => streamText(ctx, {
6677
7841
  ...params,
6678
7842
  sessionId: agent.session.id
@@ -6693,6 +7857,7 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
6693
7857
  embedder,
6694
7858
  mode,
6695
7859
  routeOverride: resolved.routeOverride,
7860
+ ...resolveJevField(resolved.jev, resolved.dbDir),
6696
7861
  call: (params) => streamText(ctx, {
6697
7862
  ...params,
6698
7863
  sessionId: agent.session.id
@@ -7017,7 +8182,8 @@ function apply(ctx, config = {}) {
7017
8182
  ...callParams,
7018
8183
  sessionId: ""
7019
8184
  }),
7020
- logRequest: logIngestRequest
8185
+ logRequest: logIngestRequest,
8186
+ ...resolveJevField(resolved.jev, resolved.dbDir)
7021
8187
  });
7022
8188
  /** 回填任务(进程内单例;面板轮询它的进度,工具同步等待自己的那一次运行)。 */
7023
8189
  let backfillJob;
@@ -7124,6 +8290,7 @@ function apply(ctx, config = {}) {
7124
8290
  sessionId: callParams.sessionId ?? ""
7125
8291
  }),
7126
8292
  routeOverride: resolved.routeOverride,
8293
+ ...resolveJevField(resolved.jev, resolved.dbDir),
7127
8294
  queryRewrite: resolved.queryRewrite,
7128
8295
  exportDir: `${resolved.dbDir}/exports`,
7129
8296
  historyBackfill: {
@@ -7140,6 +8307,10 @@ function apply(ctx, config = {}) {
7140
8307
  dbDir: resolved.dbDir,
7141
8308
  pluginVersion: VERSION,
7142
8309
  embedder,
8310
+ jev: {
8311
+ base: resolved.jev,
8312
+ dbDir: resolved.dbDir
8313
+ },
7143
8314
  history: {
7144
8315
  estimate: historyApi.estimate,
7145
8316
  start: historyApi.start,
@@ -7171,6 +8342,7 @@ function apply(ctx, config = {}) {
7171
8342
  embedder,
7172
8343
  mode,
7173
8344
  routeOverride: resolved.routeOverride,
8345
+ ...resolveJevField(resolved.jev, resolved.dbDir),
7174
8346
  call: (params) => streamText(ctx, {
7175
8347
  ...params,
7176
8348
  sessionId: session.id