dsh-prime-memory 0.26.29 → 0.26.30

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.
@@ -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 } 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,21 @@ 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 = Object.entries(intraAbsorbedBy).map(([absorbedId, repId]) => ({
546
+ recordId: absorbedId,
547
+ candidateIds: [repId],
548
+ action: 'merge',
549
+ }));
550
+ const rows = buildReceipts(runId, new Date().toISOString(), [...items, ...absorbedItems]);
521
551
  persistReceiptsSafely((rows) => store.recordReceipts(rows), rows, logger);
522
552
  // §F 追踪捎带:六值决策词表聚合(与凭证同源,runner 侧并入 distill_run 事件)
523
553
  byKind = rows.reduce((acc, r) => {
@@ -544,7 +574,7 @@ routeHealth) {
544
574
  * 不成对而回落 store**(2026-09-18 取证确认,见 findings R6 / Agent A);
545
575
  * ② 队满自动了结的护栏 —— 败方若属本批新记忆,不得自动退场(见下方分支)。
546
576
  */
547
- const batchIds = new Set(extracted.map((e) => e.record_id));
577
+ const batchIds = new Set(effective.map((e) => e.record_id));
548
578
  /**
549
579
  * update/merge 取代掉的旧记录 → **取代它的**新记录 id。
550
580
  * 用 Map 而非 Set:退场标记要带 `by`,否则"被谁取代"只能靠时间猜。
@@ -560,7 +590,7 @@ routeHealth) {
560
590
  */
561
591
  const rejected = [];
562
592
  const now = Date.now();
563
- for (const m of extracted) {
593
+ for (const m of effective) {
564
594
  const decision = byRecord.get(m.record_id);
565
595
  if (!decision || decision.action === 'skip')
566
596
  continue;
@@ -746,7 +776,14 @@ routeHealth) {
746
776
  stampValidity(mergedRec, now, mt.validToSource);
747
777
  added.push(mergedRec);
748
778
  }
749
- await store.appendNew(added);
779
+ await store.appendNew(added, {
780
+ vectors: added.map((r) => {
781
+ // 写侧复用:代表项正文未被改写 → 复用批内向量(免掉同一条记忆的第二次嵌入)。
782
+ // merge/update 产出的 merged_content 是**新文本**,同 record_id 也不得复用。
783
+ const orig = effective.find((m) => m.record_id === r.id)?.content;
784
+ return orig !== undefined && orig === r.content ? vecByRecordId.get(r.id) : undefined;
785
+ }),
786
+ });
750
787
  if (supersededBy.size > 0) {
751
788
  // **软删**(取代):不是物理删除——主表行保留 + 取代标记(marker 单源,门1 解耦后
752
789
  // 不再闭合 valid_to),FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去
@@ -826,7 +863,7 @@ routeHealth) {
826
863
  states[f].memoriesSinceL3 += addedByFamily[f];
827
864
  }
828
865
  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}`);
866
+ 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
867
  return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind, failedMessages };
831
868
  }
832
869
  /** 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);
@@ -24,9 +24,10 @@ export type EmbedWorkerCall = {
24
24
  } | {
25
25
  type: 'embed';
26
26
  texts: string[];
27
- priority: boolean;
27
+ priority: boolean; /** 服务侧关联号(#22 断点续试进度归属;透传回 embedProgress) */
28
+ cid?: number;
28
29
  };
29
- /** worker → 主线程的应答。 */
30
+ /** worker → 主线程的应答(仅请求-应答配对;embedProgress 是单向实时进度、不在此列,否则并集成员无 ok 会破掉窄化)。 */
30
31
  export type EmbedWorkerReply = {
31
32
  id: number;
32
33
  ok: true;
@@ -41,12 +42,20 @@ export type EmbedWorkerReply = {
41
42
  type: 'embedded';
42
43
  vectors: Float32Array[];
43
44
  tokensMax?: number;
45
+ ack?: number;
44
46
  } | {
45
47
  id: number;
46
48
  ok: false;
47
49
  stage: 'load' | 'infer';
48
50
  error: string;
49
51
  };
52
+ /** 通道消息全集 = 请求应答 + 单向实时进度(#22 断点续试;progress 带 cid,不进 pending 配对)。 */
53
+ export type EmbedWorkerMessage = EmbedWorkerReply | {
54
+ id: number;
55
+ cid?: number;
56
+ type: 'embedProgress';
57
+ done: number;
58
+ };
50
59
  /** worker 通道抽象(测试缝:注入假实现验证协议与状态机)。 */
51
60
  export interface EmbedWorkerChannel {
52
61
  request(call: EmbedWorkerCall): Promise<EmbedWorkerReply>;
@@ -54,6 +63,10 @@ export interface EmbedWorkerChannel {
54
63
  terminate(): void;
55
64
  /** worker 意外崩溃通知(此后所有未决请求已被通道拒绝)。 */
56
65
  setOnCrash(cb: (error: string) => void): void;
66
+ /** 通道是否可用(#22 断点续试判定;假通道不实现,服务侧对 undefined 按「可用」处理)。 */
67
+ isAlive?(): boolean;
68
+ /** 指定 cid 的多条目批最后实时断点(#22;failAll 不清进度,崩溃后仍可读)。 */
69
+ lastProgressFor?(cid: number): number | undefined;
57
70
  }
58
71
  /**
59
72
  * 退出描述归一化(issue #22 取证面):被信号硬杀时 Node 给的是 `code===null` + `signal`,
@@ -85,15 +98,49 @@ export interface LocalEmbeddingOptions {
85
98
  * `HARD_CAP_TOKENS` 自动推导。超限只 clamp + warn,不抛错。
86
99
  */
87
100
  maxInputTokens?: number;
101
+ /**
102
+ * 崩溃有界自愈开关(config `embedding.respawnOnCrash`,默认 true)。
103
+ * 关闭后回到旧行为:崩一次停摆到宿主重启/换源(#22 机理刀前的语义)。
104
+ */
105
+ respawnOnCrash?: boolean;
88
106
  }
89
107
  export declare class LocalEmbeddingService implements EmbeddingService {
90
108
  private state;
91
109
  private loadError;
92
- private readonly channel;
110
+ private channel;
93
111
  private readonly entry;
94
112
  private readonly logger?;
113
+ /** 通道重建上下文(构造期固化;respawn 时原样重建,不重新推导预算)。 */
114
+ private readonly cfgObj;
115
+ private readonly useWorker;
116
+ private readonly workerPath;
117
+ private readonly childPath;
118
+ private readonly injectedChannel;
119
+ private readonly respawnOnCrash;
120
+ /** 生效 token 预算(留痕用,见 embedding-source 的 recordTokenBudget 调用点)。 */
121
+ private readonly tokenBudgetTokens;
122
+ /** 自愈发起时刻滚动窗(60s ≤2 次;只允许 crashCb 单点读写)。 */
123
+ private readonly respawnTimes;
124
+ /** 服务侧请求关联号(#22 断点续试:多条目批带 cid,进度按 cid 归属)。 */
125
+ private nextCid;
126
+ /** 在飞自愈合流点——非空期间所有崩溃事件归并到同一次重建(防 fork 风暴)。 */
127
+ private respawnPromise;
95
128
  constructor(entry: CatalogEntry, modelDir: string, opts: LocalEmbeddingOptions);
129
+ /** 真通道工厂(构造与自愈共用同一重建路径;假通道注入缝原样返回同一实例,不重建)。 */
130
+ private createChannel;
131
+ /** 60s 滚动窗内是否还允许一次自愈(调用即计入,先占位后重建)。 */
132
+ private respawnAllowed;
133
+ /**
134
+ * 单点自愈:关旧通道→新通道→重预热→markReady。
135
+ * 不变量:在飞期间 `respawnPromise` 非空且 `state='loading'`(isReady 为假),
136
+ * 现有 vectorsReady 门/EmbedHelper 降级链天然接管半成品窗口,不加新契约态。
137
+ */
138
+ private respawnChannel;
139
+ /** 自愈在飞时外部请求的等待缝(带超时;超时/失败都让调用方走既有降级链)。 */
140
+ private awaitRespawn;
96
141
  getDimensions(): number;
142
+ /** 生效的单条 token 预算(=子进程侧 shrinkToBudget 的硬帽;embedding_meta 留痕用)。 */
143
+ getTokenBudget(): number;
97
144
  getProviderInfo(): EmbeddingProviderInfo;
98
145
  isReady(): boolean;
99
146
  /** 状态(进度展示用)。 */