dsh-prime-memory 0.26.29 → 0.26.31

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * L1 **批内自去重**(同轮/同会话)——纯函数,零 IO、零 import 副作用。
3
+ *
4
+ * 为什么需要它(#现场取证,见 .agents/plans/pm-dedupe-scale-drsc-hint/findings.md §4):
5
+ * 既有 L1 去重只把**新记忆**和**库里的候选**对比(`store.searchCandidates`),同一批
6
+ * `extracted[]` 之间**从不互相比较**。于是同一段对话被抽成多条碎片时,它们会一起进同一条
7
+ * ≤20 条的 LLM 去重 prompt,只能由模型产出 `merged_content` 把内容"摞在一起"——
8
+ * 既费一次 LLM 判定,又让落库正文变肿。
9
+ *
10
+ * 做法:在进 LLM 之前先做两级收拢,**只减条数、不改语义**:
11
+ * ① 精确层:归一化(trim / NFKC 全半角 / 空白折叠 / 去零宽)后同内容即合并;
12
+ * ② 向量层:**仅同 family + 同 type** 内、余弦 ≥ tau 贪心聚类。
13
+ * 代表项规则:正文最长者(等长取 priority 高者,再等取先出现者);其 record_id 与 content
14
+ * **原样保留**,因此调用方可以把它自己的向量直接复用(正文没被改写,向量不会错配)。
15
+ *
16
+ * 明确不做:不产生合并正文(那是 LLM merge 的职责,见 ADR-0006 receipt 一致性);
17
+ * 不跨族/跨类合并;不替调用方决定 store/skip。
18
+ */
19
+ /**
20
+ * 用于比较的归一化文本。
21
+ * NFKC 会把全角标点/字母折到半角;零宽字符(U+200B–200F / U+FEFF)直接剔除——
22
+ * 它们肉眼不可见,却足以让两条"同一句话"取不同 hash。
23
+ */
24
+ export function normalizeForDedupe(s) {
25
+ return s
26
+ .normalize('NFKC')
27
+ .replace(/[\u200b-\u200f\ufeff]/gu, '')
28
+ .replace(/\s+/gu, ' ')
29
+ .trim();
30
+ }
31
+ /** 余弦相似度;任一向量缺失/长度不等/零向量时返回 undefined(不可比,绝不猜)。 */
32
+ export function cosineMaybe(a, b) {
33
+ if (!a || !b || a.length === 0 || a.length !== b.length)
34
+ return undefined;
35
+ let dot = 0;
36
+ let na = 0;
37
+ let nb = 0;
38
+ for (let i = 0; i < a.length; i++) {
39
+ dot += a[i] * b[i];
40
+ na += a[i] * a[i];
41
+ nb += b[i] * b[i];
42
+ }
43
+ if (na === 0 || nb === 0)
44
+ return undefined;
45
+ return dot / Math.sqrt(na * nb);
46
+ }
47
+ /** 代表项判定:正文最长 → priority 高 → 先出现。返回簇内下标。 */
48
+ function pickRepresentative(items, idx) {
49
+ let best = idx[0];
50
+ for (const i of idx) {
51
+ const a = items[i];
52
+ const b = items[best];
53
+ const la = a.content.length;
54
+ const lb = b.content.length;
55
+ if (la > lb)
56
+ best = i;
57
+ else if (la === lb) {
58
+ const pa = a.priority ?? 0;
59
+ const pb = b.priority ?? 0;
60
+ if (pa > pb)
61
+ best = i;
62
+ // 等长等优先级:保留先出现者(不覆盖 best)
63
+ }
64
+ }
65
+ return best;
66
+ }
67
+ function unionNums(parts) {
68
+ const all = parts.filter((p) => Array.isArray(p));
69
+ if (all.length === 0)
70
+ return undefined;
71
+ return Array.from(new Set(all.flat())).sort((x, y) => x - y);
72
+ }
73
+ function unionStrs(parts) {
74
+ const all = parts.filter((p) => Array.isArray(p));
75
+ if (all.length === 0)
76
+ return undefined;
77
+ return Array.from(new Set(all.flat()));
78
+ }
79
+ /** 把一簇下标压成「代表项 + 吸收清单」。代表项字段保持原样,只并入可并集字段。 */
80
+ function collapse(items, idx) {
81
+ const repIdx = pickRepresentative(items, idx);
82
+ const rep = items[repIdx];
83
+ const absorbed = idx.filter((i) => i !== repIdx).map((i) => items[i].record_id);
84
+ const ts = unionNums(idx.map((i) => items[i].timestamps));
85
+ const src = unionStrs(idx.map((i) => items[i].source_message_ids));
86
+ const maxPriority = idx.reduce((m, i) => Math.max(m, items[i].priority ?? 0), rep.priority ?? 0);
87
+ const merged = {
88
+ ...rep,
89
+ ...(maxPriority > 0 ? { priority: maxPriority } : {}),
90
+ ...(ts === undefined ? {} : { timestamps: ts }),
91
+ ...(src === undefined ? {} : { source_message_ids: src }),
92
+ };
93
+ return { rep: merged, absorbed };
94
+ }
95
+ function finish(items, clusters) {
96
+ const effective = [];
97
+ const absorbed = [];
98
+ const absorbedBy = {};
99
+ for (const idx of clusters) {
100
+ const { rep, absorbed: absorbedIds } = collapse(items, idx);
101
+ effective.push(rep);
102
+ if (absorbedIds.length > 0) {
103
+ absorbed.push({ representative: rep.record_id, absorbed: absorbedIds });
104
+ for (const id of absorbedIds)
105
+ absorbedBy[id] = rep.record_id;
106
+ }
107
+ }
108
+ return { effective, absorbed, absorbedBy };
109
+ }
110
+ /**
111
+ * 精确层:归一化后同内容合并(含全半角/空白/零宽差异)。
112
+ * 空内容条目**不参与**合并(各自成簇)——正文为空是上游缺陷,静默吞掉会更难查。
113
+ */
114
+ export function clusterExact(items) {
115
+ if (items.length === 0)
116
+ return { effective: [], absorbed: [], absorbedBy: {} };
117
+ const byKey = new Map();
118
+ const order = [];
119
+ items.forEach((it, i) => {
120
+ const key = normalizeForDedupe(it.content);
121
+ if (key.length === 0) {
122
+ // 空内容单独成簇:用 record_id 做键,保证永不与别人合并
123
+ byKey.set(`\u0000${it.record_id}#${i}`, [i]);
124
+ order.push(`\u0000${it.record_id}#${i}`);
125
+ return;
126
+ }
127
+ const prev = byKey.get(key);
128
+ if (prev)
129
+ prev.push(i);
130
+ else {
131
+ byKey.set(key, [i]);
132
+ order.push(key);
133
+ }
134
+ });
135
+ return finish(items, order.map((k) => byKey.get(k)));
136
+ }
137
+ /**
138
+ * 向量层:**仅同 family + 同 type** 组内,余弦 ≥ tau 即并入先出现的代表簇。
139
+ * 缺向量 / 长度不等 / 零向量 → 不可比,直接自成代表(不猜相似)。
140
+ * 先精确层、后本层是刻意顺序:精确重复不必再花一次比较,且能让代表项更稳定。
141
+ */
142
+ export function clusterByVector(items, vectors, tau) {
143
+ if (items.length === 0)
144
+ return { effective: [], absorbed: [], absorbedBy: {} };
145
+ const threshold = Number.isFinite(tau) ? Math.min(0.9999, Math.max(0.5, tau)) : 0.92;
146
+ const clusters = [];
147
+ /** groupKey → 该组已建立的代表簇下标(簇在 clusters 里的位置)。 */
148
+ const repsByGroup = new Map();
149
+ items.forEach((it, i) => {
150
+ const group = `${it.family ?? ''}\u0000${it.type ?? ''}`;
151
+ const repIdx = repsByGroup.get(group);
152
+ if (repIdx) {
153
+ const vec = vectors[i];
154
+ let hit;
155
+ let bestScore = -Infinity;
156
+ for (const ci of repIdx) {
157
+ const cand = clusters[ci][0];
158
+ const score = cosineMaybe(vec, vectors[cand]);
159
+ if (score !== undefined && score >= threshold && score > bestScore) {
160
+ bestScore = score;
161
+ hit = ci;
162
+ }
163
+ }
164
+ if (hit !== undefined) {
165
+ clusters[hit].push(i);
166
+ return;
167
+ }
168
+ }
169
+ const ci = clusters.length;
170
+ clusters.push([i]);
171
+ const list = repsByGroup.get(group);
172
+ if (list)
173
+ list.push(ci);
174
+ else
175
+ repsByGroup.set(group, [ci]);
176
+ });
177
+ return finish(items, clusters);
178
+ }
179
+ /**
180
+ * 把「被吸收 → 代表」关系映射成 receipt 输入项。
181
+ *
182
+ * 为什么用既有 `kind='merge'` + `candidateIds=[代表 id]` 而不新增表列/词表即可:
183
+ * 吸收本质上就是「本条被并入另一条」,`merge` 正是这个词;`candidateIds` 载代表项 id,
184
+ * 于是「本条被批内哪条吸收」只需一次 `(runId, recordId)` 回溯(inputDigest 含代表 id)。
185
+ * 关闭特性时 `absorbedBy` 为空 ⇒ 不产生任何额外行(凭证与改动前逐字节一致)。
186
+ */
187
+ export function receiptItemsForAbsorbed(absorbedBy) {
188
+ return Object.entries(absorbedBy).map(([absorbedId, repId]) => ({
189
+ recordId: absorbedId,
190
+ candidateIds: [repId],
191
+ action: 'merge',
192
+ }));
193
+ }
194
+ /**
195
+ * 一次完成:精确层 → (可选)向量层,并把“代表项→向量”与“被吸收→代表”两表一并交回。
196
+ *
197
+ * 嵌入不可用时**降级只做精确层**(catch 后留一条 warn),绝不因向量故障阻断蒸馏——
198
+ * 它只是省 LLM 判定与写侧重嵌的优化,不是正确性前置。
199
+ */
200
+ export async function preDedupeBatch(items, embedMany, opts = {}) {
201
+ if (opts.enabled === false || items.length === 0) {
202
+ return { effective: [...items], vectors: new Map(), absorbedBy: {}, raw: items.length, exact: items.length, vector: items.length };
203
+ }
204
+ const tauRaw = opts.tau ?? 0.92;
205
+ const tau = Math.min(0.99, Math.max(0.85, Number.isFinite(tauRaw) ? tauRaw : 0.92));
206
+ if (tau !== tauRaw)
207
+ opts.onWarn?.(`[memory] extract.intraBatchCosine=${tauRaw} 超出有效区间 0.85–0.99,已按 ${tau} 处理`);
208
+ const exact = clusterExact(items);
209
+ let vecs = exact.effective.map(() => undefined);
210
+ const exactVecById = new Map();
211
+ try {
212
+ vecs = await embedMany(exact.effective.map((m) => m.content));
213
+ exact.effective.forEach((m, i) => {
214
+ const v = vecs[i];
215
+ if (v)
216
+ exactVecById.set(m.record_id, v);
217
+ });
218
+ }
219
+ catch (err) {
220
+ opts.onWarn?.(`[memory] L1 批内向量不可用,仅做精确层: ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ const byVec = clusterByVector(exact.effective, vecs, tau);
223
+ // 只回最终代表项的向量:被向量层吸收的条目(如 r3)虽然自己算过向量,但它不会入库,
224
+ // 留在表里只会误导写侧(按 record_id 查得到,却根本没有这条记录)。
225
+ const vectors = new Map();
226
+ for (const m of byVec.effective) {
227
+ const v = exactVecById.get(m.record_id);
228
+ if (v)
229
+ vectors.set(m.record_id, v);
230
+ }
231
+ return {
232
+ effective: byVec.effective,
233
+ vectors,
234
+ absorbedBy: { ...exact.absorbedBy, ...byVec.absorbedBy },
235
+ raw: items.length,
236
+ exact: exact.effective.length,
237
+ vector: byVec.effective.length,
238
+ };
239
+ }
@@ -16,6 +16,7 @@ import { normTags, normUsageScenario, readCognitiveHall, readWing } from '../met
16
16
  import { acceptTitle } from '../util/md-name.js';
17
17
  import { clampPriority } from '../util/priority.js';
18
18
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
19
+ import { preDedupeBatch, receiptItemsForAbsorbed } from './intra-dedupe.js';
19
20
  import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
20
21
  import { stampValidity, validToSourceOf } from '../store/validity.js';
21
22
  import { familyForType, normPersistence, normScope, resolveRecordFamily, resolveRecordScope, WING_FALLBACK } from '../types.js';
@@ -456,10 +457,30 @@ routeHealth) {
456
457
  markExtracted(states, mode, lastScene);
457
458
  return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [], failedMessages: [] };
458
459
  }
460
+ // ── Step 1.5: 批内自去重(同轮/同会话)──
461
+ // 根因:候选池只来自库,同批 extracted[] 之间从不互比 → 同一段对话抽出的多条碎片
462
+ // 全被塞进同一条 LLM 批,只能靠模型的 merged_content 把内容"撬在一起"。
463
+ // 逻辑全在 `preDedupeBatch`(可注入 embedMany,故不必驱动 LLM 就能单测接线)。
464
+ const pre = await preDedupeBatch(extracted, (texts) => store.embedMany(texts), {
465
+ enabled: cfg.extract.intraBatchDedupe !== false,
466
+ tau: cfg.extract.intraBatchCosine ?? 0.92,
467
+ onWarn: (m) => logger.warn(m),
468
+ });
469
+ const effective = pre.effective;
470
+ /** 代表项 record_id → 其**本正文**的向量(候选召回与写侧复用同一份,免二次嵌入)。 */
471
+ const vecByRecordId = pre.vectors;
472
+ /** 批内吸收关系(被吸收 record_id → 代表 record_id),给 receipt 审计用。 */
473
+ const intraAbsorbedBy = pre.absorbedBy;
474
+ if (cfg.extract.intraBatchDedupe !== false) {
475
+ logger.info(`[memory] L1 批内自去重:raw=${pre.raw} exact=${pre.exact} ` +
476
+ `vector=${pre.vector} effective=${effective.length}(吸收 ${Object.keys(intraAbsorbedBy).length} 条)`);
477
+ }
459
478
  // ── Step 2: 去重(batch 冲突检测;候选只在本族内召回,去重永不跨族) ──
460
- const matches = await Promise.all(extracted.map(async (m) => ({
479
+ const matches = await Promise.all(effective.map(async (m) => ({
461
480
  newMemory: m,
462
- candidates: await store.searchCandidates(m.content, cfg.extract.candidatePool, m.family, wsFilter),
481
+ candidates: await store.searchCandidates(m.content, cfg.extract.candidatePool, m.family, wsFilter, {
482
+ queryVector: vecByRecordId.get(m.record_id),
483
+ }),
463
484
  })));
464
485
  // 按固定条数切块发送,失败半径与上下文同时可控(#10 去重侧同精)
465
486
  const dedupBatches = [];
@@ -492,12 +513,12 @@ routeHealth) {
492
513
  }
493
514
  // 去重决策统计:无决策的记录按 skip 处理,聚合成单行日志便于排查
494
515
  const actionCount = {};
495
- for (const m of extracted) {
516
+ for (const m of effective) {
496
517
  const action = byRecord.get(m.record_id)?.action ?? 'skip(未返回)';
497
518
  actionCount[action] = (actionCount[action] ?? 0) + 1;
498
519
  }
499
520
  const candidateTotal = matches.reduce((n, m) => n + m.candidates.length, 0);
500
- logger.info(`[memory] L1 去重判定:${extracted.length} 条候选记忆召回 ${candidateTotal} 条已有记录,决策 ${Object.entries(actionCount)
521
+ logger.info(`[memory] L1 去重判定:${effective.length} 条候选记忆召回 ${candidateTotal} 条已有记录,决策 ${Object.entries(actionCount)
501
522
  .map(([k, v]) => `${k}=${v}`)
502
523
  .join(' ')}`);
503
524
  // ── §B 决策凭证:在应用决策前留痕(旁路设施,写失败绝不中断蒸馏) ──
@@ -512,12 +533,17 @@ routeHealth) {
512
533
  const runId = newRunId();
513
534
  let byKind;
514
535
  {
515
- const items = extracted.map((m, i) => ({
536
+ const items = effective.map((m, i) => ({
516
537
  recordId: m.record_id,
517
538
  candidateIds: (matches[i]?.candidates ?? []).map((c) => c.id),
518
539
  action: byRecord.get(m.record_id)?.action,
519
540
  }));
520
- const rows = buildReceipts(runId, new Date().toISOString(), items);
541
+ // 批内被吸收的条目也必须留痕:否则「这条为何没进库」在库里完全查不到(skip 是现有
542
+ // 唯一零痕迹分支,批内吸收不能重蹈)。用既有 `kind='merge'` + candidateIds=[代表 id]:
543
+ // 不新增表列、不扩词表,回答“本条被批内哪条吸收”只需一次 (runId, recordId) 回溯。
544
+ // 关闭特性时 intraAbsorbedBy 为空 ⇒ 凭证与改动前逐字节一致。
545
+ const absorbedItems = receiptItemsForAbsorbed(intraAbsorbedBy);
546
+ const rows = buildReceipts(runId, new Date().toISOString(), [...items, ...absorbedItems]);
521
547
  persistReceiptsSafely((rows) => store.recordReceipts(rows), rows, logger);
522
548
  // §F 追踪捎带:六值决策词表聚合(与凭证同源,runner 侧并入 distill_run 事件)
523
549
  byKind = rows.reduce((acc, r) => {
@@ -544,7 +570,7 @@ routeHealth) {
544
570
  * 不成对而回落 store**(2026-09-18 取证确认,见 findings R6 / Agent A);
545
571
  * ② 队满自动了结的护栏 —— 败方若属本批新记忆,不得自动退场(见下方分支)。
546
572
  */
547
- const batchIds = new Set(extracted.map((e) => e.record_id));
573
+ const batchIds = new Set(effective.map((e) => e.record_id));
548
574
  /**
549
575
  * update/merge 取代掉的旧记录 → **取代它的**新记录 id。
550
576
  * 用 Map 而非 Set:退场标记要带 `by`,否则"被谁取代"只能靠时间猜。
@@ -560,7 +586,7 @@ routeHealth) {
560
586
  */
561
587
  const rejected = [];
562
588
  const now = Date.now();
563
- for (const m of extracted) {
589
+ for (const m of effective) {
564
590
  const decision = byRecord.get(m.record_id);
565
591
  if (!decision || decision.action === 'skip')
566
592
  continue;
@@ -746,7 +772,14 @@ routeHealth) {
746
772
  stampValidity(mergedRec, now, mt.validToSource);
747
773
  added.push(mergedRec);
748
774
  }
749
- await store.appendNew(added);
775
+ await store.appendNew(added, {
776
+ vectors: added.map((r) => {
777
+ // 写侧复用:代表项正文未被改写 → 复用批内向量(免掉同一条记忆的第二次嵌入)。
778
+ // merge/update 产出的 merged_content 是**新文本**,同 record_id 也不得复用。
779
+ const orig = effective.find((m) => m.record_id === r.id)?.content;
780
+ return orig !== undefined && orig === r.content ? vecByRecordId.get(r.id) : undefined;
781
+ }),
782
+ });
750
783
  if (supersededBy.size > 0) {
751
784
  // **软删**(取代):不是物理删除——主表行保留 + 取代标记(marker 单源,门1 解耦后
752
785
  // 不再闭合 valid_to),FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去
@@ -826,7 +859,7 @@ routeHealth) {
826
859
  states[f].memoriesSinceL3 += addedByFamily[f];
827
860
  }
828
861
  markExtracted(states, mode, lastScene);
829
- 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}`);
862
+ logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条(批内收拢 ${effective.length}),去重后新增 ${added.length} 条(取代退场 ${supersededBy.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
830
863
  return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind, failedMessages };
831
864
  }
832
865
  /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
package/dist/stats.js CHANGED
@@ -18,6 +18,7 @@ import { EFFORT_CHOICES, resolveDataDir } from './config.js';
18
18
  import { typeCatalogEntries } from './memory-types.js';
19
19
  import { effectiveCfg } from './pipeline/runner.js';
20
20
  import { emptyRecallStats } from './hooks/recall.js';
21
+ import { probeDrscHealth } from './clients/drsc-inproc.js';
21
22
  import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
22
23
  import { mergeRoom, renameRoom, mergeRoomCatalog } from './room-review.js';
23
24
  import { scanSources } from './memport/scan.js';
@@ -122,6 +123,7 @@ export const MEMORY_ENDPOINTS = [
122
123
  'dsh-memory/llm-providers',
123
124
  'dsh-memory/llm-models',
124
125
  'dsh-memory/embedding-state-get',
126
+ 'dsh-memory/drsc-health',
125
127
  'dsh-memory/embedding-source-set',
126
128
  'dsh-memory/embedding-download-start',
127
129
  'dsh-memory/embedding-download-cancel',
@@ -2354,6 +2356,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
2354
2356
  const v = { supported: true, ...(await embedManager.snapshot()) };
2355
2357
  return v;
2356
2358
  }
2359
+ // DRSC 健康(设置面板 banner):纯观测,不改读写路径,故不依赖 ctx/embedManager。
2360
+ case 'dsh-memory/drsc-health': {
2361
+ const v = await probeDrscHealth();
2362
+ return v;
2363
+ }
2357
2364
  case 'dsh-memory/embedding-source-set': {
2358
2365
  if (!embedManager)
2359
2366
  throw new Error('嵌入管理器未初始化(存储不可用)');
@@ -69,7 +69,9 @@ export declare function makeLocalServiceFactory(installer: RuntimeInstaller, dow
69
69
  /** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
70
70
  childPath?: string,
71
71
  /** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
72
- maxInputTokens?: number): (modelId: string) => LocalEmbeddingService | null;
72
+ maxInputTokens?: number,
73
+ /** 崩溃有界自愈开关(config `embedding.respawnOnCrash`;缺省 true,与 LocalEmbeddingService 内默认一致)。 */
74
+ respawnOnCrash?: boolean): (modelId: string) => LocalEmbeddingService | null;
73
75
  export interface EmbeddingManagerDeps {
74
76
  dataDir: string;
75
77
  cfg: MemoryConfig;
@@ -159,7 +159,9 @@ export function makeLocalServiceFactory(installer, downloader, logger, maxInputC
159
159
  /** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
160
160
  childPath,
161
161
  /** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
162
- maxInputTokens) {
162
+ maxInputTokens,
163
+ /** 崩溃有界自愈开关(config `embedding.respawnOnCrash`;缺省 true,与 LocalEmbeddingService 内默认一致)。 */
164
+ respawnOnCrash) {
163
165
  return (modelId) => {
164
166
  const entry = catalogById(modelId);
165
167
  if (!entry)
@@ -170,6 +172,7 @@ maxInputTokens) {
170
172
  logger,
171
173
  maxInputChars,
172
174
  maxInputTokens,
175
+ respawnOnCrash,
173
176
  });
174
177
  };
175
178
  }
@@ -250,7 +253,7 @@ export class EmbeddingManager {
250
253
  /** 构造绑定真实运行时 loader 的本地服务(deps.makeLocal 可注入,测试替换)。 */
251
254
  makeLocalService(modelId) {
252
255
  const factory = this.deps.makeLocal ??
253
- makeLocalServiceFactory(this.installer, this.downloader, this.deps.logger, this.deps.cfg.embedding.maxInputChars, undefined, this.deps.cfg.embedding.maxInputTokens);
256
+ makeLocalServiceFactory(this.installer, this.downloader, this.deps.logger, this.deps.cfg.embedding.maxInputChars, undefined, this.deps.cfg.embedding.maxInputTokens, this.deps.cfg.embedding.respawnOnCrash);
254
257
  return factory(modelId);
255
258
  }
256
259
  /** 活切换请求:验证通过即接受,后台执行应用链(进度轮询可见)。 */
@@ -335,10 +335,18 @@ export declare class L1Store {
335
335
  }): ConflictPair[];
336
336
  /** §C 打上裁决结论(已裁决的不覆盖)。 */
337
337
  resolveConflictPending(pairId: string, resolution: ConflictResolution, resolvedAt: string): number;
338
- /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
339
- appendNew(records: MemoryRecord[]): Promise<void>;
340
- /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
341
- upsert(record: MemoryRecord): Promise<void>;
338
+ /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。
339
+ *
340
+ * `opts.vectors`:调用方(L1 批内自去重)已算好的向量;非 undefined 位直接复用,
341
+ * 其余现嵌。长度不等时**整批回落实嵌**(不猜位置,宁可多嵌一次)。 */
342
+ appendNew(records: MemoryRecord[], opts?: {
343
+ vectors?: Array<Float32Array | undefined>;
344
+ }): Promise<void>;
345
+ /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。
346
+ * `opts.vector`:同 `appendNew`,merge 产出的**新正文**通常不复用(调用方不传即现嵌)。 */
347
+ upsert(record: MemoryRecord, opts?: {
348
+ vector?: Float32Array;
349
+ }): Promise<void>;
342
350
  /** 活切换嵌入源:同步换底层服务(嵌入源三态切换用)。 */
343
351
  setEmbeddingService(svc: EmbeddingService): void;
344
352
  /** 向量写入能力是否就绪。`reindex` 在未就绪时**静默短路**成 0/0/0
@@ -500,6 +508,14 @@ export declare class L1Store {
500
508
  scanAllMetadata(cb: (recordId: string, metadata: Record<string, unknown> | null) => void): number;
501
509
  /** 查询向量(域软门禁用):复用既有嵌入源;失败/未就绪返回 undefined,调用方降级。 */
502
510
  embedText(text: string, timeoutMs?: number): Promise<Float32Array | undefined>;
511
+ /**
512
+ * 批量取向量(批内自去重用):走与写侧同一条 `helper.batch`。
513
+ *
514
+ * 为什么不用逐条 `embedText`:批内代表项的向量要同时喂给「候选召回」与「写入腿」,
515
+ * 一次取回才能把「同一条记忆嵌两次」压成一次;且熔断态下 `batch` 统一返回 undefined,
516
+ * 调用方能自然降级为「只做精确层」,不必各自判错。
517
+ */
518
+ embedMany(texts: string[]): Promise<Array<Float32Array | undefined>>;
503
519
  /**
504
520
  * 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
505
521
  * 传入 family 时只在同族记录里召回(去重永不跨族);传入 workspaceId 时
@@ -509,7 +525,14 @@ export declare class L1Store {
509
525
  * 理由:候选池决定**新的去重决策**,若此处跨工作区,产出的是「项目 B 里看不见、
510
526
  * 但已经决定了项目 A 记忆去向」的记录——比不隔离更糟。
511
527
  */
512
- searchCandidates(query: string, limit: number, family?: MemoryFamily, workspaceId?: string): Promise<MemoryRecord[]>;
528
+ searchCandidates(query: string, limit: number, family?: MemoryFamily, workspaceId?: string,
529
+ /**
530
+ * 调用方已算好的查询向量(批内自去重场景):给了就不再内部重嵌一次。
531
+ * 不传 → 维持旧行为(内部 `helper.query`)。增参不改旧签名,既有调用零改动。
532
+ */
533
+ opts?: {
534
+ queryVector?: Float32Array;
535
+ }): Promise<MemoryRecord[]>;
513
536
  /**
514
537
  * 增量重嵌入(embedding 配置变化 / 周期性补齐用):只处理缺失向量的记录,
515
538
  * 排除已判定"当前 provider 不可嵌入"的 skip 集。返回写入/失败/跳过数——
package/dist/store/l1.js CHANGED
@@ -486,8 +486,11 @@ export class L1Store {
486
486
  resolveConflictPending(pairId, resolution, resolvedAt) {
487
487
  return this.db.resolveConflictPending(pairId, resolution, resolvedAt);
488
488
  }
489
- /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
490
- async appendNew(records) {
489
+ /** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。
490
+ *
491
+ * `opts.vectors`:调用方(L1 批内自去重)已算好的向量;非 undefined 位直接复用,
492
+ * 其余现嵌。长度不等时**整批回落实嵌**(不猜位置,宁可多嵌一次)。 */
493
+ async appendNew(records, opts) {
491
494
  if (records.length === 0)
492
495
  return;
493
496
  // 写侧标注汇流口(task_3.2):所有新写路径(pipeline / memory_add / import / memport /
@@ -512,7 +515,19 @@ export class L1Store {
512
515
  for (const [day, list] of byDay) {
513
516
  await appendJsonl(path.join(this.recordsDir, `${day}.jsonl`), list);
514
517
  }
515
- const vecs = await this.helper.batch(records.map((r) => r.content));
518
+ const reuse = opts?.vectors;
519
+ let vecs;
520
+ if (reuse && reuse.length === records.length && reuse.some((v) => v !== undefined)) {
521
+ const missing = records.map((_, i) => i).filter((i) => reuse[i] === undefined);
522
+ const fresh = missing.length > 0 ? await this.helper.batch(missing.map((i) => records[i].content)) : [];
523
+ vecs = reuse.slice();
524
+ missing.forEach((ri, k) => {
525
+ vecs[ri] = fresh[k];
526
+ });
527
+ }
528
+ else {
529
+ vecs = await this.helper.batch(records.map((r) => r.content));
530
+ }
516
531
  // 单事务批量写:逐条开事务在 WAL FULL 下每条一次 fsync。
517
532
  // 双写失败闭环:JSONL 事实源已先行追加,DB 缺行 = 这批记忆检索不可见、
518
533
  // 去重候选缺失(重复记忆会累积)。upsert 内部已有逐条 warn,这里升 error
@@ -524,15 +539,16 @@ export class L1Store {
524
539
  // 写对称:新增也即时上盘(不阻塞、失败只 warn)——否则「文件树 = 库当前状态」只对删成立。
525
540
  this.fireFileSync(records.map((r) => r.id), 'appendNew');
526
541
  }
527
- /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
528
- async upsert(record) {
542
+ /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。
543
+ * `opts.vector`:同 `appendNew`,merge 产出的**新正文**通常不复用(调用方不传即现嵌)。 */
544
+ async upsert(record, opts) {
529
545
  if (!record.family)
530
546
  record.family = familyForType(record.type);
531
547
  // 写侧标注汇流口(与 appendNew 同口径):merge 分支会继承胜方的 name;
532
548
  // 两边都没名字时在这里补上,别让新铸的行继续带空名。
533
549
  stampValidity(record, Date.now());
534
550
  this.assignMissingNames([record], { persist: false });
535
- const vec = (await this.helper.batch([record.content]))[0];
551
+ const vec = opts?.vector ?? (await this.helper.batch([record.content]))[0];
536
552
  if (!this.db.upsertL1(record, vec)) {
537
553
  this.logger?.error(`[memory] L1 检索库写入失败 id=${record.id}(JSONL 事实源完好),该记忆暂不可检索,重建可修复`);
538
554
  }
@@ -923,6 +939,16 @@ export class L1Store {
923
939
  embedText(text, timeoutMs) {
924
940
  return this.helper.query(text, timeoutMs);
925
941
  }
942
+ /**
943
+ * 批量取向量(批内自去重用):走与写侧同一条 `helper.batch`。
944
+ *
945
+ * 为什么不用逐条 `embedText`:批内代表项的向量要同时喂给「候选召回」与「写入腿」,
946
+ * 一次取回才能把「同一条记忆嵌两次」压成一次;且熔断态下 `batch` 统一返回 undefined,
947
+ * 调用方能自然降级为「只做精确层」,不必各自判错。
948
+ */
949
+ embedMany(texts) {
950
+ return this.helper.batch(texts);
951
+ }
926
952
  /**
927
953
  * 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
928
954
  * 传入 family 时只在同族记录里召回(去重永不跨族);传入 workspaceId 时
@@ -932,7 +958,12 @@ export class L1Store {
932
958
  * 理由:候选池决定**新的去重决策**,若此处跨工作区,产出的是「项目 B 里看不见、
933
959
  * 但已经决定了项目 A 记忆去向」的记录——比不隔离更糟。
934
960
  */
935
- async searchCandidates(query, limit, family, workspaceId) {
961
+ async searchCandidates(query, limit, family, workspaceId,
962
+ /**
963
+ * 调用方已算好的查询向量(批内自去重场景):给了就不再内部重嵌一次。
964
+ * 不传 → 维持旧行为(内部 `helper.query`)。增参不改旧签名,既有调用零改动。
965
+ */
966
+ opts) {
936
967
  // 治理哨兵(治理 W0 T0.2):标记去重候选路径;治理权重误入即抛(dev/test)。
937
968
  return markDedupPath(async () => {
938
969
  if (this.db.countL1() === 0)
@@ -940,7 +971,7 @@ export class L1Store {
940
971
  const caps = this.db.getCapabilities();
941
972
  if (caps.vectorSearch && this.helper.vectorReady()) {
942
973
  try {
943
- const vec = await this.helper.query(query);
974
+ const vec = opts?.queryVector ?? (await this.helper.query(query));
944
975
  if (vec) {
945
976
  const hits = this.db.searchL1Vector(vec, limit, family, workspaceId);
946
977
  if (hits.length > 0)
@@ -997,12 +1028,16 @@ export class L1Store {
997
1028
  }
998
1029
  const pending = [];
999
1030
  chunk.forEach((c, j) => {
1000
- if (isZeroVector(vecs[j])) {
1031
+ // #22 机理刀:断点续试只补「尾部未完段」,头 ack 段的向量随崩溃子进程一起丢了
1032
+ // → embedBatch 对这些位返回空洞(undefined)。这里必须当作「本 chunk 暂未取到向量」跳过
1033
+ // (交后台 backfill 逐条补),否则 undefined 进 isZeroVector 会抛 TypeError 打断整轮重嵌。
1034
+ const vec = vecs[j];
1035
+ if (vec == null || isZeroVector(vec)) {
1001
1036
  skipped++;
1002
1037
  skippedNow.push(c.id);
1003
1038
  return;
1004
1039
  }
1005
- pending.push({ id: c.id, embedding: vecs[j] });
1040
+ pending.push({ id: c.id, embedding: vec });
1006
1041
  });
1007
1042
  if (pending.length > 0) {
1008
1043
  const ok = this.db.updateL1VecBatch(pending);