@kenz1117/dsh-engram 0.7.9 → 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/README.en.md +36 -16
- package/README.md +36 -16
- package/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +1196 -24
- package/package.json +1 -1
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
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
* 构造
|
|
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
|
-
|
|
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
|
|
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
|