@max-null/dsh-memory 0.20.0 → 0.22.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 CHANGED
@@ -122,7 +122,9 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
122
122
 
123
123
  **动机**:2026-10-18 的事故——`.dsh` 下的 storages 被外部整目录删除后,本插件的写入路径(读—改—写全量覆盖)在空磁盘上把内存里的 global 记忆覆盖成一条;而当时 `memory.json` **没有任何一份存在于 `.dsh` 之外**,无从恢复。
124
124
 
125
- **机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14 份(可配,**只作用于记忆快照**),超期自动清理。
125
+ **机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<DSH_HOME 的兄弟目录>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14 份(可配,**只作用于记忆快照**),超期自动清理。
126
+
127
+ 默认根**跟着 `DSH_HOME` 走**:缺省 `DSH_HOME=~/.dsh` 时就是 `~/.dsh-backups/memory`;隔离实例(不同 `DSH_HOME`)各自落在自己的兄弟目录,不会把快照混进同一份。同时它仍在 `DSH_HOME` **之外**——2026-10-18 那次事故是整个 `.dsh` 被删,备份必须活在外面。
126
128
 
127
129
  **日记另走一条路**:`<DSH_HOME>/diary/*.md` 与记忆同一轮复制,但落在**固定目录** `~/.dsh-backups/memory/diary-archive/`,**只增不删**,不参与上面的轮转。两边的不对称是实的:记忆快照丢一份还有别的份(同一份数据的多个时间点副本),归档里每一篇都是日记的唯一副本,丢了没有任何地方能重建。日记那一侧改过就以日记为准覆盖(人可能在事后改过错字),归档里有而日记里已没有的篇目一律保留——那正是归档的全部价值。归档目录名不匹配轮转正则,因此不需要给清理逻辑开白名单。
128
130
 
@@ -130,18 +132,62 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
130
132
 
131
133
  | 项 | 默认值 |
132
134
  |---|---|
133
- | 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project)+ `<DSH_HOME>/diary/*.md` |
134
- | 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
135
+ | 备份内容 | `memory.json`(global)+ 每个打开过的工作区各一份 `memory_project_*.json` + `<DSH_HOME>/diary/*.md` |
136
+ | 目标目录 | 记忆:`<DSH_HOME 的兄弟目录>/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**;缺省 `DSH_HOME` 下即 `~/.dsh-backups/memory/`);日记:同一根下的 `diary-archive/` |
135
137
  | 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
136
138
  | 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
137
139
  | 失败行为 | 只写日志,不影响记忆读写与启动 |
138
140
 
139
141
  **边界**:
140
142
 
141
- - 只备份**当前工作区**的 project 文件(`<cwd>/.dsh/storages/`);其它工作区的 project 记忆不在备份范围。
143
+ - 备份**本轮运行打开过的所有工作区**的 project 文件:来源是引擎按会话 cwd 记下的那份工作区账,每轮备份时现取。**本轮一次都没打开过的工作区不在范围内**——备份是定时任务,没有「当前工作区」可问,打开过的才留得下痕迹。多个工作区给出同名文件时加递增后缀,绝不覆盖。
142
144
  - 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
143
145
  - 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
144
146
 
147
+ ## 云同步(2026-11-11)
148
+
149
+ 把本机记忆镜像到一个 git 仓库,多个环境之间共享同一份记忆。
150
+
151
+ **为什么是「一条记忆一个文件」。** 整份 `memory.json` 放进 git 会撞:2026-11-06 实测,两端各新增一条记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」解决会**静默丢掉另一端那条**,而 git 仍报「合并成功」。改成一条一个文件后同一场景是 `Merge made by the 'ort' strategy`,0 冲突。
152
+
153
+ **项目身份用项目名,不用路径。** 本机 `H:\MaxNull\WorkStation` 与另一台机器 `D:\WorkStation` 是同一个项目——目录名(统一小写)决定它在 `records/project/` 下落到哪。用绝对路径哈希的话,换个盘符就成了另一个项目,记忆会静默分裂成两份互不相认的。
154
+
155
+ ```yaml
156
+ memory:
157
+ sync:
158
+ mirrorDir: '' # 缺省 <DSH_HOME>/memory-sync
159
+ remote: '' # https 远端;缺省只做本地提交
160
+ branch: main
161
+ envName: '' # 本环境短名,冲突留证时用作后缀
162
+ ```
163
+
164
+ token **不写进配置**:从环境变量 `DSH_MEMORY_SYNC_TOKEN` 或既有凭据机制取。
165
+
166
+ | 工具 | 作用 |
167
+ |---|---|
168
+ | `memory_sync` | `status` 只看状态 / `pull` 拉取并导入 / `push` 镜像并推送 |
169
+ | `memory_conflicts` | 列出**未被自动裁决**的冲突 |
170
+
171
+ **仓库形态**:
172
+
173
+ ```
174
+ <mirrorDir>/
175
+ records/global/<uuid>.json 一条记忆一个文件
176
+ records/project/<项目名>/<uuid>.json
177
+ tombstones/<uuid>.json forget 留下的痕迹(不删记录文件)
178
+ conflicts/<uuid>.<env>.json 两边都改过时,两个版本都留着
179
+ ```
180
+
181
+ **边界**:
182
+
183
+ - **冲突不自动裁决。** 同一条被两个环境同时修改时,两个版本都写进 `conflicts/`,本地版本保留并标记待处理。记忆是长期资产,被静默覆盖的那份就没了。代价是冲突会堆到有人处理为止。
184
+ - **删除留墓碑,不删文件。** `memory_forget` 是**真删**(`table.delete`),而另一端的「刚加、我还没拉到」在本地看起来完全一样。靠「上次同步的 id 快照」(`.sync-state.json`)区分:曾同步过、现在没了才是删除。**首次同步永远不产生墓碑**——那时没有「曾经有过」这个信息。
185
+ - **本地瞬态字段不进镜像**:`hitCount` / `lastUsedAt` 每次检索命中都会改,带上它们会让仓库在内容毫无变化时持续变脏。隔离记录(`quarantined`)**整条不导出**——隔离区的存在理由就是它可能含凭据类内容。
186
+ - **首次同步必须 clone**。本地 `git init` 出来的仓库与远端没有共同祖先,push 会被拒(`not a simple fast-forward`)。这条是 2026-11-11 第一次连真仓库时撞出来的。
187
+ - 传输层用 `isomorphic-git`(纯 JS,走 HTTPS + PAT),**不依赖系统 git**——移动端装的东西会被运行时更新清掉,不该把同步链的地基押在上面。
188
+
189
+ **没有调度器。** 目前靠模型显式调用 `memory_sync`;`autoPull` / `autoPush` 尚未实现。
190
+
145
191
  ## 使用流程(写入即生效,人为例外干预)
146
192
 
147
193
  提示词模板库(0.6.0):`prompt_search / prompt_get / prompt_list / prompt_add` 四个工具管理**模板库**——
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
- /** project 记忆的根(`<cwd>/.dsh/storages`)。 */
35
- projectRoot: string;
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(options.projectRoot, log).map(name => ({ name, from: join(options.projectRoot, name) }));
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
- copyFileSync(source.from, join(dir, source.name));
156
- files.push(source.name);
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
- projectRoot: options.projectRoot,
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
@@ -292,6 +292,45 @@ declare module '@deepseek-ai/cordis' {
292
292
  'memory/changed'(change: MemoryChange): void;
293
293
  }
294
294
  }
295
+ /**
296
+ * 存储层状态保留旧枚举(auto/suggest)+ injected 可选——旧文件必须能
297
+ * 过 schema 校验,读时由 normalizeBlock 迁移(一次性兼容转换,不重写文件)。
298
+ */
299
+ type StoredStatus = MemoryStatus | 'auto' | 'suggest';
300
+ interface StoredBlock {
301
+ namespace: MemoryNamespace;
302
+ status: StoredStatus;
303
+ injected?: boolean;
304
+ /** 隔离位(2026-09-15);缺省 = 未隔离。 */
305
+ quarantined?: boolean;
306
+ /** 隔离原因(规则名)。 */
307
+ quarantineReason?: string;
308
+ /** 命中次数(2026-09-15 淘汰机制);缺省 0。 */
309
+ hitCount?: number;
310
+ /** 写入来源(2026-09-15);缺省 = 旧记录(来源未知;迁移不动它)。 */
311
+ source?: MemorySource;
312
+ /** 注入由信号自动开启(true)还是人工设置(false/缺省);只有自动开的会被自动降级。 */
313
+ injectedAuto?: boolean;
314
+ /** 有效性锚点(2026-09-15);缺省 = 未绑定锚点。 */
315
+ anchor?: MemoryAnchor;
316
+ /** 锚点已失效(会话启动校验写入);失效只降权并标注,不删除。 */
317
+ stale?: boolean;
318
+ /** 失效原因(锚点名 + 声明值 vs 当前值)。 */
319
+ staleReason?: string;
320
+ content: string;
321
+ keywords: string[];
322
+ createdAt: number;
323
+ updatedAt: number;
324
+ lastUsedAt?: number;
325
+ /** 混合检索向量(0.5.2,仅有 embeddings 配置时生成;明文可读)。 */
326
+ vector?: number[];
327
+ /** 记录类别(0.6.0 模板库;缺省 fact)。 */
328
+ kind?: MemoryKind;
329
+ /** 记忆主题(0.20.0):见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …;缺省 = 未分类。 */
330
+ topic?: string;
331
+ /** prompt 索引元数据(仅 kind==='prompt')。 */
332
+ meta?: PromptMetaIndex;
333
+ }
295
334
  /**
296
335
  * 嵌入能力(0.5.2 混合检索):缺省不配置 = 纯 BM25(行为不变);
297
336
  * 配置后 memory_search 以 BM25 与语义结果做 RRF 融合,向量持久化到
@@ -330,6 +369,12 @@ export interface MemoryConfig {
330
369
  * 实现只读文件、只复制,见 `backup.ts` 的文件头。
331
370
  */
332
371
  backup?: MemoryBackupConfig;
372
+ /**
373
+ * 云同步配置(2026-11-11):`{ mirrorDir, remote?, branch?, envName? }`。
374
+ *
375
+ * 缺省不配 = 不同步。设计见 `docs/设计/2026-11-06-记忆云同步-设计方案.md`。
376
+ */
377
+ sync?: unknown;
333
378
  /**
334
379
  * 日记开关的**部署默认值**(2026-10-24)。运行中的选择在面板里改,写入
335
380
  * `$DSH_HOME/memory-settings.json`;两边都开才开——否则部署时关掉会被
@@ -360,6 +405,14 @@ export declare class MemoryEngine extends Service {
360
405
  private globalTable?;
361
406
  /** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
362
407
  private projectTables;
408
+ /**
409
+ * 各 storage backend 的清理函数(摘名 + 关闭)。
410
+ *
411
+ * 不挂在独立 effect 上:cordis 的 dispose 后进先出,运行时才注册的那些会比
412
+ * `memory.domainsClose` 早执行,导致 domain 还握着 unit 时 backend 先关。
413
+ * 统一由 {@link closeBackends} 在 domain 全部关闭之后按插入顺序执行。
414
+ */
415
+ private readonly backendClosers;
363
416
  /** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
364
417
  private readonly neighborCache;
365
418
  /** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
@@ -394,6 +447,15 @@ export declare class MemoryEngine extends Service {
394
447
  get lastSweepReport(): SweepReport | undefined;
395
448
  constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
396
449
  protected [Service.init](): Promise<void>;
450
+ /**
451
+ * 注册一个 storage backend 并返回它的清理函数(摘名 + 关闭)。
452
+ *
453
+ * 清理函数**不立刻挂到 effect 上**,而是交给 {@link closeBackends} 在 domain 全部关闭
454
+ * 之后统一执行——理由见 `[Service.init]` 的注释。
455
+ */
456
+ private registerBackend;
457
+ /** 依次摘名并关闭所有已注册的 backend;必须由外部保证 domain 已先关闭。 */
458
+ private closeBackends;
397
459
  /** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
398
460
  private openGlobalFacility;
399
461
  /** 工作区 project 存储根(<workspace>/.dsh/storages,随 git 分享)。 */
@@ -405,6 +467,27 @@ export declare class MemoryEngine extends Service {
405
467
  * 表已打开是常态,那时这条路径不该付出任何异步代价。
406
468
  */
407
469
  projectOpen(projectCwd?: string): boolean;
470
+ /**
471
+ * 已经打开过的工作区 project 存储根(`<cwd>/.dsh/storages`),供备份遍历。
472
+ *
473
+ * 备份是定时任务、没有会话 cwd,所以「现在在哪个工作区」这个问题它答不出来。
474
+ * 此前它拿 `process.cwd()` 兜底,而那是**进程启动目录**(桌面壳里通常是 profile
475
+ * 目录),与真实工作区无关——2026-11-05 实测:292 条项目记忆从未进过快照,备份
476
+ * 一直对着一个空目录反复报成功。
477
+ *
478
+ * 打开过的表由会话 cwd 驱动,见过的工作区都留在 {@link projectTables} 里;这是
479
+ * 备份唯一可靠的工作区来源。返回空数组表示「这次运行还没打开过任何工作区」,
480
+ * 由调用方决定回退策略。
481
+ */
482
+ projectStorageRoots(): string[];
483
+ /**
484
+ * 已经打开过的工作区 cwd 列表(同步导出用)。
485
+ *
486
+ * 与 {@link projectStorageRoots} 的区别:那个给的是存储根(`<cwd>/.dsh/storages`),
487
+ * 这个给 cwd 本身。导出要按 cwd 调 `list({ namespace: 'project' }, cwd)`,从存储根
488
+ * 反推 cwd 依赖目录层级,不如直接给。
489
+ */
490
+ projectCwds(): string[];
408
491
  /**
409
492
  * 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
410
493
  * 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
@@ -806,6 +889,21 @@ export declare class MemoryEngine extends Service {
806
889
  * 起可改 `injected`:**省略则保留原值**(连 `injectedAuto` 一起不动),给值即接管。若新
807
890
  * 内容命中危险规则则转为隔离(与写入路径同一判定),并连带撤下注入。
808
891
  */
892
+ /**
893
+ * 按**指定 id** 写入一条外来记录——同步导入专用。
894
+ *
895
+ * 与 {@link remember} 的区别:`remember` 生成新 id 并走内容判定;`adopt` 接收另一端
896
+ * 记录的 id 与整块内容,原样落进来。**必须保 id**——否则同一件事在五个环境里会变成
897
+ * 五条互不相干的记忆,而「共享一份记忆」正是同步要达成的事。
898
+ *
899
+ * 与 {@link update} 的区别:`update` 只改可编辑字段(content / keywords / injected /
900
+ * anchor)并把 `updatedAt` 推到当下;`adopt` 连 `createdAt`、`stale`、隔离标记一起搬
901
+ * ——那是「另一端此刻的完整状态」,不是一次本地编辑。因此也**不重算 `updatedAt`**:
902
+ * 拿导入时刻覆盖掉原始时间会让冲突判定(按 `updatedAt` 取新)失效。
903
+ *
904
+ * 仍然过 `putBlock` 的 schema 校验(门一):外来数据一样要合规。
905
+ */
906
+ adopt(id: MemoryId, block: StoredBlock, projectCwd?: string): Promise<MemoryRecord>;
809
907
  update(id: MemoryId, patch: MemoryPatch, projectCwd?: string): Promise<MemoryRecord>;
810
908
  /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
811
909
  private updateInner;
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 只注册一次:registry 对重名注册抛 duplicate-backend
217
- // (storage/tests/registry.spec 实测),reload 不得重复注册。
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.ctx.storage.backend.register('memory-global', globalBackend);
240
+ this.backendClosers.push(this.registerBackend('memory-global', globalBackend));
220
241
  await this.openGlobalFacility();
221
- this.ctx.effect(() => () => {
222
- void this.facility?.closeAll();
242
+ this.ctx.effect(() => async () => {
243
+ await this.facility?.closeAll();
223
244
  for (const facility of this.projectFacilities.values())
224
- void facility.closeAll();
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,31 @@ 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
+ }
316
+ /**
317
+ * 已经打开过的工作区 cwd 列表(同步导出用)。
318
+ *
319
+ * 与 {@link projectStorageRoots} 的区别:那个给的是存储根(`<cwd>/.dsh/storages`),
320
+ * 这个给 cwd 本身。导出要按 cwd 调 `list({ namespace: 'project' }, cwd)`,从存储根
321
+ * 反推 cwd 依赖目录层级,不如直接给。
322
+ */
323
+ projectCwds() {
324
+ return [...this.projectTables.keys()];
325
+ }
255
326
  /**
256
327
  * 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
257
328
  * 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
@@ -323,7 +394,10 @@ export class MemoryEngine extends Service {
323
394
  }
324
395
  if (!this.registeredProjectBackends.has(backendName)) {
325
396
  const backend = new JsonStorageBackend(root);
326
- this.ctx.storage.backend.register(backendName, backend);
397
+ // 与 `memory-global` 同样走 closers 列表:`registeredProjectBackends` 只能防**同一
398
+ // 实例**内的重复注册,换一个实例这个 Set 就是空的,挡不住上一个 fiber 残留的名字
399
+ // (issue #3 里这一点被 `memory-global` 先撞上,所以看不出来)。
400
+ this.backendClosers.push(this.registerBackend(backendName, backend));
327
401
  this.registeredProjectBackends.add(backendName);
328
402
  }
329
403
  const facility = new DomainFacility(this.ctx, { backend: backendName, routes: {} });
@@ -1519,6 +1593,34 @@ export class MemoryEngine extends Service {
1519
1593
  * 起可改 `injected`:**省略则保留原值**(连 `injectedAuto` 一起不动),给值即接管。若新
1520
1594
  * 内容命中危险规则则转为隔离(与写入路径同一判定),并连带撤下注入。
1521
1595
  */
1596
+ /**
1597
+ * 按**指定 id** 写入一条外来记录——同步导入专用。
1598
+ *
1599
+ * 与 {@link remember} 的区别:`remember` 生成新 id 并走内容判定;`adopt` 接收另一端
1600
+ * 记录的 id 与整块内容,原样落进来。**必须保 id**——否则同一件事在五个环境里会变成
1601
+ * 五条互不相干的记忆,而「共享一份记忆」正是同步要达成的事。
1602
+ *
1603
+ * 与 {@link update} 的区别:`update` 只改可编辑字段(content / keywords / injected /
1604
+ * anchor)并把 `updatedAt` 推到当下;`adopt` 连 `createdAt`、`stale`、隔离标记一起搬
1605
+ * ——那是「另一端此刻的完整状态」,不是一次本地编辑。因此也**不重算 `updatedAt`**:
1606
+ * 拿导入时刻覆盖掉原始时间会让冲突判定(按 `updatedAt` 取新)失效。
1607
+ *
1608
+ * 仍然过 `putBlock` 的 schema 校验(门一):外来数据一样要合规。
1609
+ */
1610
+ async adopt(id, block, projectCwd) {
1611
+ return this.writeScope(projectCwd, async () => {
1612
+ const table = block.namespace === 'project'
1613
+ ? await this.projectTableFor(projectCwd)
1614
+ : this.requireTable('global');
1615
+ if (table === undefined) {
1616
+ throw new Error('adopt: project memory requires a workspace cwd');
1617
+ }
1618
+ await this.putBlock(table, id, block);
1619
+ const record = toRecord(id, block);
1620
+ this.ctx.emit('memory/changed', { operation: 'remembered', record });
1621
+ return record;
1622
+ });
1623
+ }
1522
1624
  async update(id, patch, projectCwd) {
1523
1625
  return this.writeScope(projectCwd, () => this.updateInner(id, patch, projectCwd));
1524
1626
  }
package/dist/index.d.ts CHANGED
@@ -33,6 +33,12 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
33
33
  semanticTopK: z<number, number, "plain">;
34
34
  embeddings: z<any, any, "plain">;
35
35
  backup: z<any, any, "plain">;
36
+ /**
37
+ * 云同步配置(2026-11-11):`{ mirrorDir, remote?, branch?, envName? }`。
38
+ *
39
+ * 缺省不配 = 不同步。设计见 `docs/设计/2026-11-06-记忆云同步-设计方案.md`。
40
+ */
41
+ sync: z<any, any, "plain">;
36
42
  /**
37
43
  * 日记开关。默认开——这是**功能默认,不是采集默认**。
38
44
  *
@@ -54,6 +60,12 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
54
60
  semanticTopK: z<number, number, "plain">;
55
61
  embeddings: z<any, any, "plain">;
56
62
  backup: z<any, any, "plain">;
63
+ /**
64
+ * 云同步配置(2026-11-11):`{ mirrorDir, remote?, branch?, envName? }`。
65
+ *
66
+ * 缺省不配 = 不同步。设计见 `docs/设计/2026-11-06-记忆云同步-设计方案.md`。
67
+ */
68
+ sync: z<any, any, "plain">;
57
69
  /**
58
70
  * 日记开关。默认开——这是**功能默认,不是采集默认**。
59
71
  *
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * model tools, a stable guidance section, and a recall context.
6
6
  */
7
7
  import { homedir } from 'node:os';
8
- import { join } from 'node:path';
8
+ import { join, dirname } from 'node:path';
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools';
10
10
  import { MemoryEngine } from "./engine.js";
11
+ import { conflictFiles, syncPull, syncPush, syncStatus } from "./sync-service.js";
11
12
  import { MemoryGateway } from "./remote.js";
12
13
  import { runMemoryBackup } from "./backup.js";
13
14
  import { ANCHOR_KINDS } from "./anchors.js";
@@ -43,6 +44,12 @@ export const Config = z.object({
43
44
  semanticTopK: z.number(),
44
45
  embeddings: z.any(),
45
46
  backup: z.any(),
47
+ /**
48
+ * 云同步配置(2026-11-11):`{ mirrorDir, remote?, branch?, envName? }`。
49
+ *
50
+ * 缺省不配 = 不同步。设计见 `docs/设计/2026-11-06-记忆云同步-设计方案.md`。
51
+ */
52
+ sync: z.any(),
46
53
  /**
47
54
  * 日记开关。默认开——这是**功能默认,不是采集默认**。
48
55
  *
@@ -62,6 +69,21 @@ export const Config = z.object({
62
69
  * @param config - 插件配置。
63
70
  * @returns 是否启用日记。
64
71
  */
72
+ /**
73
+ * 定时备份的默认根:`DSH_HOME` 的**兄弟目录** `.dsh-backups/memory`。
74
+ *
75
+ * 为什么是兄弟目录而不是直接 `homedir()`:隔离实例(不同 `DSH_HOME` 并行跑,比如 dev
76
+ * 与装版)本该各有自己的备份树。写死 `homedir()` 会让它们的快照混进同一份目录——
77
+ * 2026-11-07 实测:dev 实例把 `globalRoot` 指向 `home-100` 的快照写进了装版的备份根,
78
+ * 两份 manifest 混在一起,人工恢复时容易拿错。
79
+ *
80
+ * 兄弟目录同时保证它**在 `DSH_HOME` 之外**——2026-10-18 那次事故是整个 `.dsh` 被删,
81
+ * 备份必须活在外面,所以不能退化成 `$DSH_HOME/backups`。
82
+ */
83
+ function defaultBackupRoot() {
84
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
85
+ return join(dirname(dshHome), '.dsh-backups', 'memory');
86
+ }
65
87
  function diaryOn(config) {
66
88
  const raw = config?.diary;
67
89
  if (raw === undefined || raw === null)
@@ -990,32 +1012,124 @@ export async function apply(ctx, config) {
990
1012
  },
991
1013
  presentCall: args => present('Read diary', 'read', args.date),
992
1014
  }));
1015
+ // ── 云同步(2026-11-11)──────────────────────────────────────────────
1016
+ //
1017
+ // **不向模型暴露 git**:模型只说「同步一下 / 看看状态」,具体 add/commit/push 由插件
1018
+ // 决定。把 `git pull --rebase && git add && git commit && git push` 那一串交给模型,
1019
+ // 出错难排查,也容易越权。
1020
+ //
1021
+ // 缺省不启用:没配 `memory.sync` 时这两个工具直接报「未配置」,而不是默默什么都不做——
1022
+ // 静默不动作比报错更坏。
1023
+ const syncConfig = () => {
1024
+ const raw = (config?.sync ?? {});
1025
+ const mirrorDir = typeof raw.mirrorDir === 'string' && raw.mirrorDir !== ''
1026
+ ? raw.mirrorDir
1027
+ : join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'memory-sync');
1028
+ return {
1029
+ mirrorDir,
1030
+ ...typeof raw.remote === 'string' && raw.remote !== '' ? { remote: raw.remote } : {},
1031
+ ...typeof raw.branch === 'string' && raw.branch !== '' ? { branch: raw.branch } : {},
1032
+ ...typeof raw.envName === 'string' && raw.envName !== '' ? { envName: raw.envName } : {},
1033
+ };
1034
+ };
1035
+ ctx.tools.register(defineTool({
1036
+ name: registered('memory_sync'),
1037
+ description: 'Push this machine\'s memories to the sync repo, pull other machines\' memories back, or inspect sync state. Conflicts are never auto-resolved — both versions are kept under conflicts/ for a human to settle. 把本机记忆推到同步仓库 / 拉回其他机器的记忆 / 查看同步状态。冲突不会自动裁决:两边版本都留在 conflicts/ 里等人处理。',
1038
+ parameters: {
1039
+ action: {
1040
+ type: 'string',
1041
+ required: true,
1042
+ enum: ['status', 'pull', 'push'],
1043
+ description: 'status = 只看状态(不读不写);pull = 拉取并导入;push = 镜像、提交并推送。',
1044
+ },
1045
+ },
1046
+ output: {
1047
+ schema: { type: 'object', additionalProperties: true, properties: {} },
1048
+ render: (_args, value) => renderJson(value),
1049
+ },
1050
+ async execute(args) {
1051
+ const cfg = syncConfig();
1052
+ const run = args.action === 'push'
1053
+ ? await syncPush(memory, cfg)
1054
+ : args.action === 'pull'
1055
+ ? await syncPull(memory, cfg)
1056
+ : await syncStatus(cfg);
1057
+ // 工具返回值要求 `Record<string, JsonValue>`;这几个结果都是普通可序列化对象,
1058
+ // 结构化类型对不上而已。走一遍 JSON 往返顺便保证它确实可序列化。
1059
+ return JSON.parse(JSON.stringify(run));
1060
+ },
1061
+ presentCall: args => present('Sync memory', 'other'),
1062
+ }));
1063
+ ctx.tools.register(defineTool({
1064
+ name: registered('memory_conflicts'),
1065
+ description: 'List memory conflicts left by sync. Sync never auto-resolves them: if two machines changed the same memory, both versions were kept. 列出同步留下的记忆冲突。同步不会自动裁决——两端改过同一条时,两个版本都留着。',
1066
+ parameters: {
1067
+ action: {
1068
+ type: 'string',
1069
+ required: true,
1070
+ enum: ['list'],
1071
+ description: 'list = 列出 conflicts/ 下的冲突文件。',
1072
+ },
1073
+ },
1074
+ output: {
1075
+ schema: { type: 'object', additionalProperties: true, properties: {} },
1076
+ render: (_args, value) => renderJson(value),
1077
+ },
1078
+ async execute() {
1079
+ const cfg = syncConfig();
1080
+ const files = conflictFiles(cfg);
1081
+ return { count: files.length, files };
1082
+ },
1083
+ presentCall: () => present('List memory conflicts', 'other'),
1084
+ }));
993
1085
  // 定时备份(2026-10-18 事故后新增):只读复制,绝不走引擎的写入路径。
994
1086
  // 首次延迟 1 分钟避开启动高峰;此后按 `backup.intervalMinutes` 重复;`unref` 不挡进程退出。
995
1087
  const backup = config?.backup;
996
1088
  if (backup?.enabled !== false) {
997
- const targetRoot = backup?.targetRoot ?? join(homedir(), '.dsh-backups', 'memory');
1089
+ const targetRoot = backup?.targetRoot ?? defaultBackupRoot();
998
1090
  const intervalMs = Math.max(1, backup?.intervalMinutes ?? 60) * 60_000;
999
1091
  const keep = Math.max(1, backup?.keep ?? 14);
1000
- const roots = {
1001
- globalRoot: config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages'),
1002
- projectRoot: config?.projectRoot ?? join(process.cwd(), '.dsh', 'storages'),
1003
- // 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
1004
- diaryRoot: diaryDir(),
1092
+ const globalRoot = config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages');
1093
+ /**
1094
+ * 本次要备份的 project 根。
1095
+ *
1096
+ * **每次备份时现算**,不能像 `globalRoot` 那样在启动时定死:工作区是随会话逐步
1097
+ * 打开的,启动那一刻引擎里一个都没有,那时算出来永远是空的。
1098
+ *
1099
+ * 三档来源:引擎打开过的工作区(由会话 cwd 驱动,最可靠)、显式配置的
1100
+ * `projectRoot`、两者都空时回退 `process.cwd()`。中间那一档要注意:`MemoryConfig`
1101
+ * 把它注释成「project 记忆的根」,但**引擎实现从不读它**——它只在这里影响备份。
1102
+ * 最后一档只是兜底——它是进程启动目录而非工作区,桌面壳里通常指向 profile
1103
+ * 目录,那正是「项目记忆零快照」的成因(2026-11-05 实测:738 KB / 292 条从未
1104
+ * 进过快照,备份一直对着空目录报成功)。
1105
+ */
1106
+ const projectRoots = () => {
1107
+ const found = new Set(memory.projectStorageRoots());
1108
+ if (config?.projectRoot !== undefined)
1109
+ found.add(config.projectRoot);
1110
+ if (found.size === 0)
1111
+ found.add(join(process.cwd(), '.dsh', 'storages'));
1112
+ return [...found];
1113
+ };
1114
+ const backupOnce = () => {
1115
+ runMemoryBackup({
1116
+ globalRoot,
1117
+ projectRoots: projectRoots(),
1118
+ // 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
1119
+ diaryRoot: diaryDir(),
1120
+ targetRoot,
1121
+ keep,
1122
+ });
1005
1123
  };
1006
1124
  ctx.effect(() => {
1007
- const first = setTimeout(() => {
1008
- runMemoryBackup({ ...roots, targetRoot, keep });
1009
- }, 60_000);
1010
- const timer = setInterval(() => {
1011
- runMemoryBackup({ ...roots, targetRoot, keep });
1012
- }, intervalMs);
1125
+ const first = setTimeout(backupOnce, 60_000);
1126
+ const timer = setInterval(backupOnce, intervalMs);
1013
1127
  timer.unref();
1014
1128
  return () => {
1015
1129
  clearTimeout(first);
1016
1130
  clearInterval(timer);
1017
1131
  // 关闭时补一次:定时器只在运行期间生效,关 DSH 前最后一段变更靠这里兜住。
1018
- runMemoryBackup({ ...roots, targetRoot, keep });
1132
+ backupOnce();
1019
1133
  };
1020
1134
  });
1021
1135
  }