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.
- package/CHANGELOG.md +18 -0
- package/README.de.md +2 -0
- package/README.en.md +2 -0
- package/README.es.md +2 -0
- package/README.fr.md +2 -0
- package/README.it.md +2 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/dist/client.js +1389 -1274
- package/dist/clients/drsc-inproc.d.ts +34 -0
- package/dist/clients/drsc-inproc.js +88 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +8 -0
- package/dist/contract.d.ts +16 -0
- package/dist/embedding-child.cjs +18 -2
- package/dist/embedding-worker.cjs +10 -3
- package/dist/index.js +7 -1
- package/dist/pipeline/intra-dedupe.d.ts +85 -0
- package/dist/pipeline/intra-dedupe.js +224 -0
- package/dist/pipeline/l1.js +47 -10
- package/dist/stats.js +7 -0
- package/dist/store/embedding-source.d.ts +3 -1
- package/dist/store/embedding-source.js +5 -2
- package/dist/store/l1.d.ts +28 -5
- package/dist/store/l1.js +45 -10
- package/dist/store/local-embedding.d.ts +50 -3
- package/dist/store/local-embedding.js +232 -13
- package/dist/store/sqlite.d.ts +8 -0
- package/dist/store/sqlite.js +17 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/dist/pipeline/l1.js
CHANGED
|
@@ -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(
|
|
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
|
|
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 去重判定:${
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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}
|
|
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
|
|
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
|
/** 活切换请求:验证通过即接受,后台执行应用链(进度轮询可见)。 */
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
/** 状态(进度展示用)。 */
|