dsh-prime-memory 0.26.5-beta.0 → 0.26.6

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/client.js +36785 -134
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +8 -0
  7. package/dist/contract.d.ts +76 -0
  8. package/dist/embedding-child.cjs +192 -0
  9. package/dist/embedding-worker.cjs +2 -0
  10. package/dist/graph/apply.d.ts +9 -0
  11. package/dist/graph/apply.js +12 -2
  12. package/dist/graph/constraints.d.ts +3 -1
  13. package/dist/graph/constraints.js +4 -2
  14. package/dist/graph/preview.d.ts +92 -0
  15. package/dist/graph/preview.js +186 -0
  16. package/dist/graph/search.d.ts +32 -6
  17. package/dist/graph/search.js +102 -12
  18. package/dist/graph/types.d.ts +26 -1
  19. package/dist/graph/types.js +26 -1
  20. package/dist/hooks/recall.js +36 -2
  21. package/dist/index.js +23 -3
  22. package/dist/llm.d.ts +7 -2
  23. package/dist/llm.js +14 -22
  24. package/dist/pipeline/graph.d.ts +26 -1
  25. package/dist/pipeline/graph.js +74 -4
  26. package/dist/pipeline/l1.js +87 -26
  27. package/dist/pipeline/md-sync.js +4 -2
  28. package/dist/pipeline/runner.d.ts +26 -0
  29. package/dist/pipeline/runner.js +74 -7
  30. package/dist/pipeline/slot-migrate.d.ts +3 -2
  31. package/dist/pipeline/slot-migrate.js +3 -2
  32. package/dist/projection/recall.d.ts +10 -3
  33. package/dist/projection/recall.js +14 -0
  34. package/dist/settings.js +3 -0
  35. package/dist/stats.d.ts +1 -1
  36. package/dist/stats.js +47 -12
  37. package/dist/store/embedding-source.d.ts +4 -2
  38. package/dist/store/embedding-source.js +5 -2
  39. package/dist/store/graph-store.d.ts +111 -4
  40. package/dist/store/graph-store.js +324 -15
  41. package/dist/store/l1.d.ts +17 -9
  42. package/dist/store/l1.js +55 -18
  43. package/dist/store/local-embedding.d.ts +9 -5
  44. package/dist/store/local-embedding.js +137 -17
  45. package/dist/store/memory-md-disable.d.ts +3 -2
  46. package/dist/store/memory-md.d.ts +3 -3
  47. package/dist/store/memory-md.js +6 -4
  48. package/dist/store/runtime-installer.d.ts +13 -1
  49. package/dist/store/runtime-installer.js +40 -2
  50. package/dist/store/session-recalled.d.ts +9 -3
  51. package/dist/store/session-recalled.js +24 -5
  52. package/dist/store/sqlite.d.ts +24 -12
  53. package/dist/store/sqlite.js +97 -31
  54. package/dist/store/supersede.d.ts +10 -2
  55. package/dist/store/supersede.js +10 -2
  56. package/dist/store/validity.d.ts +150 -0
  57. package/dist/store/validity.js +209 -0
  58. package/dist/tools/index.d.ts +15 -1
  59. package/dist/tools/index.js +393 -11
  60. package/dist/types.d.ts +9 -0
  61. package/dist/util/json-repair.d.ts +48 -0
  62. package/dist/util/json-repair.js +285 -0
  63. package/dist/validity-backfill.d.ts +78 -0
  64. package/dist/validity-backfill.js +160 -0
  65. package/dsh.plugin.json +2 -2
  66. package/package.json +5 -4
@@ -2,7 +2,7 @@ import type { MemoryLogger } from '../types.js';
2
2
  import type { CatalogEntry } from './model-catalog.js';
3
3
  import type { EmbedCallOptions, EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
4
4
  type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
5
- /** worker 启动参数(workerData;构造后不可变)。 */
5
+ /** 推理侧启动参数(worker 经 workerData / 子进程经 env DSH_EMBED_CFG;构造后不可变)。 */
6
6
  export interface EmbedWorkerData {
7
7
  runtimeDir: string;
8
8
  modelDir: string;
@@ -49,11 +49,15 @@ export interface EmbedWorkerChannel {
49
49
  setOnCrash(cb: (error: string) => void): void;
50
50
  }
51
51
  export interface LocalEmbeddingOptions {
52
- /** 数据目录 runtime/(worker 据此 createRequire 加载 transformers)。 */
52
+ /** 数据目录 runtime/(推理侧据此 createRequire 加载 transformers)。 */
53
53
  runtimeDir: string;
54
- /** worker 资产路径(默认 dist/embedding-worker.cjs;测试可显式指定)。 */
54
+ /** 子进程资产路径(默认 dist/embedding-child.cjs;测试可显式指定)。 */
55
+ childPath?: string;
56
+ /** 旧 worker 资产路径(仅 useWorker=true 时生效;默认 dist/embedding-worker.cjs)。 */
55
57
  workerPath?: string;
56
- /** 通道注入缝(测试用;缺省 spawn 真实 worker)。 */
58
+ /** 回滚逃生门:true 切回 worker_threads 通道(fix #11 前的旧路径)。 */
59
+ useWorker?: boolean;
60
+ /** 通道注入缝(测试用;缺省起真实子进程通道)。 */
57
61
  channel?: EmbedWorkerChannel;
58
62
  logger?: MemoryLogger;
59
63
  maxInputChars?: number;
@@ -77,7 +81,7 @@ export declare class LocalEmbeddingService implements EmbeddingService {
77
81
  waitForReady(): Promise<void>;
78
82
  embed(text: string, callOpts?: EmbedCallOptions): Promise<Float32Array>;
79
83
  embedBatch(texts: string[], callOpts?: EmbedCallOptions): Promise<Float32Array[]>;
80
- /** 释放 worker 线程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
84
+ /** 释放推理子进程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
81
85
  * 再复用——防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
82
86
  close(): void;
83
87
  /** 内层钳制(仅缩短):超时放弃等待(迟到回复由通道按 id 丢弃),调用方降级。 */
@@ -1,25 +1,35 @@
1
1
  /**
2
- * 本地嵌入服务(worker 线程化版):transformers.js 的模型加载与 ONNX 推理全部在
3
- * worker_threads 子线程执行(resources/embedding-worker.cjs),本类只是主线程侧的
4
- * 协议代理——onnxruntime-node 的 run/loadModel 是主线程同步调用,留在宿主事件循环
5
- * 会冻结整个 dsh 页面(0.8.6 修复的真实事故)。
2
+ * 本地嵌入服务(子进程隔离版,fix #11):transformers.js 的模型加载与 ONNX 推理
3
+ * 全部在 fork 出的独立子进程执行(resources/embedding-child.cjs),本类只是主线程侧
4
+ * 的协议代理——双重隔离:① onnxruntime-node 的 run/loadModel 是同步调用,留在宿主
5
+ * 事件循环会冻结整个 dsh 页面(0.8.6 修复的真实事故);② 插件 runtime 的 sharp/libvips
6
+ * 与宿主 Electron 的 sharp/libvips 同进程加载会撞 libvips-42.dll 名字(OS LoadLibrary
7
+ * 层冲突)——worker_thread 与主线程同进程,撞名前提仍在(issue #11);子进程自带独立
8
+ * DLL 表,冲突物理消失。
6
9
  *
7
- * - 懒加载:首次嵌入/warmup 才让 worker 加载模型,close()=terminate 释放线程与
10
+ * - 懒加载:首次嵌入/warmup 才让子进程加载模型,close()=kill 释放进程与
8
11
  * 模型(嵌入源切走/关闭时调用),terminated 后不可复用;
9
- * - 模型从数据目录 models/<id>/ 本地加载(worker 侧 env.allowRemoteModels=false);
12
+ * - 模型从数据目录 models/<id>/ 本地加载(子进程侧 env.allowRemoteModels=false);
10
13
  * - channel 可注入(测试缝):用假通道验证协议与状态机,不触真模型;
11
- * - callOpts.timeoutMs 经 Promise.race 钳制(迟到回复按 id 丢弃——推理在
12
- * worker 线程无法真正取消,但主线程可以停止等待并降级 FTS);
14
+ * - useWorker=true 切回旧 worker_threads 通道(回滚逃生门;若宿主侧 DLL 冲突
15
+ * 将来被根治,可一行切回获得 transfer 零拷贝的微弱性能优势);
16
+ * - callOpts.timeoutMs 经 Promise.race 钉制(迟到回复按 id 丢弃——推理在
17
+ * 子进程无法真正取消,但主线程可以停止等待并降级 FTS);
13
18
  * - 池化方式来自模型目录(BGE 系 CLS / Gemma 系 MEAN),normalize 交给
14
19
  * pipeline 内建 L2 归一(与远程路径的 sanitizeAndNormalize 语义一致)。
15
20
  */
21
+ import { fork, spawn } from 'node:child_process';
16
22
  import * as path from 'node:path';
17
23
  import { fileURLToPath } from 'node:url';
18
24
  import { Worker } from 'node:worker_threads';
19
- /** 默认 worker 资产路径:dist/store/ → dist/embedding-worker.cjs(构建期拷入)。 */
25
+ /** 默认 worker 资产路径(旧通道,useWorker 逃生门):dist/store/ → dist/embedding-worker.cjs(构建期拷入)。 */
20
26
  function defaultWorkerPath() {
21
27
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'embedding-worker.cjs');
22
28
  }
29
+ /** 默认子进程资产路径(fix #11 主通道):dist/store/ → dist/embedding-child.cjs(构建期拷入)。 */
30
+ function defaultChildPath() {
31
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'embedding-child.cjs');
32
+ }
23
33
  /** 真实通道:spawn worker_threads + 自增 id 配对 + 崩溃兜底拒绝。 */
24
34
  class RealWorkerChannel {
25
35
  worker;
@@ -86,6 +96,113 @@ class RealWorkerChannel {
86
96
  }
87
97
  }
88
98
  }
99
+ /**
100
+ * 真实子进程通道(fix #11 默认):fork 独立进程隔离 sharp/libvips DLL 冲突。
101
+ * 与 RealWorkerChannel 同协议语义,差异仅在传输层:
102
+ * - 配置经 env DSH_EMBED_CFG(JSON 字符串,fork env 只收标量);
103
+ * - 向量经 number[][] 序列化回传(IPC JSON 通道不支持 TypedArray;512d×32 条
104
+ * ≈64KB,<5ms,不构成瓶颈),主线程侧收到后重建 Float32Array;
105
+ * - 终止 Windows 走 taskkill /T /F(同 runtime-installer 模式,防只杀包装进程),
106
+ * 其余 SIGTERM + 3s 强杀钉底。
107
+ */
108
+ class RealChildChannel {
109
+ child;
110
+ pending = new Map();
111
+ nextId = 1;
112
+ terminated = false;
113
+ crashed;
114
+ crashCb;
115
+ constructor(childPath, cfg) {
116
+ this.child = fork(childPath, [], {
117
+ // execPath 显式沿用当前二进制:宿主 Electron 自带 Node runtime,打包后 PATH 里没有 node
118
+ execPath: process.execPath,
119
+ env: { ...process.env, DSH_EMBED_CFG: JSON.stringify(cfg) },
120
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
121
+ });
122
+ this.child.on('message', (msg) => {
123
+ if (msg && msg.type === 'fatal') {
124
+ this.failAll(`本地嵌入子进程致命错误: ${msg.error ?? '未知'}`);
125
+ return;
126
+ }
127
+ const id = msg.id;
128
+ if (typeof id !== 'number')
129
+ return;
130
+ const entry = this.pending.get(id);
131
+ if (!entry)
132
+ return; // 迟到回复(调用方已超时放弃)
133
+ this.pending.delete(id);
134
+ // 子进程侧 vectors 是 number[][](IPC JSON 序列化),本侧重建 Float32Array 保持协议类型不变
135
+ const reply = msg;
136
+ if (reply.ok && reply.type === 'embedded') {
137
+ reply.vectors = reply.vectors.map((v) => new Float32Array(v));
138
+ }
139
+ entry.resolve(reply);
140
+ });
141
+ // error(fork 启动失败等)与 exit(含 fatal 后自杀、OOM 被杀)都钉底拒绝
142
+ this.child.on('error', (err) => this.failAll(`本地嵌入子进程错误: ${err.message}`));
143
+ this.child.on('exit', (code) => {
144
+ if (!this.terminated)
145
+ this.failAll(`本地嵌入子进程退出(code=${code})`);
146
+ });
147
+ }
148
+ request(call) {
149
+ // 已释放/已崩溃的通道快速拒绝——send 到死进程是静默无回应,调用方会挂到超时
150
+ if (this.terminated)
151
+ return Promise.reject(new Error('嵌入子进程已释放'));
152
+ if (this.crashed)
153
+ return Promise.reject(new Error(this.crashed));
154
+ const id = this.nextId++;
155
+ return new Promise((resolve, reject) => {
156
+ this.pending.set(id, { resolve, reject });
157
+ // 递送失败(通道已关/子进程已死)走 send 回调拒绝,不留挂死的 pending
158
+ this.child.send({ ...call, id }, (err) => {
159
+ if (!err)
160
+ return;
161
+ const entry = this.pending.get(id);
162
+ if (entry) {
163
+ this.pending.delete(id);
164
+ entry.reject(new Error(`嵌入子进程 IPC 发送失败: ${err.message}`));
165
+ }
166
+ });
167
+ });
168
+ }
169
+ terminate() {
170
+ if (this.terminated)
171
+ return;
172
+ this.terminated = true;
173
+ this.failAll('嵌入子进程已释放');
174
+ if (process.platform === 'win32' && this.child.pid !== undefined) {
175
+ // 同 runtime-installer 的 taskkill 模式:Windows 上 child.kill() 信号语义弱,
176
+ // /T 按进程树杀防孤儿;taskkill 自身启动失败回退裸 kill
177
+ const tk = spawn('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { windowsHide: true });
178
+ tk.on('error', () => this.child.kill());
179
+ }
180
+ else {
181
+ this.child.kill('SIGTERM');
182
+ // 钉底:子进程卡在同步 native 调用时收不到 SIGTERM → 3s 后强杀;自然退出则撤销定时器
183
+ const force = setTimeout(() => {
184
+ if (this.child.exitCode === null && this.child.signalCode === null)
185
+ this.child.kill('SIGKILL');
186
+ }, 3000);
187
+ this.child.once('exit', () => clearTimeout(force));
188
+ }
189
+ }
190
+ setOnCrash(cb) {
191
+ this.crashCb = cb;
192
+ // 构造与回调注册之间发生的崩溃(fork 即失败等)不丢通知
193
+ if (this.crashed)
194
+ cb(this.crashed);
195
+ }
196
+ failAll(error) {
197
+ for (const [, entry] of this.pending)
198
+ entry.reject(new Error(error));
199
+ this.pending.clear();
200
+ if (!this.terminated && !this.crashed) {
201
+ this.crashed = error;
202
+ this.crashCb?.(error);
203
+ }
204
+ }
205
+ }
89
206
  export class LocalEmbeddingService {
90
207
  state = 'idle';
91
208
  loadError = null;
@@ -96,15 +213,18 @@ export class LocalEmbeddingService {
96
213
  this.entry = entry;
97
214
  this.logger = opts.logger;
98
215
  const maxInputChars = opts.maxInputChars && opts.maxInputChars > 0 ? opts.maxInputChars : 5000;
216
+ const cfgObj = {
217
+ runtimeDir: opts.runtimeDir,
218
+ modelDir,
219
+ pooling: entry.pooling,
220
+ dtype: 'q8',
221
+ maxInputChars,
222
+ };
99
223
  this.channel =
100
224
  opts.channel ??
101
- new RealWorkerChannel(opts.workerPath ?? defaultWorkerPath(), {
102
- runtimeDir: opts.runtimeDir,
103
- modelDir,
104
- pooling: entry.pooling,
105
- dtype: 'q8',
106
- maxInputChars,
107
- });
225
+ (opts.useWorker
226
+ ? new RealWorkerChannel(opts.workerPath ?? defaultWorkerPath(), cfgObj)
227
+ : new RealChildChannel(opts.childPath ?? defaultChildPath(), cfgObj));
108
228
  // 崩溃不自愈(换源/重启恢复):拒绝语义沿 EmbedHelper 降级链走 FTS
109
229
  this.channel.setOnCrash((error) => {
110
230
  if (this.state === 'terminated')
@@ -186,7 +306,7 @@ export class LocalEmbeddingService {
186
306
  }
187
307
  return reply.vectors;
188
308
  }
189
- /** 释放 worker 线程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
309
+ /** 释放推理子进程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
190
310
  * 再复用——防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
191
311
  close() {
192
312
  this.state = 'terminated';
@@ -12,8 +12,9 @@ export type DisableOutcome = 'renamed' | 'created' | 'already' | 'no-workspace'
12
12
  export { isRetiredRecord };
13
13
  export interface DisableDeps {
14
14
  /**
15
- * 刚被退场的那条记录。调用方应传**退场后**的快照(带 `validTo`),这样渲染出的
16
- * frontmatter 自然包含 `retired: true`——凭证自己就说清了它为什么存在。
15
+ * 刚被退场的那条记录。调用方应传**退场后**的快照(带 `dsh_superseded` 标记,
16
+ * 门1 解耦后 md 层按 marker 判退场),这样渲染出的 frontmatter 自然包含
17
+ * `retired: true`——凭证自己就说清了它为什么存在。
17
18
  */
18
19
  record: MemoryRecord;
19
20
  /** `cfg.memExport.root`(只管无 workspace 那批,这里用不到但落点函数要它)。 */
@@ -63,14 +63,14 @@ export type MdRecordInput = Pick<MemoryRecord, 'id' | 'content' | 'type' | 'prio
63
63
  */
64
64
  export declare function targetFor(r: MdRecordInput, cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): MdTarget;
65
65
  /**
66
- * 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
67
- * `retiredL1Count`/`listRetiredL1` 一致)。
66
+ * 退场判据:与库侧同口径(门1 解耦后**只认 marker**,与 `supersede.isRetired`/
67
+ * `retiredL1Sql` 一致;valid_to 回归现实有效期,不再拿它判退场)。
68
68
  *
69
69
  * 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
70
70
  * 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
71
71
  * 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
72
72
  */
73
- export declare function isRetiredRecord(r: Pick<MemoryRecord, 'validTo'>): boolean;
73
+ export declare function isRetiredRecord(r: Pick<MemoryRecord, 'metadata'>): boolean;
74
74
  /**
75
75
  * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
76
76
  * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
@@ -21,6 +21,7 @@ import { memoryTypeDirs, UNCLASSIFIED_DIR } from '../memory-types.js';
21
21
  import { deriveName } from '../util/md-name.js';
22
22
  import { normalizeContentMd } from '../util/md-normalize.js';
23
23
  import { sanitizeFilename } from './scenes.js';
24
+ import { readSupersedeMarker } from './supersede.js';
24
25
  /**
25
26
  * 允许当目录名的 type 值(pm-name-distill task_1.3:由词表单一真源派生,不再手抄)。
26
27
  *
@@ -127,15 +128,15 @@ function warnMissingWorkspace(logger, ws) {
127
128
  logger.warn(`[memory] workspace 目录不存在,该条投射落点退回单根: ${ws}`);
128
129
  }
129
130
  /**
130
- * 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
131
- * `retiredL1Count`/`listRetiredL1` 一致)。
131
+ * 退场判据:与库侧同口径(门1 解耦后**只认 marker**,与 `supersede.isRetired`/
132
+ * `retiredL1Sql` 一致;valid_to 回归现实有效期,不再拿它判退场)。
132
133
  *
133
134
  * 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
134
135
  * 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
135
136
  * 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
136
137
  */
137
138
  export function isRetiredRecord(r) {
138
- return typeof r.validTo === 'number' && r.validTo > 0;
139
+ return readSupersedeMarker(r.metadata) !== undefined;
139
140
  }
140
141
  /**
141
142
  * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
@@ -230,7 +231,8 @@ export function renderRecord(r) {
230
231
  // 「换了 id、正文没动」也误报被改。
231
232
  lines.push(fmLine('hash', contentHash(r.content)));
232
233
  // 退场标记只在真退场时写:不留 `retired: false` 这种噪声键(键集越稳,人越不容易改错)。
233
- if (r.validTo !== undefined)
234
+ // 判据走本文件的 `isRetiredRecord`(门1 解耦后只认 marker,与库侧同口径)。
235
+ if (isRetiredRecord(r))
234
236
  lines.push(fmLine('retired', true));
235
237
  lines.push('---', '');
236
238
  // 正文过一道版式归一(P4.2):无结构存量文本出口就是标准形状。归一是**幂等**的,
@@ -36,8 +36,20 @@ export declare class RuntimeInstaller {
36
36
  private pkgJsonPath;
37
37
  /** 已就位版本(读 package.json;未安装返回 null)。 */
38
38
  installedVersion(): Promise<string | null>;
39
- /** 是否就绪(版本精确匹配目标)。 */
39
+ /** 是否就绪(版本精确匹配目标 + sharp 平台原生件就位)。 */
40
40
  isReady(): Promise<boolean>;
41
+ /**
42
+ * 当前平台的 sharp 原生件候选路径(测试注入缝与单测共用):
43
+ * 绑定 .node 在 @img/sharp-<os>-<arch>/lib/,libvips 在 @img/sharp-libvips-<os>-<arch>/lib/。
44
+ * linux 有 glibc/musl 双候选;libvips 库文件名含版本串(仅 win 的 libvips-42.dll 稳定),
45
+ * 故统一按 lib 目录内存在 libvips* 文件判定,不钉文件名。
46
+ */
47
+ sharpNativeCandidates(): Array<{
48
+ binding: string;
49
+ libvipsDir: string;
50
+ }>;
51
+ /** 任一候选同时具备绑定 .node 与 libvips 库文件 → true(ENOENT/读取失败均计缺失)。 */
52
+ private sharpNativePresent;
41
53
  /**
42
54
  * 确保运行时就位:版本匹配直接就绪;否则安装(忙时并 await 同一次任务)。
43
55
  * 返回是否就绪(失败/取消返回 false 并在 progress.error 说明原因)。
@@ -102,9 +102,47 @@ export class RuntimeInstaller {
102
102
  return null;
103
103
  }
104
104
  }
105
- /** 是否就绪(版本精确匹配目标)。 */
105
+ /** 是否就绪(版本精确匹配目标 + sharp 平台原生件就位)。 */
106
106
  async isReady() {
107
- return (await this.installedVersion()) === this.target;
107
+ if ((await this.installedVersion()) !== this.target)
108
+ return false;
109
+ // fix #11 辅助检测:--ignore-scripts 安装半坏(平台原生包缺失/被剪)时,
110
+ // transformers 顶层 require('sharp') 必炸——判存在性,缺失报未就绪触发重装。
111
+ if (!(await this.sharpNativePresent())) {
112
+ this.logger?.warn('[memory] 运行时 sharp 平台原生件缺失(半装状态),isReady=false 触发重装');
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /**
118
+ * 当前平台的 sharp 原生件候选路径(测试注入缝与单测共用):
119
+ * 绑定 .node 在 @img/sharp-<os>-<arch>/lib/,libvips 在 @img/sharp-libvips-<os>-<arch>/lib/。
120
+ * linux 有 glibc/musl 双候选;libvips 库文件名含版本串(仅 win 的 libvips-42.dll 稳定),
121
+ * 故统一按 lib 目录内存在 libvips* 文件判定,不钉文件名。
122
+ */
123
+ sharpNativeCandidates() {
124
+ const arch = process.arch; // x64 | arm64
125
+ const osKeys = process.platform === 'linux' ? ['linux', 'linuxmusl'] : process.platform === 'darwin' ? ['darwin'] : ['win32'];
126
+ const img = path.join(this.runtimeDir, 'node_modules', '@img');
127
+ return osKeys.map((k) => ({
128
+ binding: path.join(img, `sharp-${k}-${arch}`, 'lib', `sharp-${k}-${arch}.node`),
129
+ libvipsDir: path.join(img, `sharp-libvips-${k}-${arch}`, 'lib'),
130
+ }));
131
+ }
132
+ /** 任一候选同时具备绑定 .node 与 libvips 库文件 → true(ENOENT/读取失败均计缺失)。 */
133
+ async sharpNativePresent() {
134
+ for (const c of this.sharpNativeCandidates()) {
135
+ try {
136
+ await fs.access(c.binding);
137
+ const files = await fs.readdir(c.libvipsDir);
138
+ if (files.some((f) => f.startsWith('libvips')))
139
+ return true;
140
+ }
141
+ catch {
142
+ /* 本候选不命中,试下一个 */
143
+ }
144
+ }
145
+ return false;
108
146
  }
109
147
  /**
110
148
  * 确保运行时就位:版本匹配直接就绪;否则安装(忙时并 await 同一次任务)。
@@ -1,4 +1,4 @@
1
- import type { MemoryLogger } from '../types.js';
1
+ import type { MemoryLogger, RecallLane } from '../types.js';
2
2
  export declare class SessionRecalledStore {
3
3
  private readonly logger?;
4
4
  private readonly file;
@@ -12,8 +12,9 @@ export declare class SessionRecalledStore {
12
12
  constructor(dataDir: string, logger?: MemoryLogger | undefined);
13
13
  revision(): number;
14
14
  private init;
15
- /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
16
- add(sessionKey: string, recordIds: readonly string[], injectionId?: string): void;
15
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。
16
+ * laneById 只带真正有归因的条目(hybrid 融合才挂 lanes;单路/旧路不传)。 */
17
+ add(sessionKey: string, recordIds: readonly string[], injectionId?: string, laneById?: ReadonlyMap<string, readonly RecallLane[]>): void;
17
18
  /**
18
19
  * 标为受保护(判过时/软退场):条目留在列表里,且不受上限淘汰与压缩清理影响。
19
20
  * id 不在本会话列表里时忽略(不凭这个调用新建条目)。
@@ -34,6 +35,11 @@ export declare class SessionRecalledStore {
34
35
  * 未出现过取代的会话返回共享空集,不每次新建)。
35
36
  */
36
37
  supersededOf(sessionKey: string): ReadonlySet<string>;
38
+ /**
39
+ * 本会话的路归因表(record id → 管道串;只读共享,投影/端点用)。
40
+ * 不在表里 = 无从归因(单路检索/旧文件条目),展示层不渲染徽标。
41
+ */
42
+ lanesOf(sessionKey: string): ReadonlyMap<string, string>;
37
43
  /**
38
44
  * 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
39
45
  *
@@ -23,9 +23,12 @@ import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
23
23
  import { RECALL_DEDUPE_IDS_CAP, RECALL_DEDUPE_SESSION_CAP } from './recall-dedupe.js';
24
24
  /** 共享空集(未出现过取代的会话重复取用,不必每次新建)。 */
25
25
  const EMPTY_SET = new Set();
26
+ /** 共享空归因表(同 EMPTY_SET 口径)。 */
27
+ const EMPTY_LANES = new Map();
26
28
  /**
27
- * 读 `injectedBy` 的宽容解析:非对象/值非字符串一律丢弃。
28
- * 宁可让那条记录“无从判”(对账不动它),也不把脏值喂给可见性判定——
29
+ * 读 `Record<string, string>` 型字段的宽容解析(injectedBy/lanes 共用):
30
+ * 非对象/值非字符串一律丢弃。
31
+ * 宁可让那条记录“无从判”(对账不动它/徽标不渲染),也不把脏值喂给下游判定——
29
32
  * 一个错的 message id 会把仍在本上下文里的正文误标成“已被摘要取代”。
30
33
  */
31
34
  function toIdMap(raw) {
@@ -93,17 +96,19 @@ export class SessionRecalledStore {
93
96
  protectedIds: new Set(Array.isArray(entry.protectedIds) ? entry.protectedIds : []),
94
97
  supersededIds: new Set(Array.isArray(entry.supersededIds) ? entry.supersededIds : []),
95
98
  injectedBy: toIdMap(entry.injectedBy),
99
+ laneOf: toIdMap(entry.lanes),
96
100
  updatedAt: entry.updatedAt ?? now,
97
101
  });
98
102
  }
99
103
  }
100
- /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
101
- add(sessionKey, recordIds, injectionId) {
104
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。
105
+ * laneById 只带真正有归因的条目(hybrid 融合才挂 lanes;单路/旧路不传)。 */
106
+ add(sessionKey, recordIds, injectionId, laneById) {
102
107
  if (!sessionKey || recordIds.length === 0)
103
108
  return;
104
109
  let entry = this.entries.get(sessionKey);
105
110
  if (!entry) {
106
- entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), updatedAt: 0 };
111
+ entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), laneOf: new Map(), updatedAt: 0 };
107
112
  this.entries.set(sessionKey, entry);
108
113
  }
109
114
  const stamp = typeof injectionId === 'string' && injectionId.length > 0 ? injectionId : undefined;
@@ -114,6 +119,11 @@ export class SessionRecalledStore {
114
119
  // 记下这次注入落在哪条消息上:没有它,对账就无从判“这条正文还在不在”。
115
120
  if (stamp !== undefined)
116
121
  entry.injectedBy.set(id, stamp);
122
+ // 路归因:最后一次注入赢;本轮无归因时**不清旧值**(徽标宁可残留一轮
123
+ // 也不要闪无——真实路集在下一次带归因注入时自然覆盖)。
124
+ const laneArr = laneById?.get(id);
125
+ if (laneArr !== undefined && laneArr.length > 0)
126
+ entry.laneOf.set(id, laneArr.join('|'));
117
127
  // 淘汰只能淘汰**没被判过时的**:受保护条目数本身超顶时宁可超顶,也不能把人的裁决
118
128
  // 凭证挤掉(那等于“因为后面又召回了几条,所以之前那条的判定历史丢了”)。
119
129
  while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
@@ -124,6 +134,7 @@ export class SessionRecalledStore {
124
134
  entry.protectedIds.delete(oldest);
125
135
  entry.supersededIds.delete(oldest);
126
136
  entry.injectedBy.delete(oldest);
137
+ entry.laneOf.delete(oldest);
127
138
  }
128
139
  }
129
140
  entry.updatedAt = Date.now();
@@ -197,6 +208,13 @@ export class SessionRecalledStore {
197
208
  supersededOf(sessionKey) {
198
209
  return this.entries.get(sessionKey)?.supersededIds ?? EMPTY_SET;
199
210
  }
211
+ /**
212
+ * 本会话的路归因表(record id → 管道串;只读共享,投影/端点用)。
213
+ * 不在表里 = 无从归因(单路检索/旧文件条目),展示层不渲染徽标。
214
+ */
215
+ lanesOf(sessionKey) {
216
+ return this.entries.get(sessionKey)?.laneOf ?? EMPTY_LANES;
217
+ }
200
218
  /**
201
219
  * 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
202
220
  *
@@ -302,6 +320,7 @@ export class SessionRecalledStore {
302
320
  ...(e.protectedIds.size > 0 ? { protectedIds: [...e.protectedIds] } : {}),
303
321
  ...(e.supersededIds.size > 0 ? { supersededIds: [...e.supersededIds] } : {}),
304
322
  ...(e.injectedBy.size > 0 ? { injectedBy: Object.fromEntries(e.injectedBy) } : {}),
323
+ ...(e.laneOf.size > 0 ? { lanes: Object.fromEntries(e.laneOf) } : {}),
305
324
  updatedAt: e.updatedAt,
306
325
  };
307
326
  }
@@ -168,20 +168,22 @@ export declare class MemoryDb {
168
168
  * **软删**(记忆退场):保留主表行,撤出检索面。
169
169
  *
170
170
  * 与 `deleteL1Batch` 的差别**只有一处**:不动 `l1_records` 行本身。
171
- * `valid_to` 闭合 + `metadata_json` 写取代标记 → 记录仍能被 `listL1` 列出、
172
- * 能被 `clearRetireMarker` + upsert 恢复;而 FTS 与向量行照旧删除,于是检索面
173
- * (含去重候选召回)自然看不到它 —— **检索 SQL 一行都不用改**,活动记录零漂移
174
- * 因此是构造性的,不是比对出来的。
171
+ * `metadata_json` 写取代标记 → 记录仍能被 `listL1` 列出、能被 `clearRetireMarker`
172
+ * + upsert 恢复;而 FTS 与向量行照旧删除,于是检索面(含去重候选召回)自然看不到它。
173
+ *
174
+ * 门1 解耦(expiry-tiering R1/task_2.3):退场**只写 marker,不再闭合 valid_to**——
175
+ * valid_to 回归纯现实有效期,退场与否由 marker 单源回答(`isRetired`)。
175
176
  *
176
177
  * 顺序刻意如此:先打标记(可逆的那一半),再撤检索面,且整体在一个事务里。
177
178
  * 反过来先撤索引而打标记失败,记录会落在"检索不到、也没被标记"的状态 ——
178
179
  * 既查不出来也恢复不了,是最坏的一种中间态。
179
180
  *
180
- * **幂等**:已退场(`valid_to` 非空或已有标记)的 id 不再重复写标记,
181
+ * **幂等**:已退场(带 marker)的 id 不再重复写标记,
181
182
  * 保留首次退场的原因与时刻(「谁先取代了它」不该被后一次调用改写)。
182
183
  *
183
- * **不调** `graphStore.markSourcesDeleted`:那是"来源已物理消失"的传播,
184
- * 而软删的记录仍活在主表里 —— 图谱侧的退役语义另计(见计划 findings R-a)。
184
+ * **不调** `graphStore.markSourcesDeleted`(那是"来源已物理消失"的传播,软删行一直在),
185
+ * 而是调 `markSourcesRetired`(计划 R9 对偶刀):按"来源是否全部退场态"重算清扫,
186
+ * 全退场的 active/disputed 节点·边标 archived 墓碑(派生投影由当前事实重算,非事件改写)。
185
187
  */
186
188
  retireL1Batch(ids: string[], info: SupersedeInfo): number;
187
189
  /**
@@ -193,12 +195,14 @@ export declare class MemoryDb {
193
195
  * 清掉退场标记(恢复的**前半**)。返回清完标记的记录,供调用方 re-upsert 以重建
194
196
  * FTS/向量 —— 那条路径(`upsertL1InTx`)已存在,不在这里重复实现。
195
197
  *
196
- * 只清 `valid_to` 与标记键,**不碰内容**:恢复不该修改记忆本身。
197
- * 返回的 `validTo` 显式置 `undefined`(而非留着旧 epoch),否则 upsert 会
198
- * 用 `toIso(旧值)` 把 `valid_to` 又写回去,恢复静默失败。
198
+ * 门1 解耦(task_2.3 的对偶面):只剥 marker,**不碰 valid_to**——
199
+ * 行若本就带着真实历史有效期(导入/抽取写入),恢复不该把它抹掉;
200
+ * 旧版 retire 写进 valid_to 的退场时刻对新行只影响 classify 落到
201
+ * historical/expired_suspect(仍可检可注入,带标注),由 validity-backfill 逐步纠偏。
202
+ * 返回的 `validTo` 保持原值——upsert 照原样写回,不会静默改列。
199
203
  */
200
204
  clearRetireMarker(ids: string[]): MemoryRecord[];
201
- /** 已退场记录列表(面板用):`valid_to` 非空即已退场。失败返回空。 */
205
+ /** 已退场记录列表(面板用):与 `retiredL1Sql()` 同判据(marker 单源)。失败返回空。 */
202
206
  listRetiredL1(opts: {
203
207
  limit: number;
204
208
  offset: number;
@@ -530,7 +534,15 @@ export declare class MemoryDb {
530
534
  * 返回扫描行数;存储降级返回 0。
531
535
  */
532
536
  scanL1Metadata(cb: (recordId: string, metadata: Record<string, unknown> | null) => void): number;
533
- /** retired 行计数(退场判定与 listRetiredL1 同口径:valid_to 非空)。失败返回 0。 */
537
+ /**
538
+ * 反向取代链查询(task_4.4,门3):"谁被这条记录取代了"——扫 metadata_json 里
539
+ * 取代标记的 `by` 指向本 id 的行。百~千量级全扫可接受(与 `retiredL1Sql` 同属
540
+ * LIKE 面);超过阈值时留 warn——真到万级该建索引,不在本计划面里预先造。
541
+ * **只回退场行**:"被取代者"正是带 marker 的旧行(活跃行若 by 指过来是脏数据);
542
+ * 模式含闭合引号(`"by":"<id>"`),不会前缀误配更长 id。
543
+ */
544
+ findSupersededTargets(newId: string): string[];
545
+ /** retired 行计数(退场判定与 listRetiredL1 同口径:`retiredL1Sql` marker 单源)。失败返回 0。 */
534
546
  retiredL1Count(): number;
535
547
  /**
536
548
  * Room 计数(**标签自生长分类**):展开 `metadata.tags` 聚合,1 个 slug tag = 1 个 Room。