@max-null/dsh-memory 0.21.0 → 0.22.1

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,54 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
142
144
  - 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
143
145
  - 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
144
146
 
147
+ ## 云同步(2026-11-11)· **默认关闭**
148
+
149
+ 把本机记忆镜像到一个 git 仓库,多个环境之间共享同一份记忆。
150
+
151
+ > **默认不启用。** 不配 `memory.sync.remote` 时,`memory_sync` 与 `memory_conflicts`
152
+ > **根本不注册**(模型看不到,也就不会去调),自述里的工具数也停在 14。只配 `mirrorDir`
153
+ > 不配 `remote` 同样不启用——本地镜像不解决任何问题。这是实验性功能,不该影响别人的安装。
154
+
155
+ **为什么是「一条记忆一个文件」。** 整份 `memory.json` 放进 git 会撞:2026-11-06 实测,两端各新增一条记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」解决会**静默丢掉另一端那条**,而 git 仍报「合并成功」。改成一条一个文件后同一场景是 `Merge made by the 'ort' strategy`,0 冲突。
156
+
157
+ **项目身份用项目名,不用路径。** 本机 `H:\MaxNull\WorkStation` 与另一台机器 `D:\WorkStation` 是同一个项目——目录名(统一小写)决定它在 `records/project/` 下落到哪。用绝对路径哈希的话,换个盘符就成了另一个项目,记忆会静默分裂成两份互不相认的。
158
+
159
+ ```yaml
160
+ memory:
161
+ sync:
162
+ mirrorDir: '' # 缺省 <DSH_HOME>/memory-sync
163
+ remote: '' # https 远端;缺省只做本地提交
164
+ branch: main
165
+ envName: '' # 本环境短名,冲突留证时用作后缀
166
+ ```
167
+
168
+ token **不写进配置**:从环境变量 `DSH_MEMORY_SYNC_TOKEN` 或既有凭据机制取。
169
+
170
+ | 工具 | 作用 |
171
+ |---|---|
172
+ | `memory_sync` | `status` 只看状态 / `pull` 拉取并导入 / `push` 镜像并推送 |
173
+ | `memory_conflicts` | 列出**未被自动裁决**的冲突 |
174
+
175
+ **仓库形态**:
176
+
177
+ ```
178
+ <mirrorDir>/
179
+ records/global/<uuid>.json 一条记忆一个文件
180
+ records/project/<项目名>/<uuid>.json
181
+ tombstones/<uuid>.json forget 留下的痕迹(不删记录文件)
182
+ conflicts/<uuid>.<env>.json 两边都改过时,两个版本都留着
183
+ ```
184
+
185
+ **边界**:
186
+
187
+ - **冲突不自动裁决。** 同一条被两个环境同时修改时,两个版本都写进 `conflicts/`,本地版本保留并标记待处理。记忆是长期资产,被静默覆盖的那份就没了。代价是冲突会堆到有人处理为止。
188
+ - **删除留墓碑,不删文件。** `memory_forget` 是**真删**(`table.delete`),而另一端的「刚加、我还没拉到」在本地看起来完全一样。靠「上次同步的 id 快照」(`.sync-state.json`)区分:曾同步过、现在没了才是删除。**首次同步永远不产生墓碑**——那时没有「曾经有过」这个信息。
189
+ - **本地瞬态字段不进镜像**:`hitCount` / `lastUsedAt` 每次检索命中都会改,带上它们会让仓库在内容毫无变化时持续变脏。隔离记录(`quarantined`)**整条不导出**——隔离区的存在理由就是它可能含凭据类内容。
190
+ - **首次同步必须 clone**。本地 `git init` 出来的仓库与远端没有共同祖先,push 会被拒(`not a simple fast-forward`)。这条是 2026-11-11 第一次连真仓库时撞出来的。
191
+ - 传输层用 `isomorphic-git`(纯 JS,走 HTTPS + PAT),**不依赖系统 git**——移动端装的东西会被运行时更新清掉,不该把同步链的地基押在上面。
192
+
193
+ **没有调度器。** 目前靠模型显式调用 `memory_sync`;`autoPull` / `autoPush` 尚未实现。
194
+
145
195
  ## 使用流程(写入即生效,人为例外干预)
146
196
 
147
197
  提示词模板库(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,14 +5,15 @@
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";
14
15
  import { mountMemoryApi } from "./routes.js";
15
- import { SELF_DESCRIPTION } from "./self.js";
16
+ import { buildSelfDescription } from "./self.js";
16
17
  import { appendEntry, diaryDir, lastWriteAt, pointerLine, readDay, recentDates } from "./diary.js";
17
18
  import z from '@deepseek-ai/schemastery';
18
19
  import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } from "./injection.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)
@@ -486,12 +508,18 @@ export async function apply(ctx, config) {
486
508
  order: 115,
487
509
  text: GUIDANCE,
488
510
  });
511
+ // 云同步是**默认关闭**的(2026-11-11):实验性功能不该影响别人的安装。判据是
512
+ // 「配了 remote」——只配 mirrorDir 没有意义(本地镜像不解决任何问题)。
513
+ // 关闭时那两个工具**根本不注册**,而不是注册了再报错:模型看不到就不会去调。
514
+ const syncRaw = (config?.sync ?? {});
515
+ const syncEnabled = typeof syncRaw.remote === 'string' && syncRaw.remote !== '';
489
516
  // 0.3.2:记忆机制自述——常驻注入(不落用户存储),随发版更新。
490
517
  // order 49 < recall(50):机制说明在具体记忆内容之前。
491
518
  ctx.systemPrompt.context({
492
519
  name: 'memory:self',
493
520
  order: 49,
494
- text: SELF_DESCRIPTION,
521
+ // 自述里的工具清单必须与实际注册的一致:漏一个,模型就按一份不存在的工具面行事。
522
+ text: buildSelfDescription(syncEnabled),
495
523
  });
496
524
  const injectionBudget = config?.injectionBudget ?? DEFAULT_INJECTION_BUDGET;
497
525
  const summaryChars = config?.summaryChars ?? DEFAULT_SUMMARY_CHARS;
@@ -990,11 +1018,83 @@ export async function apply(ctx, config) {
990
1018
  },
991
1019
  presentCall: args => present('Read diary', 'read', args.date),
992
1020
  }));
1021
+ // ── 云同步(2026-11-11)──────────────────────────────────────────────
1022
+ //
1023
+ // **不向模型暴露 git**:模型只说「同步一下 / 看看状态」,具体 add/commit/push 由插件
1024
+ // 决定。把 `git pull --rebase && git add && git commit && git push` 那一串交给模型,
1025
+ // 出错难排查,也容易越权。
1026
+ //
1027
+ // 缺省不启用:没配 `memory.sync` 时这两个工具直接报「未配置」,而不是默默什么都不做——
1028
+ // 静默不动作比报错更坏。
1029
+ const syncConfig = () => {
1030
+ const raw = (config?.sync ?? {});
1031
+ const mirrorDir = typeof raw.mirrorDir === 'string' && raw.mirrorDir !== ''
1032
+ ? raw.mirrorDir
1033
+ : join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'memory-sync');
1034
+ return {
1035
+ mirrorDir,
1036
+ ...typeof raw.remote === 'string' && raw.remote !== '' ? { remote: raw.remote } : {},
1037
+ ...typeof raw.branch === 'string' && raw.branch !== '' ? { branch: raw.branch } : {},
1038
+ ...typeof raw.envName === 'string' && raw.envName !== '' ? { envName: raw.envName } : {},
1039
+ };
1040
+ };
1041
+ if (syncEnabled) {
1042
+ ctx.tools.register(defineTool({
1043
+ name: registered('memory_sync'),
1044
+ 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/ 里等人处理。',
1045
+ parameters: {
1046
+ action: {
1047
+ type: 'string',
1048
+ required: true,
1049
+ enum: ['status', 'pull', 'push'],
1050
+ description: 'status = 只看状态(不读不写);pull = 拉取并导入;push = 镜像、提交并推送。',
1051
+ },
1052
+ },
1053
+ output: {
1054
+ schema: { type: 'object', additionalProperties: true, properties: {} },
1055
+ render: (_args, value) => renderJson(value),
1056
+ },
1057
+ async execute(args) {
1058
+ const cfg = syncConfig();
1059
+ const run = args.action === 'push'
1060
+ ? await syncPush(memory, cfg)
1061
+ : args.action === 'pull'
1062
+ ? await syncPull(memory, cfg)
1063
+ : await syncStatus(cfg);
1064
+ // 工具返回值要求 `Record<string, JsonValue>`;这几个结果都是普通可序列化对象,
1065
+ // 结构化类型对不上而已。走一遍 JSON 往返顺便保证它确实可序列化。
1066
+ return JSON.parse(JSON.stringify(run));
1067
+ },
1068
+ presentCall: args => present('Sync memory', 'other'),
1069
+ }));
1070
+ ctx.tools.register(defineTool({
1071
+ name: registered('memory_conflicts'),
1072
+ description: 'List memory conflicts left by sync. Sync never auto-resolves them: if two machines changed the same memory, both versions were kept. 列出同步留下的记忆冲突。同步不会自动裁决——两端改过同一条时,两个版本都留着。',
1073
+ parameters: {
1074
+ action: {
1075
+ type: 'string',
1076
+ required: true,
1077
+ enum: ['list'],
1078
+ description: 'list = 列出 conflicts/ 下的冲突文件。',
1079
+ },
1080
+ },
1081
+ output: {
1082
+ schema: { type: 'object', additionalProperties: true, properties: {} },
1083
+ render: (_args, value) => renderJson(value),
1084
+ },
1085
+ async execute() {
1086
+ const cfg = syncConfig();
1087
+ const files = conflictFiles(cfg);
1088
+ return { count: files.length, files };
1089
+ },
1090
+ presentCall: () => present('List memory conflicts', 'other'),
1091
+ }));
1092
+ }
993
1093
  // 定时备份(2026-10-18 事故后新增):只读复制,绝不走引擎的写入路径。
994
1094
  // 首次延迟 1 分钟避开启动高峰;此后按 `backup.intervalMinutes` 重复;`unref` 不挡进程退出。
995
1095
  const backup = config?.backup;
996
1096
  if (backup?.enabled !== false) {
997
- const targetRoot = backup?.targetRoot ?? join(homedir(), '.dsh-backups', 'memory');
1097
+ const targetRoot = backup?.targetRoot ?? defaultBackupRoot();
998
1098
  const intervalMs = Math.max(1, backup?.intervalMinutes ?? 60) * 60_000;
999
1099
  const keep = Math.max(1, backup?.keep ?? 14);
1000
1100
  const globalRoot = config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages');
package/dist/self.d.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export declare const SELF_VERSION: string;
2
+ /** 能力全貌(面板 / 远端视图用;同步未启用时也展示它的存在)。 */
2
3
  export declare const SELF_DESCRIPTION: string;
4
+ /** 按当前是否启用同步生成自述——系统提示那一路用这个。 */
5
+ export declare function buildSelfDescription(syncEnabled: boolean): string;
package/dist/self.js CHANGED
@@ -24,22 +24,33 @@ export const SELF_VERSION = (() => {
24
24
  return 'unknown';
25
25
  }
26
26
  })();
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 文件、**永不注入**,走这条通道,不会混进记忆检索);'
29
- + '**日记工具** diary_read / diary_write:日记是 md 文件,落在 `<DSH_HOME>/diary`,与记忆**分开存、不进检索、不占注入预算**。记的是经历——发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么);不是任务清单(那是 git log),也不是结论(那是 memory_save)。读它不吃预算,需要时整篇读。'
30
- + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
31
- + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
32
- + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
33
- + '② 用户纠正了你的做法或理解——把正确结论记下来;'
34
- + '③ 得出跨会话仍然有用的结论、路径或坑,而非本次任务的临时细节。'
35
- + '**不要记**:临时状态、未经验证的推测、能从代码或文档直接读到的事实(那类记「去哪查」即可)。'
36
- + '**怎么写**:一条一件事,并给多角度关键词(同义词、缩写、中英),否则将来检索不到它。'
37
- + '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确;绑错了用 memory_update 改(当场重新判定,成立即复活),不要了就传 `anchor: null` 清除。当前锚点随记录一起返回。'
38
- + '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
39
- + '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
40
- + '常驻开关由人(或模型显式传 `injected`)决定:**自动改变注入状态的规则已全部停用**——命中次数不再自动升常驻(它只喂体检报告的候选提示),30 天未命中的自动撤下也已改为**只计量不生效**(撤下来就基本不会自己回去,那种动作不该由一个没有分布依据的阈值决定)。**给 memory_save / memory_update 传 `injected` 即显式接管**。'
41
- + '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
42
- + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
43
- + '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
44
- + '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
45
- + '(English: dsh-memory provides cross-session memory. Writes take effect immediately; save durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). Always add multi-angle keywords. Pass `injected: true` to keep one resident every turn — reserve that for rules and standing agreements; facts stay search-only. Resident injection runs on a budget, so a pinned memory is only queued, never guaranteed to be in context.)';
27
+ function buildDescription(syncEnabled) {
28
+ return (`[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
29
+ + '你有 ' + (syncEnabled ? 16 : 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 文件、**永不注入**,走这条通道,不会混进记忆检索);'
30
+ + (syncEnabled
31
+ ? '**同步工具** memory_sync / memory_conflicts:跨环境同步;`memory_sync` 的 `status` 只看状态、`pull` 拉取并导入、`push` 镜像并推送,`memory_conflicts` 列出**未被自动裁决**的冲突,同步**不会**自动决定哪一边对——两边都改过同一条时两个版本都留着等人处理,记忆被静默覆盖就没了;'
32
+ : '')
33
+ + '**日记工具** diary_read / diary_write:日记是 md 文件,落在 `<DSH_HOME>/diary`,与记忆**分开存、不进检索、不占注入预算**。记的是经历——发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么);不是任务清单(那是 git log),也不是结论(那是 memory_save)。读它不吃预算,需要时整篇读。'
34
+ + '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
35
+ + '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
36
+ + '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
37
+ + '② 用户纠正了你的做法或理解——把正确结论记下来;'
38
+ + '③ 得出跨会话仍然有用的结论、路径或坑,而非本次任务的临时细节。'
39
+ + '**不要记**:临时状态、未经验证的推测、能从代码或文档直接读到的事实(那类记「去哪查」即可)。'
40
+ + '**怎么写**:一条一件事,并给多角度关键词(同义词、缩写、中英),否则将来检索不到它。'
41
+ + '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确;绑错了用 memory_update 改(当场重新判定,成立即复活),不要了就传 `anchor: null` 清除。当前锚点随记录一起返回。'
42
+ + '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
43
+ + '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
44
+ + '常驻开关由人(或模型显式传 `injected`)决定:**自动改变注入状态的规则已全部停用**——命中次数不再自动升常驻(它只喂体检报告的候选提示),30 天未命中的自动撤下也已改为**只计量不生效**(撤下来就基本不会自己回去,那种动作不该由一个没有分布依据的阈值决定)。**给 memory_save / memory_update 传 `injected` 即显式接管**。'
45
+ + '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
46
+ + '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
47
+ + '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
48
+ + '发现某条记忆与实际不符时用 memory_update 修正(保持生效);确认没用的用 memory_forget。'
49
+ + '(English: dsh-memory provides cross-session memory. Writes take effect immediately; save durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). Always add multi-angle keywords. Pass `injected: true` to keep one resident every turn — reserve that for rules and standing agreements; facts stay search-only. Resident injection runs on a budget, so a pinned memory is only queued, never guaranteed to be in context.)');
50
+ }
51
+ /** 能力全貌(面板 / 远端视图用;同步未启用时也展示它的存在)。 */
52
+ export const SELF_DESCRIPTION = buildDescription(true);
53
+ /** 按当前是否启用同步生成自述——系统提示那一路用这个。 */
54
+ export function buildSelfDescription(syncEnabled) {
55
+ return buildDescription(syncEnabled);
56
+ }
@@ -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
+ }>>;