@max-null/dsh-memory 0.21.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
 
@@ -131,7 +133,7 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
131
133
  | 项 | 默认值 |
132
134
  |---|---|
133
135
  | 备份内容 | `memory.json`(global)+ 每个打开过的工作区各一份 `memory_project_*.json` + `<DSH_HOME>/diary/*.md` |
134
- | 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
136
+ | 目标目录 | 记忆:`<DSH_HOME 的兄弟目录>/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**;缺省 `DSH_HOME` 下即 `~/.dsh-backups/memory/`);日记:同一根下的 `diary-archive/` |
135
137
  | 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
136
138
  | 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
137
139
  | 失败行为 | 只写日志,不影响记忆读写与启动 |
@@ -142,6 +144,50 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
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/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`;两边都开才开——否则部署时关掉会被
@@ -435,6 +480,14 @@ export declare class MemoryEngine extends Service {
435
480
  * 由调用方决定回退策略。
436
481
  */
437
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[];
438
491
  /**
439
492
  * 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
440
493
  * 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
@@ -836,6 +889,21 @@ export declare class MemoryEngine extends Service {
836
889
  * 起可改 `injected`:**省略则保留原值**(连 `injectedAuto` 一起不动),给值即接管。若新
837
890
  * 内容命中危险规则则转为隔离(与写入路径同一判定),并连带撤下注入。
838
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>;
839
907
  update(id: MemoryId, patch: MemoryPatch, projectCwd?: string): Promise<MemoryRecord>;
840
908
  /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
841
909
  private updateInner;
package/dist/engine.js CHANGED
@@ -313,6 +313,16 @@ export class MemoryEngine extends Service {
313
313
  projectStorageRoots() {
314
314
  return [...this.projectTables.keys()].map(cwd => this.projectRootFor(cwd));
315
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
+ }
316
326
  /**
317
327
  * 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
318
328
  * 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
@@ -1583,6 +1593,34 @@ export class MemoryEngine extends Service {
1583
1593
  * 起可改 `injected`:**省略则保留原值**(连 `injectedAuto` 一起不动),给值即接管。若新
1584
1594
  * 内容命中危险规则则转为隔离(与写入路径同一判定),并连带撤下注入。
1585
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
+ }
1586
1624
  async update(id, patch, projectCwd) {
1587
1625
  return this.writeScope(projectCwd, () => this.updateInner(id, patch, projectCwd));
1588
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,11 +1012,81 @@ 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
1092
  const globalRoot = config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages');
package/dist/self.js CHANGED
@@ -25,7 +25,8 @@ export const SELF_VERSION = (() => {
25
25
  }
26
26
  })();
27
27
  export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
28
- + '你有 14 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索);'
28
+ + '你有 16 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索);'
29
+ + '**同步工具** memory_sync / memory_conflicts:跨环境同步;`memory_sync` 的 `status` 只看状态、`pull` 拉取并导入、`push` 镜像并推送,`memory_conflicts` 列出**未被自动裁决**的冲突,同步**不会**自动决定哪一边对——两边都改过同一条时两个版本都留着等人处理,记忆被静默覆盖就没了;'
29
30
  + '**日记工具** diary_read / diary_write:日记是 md 文件,落在 `<DSH_HOME>/diary`,与记忆**分开存、不进检索、不占注入预算**。记的是经历——发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么);不是任务清单(那是 git log),也不是结论(那是 memory_save)。读它不吃预算,需要时整篇读。'
30
31
  + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
31
32
  + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
@@ -44,13 +44,18 @@ export interface SyncExportRun {
44
44
  readonly skippedQuarantined: number;
45
45
  }
46
46
  /**
47
- * 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
48
- * `projectBackendName`:cwd 小写后过 djb2,取 base36)。
47
+ * 项目在同步面上的目录名——**项目目录名,不是路径哈希**。
49
48
  *
50
- * 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
51
- * 看起来像「这个项目的记忆凭空少了一批」。
49
+ * 2026-11-06 的第一版用的是 `cwd` 的 djb2 哈希(与引擎本地文件名同口径)。那个身份
50
+ * 只在一台机器内成立:换台机器换个盘符,同一个项目就成了另一个项目,记忆会静默分裂
51
+ * 成互不相认的两份。2026-11-11 改成项目名,依据是用户的三条约束——「以项目名为准」
52
+ * 「不会出现同名不同项目」「工作机上的目录不一样」。
53
+ *
54
+ * 统一小写:Windows 路径大小写不敏感、WSL/Linux 敏感,同一个项目在两处拼写可能不同;
55
+ * 不规范化就会变成 `records/project/WorkStation` 与 `records/project/workstation`
56
+ * 两个目录,又一次静默分裂。
52
57
  */
53
- export declare function workspaceSlug(cwd: string): string;
58
+ export declare function projectName(cwd: string): string;
54
59
  /**
55
60
  * 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
56
61
  *
@@ -74,19 +74,28 @@ function blocksOf(document) {
74
74
  return out;
75
75
  }
76
76
  /**
77
- * 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
78
- * `projectBackendName`:cwd 小写后过 djb2,取 base36)。
77
+ * 项目在同步面上的目录名——**项目目录名,不是路径哈希**。
79
78
  *
80
- * 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
81
- * 看起来像「这个项目的记忆凭空少了一批」。
79
+ * 2026-11-06 的第一版用的是 `cwd` 的 djb2 哈希(与引擎本地文件名同口径)。那个身份
80
+ * 只在一台机器内成立:换台机器换个盘符,同一个项目就成了另一个项目,记忆会静默分裂
81
+ * 成互不相认的两份。2026-11-11 改成项目名,依据是用户的三条约束——「以项目名为准」
82
+ * 「不会出现同名不同项目」「工作机上的目录不一样」。
83
+ *
84
+ * 统一小写:Windows 路径大小写不敏感、WSL/Linux 敏感,同一个项目在两处拼写可能不同;
85
+ * 不规范化就会变成 `records/project/WorkStation` 与 `records/project/workstation`
86
+ * 两个目录,又一次静默分裂。
82
87
  */
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);
88
+ export function projectName(cwd) {
89
+ // **不能用 `node:path` 的 basename**:它按平台分隔符切分,同一个字符串在 Windows 上
90
+ // 得到 `demo`、在 Linux 上得到整串 `H:\proj\demo`。2026-11-11 的 CI 就是这么红的
91
+ // (本地 Windows 全绿,Linux runner 上 `records/project/h:\proj\demo/`)。
92
+ // 两种分隔符都认,结果才跨平台一致——这也正是这个函数存在的意义。
93
+ const segments = cwd.replace(/\\/gu, '/').split('/').filter(segment => segment !== '');
94
+ const base = segments[segments.length - 1] ?? '';
95
+ // 盘符根(`H:\`)与路径根(`/`)都退到 `root`,不产生空目录名。
96
+ if (base === '' || /^[a-z]:$/iu.test(base))
97
+ return 'root';
98
+ return base.toLowerCase();
90
99
  }
91
100
  /** 只保留白名单字段;值为 `undefined` 的键不落进文件(避免 `"x": null` 噪声)。 */
92
101
  function pickSyncedFields(block) {
@@ -129,7 +138,7 @@ export function exportRecords(options) {
129
138
  const projects = [...(options.projectDocuments ?? [])].sort((left, right) => left.cwd.localeCompare(right.cwd));
130
139
  for (const { cwd, document } of projects) {
131
140
  for (const [id, block] of Object.entries(blocksOf(document))) {
132
- const file = recordFile(id, block, [RECORDS_DIR, 'project', workspaceSlug(cwd)]);
141
+ const file = recordFile(id, block, [RECORDS_DIR, 'project', projectName(cwd)]);
133
142
  if (file === undefined) {
134
143
  skippedQuarantined += 1;
135
144
  continue;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 同步镜像的本地 git 仓库层。
3
+ *
4
+ * **这一步不联网**:只做 `init` / `add` / `commit`。远端 push 是下一步,单独一层——
5
+ * 分层的理由是可验证性:本地仓库能在完全离线的情况下验通「记忆库 → 镜像 → 有历史的
6
+ * 仓库」整条链,网络问题留到那一层单独排查。
7
+ *
8
+ * `isomorphic-git` 走**动态 import**(体积理由见设计 §五):没开同步的人不加载它。
9
+ *
10
+ * **无改动不产生空提交**:定时跑一次同步是常态,每次都留一个空提交会让 `git log`
11
+ * 变成时间戳噪声,真正的变更被淹掉。
12
+ *
13
+ * @module dsh-memory/sync-git
14
+ */
15
+ /** {@link commitMirror} 的入参。 */
16
+ export interface MirrorCommitOptions {
17
+ /** 镜像仓库目录。 */
18
+ readonly dir: string;
19
+ /** 提交信息。 */
20
+ readonly message: string;
21
+ /** 作者;isomorphic-git 没有可读的全局 git 配置,必须显式传。 */
22
+ readonly author: {
23
+ readonly name: string;
24
+ readonly email: string;
25
+ };
26
+ }
27
+ /** 一次提交的结果。 */
28
+ export interface MirrorCommitRun {
29
+ /** 是否真的产生了提交(无改动时为 `false`)。 */
30
+ readonly committed: boolean;
31
+ /** 新提交的 sha;没有提交时缺省。 */
32
+ readonly sha?: string;
33
+ /** 本次涉及的路径数(新增 + 修改 + 删除)。 */
34
+ readonly filesChanged: number;
35
+ }
36
+ /**
37
+ * 把镜像目录的当前状态提交成一个 commit。
38
+ *
39
+ * 三种路径逐个处理:新增/修改走 `add`,**删除走 `remove`**——只 `add` 的话,
40
+ * 镜像里被删掉的记录在 git 看来会一直存在。
41
+ *
42
+ * @param options - 目录、提交信息与作者。
43
+ * @returns 是否提交、sha 与涉及的路径数。
44
+ */
45
+ export declare function commitMirror(options: MirrorCommitOptions): Promise<MirrorCommitRun>;
46
+ /**
47
+ * 读仓库的提交历史(从新到旧)。测试与 `memory_sync status` 共用。
48
+ *
49
+ * @param dir - 镜像仓库目录。
50
+ * @param depth - 最多取多少条;缺省不限。
51
+ * @returns 提交列表(sha / message / 时间)。仓库尚未建立时返回空数组。
52
+ */
53
+ export declare function readHistory(dir: string, depth?: number): Promise<Array<{
54
+ sha: string;
55
+ message: string;
56
+ at: number;
57
+ }>>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 同步镜像的本地 git 仓库层。
3
+ *
4
+ * **这一步不联网**:只做 `init` / `add` / `commit`。远端 push 是下一步,单独一层——
5
+ * 分层的理由是可验证性:本地仓库能在完全离线的情况下验通「记忆库 → 镜像 → 有历史的
6
+ * 仓库」整条链,网络问题留到那一层单独排查。
7
+ *
8
+ * `isomorphic-git` 走**动态 import**(体积理由见设计 §五):没开同步的人不加载它。
9
+ *
10
+ * **无改动不产生空提交**:定时跑一次同步是常态,每次都留一个空提交会让 `git log`
11
+ * 变成时间戳噪声,真正的变更被淹掉。
12
+ *
13
+ * @module dsh-memory/sync-git
14
+ */
15
+ import { existsSync, mkdirSync } from 'node:fs';
16
+ import * as nodeFs from 'node:fs';
17
+ import { join } from 'node:path';
18
+ /** 仓库默认分支——与 GitHub 侧的约定一致。 */
19
+ const DEFAULT_BRANCH = 'main';
20
+ /** 动态载入 isomorphic-git(体积理由见文件头)。 */
21
+ async function loadGit() {
22
+ return await import('isomorphic-git');
23
+ }
24
+ /** 确保目录是一个 git 仓库;已初始化时什么都不做。 */
25
+ async function ensureRepo(dir) {
26
+ mkdirSync(dir, { recursive: true });
27
+ if (existsSync(join(dir, '.git')))
28
+ return;
29
+ const git = await loadGit();
30
+ await git.init({ fs: nodeFs, dir, defaultBranch: DEFAULT_BRANCH });
31
+ }
32
+ /**
33
+ * 把镜像目录的当前状态提交成一个 commit。
34
+ *
35
+ * 三种路径逐个处理:新增/修改走 `add`,**删除走 `remove`**——只 `add` 的话,
36
+ * 镜像里被删掉的记录在 git 看来会一直存在。
37
+ *
38
+ * @param options - 目录、提交信息与作者。
39
+ * @returns 是否提交、sha 与涉及的路径数。
40
+ */
41
+ export async function commitMirror(options) {
42
+ await ensureRepo(options.dir);
43
+ const git = await loadGit();
44
+ // statusMatrix 每行是 [filepath, head, workdir, stage]:
45
+ // head=1 表示上一个 commit 里有;workdir=0 表示工作区里已经没有。
46
+ const matrix = await git.statusMatrix({ fs: nodeFs, dir: options.dir });
47
+ const changed = matrix.filter(([, head, workdir, stage]) => head !== workdir || workdir !== stage);
48
+ if (changed.length === 0) {
49
+ return { committed: false, filesChanged: 0 };
50
+ }
51
+ for (const [filepath, , workdir] of changed) {
52
+ if (workdir === 0)
53
+ await git.remove({ fs: nodeFs, dir: options.dir, filepath });
54
+ else
55
+ await git.add({ fs: nodeFs, dir: options.dir, filepath });
56
+ }
57
+ const sha = await git.commit({
58
+ fs: nodeFs,
59
+ dir: options.dir,
60
+ message: options.message,
61
+ author: options.author,
62
+ });
63
+ return { committed: true, sha, filesChanged: changed.length };
64
+ }
65
+ /**
66
+ * 读仓库的提交历史(从新到旧)。测试与 `memory_sync status` 共用。
67
+ *
68
+ * @param dir - 镜像仓库目录。
69
+ * @param depth - 最多取多少条;缺省不限。
70
+ * @returns 提交列表(sha / message / 时间)。仓库尚未建立时返回空数组。
71
+ */
72
+ export async function readHistory(dir, depth) {
73
+ if (!existsSync(join(dir, '.git')))
74
+ return [];
75
+ const git = await loadGit();
76
+ try {
77
+ const commits = await git.log({ fs: nodeFs, dir, ...depth === undefined ? {} : { depth } });
78
+ return commits.map(entry => ({
79
+ sha: entry.oid,
80
+ message: entry.commit.message.trim(),
81
+ at: entry.commit.author.timestamp * 1000,
82
+ }));
83
+ }
84
+ catch {
85
+ // 空仓库(init 之后一个 commit 都没有)会在这里抛;那是正常状态。
86
+ return [];
87
+ }
88
+ }