@max-null/dsh-memory 0.20.0 → 0.21.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.
- package/README.md +2 -2
- package/dist/backup.d.ts +8 -2
- package/dist/backup.js +18 -4
- package/dist/engine.d.ts +30 -0
- package/dist/engine.js +71 -7
- package/dist/index.js +34 -12
- package/dist/sync-export.d.ts +60 -0
- package/dist/sync-export.js +141 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
130
130
|
|
|
131
131
|
| 项 | 默认值 |
|
|
132
132
|
|---|---|
|
|
133
|
-
| 备份内容 | `memory.json`(global)+ `memory_project_*.json
|
|
133
|
+
| 备份内容 | `memory.json`(global)+ 每个打开过的工作区各一份 `memory_project_*.json` + `<DSH_HOME>/diary/*.md` |
|
|
134
134
|
| 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
|
|
135
135
|
| 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
|
|
136
136
|
| 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
|
|
@@ -138,7 +138,7 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
138
138
|
|
|
139
139
|
**边界**:
|
|
140
140
|
|
|
141
|
-
-
|
|
141
|
+
- 备份**本轮运行打开过的所有工作区**的 project 文件:来源是引擎按会话 cwd 记下的那份工作区账,每轮备份时现取。**本轮一次都没打开过的工作区不在范围内**——备份是定时任务,没有「当前工作区」可问,打开过的才留得下痕迹。多个工作区给出同名文件时加递增后缀,绝不覆盖。
|
|
142
142
|
- 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
|
|
143
143
|
- 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
|
|
144
144
|
|
package/dist/backup.d.ts
CHANGED
|
@@ -31,8 +31,14 @@ export interface MemoryBackupConfig {
|
|
|
31
31
|
export interface MemoryBackupOptions {
|
|
32
32
|
/** global 记忆的根(`$DSH_HOME/storages`)。 */
|
|
33
33
|
globalRoot: string;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* project 记忆的根,一个工作区一项(`<cwd>/.dsh/storages`)。
|
|
36
|
+
*
|
|
37
|
+
* 是数组而非单值:项目记忆**按工作区分散**,一次运行里通常会打开好几个
|
|
38
|
+
* (本机实测同时存在 WorkStation / deepseek-harness / seek-soul-in-darkness
|
|
39
|
+
* 三份各自独立)。只备份一个等于默认放弃其余工作区的历史。
|
|
40
|
+
*/
|
|
41
|
+
projectRoots: readonly string[];
|
|
36
42
|
/** 日记的根(`$DSH_HOME/diary`);缺省不备份日记。 */
|
|
37
43
|
diaryRoot?: string;
|
|
38
44
|
/** 备份根目录。 */
|
package/dist/backup.js
CHANGED
|
@@ -129,7 +129,7 @@ export function runMemoryBackup(options) {
|
|
|
129
129
|
? undefined
|
|
130
130
|
: archiveDiary(options.diaryRoot, options.targetRoot, log);
|
|
131
131
|
const globalFiles = candidateFiles(options.globalRoot, log).map(name => ({ name, from: join(options.globalRoot, name) }));
|
|
132
|
-
const projectFiles = candidateFiles(
|
|
132
|
+
const projectFiles = options.projectRoots.flatMap(root => candidateFiles(root, log).map(name => ({ name, from: join(root, name) })));
|
|
133
133
|
const sources = [...globalFiles, ...projectFiles];
|
|
134
134
|
if (sources.length === 0) {
|
|
135
135
|
log('memory backup: 没有可备份的记忆文件');
|
|
@@ -150,10 +150,22 @@ export function runMemoryBackup(options) {
|
|
|
150
150
|
return { dir: null, files: [], pruned: [], error: message };
|
|
151
151
|
}
|
|
152
152
|
const files = [];
|
|
153
|
+
// 多个工作区可能给出同名文件(backend 名由 cwd 哈希得出,不同工作区几乎不会撞,
|
|
154
|
+
// 但不该把「备份的完整性」押在哈希碰撞概率上)。撞了就加递增后缀,绝不覆盖——
|
|
155
|
+
// 一次备份里静默少一份,等于这次备份没有做过。
|
|
156
|
+
const used = new Set();
|
|
153
157
|
for (const source of sources) {
|
|
154
158
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
let name = source.name;
|
|
160
|
+
for (let suffix = 1; used.has(name); suffix += 1) {
|
|
161
|
+
name = source.name.replace(/\.json$/u, `-${String(suffix)}.json`);
|
|
162
|
+
}
|
|
163
|
+
copyFileSync(source.from, join(dir, name));
|
|
164
|
+
used.add(name);
|
|
165
|
+
files.push(name);
|
|
166
|
+
if (name !== source.name) {
|
|
167
|
+
log(`memory backup: ${source.from} 与本次快照里的同名文件冲突,落位为 ${name}`);
|
|
168
|
+
}
|
|
157
169
|
}
|
|
158
170
|
catch (error) {
|
|
159
171
|
log(`memory backup: 复制 ${source.name} 失败: ${reason(error)}`);
|
|
@@ -163,7 +175,9 @@ export function runMemoryBackup(options) {
|
|
|
163
175
|
writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify({
|
|
164
176
|
createdAt: now.toISOString(),
|
|
165
177
|
globalRoot: options.globalRoot,
|
|
166
|
-
|
|
178
|
+
// 记全部来源根:事后要判断「某个工作区的记忆到底有没有被备份到」,
|
|
179
|
+
// 只能靠这份清单——文件名里的哈希反推不出工作区路径。
|
|
180
|
+
projectRoots: [...options.projectRoots],
|
|
167
181
|
files,
|
|
168
182
|
// 日记不在本次快照目录里,manifest 只记「这一轮归档做了什么」,不列篇名。
|
|
169
183
|
...(diary === undefined ? {} : { diary: { root: options.diaryRoot, ...diary } }),
|
package/dist/engine.d.ts
CHANGED
|
@@ -360,6 +360,14 @@ export declare class MemoryEngine extends Service {
|
|
|
360
360
|
private globalTable?;
|
|
361
361
|
/** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
|
|
362
362
|
private projectTables;
|
|
363
|
+
/**
|
|
364
|
+
* 各 storage backend 的清理函数(摘名 + 关闭)。
|
|
365
|
+
*
|
|
366
|
+
* 不挂在独立 effect 上:cordis 的 dispose 后进先出,运行时才注册的那些会比
|
|
367
|
+
* `memory.domainsClose` 早执行,导致 domain 还握着 unit 时 backend 先关。
|
|
368
|
+
* 统一由 {@link closeBackends} 在 domain 全部关闭之后按插入顺序执行。
|
|
369
|
+
*/
|
|
370
|
+
private readonly backendClosers;
|
|
363
371
|
/** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
|
|
364
372
|
private readonly neighborCache;
|
|
365
373
|
/** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
|
|
@@ -394,6 +402,15 @@ export declare class MemoryEngine extends Service {
|
|
|
394
402
|
get lastSweepReport(): SweepReport | undefined;
|
|
395
403
|
constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
|
|
396
404
|
protected [Service.init](): Promise<void>;
|
|
405
|
+
/**
|
|
406
|
+
* 注册一个 storage backend 并返回它的清理函数(摘名 + 关闭)。
|
|
407
|
+
*
|
|
408
|
+
* 清理函数**不立刻挂到 effect 上**,而是交给 {@link closeBackends} 在 domain 全部关闭
|
|
409
|
+
* 之后统一执行——理由见 `[Service.init]` 的注释。
|
|
410
|
+
*/
|
|
411
|
+
private registerBackend;
|
|
412
|
+
/** 依次摘名并关闭所有已注册的 backend;必须由外部保证 domain 已先关闭。 */
|
|
413
|
+
private closeBackends;
|
|
397
414
|
/** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
|
|
398
415
|
private openGlobalFacility;
|
|
399
416
|
/** 工作区 project 存储根(<workspace>/.dsh/storages,随 git 分享)。 */
|
|
@@ -405,6 +422,19 @@ export declare class MemoryEngine extends Service {
|
|
|
405
422
|
* 表已打开是常态,那时这条路径不该付出任何异步代价。
|
|
406
423
|
*/
|
|
407
424
|
projectOpen(projectCwd?: string): boolean;
|
|
425
|
+
/**
|
|
426
|
+
* 已经打开过的工作区 project 存储根(`<cwd>/.dsh/storages`),供备份遍历。
|
|
427
|
+
*
|
|
428
|
+
* 备份是定时任务、没有会话 cwd,所以「现在在哪个工作区」这个问题它答不出来。
|
|
429
|
+
* 此前它拿 `process.cwd()` 兜底,而那是**进程启动目录**(桌面壳里通常是 profile
|
|
430
|
+
* 目录),与真实工作区无关——2026-11-05 实测:292 条项目记忆从未进过快照,备份
|
|
431
|
+
* 一直对着一个空目录反复报成功。
|
|
432
|
+
*
|
|
433
|
+
* 打开过的表由会话 cwd 驱动,见过的工作区都留在 {@link projectTables} 里;这是
|
|
434
|
+
* 备份唯一可靠的工作区来源。返回空数组表示「这次运行还没打开过任何工作区」,
|
|
435
|
+
* 由调用方决定回退策略。
|
|
436
|
+
*/
|
|
437
|
+
projectStorageRoots(): string[];
|
|
408
438
|
/**
|
|
409
439
|
* 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
|
|
410
440
|
* 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
|
package/dist/engine.js
CHANGED
|
@@ -174,6 +174,14 @@ export class MemoryEngine extends Service {
|
|
|
174
174
|
globalTable;
|
|
175
175
|
/** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
|
|
176
176
|
projectTables = new Map();
|
|
177
|
+
/**
|
|
178
|
+
* 各 storage backend 的清理函数(摘名 + 关闭)。
|
|
179
|
+
*
|
|
180
|
+
* 不挂在独立 effect 上:cordis 的 dispose 后进先出,运行时才注册的那些会比
|
|
181
|
+
* `memory.domainsClose` 早执行,导致 domain 还握着 unit 时 backend 先关。
|
|
182
|
+
* 统一由 {@link closeBackends} 在 domain 全部关闭之后按插入顺序执行。
|
|
183
|
+
*/
|
|
184
|
+
backendClosers = [];
|
|
177
185
|
/** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
|
|
178
186
|
neighborCache = new Map();
|
|
179
187
|
/** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
|
|
@@ -213,17 +221,55 @@ export class MemoryEngine extends Service {
|
|
|
213
221
|
this.config = config;
|
|
214
222
|
}
|
|
215
223
|
async [Service.init]() {
|
|
216
|
-
// backend
|
|
217
|
-
//
|
|
224
|
+
// backend 注册是**一个 effect**:`dsh-storage` 的 `BackendRegistry.register()` 契约是
|
|
225
|
+
// 「registration is an effect: the returned disposer removes the name」。丢掉那个 disposer
|
|
226
|
+
// 会让名字活过 fiber——issue #3 的真实成因:插件市场的更新流程会在同一进程里重新组合
|
|
227
|
+
// profile 并做一次构建校验,本 entry 第二次激活时 `register('memory-global', …)` 撞上
|
|
228
|
+
// 自己上次留下的名字,报 `duplicate-backend`;而那句报错被上层包装成「更新前的构建
|
|
229
|
+
// 未能验证恢复,请检查该 profile」,把用户引向「配置坏了」这个错误方向。
|
|
230
|
+
//
|
|
231
|
+
// **摘名与关闭不能各挂一个 effect**:cordis 的 dispose 后进先出,而 project backend 的
|
|
232
|
+
// effect 是运行时(`openProjectTable`)才注册的,会比这里晚注册、早执行——于是 domain
|
|
233
|
+
// 还握着 unit 时 backend 先被关掉,留下没释放的 open-slot,下一个实例打开同一目录
|
|
234
|
+
// 就失败(表现为「表打开了但一条都读不到」)。所以统一收进一个 closers 列表,
|
|
235
|
+
// 由本 effect 在**所有 domain 关闭之后**依次执行。
|
|
236
|
+
//
|
|
237
|
+
// reload 不重新注册 backend(见 `reload()`:它只重开 domain),所以这样改不改变
|
|
238
|
+
// reload 的语义——这些 closers 只在 dispose 时触发。
|
|
218
239
|
const globalBackend = new JsonStorageBackend(this.config.globalRoot ?? globalRoot());
|
|
219
|
-
this.
|
|
240
|
+
this.backendClosers.push(this.registerBackend('memory-global', globalBackend));
|
|
220
241
|
await this.openGlobalFacility();
|
|
221
|
-
this.ctx.effect(() => () => {
|
|
222
|
-
|
|
242
|
+
this.ctx.effect(() => async () => {
|
|
243
|
+
await this.facility?.closeAll();
|
|
223
244
|
for (const facility of this.projectFacilities.values())
|
|
224
|
-
|
|
245
|
+
await facility.closeAll();
|
|
246
|
+
await this.closeBackends();
|
|
225
247
|
}, 'memory.domainsClose');
|
|
226
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* 注册一个 storage backend 并返回它的清理函数(摘名 + 关闭)。
|
|
251
|
+
*
|
|
252
|
+
* 清理函数**不立刻挂到 effect 上**,而是交给 {@link closeBackends} 在 domain 全部关闭
|
|
253
|
+
* 之后统一执行——理由见 `[Service.init]` 的注释。
|
|
254
|
+
*/
|
|
255
|
+
registerBackend(name, backend) {
|
|
256
|
+
const unregister = this.ctx.storage.backend.register(name, backend);
|
|
257
|
+
return async () => {
|
|
258
|
+
unregister();
|
|
259
|
+
await backend.close();
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** 依次摘名并关闭所有已注册的 backend;必须由外部保证 domain 已先关闭。 */
|
|
263
|
+
async closeBackends() {
|
|
264
|
+
for (const close of this.backendClosers.splice(0)) {
|
|
265
|
+
try {
|
|
266
|
+
await close();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// 清理失败不该让 dispose 整体失败——名字可能已被别处摘掉。
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
227
273
|
/** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
|
|
228
274
|
async openGlobalFacility() {
|
|
229
275
|
const facility = new DomainFacility(this.ctx, { backend: 'memory-global', routes: {} });
|
|
@@ -252,6 +298,21 @@ export class MemoryEngine extends Service {
|
|
|
252
298
|
return false;
|
|
253
299
|
return this.projectTables.has(join(projectCwd));
|
|
254
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* 已经打开过的工作区 project 存储根(`<cwd>/.dsh/storages`),供备份遍历。
|
|
303
|
+
*
|
|
304
|
+
* 备份是定时任务、没有会话 cwd,所以「现在在哪个工作区」这个问题它答不出来。
|
|
305
|
+
* 此前它拿 `process.cwd()` 兜底,而那是**进程启动目录**(桌面壳里通常是 profile
|
|
306
|
+
* 目录),与真实工作区无关——2026-11-05 实测:292 条项目记忆从未进过快照,备份
|
|
307
|
+
* 一直对着一个空目录反复报成功。
|
|
308
|
+
*
|
|
309
|
+
* 打开过的表由会话 cwd 驱动,见过的工作区都留在 {@link projectTables} 里;这是
|
|
310
|
+
* 备份唯一可靠的工作区来源。返回空数组表示「这次运行还没打开过任何工作区」,
|
|
311
|
+
* 由调用方决定回退策略。
|
|
312
|
+
*/
|
|
313
|
+
projectStorageRoots() {
|
|
314
|
+
return [...this.projectTables.keys()].map(cwd => this.projectRootFor(cwd));
|
|
315
|
+
}
|
|
255
316
|
/**
|
|
256
317
|
* 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
|
|
257
318
|
* 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
|
|
@@ -323,7 +384,10 @@ export class MemoryEngine extends Service {
|
|
|
323
384
|
}
|
|
324
385
|
if (!this.registeredProjectBackends.has(backendName)) {
|
|
325
386
|
const backend = new JsonStorageBackend(root);
|
|
326
|
-
|
|
387
|
+
// 与 `memory-global` 同样走 closers 列表:`registeredProjectBackends` 只能防**同一
|
|
388
|
+
// 实例**内的重复注册,换一个实例这个 Set 就是空的,挡不住上一个 fiber 残留的名字
|
|
389
|
+
// (issue #3 里这一点被 `memory-global` 先撞上,所以看不出来)。
|
|
390
|
+
this.backendClosers.push(this.registerBackend(backendName, backend));
|
|
327
391
|
this.registeredProjectBackends.add(backendName);
|
|
328
392
|
}
|
|
329
393
|
const facility = new DomainFacility(this.ctx, { backend: backendName, routes: {} });
|
package/dist/index.js
CHANGED
|
@@ -997,25 +997,47 @@ export async function apply(ctx, config) {
|
|
|
997
997
|
const targetRoot = backup?.targetRoot ?? join(homedir(), '.dsh-backups', 'memory');
|
|
998
998
|
const intervalMs = Math.max(1, backup?.intervalMinutes ?? 60) * 60_000;
|
|
999
999
|
const keep = Math.max(1, backup?.keep ?? 14);
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1000
|
+
const globalRoot = config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages');
|
|
1001
|
+
/**
|
|
1002
|
+
* 本次要备份的 project 根。
|
|
1003
|
+
*
|
|
1004
|
+
* **每次备份时现算**,不能像 `globalRoot` 那样在启动时定死:工作区是随会话逐步
|
|
1005
|
+
* 打开的,启动那一刻引擎里一个都没有,那时算出来永远是空的。
|
|
1006
|
+
*
|
|
1007
|
+
* 三档来源:引擎打开过的工作区(由会话 cwd 驱动,最可靠)、显式配置的
|
|
1008
|
+
* `projectRoot`、两者都空时回退 `process.cwd()`。中间那一档要注意:`MemoryConfig`
|
|
1009
|
+
* 把它注释成「project 记忆的根」,但**引擎实现从不读它**——它只在这里影响备份。
|
|
1010
|
+
* 最后一档只是兜底——它是进程启动目录而非工作区,桌面壳里通常指向 profile
|
|
1011
|
+
* 目录,那正是「项目记忆零快照」的成因(2026-11-05 实测:738 KB / 292 条从未
|
|
1012
|
+
* 进过快照,备份一直对着空目录报成功)。
|
|
1013
|
+
*/
|
|
1014
|
+
const projectRoots = () => {
|
|
1015
|
+
const found = new Set(memory.projectStorageRoots());
|
|
1016
|
+
if (config?.projectRoot !== undefined)
|
|
1017
|
+
found.add(config.projectRoot);
|
|
1018
|
+
if (found.size === 0)
|
|
1019
|
+
found.add(join(process.cwd(), '.dsh', 'storages'));
|
|
1020
|
+
return [...found];
|
|
1021
|
+
};
|
|
1022
|
+
const backupOnce = () => {
|
|
1023
|
+
runMemoryBackup({
|
|
1024
|
+
globalRoot,
|
|
1025
|
+
projectRoots: projectRoots(),
|
|
1026
|
+
// 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
|
|
1027
|
+
diaryRoot: diaryDir(),
|
|
1028
|
+
targetRoot,
|
|
1029
|
+
keep,
|
|
1030
|
+
});
|
|
1005
1031
|
};
|
|
1006
1032
|
ctx.effect(() => {
|
|
1007
|
-
const first = setTimeout(
|
|
1008
|
-
|
|
1009
|
-
}, 60_000);
|
|
1010
|
-
const timer = setInterval(() => {
|
|
1011
|
-
runMemoryBackup({ ...roots, targetRoot, keep });
|
|
1012
|
-
}, intervalMs);
|
|
1033
|
+
const first = setTimeout(backupOnce, 60_000);
|
|
1034
|
+
const timer = setInterval(backupOnce, intervalMs);
|
|
1013
1035
|
timer.unref();
|
|
1014
1036
|
return () => {
|
|
1015
1037
|
clearTimeout(first);
|
|
1016
1038
|
clearInterval(timer);
|
|
1017
1039
|
// 关闭时补一次:定时器只在运行期间生效,关 DSH 前最后一段变更靠这里兜住。
|
|
1018
|
-
|
|
1040
|
+
backupOnce();
|
|
1019
1041
|
};
|
|
1020
1042
|
});
|
|
1021
1043
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
|
|
5
|
+
* 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
|
|
6
|
+
* 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
|
|
7
|
+
* `Merge made by the 'ort' strategy`,0 冲突。
|
|
8
|
+
*
|
|
9
|
+
* **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
|
|
10
|
+
* cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
|
|
11
|
+
* 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
|
|
12
|
+
*
|
|
13
|
+
* **同步面不带什么**(见设计文档 §5):
|
|
14
|
+
* - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
|
|
15
|
+
* 毫无变化时持续变脏,git 历史被噪声淹没。
|
|
16
|
+
* - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
|
|
17
|
+
* - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
|
|
18
|
+
* 推到远端仓库等于把那层保护绕开。
|
|
19
|
+
*
|
|
20
|
+
* @see docs/设计/2026-11-06-记忆云同步-设计方案.md
|
|
21
|
+
*/
|
|
22
|
+
/** 一条记录在镜像里的位置与内容。 */
|
|
23
|
+
export interface SyncRecordFile {
|
|
24
|
+
/** 相对导出根的路径,如 `records/global/<uuid>.json`。 */
|
|
25
|
+
readonly path: string;
|
|
26
|
+
/** 文件内容(JSON + 末尾换行;已剔除同步面之外的字段)。 */
|
|
27
|
+
readonly content: string;
|
|
28
|
+
}
|
|
29
|
+
/** 导出的输入:各层记忆库的**解析后**文档。 */
|
|
30
|
+
export interface SyncExportOptions {
|
|
31
|
+
/** `memory.json` 解析后的对象(global 层)。 */
|
|
32
|
+
readonly globalDocument: unknown;
|
|
33
|
+
/** 各工作区的记忆库文档;`cwd` 用于算目录名,须与引擎写入时一致。 */
|
|
34
|
+
readonly projectDocuments?: ReadonlyArray<{
|
|
35
|
+
readonly cwd: string;
|
|
36
|
+
readonly document: unknown;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
/** 一次导出的结果。 */
|
|
40
|
+
export interface SyncExportRun {
|
|
41
|
+
/** 待写入的记录文件(顺序稳定:global 在前,工作区按 cwd 排序)。 */
|
|
42
|
+
readonly files: readonly SyncRecordFile[];
|
|
43
|
+
/** 因处于隔离区而未导出的条数。 */
|
|
44
|
+
readonly skippedQuarantined: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
|
|
48
|
+
* `projectBackendName`:cwd 小写后过 djb2,取 base36)。
|
|
49
|
+
*
|
|
50
|
+
* 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
|
|
51
|
+
* 看起来像「这个项目的记忆凭空少了一批」。
|
|
52
|
+
*/
|
|
53
|
+
export declare function workspaceSlug(cwd: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
|
|
56
|
+
*
|
|
57
|
+
* @param options - 各层文档。
|
|
58
|
+
* @returns 待写入的文件与隔离跳过数。
|
|
59
|
+
*/
|
|
60
|
+
export declare function exportRecords(options: SyncExportOptions): SyncExportRun;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
|
|
5
|
+
* 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
|
|
6
|
+
* 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
|
|
7
|
+
* `Merge made by the 'ort' strategy`,0 冲突。
|
|
8
|
+
*
|
|
9
|
+
* **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
|
|
10
|
+
* cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
|
|
11
|
+
* 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
|
|
12
|
+
*
|
|
13
|
+
* **同步面不带什么**(见设计文档 §5):
|
|
14
|
+
* - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
|
|
15
|
+
* 毫无变化时持续变脏,git 历史被噪声淹没。
|
|
16
|
+
* - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
|
|
17
|
+
* - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
|
|
18
|
+
* 推到远端仓库等于把那层保护绕开。
|
|
19
|
+
*
|
|
20
|
+
* @see docs/设计/2026-11-06-记忆云同步-设计方案.md
|
|
21
|
+
*/
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
/**
|
|
24
|
+
* 进入同步面的字段白名单。
|
|
25
|
+
*
|
|
26
|
+
* 用白名单而不是黑名单:`StoredBlock` 以后还会长出新字段(`topic` 就是 0.20.0 才加的),
|
|
27
|
+
* 黑名单会让新字段**默认泄漏**进来——而「一个只该存在本地的字段漏进远端仓库」这类事故,
|
|
28
|
+
* 发现的时候内容已经在别人能拉到的地方了。
|
|
29
|
+
*/
|
|
30
|
+
const SYNCED_FIELDS = [
|
|
31
|
+
'namespace',
|
|
32
|
+
'status',
|
|
33
|
+
'content',
|
|
34
|
+
'keywords',
|
|
35
|
+
'createdAt',
|
|
36
|
+
'updatedAt',
|
|
37
|
+
'injected',
|
|
38
|
+
'injectedAuto',
|
|
39
|
+
'topic',
|
|
40
|
+
'kind',
|
|
41
|
+
'anchor',
|
|
42
|
+
'stale',
|
|
43
|
+
'staleReason',
|
|
44
|
+
'source',
|
|
45
|
+
'meta',
|
|
46
|
+
];
|
|
47
|
+
/** 记录文件的目录名(与清单文件同级)。 */
|
|
48
|
+
const RECORDS_DIR = 'records';
|
|
49
|
+
/** 把 `unknown` 收窄成对象;不是对象返回 `undefined`。 */
|
|
50
|
+
function asObject(value) {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
? value
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 取出一份 unit 文档里的 blocks 表。
|
|
57
|
+
*
|
|
58
|
+
* 文档形状是 DSH storage 的 unit 格式:`{ unit, global, tables: { blocks: {...} } }`。
|
|
59
|
+
* 形状不符时返回空表而不是抛错——镜像导出不该因为一个损坏的库把整个同步卡死,
|
|
60
|
+
* 条数为 0 会被上层当作「没有可导出的东西」处理。
|
|
61
|
+
*/
|
|
62
|
+
function blocksOf(document) {
|
|
63
|
+
const doc = asObject(document);
|
|
64
|
+
const tables = asObject(doc?.tables);
|
|
65
|
+
const blocks = asObject(tables?.blocks);
|
|
66
|
+
if (blocks === undefined)
|
|
67
|
+
return {};
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [id, block] of Object.entries(blocks)) {
|
|
70
|
+
const value = asObject(block);
|
|
71
|
+
if (value !== undefined)
|
|
72
|
+
out[id] = value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
|
|
78
|
+
* `projectBackendName`:cwd 小写后过 djb2,取 base36)。
|
|
79
|
+
*
|
|
80
|
+
* 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
|
|
81
|
+
* 看起来像「这个项目的记忆凭空少了一批」。
|
|
82
|
+
*/
|
|
83
|
+
export function workspaceSlug(cwd) {
|
|
84
|
+
let hash = 5381;
|
|
85
|
+
const text = cwd.toLowerCase();
|
|
86
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
87
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0;
|
|
88
|
+
}
|
|
89
|
+
return Math.abs(hash).toString(36);
|
|
90
|
+
}
|
|
91
|
+
/** 只保留白名单字段;值为 `undefined` 的键不落进文件(避免 `"x": null` 噪声)。 */
|
|
92
|
+
function pickSyncedFields(block) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const field of SYNCED_FIELDS) {
|
|
95
|
+
const value = block[field];
|
|
96
|
+
if (value !== undefined)
|
|
97
|
+
out[field] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** 一条记录 → 一个文件;返回 `undefined` 表示这条不该进镜像(隔离区)。 */
|
|
102
|
+
function recordFile(id, block, segments) {
|
|
103
|
+
if (block.quarantined === true)
|
|
104
|
+
return undefined;
|
|
105
|
+
const payload = pickSyncedFields(block);
|
|
106
|
+
return {
|
|
107
|
+
path: join(...segments, `${id}.json`),
|
|
108
|
+
content: `${JSON.stringify(payload, null, 2)}\n`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
|
|
113
|
+
*
|
|
114
|
+
* @param options - 各层文档。
|
|
115
|
+
* @returns 待写入的文件与隔离跳过数。
|
|
116
|
+
*/
|
|
117
|
+
export function exportRecords(options) {
|
|
118
|
+
const files = [];
|
|
119
|
+
let skippedQuarantined = 0;
|
|
120
|
+
for (const [id, block] of Object.entries(blocksOf(options.globalDocument))) {
|
|
121
|
+
const file = recordFile(id, block, [RECORDS_DIR, 'global']);
|
|
122
|
+
if (file === undefined) {
|
|
123
|
+
skippedQuarantined += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
files.push(file);
|
|
127
|
+
}
|
|
128
|
+
// 工作区按 cwd 排序:让两次导出的文件顺序稳定,git 不必为顺序抖动产生 diff。
|
|
129
|
+
const projects = [...(options.projectDocuments ?? [])].sort((left, right) => left.cwd.localeCompare(right.cwd));
|
|
130
|
+
for (const { cwd, document } of projects) {
|
|
131
|
+
for (const [id, block] of Object.entries(blocksOf(document))) {
|
|
132
|
+
const file = recordFile(id, block, [RECORDS_DIR, 'project', workspaceSlug(cwd)]);
|
|
133
|
+
if (file === undefined) {
|
|
134
|
+
skippedQuarantined += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
files.push(file);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { files, skippedQuarantined };
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|