dsh-prime-memory 0.26.28 → 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.
@@ -48,3 +48,37 @@ export declare function resetCollectorReadState(): void;
48
48
  * 把传输从 fetch 换成函数调用,不顺手改任何语义。
49
49
  */
50
50
  export declare function callCollectorRead(query: string, load?: CollectorLoader): Promise<Record<string, unknown>>;
51
+ /** 五态:`ok` 不渲染 banner;`unknown` 是探针失败的兜底(不得当成故障去误报)。 */
52
+ export type DrscHealthState = 'ok' | 'not-running' | 'stale' | 'missing' | 'unknown';
53
+ export interface DrscHealth {
54
+ state: DrscHealthState;
55
+ /** 包能 import → true。`missing` 时为 false。 */
56
+ installed: boolean;
57
+ /** 门面可用(= collector 真跑起来了)→ true。装了没启用时为 false。 */
58
+ mounted: boolean;
59
+ /** 投影库 `MAX(synced_at)` 的 epoch ms;无从读取为 null。 */
60
+ lastSyncAt: number | null;
61
+ /** now - lastSyncAt;null = 无从计算。 */
62
+ lagMs: number | null;
63
+ staleAfterMs: number;
64
+ indexPath: string | null;
65
+ checkedAt: number;
66
+ error?: string;
67
+ }
68
+ /** 超过这个滞后就判 `stale`(“装了但库不新鲜”)。30 分钟与同步器节拍同量级。 */
69
+ export declare const DRSC_STALE_AFTER_MS: number;
70
+ /**
71
+ * 探针(只观测,不改变读路径)。
72
+ *
73
+ * 三条铁律(违一条就会污染读面或误报):
74
+ * 1. **不经 `moduleOf`**:它会推进 `facadeMisses` 并把结果缓进 `modulePromise`,
75
+ * 探针是观测面,不得改动读路径的重试预算与缓存——故这里直接调 `load()` 与 `drsReadFacade()`。
76
+ * 2. **复用模块级 `storeHandle`**:`openProjectionStore` 会把摘要全表载入(Issue #1 的根因),
77
+ * 每 60s 轮询重开一次就是事故;只在 undefined 时开。
78
+ * 3. **读库失败一律落 `unknown`**:探针失败不等于功能坏了,不得当成故障吓用户。
79
+ */
80
+ export declare function probeDrscHealth(opts?: {
81
+ load?: CollectorLoader;
82
+ now?: number;
83
+ staleAfterMs?: number;
84
+ }): Promise<DrscHealth>;
@@ -156,3 +156,91 @@ function positiveInt(raw) {
156
156
  }
157
157
  /** 统一形后的模块不再需要真 QueryContext,这个占位只用来占位。 */
158
158
  const READ_SURFACE = 'inproc-read-surface';
159
+ /** 超过这个滞后就判 `stale`(“装了但库不新鲜”)。30 分钟与同步器节拍同量级。 */
160
+ export const DRSC_STALE_AFTER_MS = 30 * 60 * 1000;
161
+ /** `synced_at` 实测是 TEXT ISO(如 "2026-11-01T21:00:57.415Z"),兼容数值形式。 */
162
+ function parseSyncStamp(v) {
163
+ if (typeof v === 'string' && v.trim() !== '') {
164
+ const t = Date.parse(v);
165
+ return Number.isFinite(t) ? t : null;
166
+ }
167
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
168
+ return v;
169
+ return null;
170
+ }
171
+ /**
172
+ * 探针(只观测,不改变读路径)。
173
+ *
174
+ * 三条铁律(违一条就会污染读面或误报):
175
+ * 1. **不经 `moduleOf`**:它会推进 `facadeMisses` 并把结果缓进 `modulePromise`,
176
+ * 探针是观测面,不得改动读路径的重试预算与缓存——故这里直接调 `load()` 与 `drsReadFacade()`。
177
+ * 2. **复用模块级 `storeHandle`**:`openProjectionStore` 会把摘要全表载入(Issue #1 的根因),
178
+ * 每 60s 轮询重开一次就是事故;只在 undefined 时开。
179
+ * 3. **读库失败一律落 `unknown`**:探针失败不等于功能坏了,不得当成故障吓用户。
180
+ */
181
+ export async function probeDrscHealth(opts = {}) {
182
+ const now = opts.now ?? Date.now();
183
+ const staleAfterMs = opts.staleAfterMs ?? DRSC_STALE_AFTER_MS;
184
+ const load = opts.load ?? defaultLoader;
185
+ const base = {
186
+ state: 'unknown',
187
+ installed: false,
188
+ mounted: false,
189
+ lastSyncAt: null,
190
+ lagMs: null,
191
+ staleAfterMs,
192
+ indexPath: null,
193
+ checkedAt: now,
194
+ };
195
+ let mod;
196
+ try {
197
+ mod = (await load());
198
+ }
199
+ catch (err) {
200
+ return { ...base, state: 'missing', error: err instanceof Error ? err.message : String(err) };
201
+ }
202
+ // 门面活 → 已启用(ok 态由客户端 return null,不占版面)
203
+ try {
204
+ if (typeof mod.drsReadFacade === 'function' && mod.drsReadFacade() !== undefined) {
205
+ return { ...base, state: 'ok', installed: true, mounted: true };
206
+ }
207
+ }
208
+ catch (err) {
209
+ // 门面自身抛错:按“装了没启用”继续往下判,不因此报 missing
210
+ base.error = err instanceof Error ? err.message : String(err);
211
+ }
212
+ // 装了没启用:读投影库水位区分 not-running / stale
213
+ let indexPath = null;
214
+ try {
215
+ indexPath = mod.resolveIndexDir();
216
+ if (storeHandle === undefined)
217
+ storeHandle = mod.openProjectionStore(indexPath);
218
+ const db = storeHandle.db;
219
+ if (!db)
220
+ return { ...base, state: 'unknown', installed: true, indexPath, error: '投影库句柄无 .db' };
221
+ const row = db.prepare('SELECT MAX(synced_at) AS s FROM sessions').get();
222
+ const lastSyncAt = parseSyncStamp(row?.s);
223
+ if (lastSyncAt === null) {
224
+ return { ...base, state: 'unknown', installed: true, indexPath, error: 'synced_at 无法解析(期望 ISO 字符串或 epoch ms)' };
225
+ }
226
+ const lagMs = Math.max(0, now - lastSyncAt);
227
+ return {
228
+ ...base,
229
+ state: lagMs <= staleAfterMs ? 'not-running' : 'stale',
230
+ installed: true,
231
+ lastSyncAt,
232
+ lagMs,
233
+ indexPath,
234
+ error: undefined,
235
+ };
236
+ }
237
+ catch (err) {
238
+ return {
239
+ ...base,
240
+ state: 'unknown',
241
+ installed: true,
242
+ indexPath,
243
+ error: err instanceof Error ? err.message : String(err),
244
+ };
245
+ }
246
+ }
package/dist/config.d.ts CHANGED
@@ -65,6 +65,18 @@ export interface MemoryConfig {
65
65
  dedupRetryCap?: number;
66
66
  /** 抽取结果 checkpoint 落盘:去重/落库失败后下轮从去重续跑、不重抽已成功块。R1″。 */
67
67
  extractCheckpoint?: boolean;
68
+ /**
69
+ * L1 **批内自去重**(同轮/同会话):进 LLM 跨库去重前,先做"精确归一合并 → 同族同类向量聚类"。
70
+ *
71
+ * 解的是实测缺陷:候选池只来自库,同批 `extracted[]` 从不互相比,于是同一段对话被抽成的
72
+ * 多条碎片会一起塞进 LLM 批,只能由模型把内容"撑在一起"。关闭 = 回到旧行为(逐字节一致)。
73
+ */
74
+ intraBatchDedupe?: boolean;
75
+ /**
76
+ * 批内向量的余弦阈值(有效区间 0.85–0.99,缺省 0.92)。只在同 family + 同 type 内生效。
77
+ * 越界**不报错**(配置树抛错的故障半径是整个配置),由代码 clamp 并留 warn。
78
+ */
79
+ intraBatchCosine?: number;
68
80
  };
69
81
  l2: {
70
82
  enabled: boolean;
@@ -369,6 +381,14 @@ export interface MemoryConfig {
369
381
  * 超上限会被 clamp 并留 warn(不抛错,见 model-catalog.ts `deriveTokenBudget`)。
370
382
  */
371
383
  maxInputTokens: number;
384
+ /**
385
+ * 本地嵌入子进程被硬杀后是否**有界自愈**(#22 机理刀,默认 true)。
386
+ *
387
+ * 开:崩溃由 crashCb 单点重 fork+重预热,60s 窗内 ≤2 次;多条目批按实时断点(ack)
388
+ * 续试**一次**(不整批重放,毒输入最多多吃两批不成活锁);超限回 failed(旧语义)。
389
+ * 关:回到 0.26.25 语义——崩一次停摆到宿主重启/换源。**有意翻转**当时的「不做自愈」公告。
390
+ */
391
+ respawnOnCrash: boolean;
372
392
  /** 单次调用超时(ms)。 */
373
393
  timeoutMs: number;
374
394
  /** 允许本地嵌入模型档(部署上限:公司环境可禁下载与本地推理)。 */
package/dist/config.js CHANGED
@@ -159,6 +159,11 @@ export const memorySchema = Schema.object({
159
159
  candidatePool: Schema.number().min(1).max(20).default(5),
160
160
  dedupRetryCap: Schema.number().min(1).max(20).default(5),
161
161
  extractCheckpoint: Schema.boolean().default(true),
162
+ // 批内自去重(#同会话碎片收拢):默认开;关闭即回到 0.26.30 前的行为。
163
+ intraBatchDedupe: Schema.boolean().default(true),
164
+ // 宽外壁(0–10)只为防溢出输入;有效区间 0.85–0.99 在代码内 clamp+warn,
165
+ // 不用 min/max 卡窄区间——schemastery 越界会抛,故障半径是整个配置树。
166
+ intraBatchCosine: Schema.number().min(0).max(10).default(0.92),
162
167
  }),
163
168
  l2: Schema.object({
164
169
  enabled: Schema.boolean().default(true),
@@ -347,6 +352,9 @@ export const memorySchema = Schema.object({
347
352
  maxInputTokens: Schema.number().min(0).max(100_000).default(0),
348
353
  timeoutMs: Schema.number().min(1000).max(300_000).default(10_000),
349
354
  allowLocalModels: Schema.boolean().default(true),
355
+ // #22 机理刀:只增不改。**默认开**有界自愈(重 fork ≤2/60s + ack 断点续试一次),
356
+ // 有意翻转 0.26.25「不做自愈」公告;关键即回旧语义(崩一次停摆到重启)。
357
+ respawnOnCrash: Schema.boolean().default(true),
350
358
  mirror: Schema.string().default('https://hf-mirror.com'),
351
359
  proxy: Schema.string().default(''),
352
360
  }),
@@ -1968,6 +1968,8 @@ export interface DshMemoryRequestMap {
1968
1968
  'dsh-memory/llm-providers': Record<string, never>;
1969
1969
  'dsh-memory/llm-models': LlmModelsRequest;
1970
1970
  'dsh-memory/embedding-state-get': Record<string, never>;
1971
+ /** DRSC(参考会话库)健康探针:设置面板 banner 用(无入参)。 */
1972
+ 'dsh-memory/drsc-health': Record<string, never>;
1971
1973
  'dsh-memory/embedding-source-set': EmbeddingSourceSetRequest;
1972
1974
  'dsh-memory/embedding-download-start': EmbeddingDownloadStartRequest;
1973
1975
  'dsh-memory/embedding-download-cancel': Record<string, never>;
@@ -1996,6 +1998,19 @@ export interface DshMemoryRequestMap {
1996
1998
  'dsh-memory/mem-orphans': MemOrphansRequest;
1997
1999
  'dsh-memory/skills-action': SkillsActionRequest;
1998
2000
  }
2001
+ /** DRSC 健康五态(与 `drsc-inproc.probeDrscHealth` 返回同步;此处只保留形状,不引 node 侧代码)。 */
2002
+ export type DrscHealthStateName = 'ok' | 'not-running' | 'stale' | 'missing' | 'unknown';
2003
+ export interface DrscHealthResponse {
2004
+ state: DrscHealthStateName;
2005
+ installed: boolean;
2006
+ mounted: boolean;
2007
+ lastSyncAt: number | null;
2008
+ lagMs: number | null;
2009
+ staleAfterMs: number;
2010
+ indexPath: string | null;
2011
+ checkedAt: number;
2012
+ error?: string;
2013
+ }
1999
2014
  export interface DshMemoryResponseMap {
2000
2015
  'dsh-memory/stats': StatsResponse;
2001
2016
  'dsh-memory/token-cost': TokenCostResponse;
@@ -2036,6 +2051,7 @@ export interface DshMemoryResponseMap {
2036
2051
  'dsh-memory/llm-providers': LlmProvidersResponse;
2037
2052
  'dsh-memory/llm-models': LlmModelsResponse;
2038
2053
  'dsh-memory/embedding-state-get': EmbeddingStateResponse;
2054
+ 'dsh-memory/drsc-health': DrscHealthResponse;
2039
2055
  'dsh-memory/embedding-source-set': EmbeddingSourceSetResponse;
2040
2056
  'dsh-memory/embedding-download-start': EmbeddingDownloadStartResponse;
2041
2057
  'dsh-memory/embedding-download-cancel': EmbeddingCancelResponse;
@@ -15,7 +15,8 @@
15
15
  * - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?,tokensMax?}(vectors 为
16
16
  * number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
17
17
  * Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)。
18
- * tokensMax = 本批真正进模型的最大 token 数(#22 护栏自证用,主线程不消费也不报错)
18
+ * tokensMax = 本批**实测**进模型的最大 token 数(#22 护栏自证用,主线程不消费也不报错);
19
+ * 初值为 -1 而非 0 —— -1 表示「不可证」,与「真的一个 token 也没进」必须区分开。
19
20
  * | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
20
21
  *
21
22
  * 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
@@ -85,7 +86,16 @@ function ensureLoaded() {
85
86
  mod.env.allowRemoteModels = false;
86
87
  if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
87
88
  }
88
- extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
89
+ // #22 机理面:关 memPattern。ORT 默认按图形预分配输入绑定驻留 BFCArena,连发时
90
+ // 新旧 T² 张量共存把峰值叠成 m×T²(实测 r0→r1 再跳 +341MB;单发/连发致死线之比
91
+ // ≈√2 即 m=2 的定量旁证)。关掉后同形状连发峰值 1583→1247MB(m:2→1 方向)、
92
+ // 稳态 infer 零代价、输出 12 位小数全同(不触发重嵌)。传参链已实证:
93
+ // pipelines.js:111/167 → onnx.js:286-293 摊进 InferenceSession.create。
94
+ // **边界**:对 macOS 分配器 trap 的消除效果本机不可证,不得据此宣称「不再崩」。
95
+ extractor = await mod.pipeline('feature-extraction', MODEL_DIR, {
96
+ dtype: DTYPE,
97
+ session_options: { enableMemPattern: false },
98
+ });
89
99
  // token 截断要用到 pipeline 内部的 tokenizer(未单独暴露 AutoTokenizer,4.x 形态)
90
100
  tokenizer = extractor && extractor.tokenizer ? extractor.tokenizer : null;
91
101
  loadState = 'ready';
@@ -122,6 +132,9 @@ function settle(request) {
122
132
  vectors: request.vectors.map((v) => Array.from(v)),
123
133
  // #22 护栏自证:本批**实测**进模型的最大 token 数(不可测时为 -1,主线程不读此字段也不会坏)
124
134
  tokensMax: request.tokensMax,
135
+ // #22 机理刀:ack = 本批已完整完成的条目数(全部完成时恒 = total);多条目批另有
136
+ // embedProgress 实时推进度——子进程被硬杀时最后的 ack 就是父侧的断点位。
137
+ ack: request.total,
125
138
  });
126
139
  }
127
140
 
@@ -155,6 +168,10 @@ async function pump() {
155
168
  // 不可证时为 -1(不是 0),不拿假数据当护栏证据。
156
169
  if (cut.seq > job.request.tokensMax) job.request.tokensMax = cut.seq;
157
170
  job.request.remaining -= 1;
171
+ // 多条目批实时上报断点(完成一条报一条;单条批无断点可续,不报)——
172
+ // 父侧若在两条之间被 SIGTRAP 打断,最后收到的 done 就是重试切片位。
173
+ // cid 由服务侧生成并回显(通道内部 id 与请求配对同形,不暴露给服务侧)。
174
+ if (job.request.total > 1) postReply({ id: job.request.id, cid: job.request.cid, type: 'embedProgress', done: job.request.total - job.request.remaining });
158
175
  settle(job.request);
159
176
  } catch (err) {
160
177
  failRequest(job.request, err, 'infer');
@@ -173,7 +190,11 @@ function handleEmbed(msg) {
173
190
  postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
174
191
  return;
175
192
  }
176
- const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false, tokensMax: 0 };
193
+ // tokensMax 初值必须是 -1(不可证)而不是 0:下面的累加用严格大于,若初值为 0,
194
+ // 则永远量不出 seq 的情形(tokenizer 不可达/预算为 0)会把 -1 压成 0,
195
+ // 而主线程的 `>= 0` 判据会把 0 当有效值印出来——正好印成「一条也没进」,
196
+ // 与「不可证」撞车,使这行日志在最需要它的时刻说谎(#22 报告人回验实测)。
197
+ const request = { id: msg.id, cid: msg.cid, total: texts.length, vectors: new Array(texts.length), remaining: texts.length, failed: false, tokensMax: -1 };
177
198
  // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
178
199
  const priority = msg.priority === true && texts.length === 1;
179
200
  for (let i = 0; i < texts.length; i++) {
@@ -62,7 +62,12 @@ function ensureLoaded() {
62
62
  mod.env.allowRemoteModels = false;
63
63
  if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
64
64
  }
65
- extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
65
+ // #22 机理面:关 memPattern(与 embedding-child.cjs 同口径,峰值 m:2→1 方向、输出等价;
66
+ // 本逃生门无 token 护栏是既有限制,注明不在机理刀里扩面)
67
+ extractor = await mod.pipeline('feature-extraction', MODEL_DIR, {
68
+ dtype: DTYPE,
69
+ session_options: { enableMemPattern: false },
70
+ });
66
71
  loadState = 'ready';
67
72
  } catch (err) {
68
73
  loadState = 'failed';
@@ -86,7 +91,7 @@ function postReply(msg, transfer) {
86
91
  function settle(request) {
87
92
  if (request.failed || request.remaining !== 0) return;
88
93
  postReply(
89
- { id: request.id, ok: true, type: 'embedded', vectors: request.vectors },
94
+ { id: request.id, ok: true, type: 'embedded', vectors: request.vectors, ack: request.total },
90
95
  request.vectors.map((v) => v.buffer),
91
96
  );
92
97
  }
@@ -113,6 +118,8 @@ async function pump() {
113
118
  const data = result[0] && result[0].data;
114
119
  job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
115
120
  job.request.remaining -= 1;
121
+ // 多条目批实时上报断点(与 child 通道同协议;cid 回显,见 embedding-child.cjs 注释)
122
+ if (job.request.total > 1) postReply({ id: job.request.id, cid: job.request.cid, type: 'embedProgress', done: job.request.total - job.request.remaining });
116
123
  settle(job.request);
117
124
  } catch (err) {
118
125
  failRequest(job.request, err, 'infer');
@@ -131,7 +138,7 @@ function handleEmbed(msg) {
131
138
  postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
132
139
  return;
133
140
  }
134
- const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
141
+ const request = { id: msg.id, cid: msg.cid, total: texts.length, vectors: new Array(texts.length), remaining: texts.length, failed: false };
135
142
  // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
136
143
  const priority = msg.priority === true && texts.length === 1;
137
144
  for (let i = 0; i < texts.length; i++) {
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { registerMemoryRpc, PLUGIN_VERSION } from './stats.js';
21
21
  import { registerLiveSettings, suppressAutoSettingsForm } from './settings.js';
22
22
  import { NoopEmbeddingService } from './store/embedding.js';
23
23
  import { EmbeddingManager, EmbeddingSourceStore, makeLocalServiceFactory, resolveInitialEmbedding, } from './store/embedding-source.js';
24
+ import { LocalEmbeddingService } from './store/local-embedding.js';
24
25
  import { ModelDownloadQueue } from './store/download-queue.js';
25
26
  import { PINNED_TRANSFORMERS_VERSION, RuntimeInstaller } from './store/runtime-installer.js';
26
27
  import { cleanupOrphanTmp, ensureDir } from './util/io.js';
@@ -161,7 +162,7 @@ export async function apply(ctx, config) {
161
162
  logger,
162
163
  proxy: config.embedding.proxy,
163
164
  });
164
- const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars, undefined, config.embedding.maxInputTokens);
165
+ const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars, undefined, config.embedding.maxInputTokens, config.embedding.respawnOnCrash);
165
166
  let initial = { svc: new NoopEmbeddingService(), dims: 0 };
166
167
  /** 管理器引用:启动重嵌链/backfill 闭包在运行期解引用(声明早于创建避免 TDZ)。 */
167
168
  // eslint-disable-next-line prefer-const -- 声明早于创建,后续单点赋值(TS 不允许 const 无初值)
@@ -190,6 +191,11 @@ export async function apply(ctx, config) {
190
191
  if (storageOk) {
191
192
  try {
192
193
  dbInit = db.init(initial.providerInfo);
194
+ // #22 口径留痕:库里向量是在哪个 token 帽下嵌出来的,事后必须可查
195
+ // (providerInfo 判据不含预算——未来调档若无声,这行是唯一证据)
196
+ if (initial.providerInfo?.provider === 'local' && embed instanceof LocalEmbeddingService) {
197
+ db.recordEmbeddingTokenBudget(embed.getTokenBudget());
198
+ }
193
199
  if (db.isDegraded()) {
194
200
  storageOk = false;
195
201
  logger.error('[memory] 检索库初始化失败(schema/FTS 不可用),记忆功能停用');
@@ -0,0 +1,85 @@
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
+ /** 聚类所需的最小结构(`PendingMemory` 天然满足)。 */
20
+ export interface DedupeShaped {
21
+ record_id: string;
22
+ content: string;
23
+ priority?: number;
24
+ family?: string;
25
+ type?: string;
26
+ /** 只有部分调用方带这两个字段;带了就取并集,没带就不凭空造。 */
27
+ timestamps?: number[];
28
+ source_message_ids?: string[];
29
+ }
30
+ export interface ClusterOutcome<T> {
31
+ /** 代表项(原对象字段 + 并入的 priority/timestamps/source_message_ids)。 */
32
+ effective: T[];
33
+ /** 逐簇的吸收关系(供 receipt 审计)。 */
34
+ absorbed: Array<{
35
+ representative: string;
36
+ absorbed: string[];
37
+ }>;
38
+ /** 扁平映射:被吸收 id → 代表 id(写 receipt 用)。 */
39
+ absorbedBy: Record<string, string>;
40
+ }
41
+ /**
42
+ * 用于比较的归一化文本。
43
+ * NFKC 会把全角标点/字母折到半角;零宽字符(U+200B–200F / U+FEFF)直接剔除——
44
+ * 它们肉眼不可见,却足以让两条"同一句话"取不同 hash。
45
+ */
46
+ export declare function normalizeForDedupe(s: string): string;
47
+ /** 余弦相似度;任一向量缺失/长度不等/零向量时返回 undefined(不可比,绝不猜)。 */
48
+ export declare function cosineMaybe(a: Float32Array | undefined, b: Float32Array | undefined): number | undefined;
49
+ /**
50
+ * 精确层:归一化后同内容合并(含全半角/空白/零宽差异)。
51
+ * 空内容条目**不参与**合并(各自成簇)——正文为空是上游缺陷,静默吞掉会更难查。
52
+ */
53
+ export declare function clusterExact<T extends DedupeShaped>(items: readonly T[]): ClusterOutcome<T>;
54
+ /**
55
+ * 向量层:**仅同 family + 同 type** 组内,余弦 ≥ tau 即并入先出现的代表簇。
56
+ * 缺向量 / 长度不等 / 零向量 → 不可比,直接自成代表(不猜相似)。
57
+ * 先精确层、后本层是刻意顺序:精确重复不必再花一次比较,且能让代表项更稳定。
58
+ */
59
+ export declare function clusterByVector<T extends DedupeShaped>(items: readonly T[], vectors: ReadonlyArray<Float32Array | undefined>, tau: number): ClusterOutcome<T>;
60
+ /** `preDedupeBatch` 的可注入口:向量获取(失败可抛,由本函数降级)。 */
61
+ export type EmbedManyFn = (texts: string[]) => Promise<Array<Float32Array | undefined>>;
62
+ export interface PreDedupeOpts {
63
+ /** 缺省 true;false 直接透传(行为与改动前逐字节一致)。 */
64
+ enabled?: boolean;
65
+ /** 有效区间 0.85–0.99,越界 clamp 并通过 onWarn 留痕(不抛)。 */
66
+ tau?: number;
67
+ onWarn?: (msg: string) => void;
68
+ }
69
+ export interface PreDedupeResult<T> {
70
+ effective: T[];
71
+ /** 代表项 record_id → 该正文的向量(候选召回与写侧复用同一份)。 */
72
+ vectors: Map<string, Float32Array>;
73
+ absorbedBy: Record<string, string>;
74
+ /** 三级计数(日志与用例断言用)。 */
75
+ raw: number;
76
+ exact: number;
77
+ vector: number;
78
+ }
79
+ /**
80
+ * 一次完成:精确层 → (可选)向量层,并把“代表项→向量”与“被吸收→代表”两表一并交回。
81
+ *
82
+ * 嵌入不可用时**降级只做精确层**(catch 后留一条 warn),绝不因向量故障阻断蒸馏——
83
+ * 它只是省 LLM 判定与写侧重嵌的优化,不是正确性前置。
84
+ */
85
+ export declare function preDedupeBatch<T extends DedupeShaped>(items: readonly T[], embedMany: EmbedManyFn, opts?: PreDedupeOpts): Promise<PreDedupeResult<T>>;
@@ -0,0 +1,224 @@
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
+ * 一次完成:精确层 → (可选)向量层,并把“代表项→向量”与“被吸收→代表”两表一并交回。
181
+ *
182
+ * 嵌入不可用时**降级只做精确层**(catch 后留一条 warn),绝不因向量故障阻断蒸馏——
183
+ * 它只是省 LLM 判定与写侧重嵌的优化,不是正确性前置。
184
+ */
185
+ export async function preDedupeBatch(items, embedMany, opts = {}) {
186
+ if (opts.enabled === false || items.length === 0) {
187
+ return { effective: [...items], vectors: new Map(), absorbedBy: {}, raw: items.length, exact: items.length, vector: items.length };
188
+ }
189
+ const tauRaw = opts.tau ?? 0.92;
190
+ const tau = Math.min(0.99, Math.max(0.85, Number.isFinite(tauRaw) ? tauRaw : 0.92));
191
+ if (tau !== tauRaw)
192
+ opts.onWarn?.(`[memory] extract.intraBatchCosine=${tauRaw} 超出有效区间 0.85–0.99,已按 ${tau} 处理`);
193
+ const exact = clusterExact(items);
194
+ let vecs = exact.effective.map(() => undefined);
195
+ const exactVecById = new Map();
196
+ try {
197
+ vecs = await embedMany(exact.effective.map((m) => m.content));
198
+ exact.effective.forEach((m, i) => {
199
+ const v = vecs[i];
200
+ if (v)
201
+ exactVecById.set(m.record_id, v);
202
+ });
203
+ }
204
+ catch (err) {
205
+ opts.onWarn?.(`[memory] L1 批内向量不可用,仅做精确层: ${err instanceof Error ? err.message : String(err)}`);
206
+ }
207
+ const byVec = clusterByVector(exact.effective, vecs, tau);
208
+ // 只回最终代表项的向量:被向量层吸收的条目(如 r3)虽然自己算过向量,但它不会入库,
209
+ // 留在表里只会误导写侧(按 record_id 查得到,却根本没有这条记录)。
210
+ const vectors = new Map();
211
+ for (const m of byVec.effective) {
212
+ const v = exactVecById.get(m.record_id);
213
+ if (v)
214
+ vectors.set(m.record_id, v);
215
+ }
216
+ return {
217
+ effective: byVec.effective,
218
+ vectors,
219
+ absorbedBy: { ...exact.absorbedBy, ...byVec.absorbedBy },
220
+ raw: items.length,
221
+ exact: exact.effective.length,
222
+ vector: byVec.effective.length,
223
+ };
224
+ }