dsh-layered-memory 0.6.0 → 0.7.0

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 (52) hide show
  1. package/README.en.md +101 -81
  2. package/README.md +91 -71
  3. package/assets/img/Hero.png +0 -0
  4. package/assets/img/Layers.png +0 -0
  5. package/assets/img/Modes.png +0 -0
  6. package/assets/img/ui-dark.jpg +0 -0
  7. package/assets/img/ui-light.jpg +0 -0
  8. package/assets/readme/flow.svg +189 -0
  9. package/assets/readme/storage.svg +115 -0
  10. package/dist/client.js +1242 -325
  11. package/dist/config.d.ts +12 -0
  12. package/dist/config.js +20 -17
  13. package/dist/hooks/capture.d.ts +17 -1
  14. package/dist/hooks/capture.js +45 -11
  15. package/dist/hooks/recall.d.ts +7 -0
  16. package/dist/hooks/recall.js +20 -4
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.js +112 -44
  19. package/dist/pipeline/rebuild.d.ts +80 -0
  20. package/dist/pipeline/rebuild.js +307 -0
  21. package/dist/pipeline/runner.d.ts +42 -4
  22. package/dist/pipeline/runner.js +125 -21
  23. package/dist/settings.js +79 -20
  24. package/dist/stats.d.ts +8 -1
  25. package/dist/stats.js +156 -14
  26. package/dist/store/download-queue.d.ts +71 -0
  27. package/dist/store/download-queue.js +313 -0
  28. package/dist/store/embedding-source.d.ts +160 -0
  29. package/dist/store/embedding-source.js +421 -0
  30. package/dist/store/embedding.d.ts +3 -1
  31. package/dist/store/embedding.js +5 -0
  32. package/dist/store/l0.d.ts +13 -5
  33. package/dist/store/l0.js +33 -6
  34. package/dist/store/l1.d.ts +15 -5
  35. package/dist/store/l1.js +40 -15
  36. package/dist/store/local-embedding.d.ts +64 -0
  37. package/dist/store/local-embedding.js +120 -0
  38. package/dist/store/model-catalog.d.ts +45 -0
  39. package/dist/store/model-catalog.js +78 -0
  40. package/dist/store/pending.d.ts +15 -0
  41. package/dist/store/pending.js +56 -0
  42. package/dist/store/runtime-installer.d.ts +60 -0
  43. package/dist/store/runtime-installer.js +181 -0
  44. package/dist/store/sqlite.d.ts +68 -7
  45. package/dist/store/sqlite.js +414 -71
  46. package/dist/store/state.d.ts +6 -0
  47. package/dist/store/state.js +11 -0
  48. package/dist/tools/index.js +6 -4
  49. package/dist/util/filelog.d.ts +2 -0
  50. package/dist/util/filelog.js +20 -3
  51. package/package.json +1 -1
  52. package/assets/readme/hero.svg +0 -58
@@ -0,0 +1,313 @@
1
+ /**
2
+ * 模型下载器(D3/D7 决策):镜像直连 + Range 断点续传 + sha256 校验 + 串行队列。
3
+ *
4
+ * - 进度是用户硬性要求(不能傻等):字节级实时进度(文件 i/N、已收/总量、EMA 速度),
5
+ * 进度对象由 RPC 轮询读取(client 1s 拉一次,records/rebuild 同款模式);
6
+ * - 断点续传:写 .part 旁车文件,重试从断点 Range 续传;服务器不支持 Range(回 200)
7
+ * 则从头重写;取消保留断点;
8
+ * - 完整性:每文件下满后流式哈希整文件比对目录 sha256(续传无法增量哈希,落盘后
9
+ * 单遍校验最简单且正确);失配删文件整体重下;
10
+ * - 磁盘门禁:下载前检查数据目录所在卷剩余空间 ≥ 模型体积 × 1.2(statfs 不可用时跳过);
11
+ * - 同一时刻只跑一个下载任务(串行队列),后续请求直接拒绝并说明。
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { promises as fs } from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { catalogById, catalogTotalBytes, MODEL_CATALOG } from './model-catalog.js';
17
+ const DISK_HEADROOM = 1.2;
18
+ export class ModelDownloadQueue {
19
+ dataDir;
20
+ opts;
21
+ progress = null;
22
+ busy = false;
23
+ abort = null;
24
+ constructor(dataDir, opts) {
25
+ this.dataDir = dataDir;
26
+ this.opts = opts;
27
+ }
28
+ /** 当前进度快照(无任务时 null)。 */
29
+ getProgress() {
30
+ return this.progress ? { ...this.progress } : null;
31
+ }
32
+ /** 是否有任务在跑(含校验阶段)。 */
33
+ isBusy() {
34
+ return this.busy;
35
+ }
36
+ modelsDir(id) {
37
+ return path.join(this.dataDir, 'models', id);
38
+ }
39
+ /** 全目录状态扫描(设置页模型卡数据源)。 */
40
+ async listStatus() {
41
+ const out = [];
42
+ for (const entry of MODEL_CATALOG) {
43
+ const dir = this.modelsDir(entry.id);
44
+ let bytes = 0;
45
+ let complete = true;
46
+ let anyFile = false;
47
+ for (const f of entry.files) {
48
+ const size = await fileSize(path.join(dir, f.path));
49
+ const partSize = await fileSize(path.join(dir, f.path + '.part'));
50
+ if (size === f.size) {
51
+ bytes += size;
52
+ anyFile = true;
53
+ }
54
+ else if (partSize !== null) {
55
+ bytes += partSize;
56
+ complete = false;
57
+ anyFile = true;
58
+ }
59
+ else if (size !== null) {
60
+ // 尺寸不吻合的残留文件按 partial 记
61
+ bytes += size;
62
+ complete = false;
63
+ anyFile = true;
64
+ }
65
+ else {
66
+ complete = false;
67
+ }
68
+ }
69
+ out.push({
70
+ id: entry.id,
71
+ state: complete && anyFile ? 'downloaded' : anyFile ? 'partial' : 'none',
72
+ bytesOnDisk: bytes,
73
+ totalBytes: catalogTotalBytes(entry),
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+ /** 单模型是否已完整下载(尺寸口径,不做哈希复验——下载完成时已验过)。 */
79
+ async isDownloaded(id) {
80
+ return (await this.listStatus()).find((s) => s.id === id)?.state === 'downloaded';
81
+ }
82
+ /** 删除已下载模型(切走后释放磁盘;正在使用/下载中的拒绝)。 */
83
+ async deleteModel(id) {
84
+ const entry = catalogById(id);
85
+ if (!entry)
86
+ return { ok: false, error: '未知模型' };
87
+ if (this.busy && this.progress?.modelId === id)
88
+ return { ok: false, error: '该模型正在下载' };
89
+ try {
90
+ await fs.rm(this.modelsDir(id), { recursive: true, force: true });
91
+ return { ok: true };
92
+ }
93
+ catch (err) {
94
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
95
+ }
96
+ }
97
+ /** 启动下载(串行队列:忙时直接拒绝)。resolve 在任务终态(done/error/cancelled)。 */
98
+ async start(id) {
99
+ const entry = catalogById(id);
100
+ if (!entry)
101
+ throw new Error(`未知模型: ${id}`);
102
+ return this.startEntry(entry);
103
+ }
104
+ /** 按给定目录项启动(测试缝:合成目录项驱动状态机,不触网)。 */
105
+ async startEntry(entry) {
106
+ if (this.busy)
107
+ throw new Error('已有下载任务进行中(串行队列,请等待或取消)');
108
+ this.busy = true;
109
+ this.abort = new AbortController();
110
+ const totalBytes = catalogTotalBytes(entry);
111
+ this.progress = {
112
+ modelId: entry.id,
113
+ phase: 'downloading',
114
+ fileIndex: 0,
115
+ fileCount: entry.files.length,
116
+ fileReceived: 0,
117
+ fileTotal: 0,
118
+ overallReceived: 0,
119
+ overallTotal: totalBytes,
120
+ speedBps: 0,
121
+ startedAt: Date.now(),
122
+ };
123
+ try {
124
+ await this.run(entry);
125
+ this.progress.phase = 'done';
126
+ this.opts.logger?.info(`[memory] 模型 ${entry.id} 下载校验完成(${totalBytes} 字节)`);
127
+ return { ...this.progress };
128
+ }
129
+ catch (err) {
130
+ const cancelled = this.progress.phase === 'cancelled';
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ if (!cancelled) {
133
+ this.progress.phase = 'error';
134
+ this.progress.error = message;
135
+ this.opts.logger?.warn(`[memory] 模型 ${entry.id} 下载失败: ${message}`);
136
+ }
137
+ return { ...this.progress };
138
+ }
139
+ finally {
140
+ this.busy = false;
141
+ this.abort = null;
142
+ }
143
+ }
144
+ /** 取消当前任务:中断 fetch,保留 .part 断点。 */
145
+ cancel() {
146
+ if (!this.busy || !this.abort)
147
+ return false;
148
+ if (this.progress)
149
+ this.progress.phase = 'cancelled';
150
+ this.abort.abort();
151
+ return true;
152
+ }
153
+ async run(entry) {
154
+ const prog = this.progress; // start() 已置位,本方法存活期内非空
155
+ // 磁盘门禁:剩余空间 ≥ 体积 × 1.2(statfs 不可用则跳过检查)
156
+ const free = await this.freeBytes();
157
+ if (free !== null) {
158
+ const need = Math.ceil(catalogTotalBytes(entry) * DISK_HEADROOM);
159
+ if (free < need) {
160
+ const fmt = (n) => (n >= 1e6 ? `${Math.round(n / 1e6)}MB` : `${Math.max(1, Math.round(n / 1e3))}KB`);
161
+ throw new Error(`磁盘剩余空间不足:需要约 ${fmt(need)}(含 20% 余量),当前 ${fmt(free)}`);
162
+ }
163
+ }
164
+ const dir = this.modelsDir(entry.id);
165
+ await fs.mkdir(path.join(dir, 'onnx'), { recursive: true });
166
+ let overall = 0;
167
+ // 之前已完整就位的文件计入整体进度(重试/断点续传场景分母口径一致)
168
+ for (const f of entry.files) {
169
+ if ((await fileSize(path.join(dir, f.path))) === f.size)
170
+ overall += f.size;
171
+ }
172
+ for (let i = 0; i < entry.files.length; i++) {
173
+ if (prog.phase === 'cancelled')
174
+ throw new Error('已取消');
175
+ const f = entry.files[i];
176
+ prog.fileIndex = i + 1;
177
+ prog.fileTotal = f.size;
178
+ const alreadyOk = (await fileSize(path.join(dir, f.path))) === f.size;
179
+ if (alreadyOk) {
180
+ prog.fileReceived = f.size;
181
+ continue;
182
+ }
183
+ overall += await this.downloadFile(entry, f, dir, (received) => {
184
+ prog.fileReceived = received;
185
+ prog.overallReceived = overall + received;
186
+ });
187
+ prog.overallReceived = overall;
188
+ }
189
+ }
190
+ /** 下载单文件到最终路径(含续传与校验),返回该文件贡献的字节数。 */
191
+ async downloadFile(entry, f, dir, onBytes) {
192
+ const finalPath = path.join(dir, f.path);
193
+ const partPath = finalPath + '.part';
194
+ const base = this.opts.mirror.replace(/\/+$/, '');
195
+ const url = `${base}/${entry.repo}/resolve/${entry.revision}/${f.path}`;
196
+ const fetchImpl = this.opts.fetchImpl ?? ((u, init) => fetch(u, init));
197
+ const prog = this.progress;
198
+ let resumeFrom = 0;
199
+ const partSize = await fileSize(partPath);
200
+ if (partSize !== null && partSize < f.size)
201
+ resumeFrom = partSize;
202
+ else if (partSize === f.size) {
203
+ // 断点已写满但尚未 rename(进程在最后一字节与改名间被杀):直接校验收编,
204
+ // 避免发 bytes=<size>- 吃 416 死循环
205
+ const pre = await sha256File(partPath);
206
+ if (pre === f.sha256) {
207
+ await fs.rename(partPath, finalPath);
208
+ return f.size;
209
+ }
210
+ await fs.rm(partPath, { force: true });
211
+ }
212
+ else if (partSize !== null) {
213
+ await fs.rm(partPath, { force: true });
214
+ }
215
+ const headers = {};
216
+ if (resumeFrom > 0)
217
+ headers.range = `bytes=${resumeFrom}-`;
218
+ let res = await fetchImpl(url, { headers, signal: this.abort?.signal });
219
+ if (res.status === 416 && resumeFrom > 0) {
220
+ // 服务器对已满 Range 回 416:删断点从零重来(一次性)
221
+ await fs.rm(partPath, { force: true });
222
+ resumeFrom = 0;
223
+ delete headers.range;
224
+ res = await fetchImpl(url, { headers, signal: this.abort?.signal });
225
+ }
226
+ if (!res.ok)
227
+ throw new Error(`HTTP ${res.status}(${f.path})`);
228
+ const appending = res.status === 206 && resumeFrom > 0;
229
+ if (!appending)
230
+ resumeFrom = 0;
231
+ // 服务器未给 content-length(chunked)时按目录尺寸兜底展示
232
+ const declared = Number(res.headers.get('content-length') ?? 0);
233
+ const expectBytes = appending ? resumeFrom + declared : declared || f.size;
234
+ const handle = await fs.open(partPath, appending ? 'a' : 'w');
235
+ let received = resumeFrom;
236
+ let lastTick = Date.now();
237
+ let lastBytes = received;
238
+ try {
239
+ if (!res.body)
240
+ throw new Error('响应无 body');
241
+ const reader = res.body.getReader();
242
+ for (;;) {
243
+ const { done, value } = await reader.read();
244
+ if (done)
245
+ break;
246
+ if (prog.phase === 'cancelled')
247
+ throw new Error('已取消');
248
+ await handle.write(value);
249
+ received += value.byteLength;
250
+ const now = Date.now();
251
+ if (now - lastTick > 200) {
252
+ const inst = ((received - lastBytes) / (now - lastTick)) * 1000;
253
+ prog.speedBps = prog.speedBps * 0.6 + inst * 0.4;
254
+ lastTick = now;
255
+ lastBytes = received;
256
+ }
257
+ onBytes(Math.min(received, f.size));
258
+ prog.fileTotal = expectBytes || f.size;
259
+ }
260
+ }
261
+ finally {
262
+ await handle.close();
263
+ }
264
+ if (received !== f.size) {
265
+ throw new Error(`下载数量不吻合:期望 ${f.size},收到 ${received}(${f.path})`);
266
+ }
267
+ // 校验(含续传):落盘后单遍流式哈希
268
+ prog.phase = 'verifying';
269
+ const sha = await sha256File(partPath);
270
+ if (sha !== f.sha256) {
271
+ await fs.rm(partPath, { force: true });
272
+ throw new Error(`sha256 校验失败(${f.path}),已删除断点,请重试`);
273
+ }
274
+ // 校验期间取消(cancel 把 phase 置 cancelled):不得被下面覆写回 downloading。
275
+ // as 断言绕开 CFA 窄化——await 期间 cancel() 可能已改写 phase
276
+ if (prog.phase === 'cancelled')
277
+ throw new Error('已取消');
278
+ prog.phase = 'downloading';
279
+ await fs.rename(partPath, finalPath);
280
+ return f.size;
281
+ }
282
+ async freeBytes() {
283
+ if (this.opts.freeBytes)
284
+ return this.opts.freeBytes();
285
+ try {
286
+ const statfs = (await import('node:fs/promises')).statfs;
287
+ if (typeof statfs !== 'function')
288
+ return null;
289
+ const s = await statfs(this.dataDir);
290
+ return Number(BigInt(s.bavail) * BigInt(s.bsize));
291
+ }
292
+ catch {
293
+ return null;
294
+ }
295
+ }
296
+ }
297
+ async function fileSize(p) {
298
+ try {
299
+ const s = await fs.stat(p);
300
+ return s.isFile() ? s.size : null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
306
+ async function sha256File(p) {
307
+ const { createReadStream } = await import('node:fs');
308
+ const hash = createHash('sha256');
309
+ const stream = createReadStream(p);
310
+ for await (const chunk of stream)
311
+ hash.update(chunk);
312
+ return hash.digest('hex');
313
+ }
@@ -0,0 +1,160 @@
1
+ import type { MemoryConfig } from '../config.js';
2
+ import type { MemoryLogger } from '../types.js';
3
+ import type { EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
4
+ import type { L0Store } from './l0.js';
5
+ import type { L1Store } from './l1.js';
6
+ import { LocalEmbeddingService } from './local-embedding.js';
7
+ import { ModelDownloadQueue } from './download-queue.js';
8
+ import { RuntimeInstaller } from './runtime-installer.js';
9
+ import type { MemoryDb } from './sqlite.js';
10
+ export type EmbeddingSourceKind = 'remote' | 'local' | 'off';
11
+ export interface EmbeddingSourceState {
12
+ source: EmbeddingSourceKind;
13
+ /** source=local 时启用的目录模型 id。 */
14
+ activeModel: string | null;
15
+ }
16
+ export declare class EmbeddingSourceStore {
17
+ private state;
18
+ private readonly file;
19
+ private writeQueue;
20
+ private readonly logger?;
21
+ constructor(dataDir: string, logger?: MemoryLogger);
22
+ get(): EmbeddingSourceState;
23
+ init(): Promise<void>;
24
+ set(next: EmbeddingSourceState): Promise<void>;
25
+ private persist;
26
+ }
27
+ export interface InitialEmbedding {
28
+ svc: EmbeddingService;
29
+ dims: number;
30
+ /** 传给 db.init 的 providerInfo(触发既有配置比对 → drop → needsReindex 链)。 */
31
+ providerInfo?: EmbeddingProviderInfo;
32
+ /** 解析降级原因(UI 展示)。 */
33
+ note?: string;
34
+ }
35
+ /** 远程档部署上限:静态四件套 + enabled。 */
36
+ export declare function remoteCeiling(cfg: MemoryConfig): boolean;
37
+ export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
38
+ /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。 */
39
+ export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger): (modelId: string) => LocalEmbeddingService | null;
40
+ export type ApplyPhase = 'idle' | 'installing-runtime' | 'warming' | 'switching' | 'reindexing' | 'done' | 'error';
41
+ export interface ReindexProgressState {
42
+ running: boolean;
43
+ l1Done: number;
44
+ l1Total: number;
45
+ l0Done: number;
46
+ l0Total: number;
47
+ startedAt: number;
48
+ cancelled: boolean;
49
+ error?: string;
50
+ }
51
+ export interface EmbeddingManagerDeps {
52
+ dataDir: string;
53
+ cfg: MemoryConfig;
54
+ db: MemoryDb;
55
+ l0: L0Store;
56
+ l1: L1Store;
57
+ sourceStore: EmbeddingSourceStore;
58
+ installer: RuntimeInstaller;
59
+ downloader: ModelDownloadQueue;
60
+ initial: InitialEmbedding;
61
+ logger: MemoryLogger;
62
+ /** 本地服务构造(默认用 makeLocalServiceFactory(installer, downloader)。 */
63
+ makeLocal?: (modelId: string) => LocalEmbeddingService | null;
64
+ }
65
+ export declare class EmbeddingManager {
66
+ readonly sourceStore: EmbeddingSourceStore;
67
+ readonly installer: RuntimeInstaller;
68
+ readonly downloader: ModelDownloadQueue;
69
+ private readonly deps;
70
+ private current;
71
+ private localSvc;
72
+ private applyPhase;
73
+ private applyMessage;
74
+ private applyStartedAt;
75
+ private applyBusy;
76
+ private reindex;
77
+ private reindexCancel;
78
+ /** 停机标志:dispose 后应用链不再推进(防卸载后的孤儿重嵌/安装)。 */
79
+ private disposedFlag;
80
+ /** 当前生效目标的 providerInfo(backfill/启动链的 meta 写入用——杜绝陈旧闭包)。 */
81
+ private currentInfo;
82
+ /** 初始解析的降级说明(活切换成功后清空,防过期提示常驻)。 */
83
+ private activeNote;
84
+ constructor(deps: EmbeddingManagerDeps);
85
+ /** 当前目标的 providerInfo(index.ts 的启动重嵌链/周期 backfill 写 meta 用)。 */
86
+ currentProviderInfo(): EmbeddingProviderInfo | undefined;
87
+ /** 取消运行时安装(RPC:npm 卡死/用户主动放弃)。 */
88
+ cancelRuntimeInstall(): boolean;
89
+ /** 当前生效服务(index.ts 初始建 store 用)。 */
90
+ getService(): EmbeddingService;
91
+ /** 构造绑定真实运行时 loader 的本地服务(deps.makeLocal 可注入,测试替换)。 */
92
+ private makeLocalService;
93
+ /** 活切换请求:验证通过即接受,后台执行应用链(进度轮询可见)。 */
94
+ requestSource(next: {
95
+ source: EmbeddingSourceKind;
96
+ activeModel?: string | null;
97
+ }): {
98
+ accepted: boolean;
99
+ error?: string;
100
+ };
101
+ /** 下载启动(串行队列忙时拒绝);完成后自动做一次可加载性预热验证(D6)。 */
102
+ startDownload(modelId: string): {
103
+ ok: boolean;
104
+ error?: string;
105
+ };
106
+ cancelDownload(): boolean;
107
+ deleteModel(modelId: string): Promise<{
108
+ ok: boolean;
109
+ error?: string;
110
+ }>;
111
+ cancelReindex(): boolean;
112
+ /** 应用链/后台任务是否在跑(backfill 并发门禁用)。 */
113
+ isBusy(): boolean;
114
+ /** 停机钩子(插件 dispose):取消 npm 安装、下载与重嵌——不留后台孤儿任务。 */
115
+ dispose(): void;
116
+ private applyChain;
117
+ private reindexNow;
118
+ /** RPC 快照(设置页嵌入区块数据源;client 忙时 1s 轮询)。 */
119
+ snapshot(): Promise<EmbeddingStateView>;
120
+ }
121
+ export interface EmbeddingStateView {
122
+ source: EmbeddingSourceKind;
123
+ activeModel: string | null;
124
+ ceilings: {
125
+ remote: boolean;
126
+ local: boolean;
127
+ };
128
+ runtime: {
129
+ targetVersion: string;
130
+ phase: 'idle' | 'installing' | 'ready' | 'error' | 'cancelled';
131
+ installedVersion: string | null;
132
+ elapsedMs: number;
133
+ lastLines: string[];
134
+ error?: string;
135
+ };
136
+ models: Array<{
137
+ id: string;
138
+ name: string;
139
+ dims: number;
140
+ contextTokens: number;
141
+ tags: string[];
142
+ description: string;
143
+ totalBytes: number;
144
+ bytesOnDisk: number;
145
+ state: 'none' | 'partial' | 'downloaded';
146
+ }>;
147
+ download: ReturnType<ModelDownloadQueue['getProgress']>;
148
+ apply: {
149
+ phase: ApplyPhase;
150
+ message: string;
151
+ startedAt: number;
152
+ busy: boolean;
153
+ };
154
+ local: {
155
+ state: 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
156
+ error: string | null;
157
+ } | null;
158
+ reindex: ReindexProgressState;
159
+ activeNote?: string;
160
+ }