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