dsh-prime-memory 0.26.5 → 0.26.9
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/CHANGELOG.md +39 -0
- package/README.en.md +62 -1
- package/README.md +60 -2
- package/dist/client.js +45 -5
- package/dist/config.d.ts +40 -0
- package/dist/config.js +33 -3
- package/dist/contract.d.ts +14 -1
- package/dist/embedding-child.cjs +192 -0
- package/dist/embedding-worker.cjs +2 -0
- package/dist/graph/apply.d.ts +9 -0
- package/dist/graph/apply.js +12 -2
- package/dist/graph/search.d.ts +11 -2
- package/dist/graph/search.js +38 -10
- package/dist/hooks/recall.js +28 -1
- package/dist/index.js +14 -1
- package/dist/llm.d.ts +21 -2
- package/dist/llm.js +56 -25
- package/dist/pipeline/graph.d.ts +17 -1
- package/dist/pipeline/graph.js +45 -2
- package/dist/pipeline/l1.d.ts +15 -2
- package/dist/pipeline/l1.js +161 -47
- package/dist/pipeline/l2.js +72 -50
- package/dist/pipeline/md-sync.js +4 -2
- package/dist/pipeline/ruminate.js +2 -0
- package/dist/pipeline/runner.d.ts +32 -0
- package/dist/pipeline/runner.js +109 -11
- package/dist/pipeline/slot-migrate.d.ts +3 -2
- package/dist/pipeline/slot-migrate.js +3 -2
- package/dist/projection/recall.d.ts +10 -3
- package/dist/projection/recall.js +14 -0
- package/dist/settings.js +6 -0
- package/dist/stats.d.ts +1 -1
- package/dist/stats.js +35 -14
- package/dist/store/embedding-source.d.ts +4 -2
- package/dist/store/embedding-source.js +5 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +71 -1
- package/dist/store/graph-store.js +221 -8
- package/dist/store/l1.d.ts +5 -0
- package/dist/store/l1.js +18 -2
- package/dist/store/local-embedding.d.ts +9 -5
- package/dist/store/local-embedding.js +137 -17
- package/dist/store/memory-md-disable.d.ts +3 -2
- package/dist/store/memory-md.d.ts +3 -3
- package/dist/store/memory-md.js +6 -4
- package/dist/store/route-health.d.ts +38 -0
- package/dist/store/route-health.js +115 -0
- package/dist/store/runtime-installer.d.ts +13 -1
- package/dist/store/runtime-installer.js +40 -2
- package/dist/store/sqlite.d.ts +24 -12
- package/dist/store/sqlite.js +97 -31
- package/dist/store/supersede.d.ts +10 -2
- package/dist/store/supersede.js +10 -2
- package/dist/store/validity.d.ts +150 -0
- package/dist/store/validity.js +209 -0
- package/dist/tools/index.d.ts +15 -1
- package/dist/tools/index.js +393 -11
- package/dist/util/json-repair.d.ts +48 -0
- package/dist/util/json-repair.js +285 -0
- package/dist/validity-backfill.d.ts +78 -0
- package/dist/validity-backfill.js +160 -0
- package/dsh.plugin.json +2 -2
- package/package.json +3 -3
package/dist/llm.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
2
2
|
import { errDetail } from './util/filelog.js';
|
|
3
|
+
import { parseJsonTolerant } from './util/json-repair.js';
|
|
3
4
|
import { recordDistillCall } from './llm-usage.js';
|
|
4
5
|
import { recordCostCall } from './token-cost.js';
|
|
5
6
|
// ── 分层输出预算(规格 C 节):结构化蒸馏用不到总闸级预算,逐层设护栏;
|
|
@@ -45,11 +46,28 @@ export const HIGH_EFFORT_TIERS = ['high', 'xhigh', 'max'];
|
|
|
45
46
|
export function layerMaxTokens(base, reasoningEffort) {
|
|
46
47
|
return HIGH_EFFORT_TIERS.includes(reasoningEffort) ? base * 4 : base;
|
|
47
48
|
}
|
|
49
|
+
/** 无可用蒸馏路由(显式一线/二线/链皆缺且 requireExplicitRoute=true):
|
|
50
|
+
* runner 据此 skip+顺延、不记退避——区别于"模型慢/超时"(那才退避)。R4。 */
|
|
51
|
+
export class NoDistillRouteError extends Error {
|
|
52
|
+
constructor(msg = '无可用蒸馏路由:已开启 llm.requireExplicitRoute,但未显式配置一线/二线/回退链') {
|
|
53
|
+
super(msg);
|
|
54
|
+
this.name = 'NoDistillRouteError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
48
57
|
/** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
|
|
49
58
|
export async function resolveModelRoute(ctx, cfg) {
|
|
50
59
|
if (cfg.llm.provider && cfg.llm.model) {
|
|
51
60
|
return { provider: cfg.llm.provider, model: cfg.llm.model };
|
|
52
61
|
}
|
|
62
|
+
// R4 fail-closed:开了 requireExplicitRoute 且二线/回退链都未配 → 不蹭宿主默认模型,
|
|
63
|
+
// 抛专用错误(runner 侧 skip+顺延、不记退避——"没配路由"≠"模型慢")。关时逐字走旧路径。
|
|
64
|
+
if (cfg.llm.requireExplicitRoute === true) {
|
|
65
|
+
const sec = cfg.llm.secondary;
|
|
66
|
+
const hasSecondary = !!(sec && sec.provider && sec.model);
|
|
67
|
+
const hasChain = (cfg.llm.fallbacks?.length ?? 0) > 0;
|
|
68
|
+
if (!hasSecondary && !hasChain)
|
|
69
|
+
throw new NoDistillRouteError();
|
|
70
|
+
}
|
|
53
71
|
// agentDefaultModel 是可选服务,缺失时不得抛错。
|
|
54
72
|
const defaults = ctx.get('agentDefaultModel');
|
|
55
73
|
const sel = defaults?.currentSelection?.();
|
|
@@ -58,6 +76,19 @@ export async function resolveModelRoute(ctx, cfg) {
|
|
|
58
76
|
}
|
|
59
77
|
throw new Error('无法解析蒸馏模型路由:请在插件 config 中配置 llm.provider / llm.model,或确保存在默认模型选择');
|
|
60
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* 二线(大容量兜底)选路(R3):单次调用输入字符 ≥ triggerChars 且二线已配 → 返回二线路由,
|
|
81
|
+
* 否则 null(走一线/链)。二线未配(provider/model 任一空)→ 永不命中,不隐式改选任意模型。
|
|
82
|
+
*/
|
|
83
|
+
export function pickSecondary(cfg, inputChars) {
|
|
84
|
+
const sec = cfg.llm.secondary;
|
|
85
|
+
if (!sec || !sec.provider || !sec.model)
|
|
86
|
+
return null;
|
|
87
|
+
const trigger = sec.triggerChars > 0 ? sec.triggerChars : Number.POSITIVE_INFINITY;
|
|
88
|
+
if (!(inputChars >= trigger))
|
|
89
|
+
return null;
|
|
90
|
+
return { provider: sec.provider, model: sec.model, ...(sec.reasoningEffort ? { reasoningEffort: sec.reasoningEffort } : {}) };
|
|
91
|
+
}
|
|
61
92
|
/**
|
|
62
93
|
* 组装蒸馏路由链(纯决策,决策表缝):主路由在前、回退条目按配置顺序在后。
|
|
63
94
|
* provider/model 缺失的条目剔除;与主路由或先前条目完全相同(provider+model)的
|
|
@@ -369,8 +400,17 @@ export async function callLLM(ctx, cfg, opts) {
|
|
|
369
400
|
// 宿主路由链:opts.layer 配了层链的层走层链完整替换,其余走全局解析;
|
|
370
401
|
// direct 模式下这一段充当付费/默认模型的兜底安全网。
|
|
371
402
|
const routes = await resolveLayerRoutes(ctx, cfg, opts.layer);
|
|
372
|
-
|
|
373
|
-
|
|
403
|
+
// R3 二线(大容量兜底):输入 ≥ triggerChars 且二线已配 → 前置二线,让肥调用先走大容量档;
|
|
404
|
+
// 二线未配时 sec=null → 链逐字不变(零漂移)。已在链里的同路由去重。
|
|
405
|
+
const sec = pickSecondary(cfg, opts.user.length);
|
|
406
|
+
const chain = sec
|
|
407
|
+
? [
|
|
408
|
+
{ provider: sec.provider, model: sec.model, effort: sec.reasoningEffort ?? '' },
|
|
409
|
+
...routes.filter((r) => !(r.provider === sec.provider && r.model === sec.model)),
|
|
410
|
+
]
|
|
411
|
+
: routes;
|
|
412
|
+
for (let i = 0; i < chain.length; i++) {
|
|
413
|
+
const route = chain[i];
|
|
374
414
|
try {
|
|
375
415
|
return await callRoute(ctx, cfg, opts, route);
|
|
376
416
|
}
|
|
@@ -380,7 +420,7 @@ export async function callLLM(ctx, cfg, opts) {
|
|
|
380
420
|
throw err;
|
|
381
421
|
lastErr = err;
|
|
382
422
|
warnRouteDeadOnce(route, opts.logger);
|
|
383
|
-
const next =
|
|
423
|
+
const next = chain[i + 1];
|
|
384
424
|
if (next) {
|
|
385
425
|
opts.logger?.info(`[memory] 蒸馏路由降级 ${route.provider}/${route.model} → ${next.provider}/${next.model}(${errDetail(err)})`);
|
|
386
426
|
}
|
|
@@ -499,35 +539,26 @@ async function callRoute(ctx, cfg, opts, route) {
|
|
|
499
539
|
opts.logger?.info(`[memory] LLM 调用 ${provider}/${model}:输入 ${user.length} 字符 → 输出 ${out.length} 字符(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无'})`);
|
|
500
540
|
return out;
|
|
501
541
|
}
|
|
502
|
-
/** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)
|
|
542
|
+
/** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息);修复/救援命中时 warn 一行。 */
|
|
503
543
|
export function parseJsonLogged(raw, what, logger) {
|
|
504
544
|
try {
|
|
505
|
-
|
|
545
|
+
const { value, level, notes } = parseJsonTolerant(raw);
|
|
546
|
+
if (level !== 'strict') {
|
|
547
|
+
logger?.warn(`[memory] ${what} JSON 已容错处理(${level}: ${notes.join('、') || '无动作'}),原文 ${raw.length} 字符`);
|
|
548
|
+
}
|
|
549
|
+
return value;
|
|
506
550
|
}
|
|
507
551
|
catch (err) {
|
|
508
552
|
logger?.error(`[memory] ${what} JSON 解析失败(${errDetail(err)}),原始输出前 400 字符: ${raw.slice(0, 400)}`);
|
|
509
553
|
throw new Error(`${what} 输出无法解析为 JSON`);
|
|
510
554
|
}
|
|
511
555
|
}
|
|
512
|
-
/**
|
|
556
|
+
/**
|
|
557
|
+
* 容错地解析 LLM 输出的 JSON。
|
|
558
|
+
*
|
|
559
|
+
* 旧版行为:剥 ```json``` 围栏 + 切到首个 `[`/`{` 到末尾 `]`/`}`,一次 JSON.parse。
|
|
560
|
+
* 新版同路径 + 三级容错(strict → repaired → salvaged),内部实现见 util/json-repair.ts。
|
|
561
|
+
*/
|
|
513
562
|
export function parseJson(raw) {
|
|
514
|
-
|
|
515
|
-
if (s.startsWith('```')) {
|
|
516
|
-
s = s.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
|
|
517
|
-
}
|
|
518
|
-
const start = s.indexOf('[');
|
|
519
|
-
const brace = s.indexOf('{');
|
|
520
|
-
let begin;
|
|
521
|
-
if (start === -1)
|
|
522
|
-
begin = brace;
|
|
523
|
-
else if (brace === -1)
|
|
524
|
-
begin = start;
|
|
525
|
-
else
|
|
526
|
-
begin = Math.min(start, brace);
|
|
527
|
-
if (begin > 0)
|
|
528
|
-
s = s.slice(begin);
|
|
529
|
-
const end = s.lastIndexOf(']') > s.lastIndexOf('}') ? s.lastIndexOf(']') + 1 : s.lastIndexOf('}') + 1;
|
|
530
|
-
if (end > 0)
|
|
531
|
-
s = s.slice(0, end);
|
|
532
|
-
return JSON.parse(s);
|
|
563
|
+
return parseJsonTolerant(raw).value;
|
|
533
564
|
}
|
package/dist/pipeline/graph.d.ts
CHANGED
|
@@ -34,11 +34,27 @@ export declare function selectContextEdges(nodes: readonly GraphNode[], edges: r
|
|
|
34
34
|
* 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
|
|
35
35
|
*/
|
|
36
36
|
export declare function isTransientGraphFailure(msg: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* 节点向量化的嵌入文本(单一形态定义,读写两侧共用):
|
|
39
|
+
* 实体名 + 别名 + 当前状态串。读侧种子查询用用户 query 的文本向量,
|
|
40
|
+
* 两边同嵌入源同空间即可——不追求"同一句话",追求"同一语义面"。
|
|
41
|
+
*/
|
|
42
|
+
export declare function nodeEmbedText(node: GraphNode): string;
|
|
43
|
+
/**
|
|
44
|
+
* 投影收尾的节点向量补写(G-vec 缺陷修:此前 upsertNodeVectors 生产零调用点,
|
|
45
|
+
* 嵌入开着时 graph_node_vec 永远是 0 行,"向量种子"读路结构性空转)。
|
|
46
|
+
*
|
|
47
|
+
* 失败隔离:嵌入失败/未就绪只 warn——向量是召回增益,缺了它图谱词法路照常,
|
|
48
|
+
* 绝不能把一个旁路补算弄挂投影主流程(与 L1 侧 restore 的向量纪律同向)。
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeNodeVectorsAfterComplete(graphs: GraphStore, touched: readonly GraphNode[], embed: ((text: string) => Promise<Float32Array | undefined>) | undefined, logger: MemoryLogger): Promise<number>;
|
|
37
51
|
/**
|
|
38
52
|
* 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
|
|
39
53
|
* 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
|
|
40
54
|
* GraphStore 管),不向 runner 上抛。
|
|
41
55
|
*/
|
|
42
|
-
export declare function runGraphProjection(ctx: Context, cfg: MemoryConfig, graphs: GraphStore, logger: MemoryLogger
|
|
56
|
+
export declare function runGraphProjection(ctx: Context, cfg: MemoryConfig, graphs: GraphStore, logger: MemoryLogger,
|
|
57
|
+
/** 节点向量补算的嵌入缝(runner 接 stores.l1.embedText;缺席/未就绪 = 不补)。 */
|
|
58
|
+
embedNode?: (text: string) => Promise<Float32Array | undefined>): Promise<boolean>;
|
|
43
59
|
/** 投影输出解析与形状归一:非对象/缺数组字段回空结构(校验侧再逐条把关)。 */
|
|
44
60
|
export declare function parseProjection(raw: string, logger?: MemoryLogger): GraphProjectionResult;
|
package/dist/pipeline/graph.js
CHANGED
|
@@ -86,12 +86,52 @@ export function isTransientGraphFailure(msg) {
|
|
|
86
86
|
low.includes('socket hang') ||
|
|
87
87
|
low.includes('network'));
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* 节点向量化的嵌入文本(单一形态定义,读写两侧共用):
|
|
91
|
+
* 实体名 + 别名 + 当前状态串。读侧种子查询用用户 query 的文本向量,
|
|
92
|
+
* 两边同嵌入源同空间即可——不追求"同一句话",追求"同一语义面"。
|
|
93
|
+
*/
|
|
94
|
+
export function nodeEmbedText(node) {
|
|
95
|
+
return [node.name, ...node.aliases, node.currentState].filter((s) => s && s.trim()).join(' ');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 投影收尾的节点向量补写(G-vec 缺陷修:此前 upsertNodeVectors 生产零调用点,
|
|
99
|
+
* 嵌入开着时 graph_node_vec 永远是 0 行,"向量种子"读路结构性空转)。
|
|
100
|
+
*
|
|
101
|
+
* 失败隔离:嵌入失败/未就绪只 warn——向量是召回增益,缺了它图谱词法路照常,
|
|
102
|
+
* 绝不能把一个旁路补算弄挂投影主流程(与 L1 侧 restore 的向量纪律同向)。
|
|
103
|
+
*/
|
|
104
|
+
export async function writeNodeVectorsAfterComplete(graphs, touched, embed, logger) {
|
|
105
|
+
if (!graphs.nodeVecReady || !embed || touched.length === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const rows = [];
|
|
108
|
+
for (const n of touched) {
|
|
109
|
+
if (n.status !== 'active' && n.status !== 'disputed')
|
|
110
|
+
continue;
|
|
111
|
+
try {
|
|
112
|
+
const v = await embed(nodeEmbedText(n));
|
|
113
|
+
if (v && v.length > 0)
|
|
114
|
+
rows.push({ nodeId: n.id, embedding: v, updatedAt: n.updatedAt });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.warn(`[memory] 节点向量计算失败(该节点跳过,词法路不受影响): ${err instanceof Error ? err.message : String(err)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (rows.length === 0)
|
|
121
|
+
return 0;
|
|
122
|
+
const written = graphs.upsertNodeVectors(rows);
|
|
123
|
+
if (written > 0)
|
|
124
|
+
logger.debug?.(`[memory] 节点向量随投影写入 ${written} 条`);
|
|
125
|
+
return written;
|
|
126
|
+
}
|
|
89
127
|
/**
|
|
90
128
|
* 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
|
|
91
129
|
* 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
|
|
92
130
|
* GraphStore 管),不向 runner 上抛。
|
|
93
131
|
*/
|
|
94
|
-
export async function runGraphProjection(ctx, cfg, graphs, logger
|
|
132
|
+
export async function runGraphProjection(ctx, cfg, graphs, logger,
|
|
133
|
+
/** 节点向量补算的嵌入缝(runner 接 stores.l1.embedText;缺席/未就绪 = 不补)。 */
|
|
134
|
+
embedNode) {
|
|
95
135
|
const claim = graphs.claimNext();
|
|
96
136
|
if (!claim)
|
|
97
137
|
return false;
|
|
@@ -110,7 +150,10 @@ export async function runGraphProjection(ctx, cfg, graphs, logger) {
|
|
|
110
150
|
// 实测 ~2min/job,正好撞线(14 dead 根因);照 backfill callTimeoutMs 先例给 240s。
|
|
111
151
|
signal: AbortSignal.timeout(GRAPH_PROJECTION_CALL_TIMEOUT_MS),
|
|
112
152
|
});
|
|
113
|
-
graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
|
|
153
|
+
const touched = graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
|
|
154
|
+
// 投影落库后的向量收尾(G-vec 缺陷修):事务外做——嵌入是网络/推理调用,
|
|
155
|
+
// LLM 与嵌入永不进事务缝是本文件头注释写死的既有纪律。
|
|
156
|
+
await writeNodeVectorsAfterComplete(graphs, touched, embedNode, logger);
|
|
114
157
|
}
|
|
115
158
|
catch (err) {
|
|
116
159
|
const detail = errDetail(err);
|
package/dist/pipeline/l1.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { MemoryConfig } from '../config.js';
|
|
3
|
+
import type { RouteHealthStore } from '../store/route-health.js';
|
|
3
4
|
import type { L1Store } from '../store/l1.js';
|
|
4
5
|
import type { MemoryState } from '../store/state.js';
|
|
5
6
|
import type { ConversationAnchor, ConversationMessage, ExtractedMemory, ExtractMode, MemoryFamily, MemoryLogger, MemoryRecord, MemoryScope } from '../types.js';
|
|
7
|
+
/**
|
|
8
|
+
* L2 场景整合单块字符硬顶(R1):与抽取同精神——旧版一次把整族 backlog 塞单调用,
|
|
9
|
+
* 库越大越必撞超时(实测 work-L2 >60s abort)。砍到 24k 后逐块整合,失败半径从
|
|
10
|
+
* “整族 backlog”缩到“一块”。默认对齐 MAX_EXTRACT_CHUNK_CHARS。
|
|
11
|
+
*/
|
|
12
|
+
export declare const MAX_SCENE_CHUNK_CHARS = 24000;
|
|
6
13
|
export interface ExtractionResult {
|
|
7
14
|
stored: number;
|
|
8
15
|
skipped: boolean;
|
|
@@ -12,6 +19,8 @@ export interface ExtractionResult {
|
|
|
12
19
|
runId?: string;
|
|
13
20
|
/** §F 追踪捎带:六值决策词表聚合;早退分支缺省。 */
|
|
14
21
|
byKind?: Record<string, number>;
|
|
22
|
+
/** R1′ 抽取块级失败的消息:调用方应留桶下轮重投(部分消费,不整批回滚)。 */
|
|
23
|
+
failedMessages?: ConversationMessage[];
|
|
15
24
|
}
|
|
16
25
|
/** 分族 checkpoint 桶(活引用,改动由调用方 save 落盘)。 */
|
|
17
26
|
export type FamilyStates = Record<MemoryFamily, MemoryState>;
|
|
@@ -55,7 +64,9 @@ export declare function toStoreRecord(m: PendingMemory, now: number, ts: number,
|
|
|
55
64
|
* 按字符预算把消息切成多块(保持顺序,单条超预算独占一块,由 callLLM 兜底截断)。
|
|
56
65
|
* 每条消息按 content 长度 + 64 字符脚手架开销(id/时间戳行)计。
|
|
57
66
|
*/
|
|
58
|
-
export declare function chunkByCharBudget
|
|
67
|
+
export declare function chunkByCharBudget<T extends {
|
|
68
|
+
content: string;
|
|
69
|
+
}>(messages: readonly T[], budgetChars: number): T[][];
|
|
59
70
|
export declare function runExtraction(ctx: Context, cfg: MemoryConfig, store: L1Store, states: FamilyStates, pending: ConversationMessage[], background: ConversationMessage[], logger: MemoryLogger, mode: ExtractMode,
|
|
60
71
|
/**
|
|
61
72
|
* §E 当前工作区标识(由调用方经 `sessionWorkspaceIdOf` 解析;拿不到传 undefined)。
|
|
@@ -74,4 +85,6 @@ anchorMap?: ReadonlyMap<string, ConversationAnchor>,
|
|
|
74
85
|
* 另两个 sidecar 里,而 merge 每次新铸 id——不搬就等于每次合并把这条记忆的
|
|
75
86
|
* `used`/人工打分清零,它会被当成“没用过”而沉底。缺省不搬(行为与改动前一致)。
|
|
76
87
|
*/
|
|
77
|
-
mergeRekey?: (oldId: string, newId: string) => void
|
|
88
|
+
mergeRekey?: (oldId: string, newId: string) => void,
|
|
89
|
+
/** R9 路由健康学习(可选):读已学到的跑通块长收紧 perChunk,成功块回记。未传=不启用。 */
|
|
90
|
+
routeHealth?: RouteHealthStore): Promise<ExtractionResult>;
|
package/dist/pipeline/l1.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { randomBytes } from 'node:crypto';
|
|
10
10
|
import { normWingEnabled } from '../config.js';
|
|
11
|
-
import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
|
|
11
|
+
import { callLLM, parseJsonLogged, resolveLayerTokens, resolveModelRoute } from '../llm.js';
|
|
12
12
|
import { buildReceipts, newRunId, persistReceiptsSafely } from '../store/receipts.js';
|
|
13
13
|
import { buildConflictPair, conflictRejectId, DEFER_MAX, normalizeClaimKey, normalizeConflictType, occupiesConflictQuota, pendingHardTotal, validateConflictPair, } from '../store/conflicts.js';
|
|
14
14
|
import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
|
|
@@ -16,9 +16,35 @@ import { normTags, normUsageScenario, readCognitiveHall, readWing } from '../met
|
|
|
16
16
|
import { acceptTitle } from '../util/md-name.js';
|
|
17
17
|
import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
|
|
18
18
|
import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
|
|
19
|
+
import { stampValidity, validToSourceOf } from '../store/validity.js';
|
|
19
20
|
import { familyForType, normPersistence, normScope, resolveRecordFamily, resolveRecordScope, WING_FALLBACK } from '../types.js';
|
|
20
21
|
import { normApplicability, resolveRepoScope } from '../repo-scope.js';
|
|
21
22
|
import { routeWorkspace } from '../workspace-route.js';
|
|
23
|
+
/**
|
|
24
|
+
* L1 抽取单块字符硬顶(修复 #10 分批过大)
|
|
25
|
+
*
|
|
26
|
+
* `cfg.llm.maxInputChars` 的语义是「单次 LLM 调用输入闸门」(默认 200k),被复用为
|
|
27
|
+
* 分块预算时算出 658k/块——一次输出须在同一 JSON 数组写完全部抽出记忆,块越大字符串越多,
|
|
28
|
+
* 任一处长中文里未转义 ASCII `"` 即整批 parse 失败、该块记忆全丢;慢模型更直接撞 120s 超时。
|
|
29
|
+
* 砍到 24k 后输出 ≈2–4k tokens,仍远低于 LAYER_MAX_TOKENS_EXTRACT(16k),不会引入截断,
|
|
30
|
+
* 单块失败半径从「几百条」缩到「几十条」。代价是多几次小调用,但每次 5–15s 完成。
|
|
31
|
+
*/
|
|
32
|
+
const MAX_EXTRACT_CHUNK_CHARS = 24_000;
|
|
33
|
+
/**
|
|
34
|
+
* L2 场景整合单块字符硬顶(R1):与抽取同精神——旧版一次把整族 backlog 塞单调用,
|
|
35
|
+
* 库越大越必撞超时(实测 work-L2 >60s abort)。砍到 24k 后逐块整合,失败半径从
|
|
36
|
+
* “整族 backlog”缩到“一块”。默认对齐 MAX_EXTRACT_CHUNK_CHARS。
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_SCENE_CHUNK_CHARS = 24_000;
|
|
39
|
+
/**
|
|
40
|
+
* L1 去重单批发起上限(与抽取同精神)
|
|
41
|
+
*
|
|
42
|
+
* 旧版一次将整批 extracted[] 丢给 formatBatchConflictPrompt 发单次 LLM 调用,候选召回默认
|
|
43
|
+
* 每新记忆 10 个 → batch=100 时 prompt 轻松上 500k 字符,同样命中 #10 的失败面。按
|
|
44
|
+
* 固定条数切块(同族内不跨子集),失败半径缩到 ≤20 条,且每块上下文仍足够。
|
|
45
|
+
* 上限 20 为工程默认,后续可提到配置。
|
|
46
|
+
*/
|
|
47
|
+
const MAX_DEDUP_BATCH_SIZE = 20;
|
|
22
48
|
/** 解析 ISO/epoch 时间证据,非法或非正值一律 undefined——不猜测。 */
|
|
23
49
|
function parseTimeEvidence(raw) {
|
|
24
50
|
const t = typeof raw === 'string' ? Date.parse(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
@@ -29,28 +55,39 @@ function parseTimeEvidence(raw) {
|
|
|
29
55
|
*
|
|
30
56
|
* `activity_start_time`/`activity_end_time` 是这两列的前身(prompt 已在产出),
|
|
31
57
|
* 这里把它们提升为结构化字段;`persistence` 由 prompt 产出,缺省即未判定。
|
|
58
|
+
* valid_to 源自 activity_* 证据链 → `validToSource='evidence'`(task_2.5 打标)。
|
|
32
59
|
*/
|
|
33
60
|
function temporalOf(meta) {
|
|
61
|
+
const validTo = parseTimeEvidence(meta?.activity_end_time);
|
|
34
62
|
return {
|
|
35
63
|
validFrom: parseTimeEvidence(meta?.activity_start_time),
|
|
36
|
-
validTo
|
|
64
|
+
validTo,
|
|
37
65
|
persistence: normPersistence(meta?.persistence),
|
|
66
|
+
...(validTo !== undefined ? { validToSource: 'evidence' } : {}),
|
|
38
67
|
};
|
|
39
68
|
}
|
|
40
69
|
/**
|
|
41
70
|
* 合并(merge/update)时的有效期并集:
|
|
42
71
|
* 起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined),否则取最晚。
|
|
43
72
|
* 持续性以新记忆为准(合并产物描述的是当前认知),新记忆未判定时继承旧记录。
|
|
73
|
+
* validToSource 随**胜出值**的来源走(task_2.5):最终 valid_to 取自哪一侧,
|
|
74
|
+
* 证据等级就读哪一侧的(旧行取其 `dsh_validity` 标注,未标注算 'unknown'——
|
|
75
|
+
* 宁判疑不判死,fail-open 与 classify 同向)。
|
|
44
76
|
*/
|
|
45
77
|
function mergeTemporal(self, targets) {
|
|
46
78
|
const froms = [self.validFrom, ...targets.map((r) => r.validFrom)].filter((t) => t !== undefined);
|
|
47
|
-
const tos = [
|
|
48
|
-
|
|
49
|
-
|
|
79
|
+
const tos = [
|
|
80
|
+
{ v: self.validTo, s: self.validToSource ?? 'unknown' },
|
|
81
|
+
...targets.map((r) => ({ v: r.validTo, s: validToSourceOf(r.metadata) })),
|
|
82
|
+
];
|
|
83
|
+
const anyOpen = tos.some((e) => e.v === undefined);
|
|
84
|
+
const closed = tos.filter((e) => e.v !== undefined);
|
|
85
|
+
const winner = closed.length > 0 ? closed.reduce((a, b) => (b.v > a.v ? b : a)) : undefined;
|
|
50
86
|
return {
|
|
51
87
|
validFrom: froms.length > 0 ? Math.min(...froms) : undefined,
|
|
52
|
-
validTo: anyOpen ||
|
|
88
|
+
validTo: anyOpen || winner === undefined ? undefined : winner.v,
|
|
53
89
|
persistence: self.persistence ?? targets.find((r) => r.persistence)?.persistence,
|
|
90
|
+
...(!anyOpen && winner !== undefined ? { validToSource: winner.s } : {}),
|
|
54
91
|
};
|
|
55
92
|
}
|
|
56
93
|
/**
|
|
@@ -152,7 +189,10 @@ export function toStoreRecord(m, now, ts, anchorMap) {
|
|
|
152
189
|
// 过不了就**不写键**,交给 `L1Store.assignMissingNames`——判据只留一份,
|
|
153
190
|
// 不在这里养第二套“看起来差不多”的名字清洗。
|
|
154
191
|
const titled = m.title === undefined ? undefined : acceptTitle(m.title);
|
|
155
|
-
|
|
192
|
+
// 时间轴派生单独算:validToSource 不进记录形状(它不是 MemoryRecord 字段,随展开
|
|
193
|
+
// 落库会改 JSONL 形状),只喂给 stampValidity 盖进 dsh_validity(task_2.5/3.2 写侧标注)。
|
|
194
|
+
const t = temporalOf(m.metadata);
|
|
195
|
+
const rec = {
|
|
156
196
|
id: m.record_id,
|
|
157
197
|
content: m.content,
|
|
158
198
|
type: m.type,
|
|
@@ -179,8 +219,13 @@ export function toStoreRecord(m, now, ts, anchorMap) {
|
|
|
179
219
|
// 文件身份键不在这里分:**起名只有一个真源**(`L1Store.assignMissingNames`)。
|
|
180
220
|
// 以前这里自己分一份、库里再分一份,两套判据迟早漂移,而漂移的后果是同一个基底
|
|
181
221
|
// 洗出两个名字(文件跳名)或两个记录抢一个名字(整条写不进去)。
|
|
182
|
-
|
|
222
|
+
validFrom: t.validFrom,
|
|
223
|
+
validTo: t.validTo,
|
|
224
|
+
persistence: t.persistence,
|
|
183
225
|
};
|
|
226
|
+
// 写侧标注(task_3.2 派生时机之一):管线盖时源头已知 evidence(LLM 直填/工具路径另算)。
|
|
227
|
+
stampValidity(rec, now, t.validToSource);
|
|
228
|
+
return rec;
|
|
184
229
|
}
|
|
185
230
|
function newId(prefix) {
|
|
186
231
|
return `${prefix}_${Date.now()}_${randomBytes(3).toString('hex')}`;
|
|
@@ -227,7 +272,9 @@ anchorMap,
|
|
|
227
272
|
* 另两个 sidecar 里,而 merge 每次新铸 id——不搬就等于每次合并把这条记忆的
|
|
228
273
|
* `used`/人工打分清零,它会被当成“没用过”而沉底。缺省不搬(行为与改动前一致)。
|
|
229
274
|
*/
|
|
230
|
-
mergeRekey
|
|
275
|
+
mergeRekey,
|
|
276
|
+
/** R9 路由健康学习(可选):读已学到的跑通块长收紧 perChunk,成功块回记。未传=不启用。 */
|
|
277
|
+
routeHealth) {
|
|
231
278
|
if (!cfg.extract.enabled)
|
|
232
279
|
return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
|
|
233
280
|
// 触发阈值(渐进爬坡 + 按会话切片计数)由 runner 判定(trigger.ts);
|
|
@@ -274,12 +321,32 @@ mergeRekey) {
|
|
|
274
321
|
// ── Step 1: 抽取(输入按 llm.maxInputChars 预算分块,情境链式衔接,不丢消息) ──
|
|
275
322
|
const backgroundMsgs = background.slice(-cfg.extract.backgroundMessages);
|
|
276
323
|
// 预留:背景消息(≤10 条 ×4000 字)+ prompt 脚手架
|
|
277
|
-
|
|
324
|
+
// 硬顶 24k(见 MAX_EXTRACT_CHUNK_CHARS 注释);若配置更小则跟随配置
|
|
325
|
+
let perChunk = Math.min(MAX_EXTRACT_CHUNK_CHARS, Math.max(20_000, cfg.llm.maxInputChars - 42_000));
|
|
326
|
+
// R5: 尊重模型真实输入上限(若声明),避免 700k 盲值撞本地模型硬限
|
|
327
|
+
if (cfg.llm.modelMaxInputChars && cfg.llm.modelMaxInputChars > 0) {
|
|
328
|
+
perChunk = Math.min(perChunk, Math.max(20_000, cfg.llm.modelMaxInputChars - 42_000));
|
|
329
|
+
}
|
|
330
|
+
// R9: 用该路由已学到的跑通块长再收紧 perChunk(best-effort;路由解析失败则跳过)
|
|
331
|
+
let rhKey;
|
|
332
|
+
if (routeHealth) {
|
|
333
|
+
try {
|
|
334
|
+
rhKey = await resolveModelRoute(ctx, cfg);
|
|
335
|
+
const lg = routeHealth.lastGoodChunkChars(rhKey.provider, rhKey.model);
|
|
336
|
+
if (lg > 0)
|
|
337
|
+
perChunk = Math.min(perChunk, Math.max(20_000, lg));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
rhKey = undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
278
343
|
const chunks = chunkByCharBudget(pending, perChunk);
|
|
279
344
|
if (chunks.length > 1) {
|
|
280
345
|
logger.info(`[memory] L1 输入超预算(${pending.length} 条消息),分 ${chunks.length} 块抽取(每块 ≤${perChunk} 字符)`);
|
|
281
346
|
}
|
|
282
347
|
const extracted = [];
|
|
348
|
+
// R1′ 抽取块级失败的消息(留桶下轮重投,实现部分消费)
|
|
349
|
+
const failedMessages = [];
|
|
283
350
|
let lastScene = chainState.lastSceneName;
|
|
284
351
|
let sceneCount = 0;
|
|
285
352
|
// 治理归属(ADR-0015 T1.7/T1.9):repoKey=basename(归一 cwd),**进管线时一次算定**
|
|
@@ -290,30 +357,52 @@ mergeRekey) {
|
|
|
290
357
|
const halls = mode === 'auto' && !hallCandidates.includes(WING_FALLBACK)
|
|
291
358
|
? [...hallCandidates, WING_FALLBACK]
|
|
292
359
|
: hallCandidates;
|
|
360
|
+
const timeBudgetMs = cfg.llm.extractTimeBudgetMs ?? 0;
|
|
361
|
+
const extractStart = Date.now();
|
|
293
362
|
for (const chunk of chunks) {
|
|
363
|
+
// R5 整轮墙钟预算:超预算把本块(及后续块)留 pending 下轮续,防慢模型独占串行泵
|
|
364
|
+
if (timeBudgetMs > 0 && chunks.length > 1 && Date.now() - extractStart > timeBudgetMs) {
|
|
365
|
+
failedMessages.push(...chunk);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
294
368
|
const userPrompt = formatExtractionPrompt({
|
|
295
369
|
newMessages: chunk,
|
|
296
370
|
backgroundMessages: backgroundMsgs,
|
|
297
371
|
previousSceneName: lastScene || '无',
|
|
298
372
|
halls,
|
|
299
373
|
});
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
374
|
+
let scenes;
|
|
375
|
+
try {
|
|
376
|
+
const raw = await callLLM(ctx, cfg, {
|
|
377
|
+
system: getExtractMemoriesSystemPrompt(mode, {
|
|
378
|
+
usageScenario: cfg.usageScenario?.enabled === true,
|
|
379
|
+
narrative: cfg.narrativeTemplates?.enabled === true,
|
|
380
|
+
// 名称蒸馏(Phase 3):示例段只在总开关开着时付(它与 title 是两层成本,
|
|
381
|
+
// 分开开关才能 A/B)。关着时这份 opts 产出的提示与改动前逐字相同。
|
|
382
|
+
title: cfg.naming?.enabled === true,
|
|
383
|
+
fewShot: cfg.naming?.enabled === true && cfg.naming.fewShot === true,
|
|
384
|
+
}),
|
|
385
|
+
user: userPrompt,
|
|
386
|
+
maxTokens: resolveLayerTokens(cfg, 'extract'),
|
|
387
|
+
layer: 'l1-extract',
|
|
388
|
+
logger,
|
|
389
|
+
});
|
|
390
|
+
const parsed = parseJsonLogged(raw, 'L1 抽取', logger);
|
|
391
|
+
if (!Array.isArray(parsed))
|
|
392
|
+
throw new Error('L1 抽取输出不是 JSON 数组');
|
|
393
|
+
scenes = parsed;
|
|
394
|
+
// R9: 记录该路由跑通的实际块长(只降不升),供下轮收紧 perChunk
|
|
395
|
+
if (routeHealth && rhKey) {
|
|
396
|
+
const chunkChars = chunk.reduce((n, m) => n + m.content.length + 64, 0);
|
|
397
|
+
routeHealth.recordGood(rhKey.provider, rhKey.model, chunkChars);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
// R1′ 块级容错:单块超时/崩只丢这块、留桶下轮重投,不掀翻整批(旧行为是整抛)
|
|
402
|
+
failedMessages.push(...chunk);
|
|
403
|
+
logger.warn(`[memory] L1 抽取块失败(${chunk.length} 条消息留待重投): ${err instanceof Error ? err.message : String(err)}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
317
406
|
for (const scene of scenes) {
|
|
318
407
|
if (!scene || typeof scene.scene_name !== 'string')
|
|
319
408
|
continue;
|
|
@@ -357,29 +446,46 @@ mergeRekey) {
|
|
|
357
446
|
}
|
|
358
447
|
}
|
|
359
448
|
if (extracted.length === 0) {
|
|
449
|
+
// 全部块失败:等价旧"整抛"语义——runner catch 会保留整片 + 记退避(不误当零产出成功)
|
|
450
|
+
if (failedMessages.length > 0) {
|
|
451
|
+
throw new Error(`L1 抽取全部 ${chunks.length} 块失败(逐块错误见上)`);
|
|
452
|
+
}
|
|
360
453
|
logger.info(`[memory] L1 抽取完成:无可提取记忆(mode=${mode},${pending.length} 条消息,${sceneCount} 个情境)`);
|
|
361
454
|
// 成功但零产出:同样推进时间戳/场景,与失败(lastExtractAt 保持 0)区分开
|
|
362
455
|
markExtracted(states, mode, lastScene);
|
|
363
|
-
return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [] };
|
|
456
|
+
return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [], failedMessages: [] };
|
|
364
457
|
}
|
|
365
458
|
// ── Step 2: 去重(batch 冲突检测;候选只在本族内召回,去重永不跨族) ──
|
|
366
459
|
const matches = await Promise.all(extracted.map(async (m) => ({
|
|
367
460
|
newMemory: m,
|
|
368
461
|
candidates: await store.searchCandidates(m.content, cfg.extract.candidatePool, m.family, wsFilter),
|
|
369
462
|
})));
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
463
|
+
// 按固定条数切块发送,失败半径与上下文同时可控(#10 去重侧同精)
|
|
464
|
+
const dedupBatches = [];
|
|
465
|
+
for (let i = 0; i < matches.length; i += MAX_DEDUP_BATCH_SIZE) {
|
|
466
|
+
dedupBatches.push(matches.slice(i, i + MAX_DEDUP_BATCH_SIZE));
|
|
467
|
+
}
|
|
468
|
+
if (dedupBatches.length > 1) {
|
|
469
|
+
logger.info(`[memory] L1 去重分为 ${dedupBatches.length} 批发送(每批 ≤${MAX_DEDUP_BATCH_SIZE} 条候选记忆)`);
|
|
470
|
+
}
|
|
471
|
+
const decisionsFlat = [];
|
|
472
|
+
for (const [bi, batch] of dedupBatches.entries()) {
|
|
473
|
+
const batchPrompt = formatBatchConflictPrompt(batch, {
|
|
474
|
+
conflictFreeze: cfg.conflictFreeze.enabled,
|
|
475
|
+
});
|
|
476
|
+
const batchRaw = await callLLM(ctx, cfg, {
|
|
477
|
+
system: getConflictDetectionSystemPrompt(mode, { conflictFreeze: cfg.conflictFreeze.enabled, title: cfg.naming?.enabled === true }),
|
|
478
|
+
user: batchPrompt,
|
|
479
|
+
maxTokens: resolveLayerTokens(cfg, 'dedup'),
|
|
480
|
+
layer: 'l1-dedup',
|
|
481
|
+
logger,
|
|
482
|
+
});
|
|
483
|
+
const batchDecisions = parseJsonLogged(batchRaw, `L1 去重判定${dedupBatches.length > 1 ? `(批 ${bi + 1}/${dedupBatches.length})` : ''}`, logger);
|
|
484
|
+
if (Array.isArray(batchDecisions))
|
|
485
|
+
decisionsFlat.push(...batchDecisions);
|
|
486
|
+
}
|
|
381
487
|
const byRecord = new Map();
|
|
382
|
-
for (const d of
|
|
488
|
+
for (const d of decisionsFlat) {
|
|
383
489
|
if (d && typeof d.record_id === 'string')
|
|
384
490
|
byRecord.set(d.record_id, d);
|
|
385
491
|
}
|
|
@@ -601,7 +707,10 @@ mergeRekey) {
|
|
|
601
707
|
const mergedTitle = mergedName === undefined || mergedName.length === 0
|
|
602
708
|
? acceptTitle(decision.merged_title)
|
|
603
709
|
: mergedName;
|
|
604
|
-
|
|
710
|
+
// 合并的有效期取并集:起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined)。
|
|
711
|
+
// validToSource 随胜出侧走(task_2.5),同样不进记录形状、只喂标注。
|
|
712
|
+
const mt = mergeTemporal(temporalOf(m.metadata), targets.map((id) => byId.get(id)).filter((r) => r !== undefined));
|
|
713
|
+
const mergedRec = {
|
|
605
714
|
id: m.record_id,
|
|
606
715
|
content: mergedContent,
|
|
607
716
|
type: decision.merged_type || m.type,
|
|
@@ -628,14 +737,19 @@ mergeRekey) {
|
|
|
628
737
|
repoKeyName: m.repoKeyName,
|
|
629
738
|
repoKeyOwner: m.repoKeyOwner,
|
|
630
739
|
applicability: m.applicability,
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
740
|
+
validFrom: mt.validFrom,
|
|
741
|
+
validTo: mt.validTo,
|
|
742
|
+
persistence: mt.persistence,
|
|
743
|
+
};
|
|
744
|
+
// 写侧标注(task_3.2):merge 产物的证据链随胜出侧透传。
|
|
745
|
+
stampValidity(mergedRec, now, mt.validToSource);
|
|
746
|
+
added.push(mergedRec);
|
|
634
747
|
}
|
|
635
748
|
await store.appendNew(added);
|
|
636
749
|
if (supersededBy.size > 0) {
|
|
637
|
-
// **软删**(取代):不是物理删除——主表行保留 +
|
|
638
|
-
// FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去
|
|
750
|
+
// **软删**(取代):不是物理删除——主表行保留 + 取代标记(marker 单源,门1 解耦后
|
|
751
|
+
// 不再闭合 valid_to),FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去
|
|
752
|
+
// `records/*.jsonl` 手工捞。
|
|
639
753
|
// 按取代者分组落盘(一次事务一组),避免逐条开事务。
|
|
640
754
|
const retiredAt = new Date(now).toISOString();
|
|
641
755
|
const byNewRecord = new Map();
|
|
@@ -712,7 +826,7 @@ mergeRekey) {
|
|
|
712
826
|
}
|
|
713
827
|
markExtracted(states, mode, lastScene);
|
|
714
828
|
logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(取代退场 ${supersededBy.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
|
|
715
|
-
return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind };
|
|
829
|
+
return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind, failedMessages };
|
|
716
830
|
}
|
|
717
831
|
/** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
|
|
718
832
|
function activeState(states) {
|