dsh-prime-memory 0.26.5 → 0.26.9

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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +60 -2
  4. package/dist/client.js +45 -5
  5. package/dist/config.d.ts +40 -0
  6. package/dist/config.js +33 -3
  7. package/dist/contract.d.ts +14 -1
  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/search.d.ts +11 -2
  13. package/dist/graph/search.js +38 -10
  14. package/dist/hooks/recall.js +28 -1
  15. package/dist/index.js +14 -1
  16. package/dist/llm.d.ts +21 -2
  17. package/dist/llm.js +56 -25
  18. package/dist/pipeline/graph.d.ts +17 -1
  19. package/dist/pipeline/graph.js +45 -2
  20. package/dist/pipeline/l1.d.ts +15 -2
  21. package/dist/pipeline/l1.js +161 -47
  22. package/dist/pipeline/l2.js +72 -50
  23. package/dist/pipeline/md-sync.js +4 -2
  24. package/dist/pipeline/ruminate.js +2 -0
  25. package/dist/pipeline/runner.d.ts +32 -0
  26. package/dist/pipeline/runner.js +109 -11
  27. package/dist/pipeline/slot-migrate.d.ts +3 -2
  28. package/dist/pipeline/slot-migrate.js +3 -2
  29. package/dist/projection/recall.d.ts +10 -3
  30. package/dist/projection/recall.js +14 -0
  31. package/dist/settings.js +6 -0
  32. package/dist/stats.d.ts +1 -1
  33. package/dist/stats.js +35 -14
  34. package/dist/store/embedding-source.d.ts +4 -2
  35. package/dist/store/embedding-source.js +5 -2
  36. package/dist/store/file-versions.d.ts +2 -0
  37. package/dist/store/file-versions.js +2 -0
  38. package/dist/store/graph-store.d.ts +71 -1
  39. package/dist/store/graph-store.js +221 -8
  40. package/dist/store/l1.d.ts +5 -0
  41. package/dist/store/l1.js +18 -2
  42. package/dist/store/local-embedding.d.ts +9 -5
  43. package/dist/store/local-embedding.js +137 -17
  44. package/dist/store/memory-md-disable.d.ts +3 -2
  45. package/dist/store/memory-md.d.ts +3 -3
  46. package/dist/store/memory-md.js +6 -4
  47. package/dist/store/route-health.d.ts +38 -0
  48. package/dist/store/route-health.js +115 -0
  49. package/dist/store/runtime-installer.d.ts +13 -1
  50. package/dist/store/runtime-installer.js +40 -2
  51. package/dist/store/sqlite.d.ts +24 -12
  52. package/dist/store/sqlite.js +97 -31
  53. package/dist/store/supersede.d.ts +10 -2
  54. package/dist/store/supersede.js +10 -2
  55. package/dist/store/validity.d.ts +150 -0
  56. package/dist/store/validity.js +209 -0
  57. package/dist/tools/index.d.ts +15 -1
  58. package/dist/tools/index.js +393 -11
  59. package/dist/util/json-repair.d.ts +48 -0
  60. package/dist/util/json-repair.js +285 -0
  61. package/dist/validity-backfill.d.ts +78 -0
  62. package/dist/validity-backfill.js +160 -0
  63. package/dsh.plugin.json +2 -2
  64. package/package.json +3 -3
@@ -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):无结构存量文本出口就是标准形状。归一是**幂等**的,
@@ -0,0 +1,38 @@
1
+ /** 单条路由健康记录。 */
2
+ export interface RouteHealthEntry {
3
+ /** 退避后实测跑通的最小块长(字符);0 = 未学到。 */
4
+ lastGoodChunkChars: number;
5
+ /** 该路由延迟 EMA(ms);0 = 未记。R5 自适应可据此调初值。 */
6
+ latencyEmaMs: number;
7
+ /** 最近一次记录时刻(ISO)。 */
8
+ updatedAt: string;
9
+ }
10
+ /** 学习值默认过期(ms):超此龄视为陈旧、读侧应重探(换模型/网关不至于被历史最小值锁死)。 */
11
+ export declare const ROUTE_HEALTH_STALE_MS: number;
12
+ export declare function routeHealthPathFor(dataDir: string): string;
13
+ export declare class RouteHealthStore {
14
+ private readonly file;
15
+ private routes;
16
+ private dirty;
17
+ constructor(file: string);
18
+ /** 读盘 fail-open:缺/坏/未知版本 → 空表、不抛。 */
19
+ load(): Promise<void>;
20
+ /** 该路由已学到、且未过期的跑通块长;0 = 无可用学习值(未学到或已陈旧)。 */
21
+ lastGoodChunkChars(provider: string, model: string, now?: number, staleMs?: number): number;
22
+ /** 该路由延迟 EMA(ms);0 = 未记。 */
23
+ latencyEma(provider: string, model: string): number;
24
+ /**
25
+ * 记一次「跑通」的块长:只降不升(更小块长才覆盖 lastGood),并更新延迟 EMA。
26
+ * @param chunkChars 本次成功的块长(字符)
27
+ * @param latencyMs 本次成功调用耗时(可选,用于 EMA)
28
+ */
29
+ recordGood(provider: string, model: string, chunkChars: number, latencyMs?: number): void;
30
+ /** 清除学习值:传 provider+model 清单条,否则清全部(面板「重置学习值」一键)。 */
31
+ reset(provider?: string, model?: string): void;
32
+ /** 全量快照(面板回显用)。 */
33
+ snapshot(): Array<{
34
+ key: string;
35
+ } & RouteHealthEntry>;
36
+ /** 有变更才落盘;失败由调用方兜底(不抛穿蒸馏)。@returns 是否写了盘 */
37
+ save(): Promise<boolean>;
38
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 蒸馏路由健康学习(R9):按 `provider/model` 记「自适应退避后实测跑通的最小块长」,
3
+ * 跨进程留存,令下次 perChunk 初值直接取跑通过的值、不必每进程重付退避风暴;
4
+ * 面板可回显「实测最小可用块长 / 最后退避长度」。
5
+ *
6
+ * 运行时学习值、非磁盘契约:
7
+ * - 读 **fail-open**(缺文件/损坏/未知版本 → 空表起步,绝不抛、不阻蒸馏——学习值丢了最多少一次优化);
8
+ * - 写走 `atomicWriteJson`(原子 + 覆盖保护);
9
+ * - **只降不升**:更小的跑通块长才覆盖 lastGood(换 provider/过期由读侧决定重探,见 R5 接线)。
10
+ *
11
+ * 本模块 Phase 1 仅落地存储与纯逻辑,尚未接线到 runExtraction(接线在 Phase 4 R5/R9)。
12
+ */
13
+ import * as path from 'node:path';
14
+ import { atomicWriteJson, readJsonStrict } from '../util/io.js';
15
+ import { ROUTE_HEALTH_FILE_VERSION } from './file-versions.js';
16
+ function keyOf(provider, model) {
17
+ return `${provider}/${model}`;
18
+ }
19
+ /** 学习值默认过期(ms):超此龄视为陈旧、读侧应重探(换模型/网关不至于被历史最小值锁死)。 */
20
+ export const ROUTE_HEALTH_STALE_MS = 7 * 24 * 60 * 60_000;
21
+ export function routeHealthPathFor(dataDir) {
22
+ return path.join(dataDir, 'distill-route-health.json');
23
+ }
24
+ export class RouteHealthStore {
25
+ file;
26
+ routes = new Map();
27
+ dirty = false;
28
+ constructor(file) {
29
+ this.file = file;
30
+ }
31
+ /** 读盘 fail-open:缺/坏/未知版本 → 空表、不抛。 */
32
+ async load() {
33
+ const r = await readJsonStrict(this.file, { expectedVersion: ROUTE_HEALTH_FILE_VERSION });
34
+ this.routes = new Map();
35
+ if (!r.ok)
36
+ return; // missing / corrupt / unreadable / unknown_version 一律空起步
37
+ const raw = r.value?.routes;
38
+ if (!raw || typeof raw !== 'object')
39
+ return;
40
+ for (const [k, v] of Object.entries(raw)) {
41
+ if (!v || typeof v !== 'object')
42
+ continue;
43
+ const e = v;
44
+ if (typeof e.lastGoodChunkChars !== 'number' || !Number.isFinite(e.lastGoodChunkChars))
45
+ continue;
46
+ this.routes.set(k, {
47
+ lastGoodChunkChars: Math.max(0, Math.floor(e.lastGoodChunkChars)),
48
+ latencyEmaMs: typeof e.latencyEmaMs === 'number' && Number.isFinite(e.latencyEmaMs) ? e.latencyEmaMs : 0,
49
+ updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : '',
50
+ });
51
+ }
52
+ }
53
+ /** 该路由已学到、且未过期的跑通块长;0 = 无可用学习值(未学到或已陈旧)。 */
54
+ lastGoodChunkChars(provider, model, now = Date.now(), staleMs = ROUTE_HEALTH_STALE_MS) {
55
+ const e = this.routes.get(keyOf(provider, model));
56
+ if (!e || e.lastGoodChunkChars <= 0)
57
+ return 0;
58
+ const t = Date.parse(e.updatedAt);
59
+ if (Number.isFinite(t) && now - t > staleMs)
60
+ return 0; // 陈旧 → 交读侧重探
61
+ return e.lastGoodChunkChars;
62
+ }
63
+ /** 该路由延迟 EMA(ms);0 = 未记。 */
64
+ latencyEma(provider, model) {
65
+ return this.routes.get(keyOf(provider, model))?.latencyEmaMs ?? 0;
66
+ }
67
+ /**
68
+ * 记一次「跑通」的块长:只降不升(更小块长才覆盖 lastGood),并更新延迟 EMA。
69
+ * @param chunkChars 本次成功的块长(字符)
70
+ * @param latencyMs 本次成功调用耗时(可选,用于 EMA)
71
+ */
72
+ recordGood(provider, model, chunkChars, latencyMs) {
73
+ if (!(chunkChars > 0) || !Number.isFinite(chunkChars))
74
+ return;
75
+ const k = keyOf(provider, model);
76
+ const cur = this.routes.get(k);
77
+ const next = {
78
+ lastGoodChunkChars: cur && cur.lastGoodChunkChars > 0
79
+ ? Math.min(cur.lastGoodChunkChars, Math.floor(chunkChars))
80
+ : Math.floor(chunkChars),
81
+ latencyEmaMs: latencyMs !== undefined && Number.isFinite(latencyMs) && latencyMs >= 0
82
+ ? cur && cur.latencyEmaMs > 0
83
+ ? cur.latencyEmaMs * 0.7 + latencyMs * 0.3
84
+ : latencyMs
85
+ : (cur?.latencyEmaMs ?? 0),
86
+ updatedAt: new Date().toISOString(),
87
+ };
88
+ this.routes.set(k, next);
89
+ this.dirty = true;
90
+ }
91
+ /** 清除学习值:传 provider+model 清单条,否则清全部(面板「重置学习值」一键)。 */
92
+ reset(provider, model) {
93
+ if (provider && model)
94
+ this.routes.delete(keyOf(provider, model));
95
+ else
96
+ this.routes.clear();
97
+ this.dirty = true;
98
+ }
99
+ /** 全量快照(面板回显用)。 */
100
+ snapshot() {
101
+ return [...this.routes.entries()].map(([key, v]) => ({ key, ...v }));
102
+ }
103
+ /** 有变更才落盘;失败由调用方兜底(不抛穿蒸馏)。@returns 是否写了盘 */
104
+ async save() {
105
+ if (!this.dirty)
106
+ return false;
107
+ const routes = {};
108
+ for (const [k, v] of this.routes)
109
+ routes[k] = v;
110
+ const payload = { version: ROUTE_HEALTH_FILE_VERSION, routes };
111
+ await atomicWriteJson(this.file, payload);
112
+ this.dirty = false;
113
+ return true;
114
+ }
115
+ }
@@ -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 同一次任务)。
@@ -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。