dsh-memory-sync 0.0.0-stage → 0.1.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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +124 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +9 -0
- package/index.js +93 -0
- package/lib/config.mjs +149 -0
- package/lib/git.mjs +286 -0
- package/lib/lock.mjs +43 -0
- package/lib/notify.mjs +53 -0
- package/lib/state.mjs +59 -0
- package/lib/status.mjs +70 -0
- package/lib/sync.mjs +193 -0
- package/lib/tool.mjs +40 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.1] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Repository renamed from `QIC-9821/dsh` to `QIC-9821/dsh-memory-sync`, so the
|
|
12
|
+
GitHub repo matches the npm package name. All metadata, badges and install
|
|
13
|
+
links point at the new URL (the old URL redirects).
|
|
14
|
+
- `package.json` description is now a single English sentence: it is what npm
|
|
15
|
+
and plugin directories use as the English description, and the Chinese copy
|
|
16
|
+
lives in `locale/zh.json` and the README.
|
|
17
|
+
|
|
18
|
+
## [0.1.0] - 2026-11-05
|
|
19
|
+
|
|
20
|
+
First public release.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Three-phase startup sync: local pull/commit/push → peer commit over ssh → local
|
|
25
|
+
pull again, so both machines end on the same commit.
|
|
26
|
+
- `peer` and `local` modes; `repoPath`, `remote`, `branch`, `peerHost`,
|
|
27
|
+
`peerUser`, `peerKeyPath`, `peerCommand` and timeouts are configurable.
|
|
28
|
+
- Startup grace window (`startupGraceSec`, default 300 s) so hot reloads do not
|
|
29
|
+
trigger a sync.
|
|
30
|
+
- Retry once after `retryDelaySec` (60 s), then silent self-heal every
|
|
31
|
+
`healIntervalSec` (600 s) until the sync succeeds.
|
|
32
|
+
- Optional OS dialog on final failure (`notifyOnFailure`).
|
|
33
|
+
- Optional machine snapshot written before each sync and committed with it
|
|
34
|
+
(`statusFile`).
|
|
35
|
+
- `memory_sync` tool for agents: `{"action":"run"}` and `{"action":"status"}`.
|
|
36
|
+
- Lock file and `last-run.json` / `sync.log` under `$DSH_HOME/memory-sync/`.
|
|
37
|
+
- Tests covering dirty-tree commits, rebase over a peer commit, non-repo errors,
|
|
38
|
+
lock exclusivity and staleness, and config normalization.
|
|
39
|
+
|
|
40
|
+
[0.1.0]: https://github.com/QIC-9821/dsh-memory-sync/releases/tag/v0.1.0
|
|
41
|
+
|
|
42
|
+
[0.1.1]: https://github.com/QIC-9821/dsh-memory-sync/releases/tag/v0.1.1
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QIC-9821
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# dsh-memory-sync
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-memory-sync)
|
|
4
|
+
[](https://github.com/QIC-9821/dsh-memory-sync/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
8
|
+
> Sync your git-backed memory folder with a peer machine every time DeepSeek Harness starts — latest memories at boot, no daemon, no cron, no manual pull.
|
|
9
|
+
|
|
10
|
+
中文: [README.md](README.md)
|
|
11
|
+
|
|
12
|
+
Typical setup: the memory folder (`~/.dsh/memories`) lives on an always-on machine; a laptop syncs its side at startup, lets the server commit and push back, then pulls the result. Nothing runs between startups.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
dsh plugin add dsh-memory-sync
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Pin a tag, or install from a checkout:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
dsh plugin add github:QIC-9821/dsh-memory-sync#v0.1.1
|
|
24
|
+
dsh plugin add /absolute/path/to/dsh-memory-sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Node.js ≥ 22 is required (the runtime bundled with Harness works too).
|
|
28
|
+
|
|
29
|
+
Then configure it in Harness settings ("Memory sync on startup"), or directly in the profile patch:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
- id: dsh-memory-sync
|
|
33
|
+
name: 'dsh-memory-sync'
|
|
34
|
+
config:
|
|
35
|
+
repoPath: '' # empty = $DSH_HOME/memories
|
|
36
|
+
remote: cloud
|
|
37
|
+
branch: main
|
|
38
|
+
mode: peer
|
|
39
|
+
peerHost: 203.0.113.10
|
|
40
|
+
peerUser: dsh
|
|
41
|
+
peerKeyPath: C:/Users/me/.ssh/id_ed25519
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
| Key | Default | Meaning |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `repoPath` | `$DSH_HOME/memories` | git working tree to sync |
|
|
49
|
+
| `remote` | `cloud` | git remote; falls back to `origin` / `cloud` |
|
|
50
|
+
| `branch` | `main` | branch name |
|
|
51
|
+
| `enabled` | `true` | master switch |
|
|
52
|
+
| `runOnStartup` | `true` | run on mount |
|
|
53
|
+
| `startupGraceSec` | `300` | mount counts as a boot only within this many seconds |
|
|
54
|
+
| `mode` | `peer` | `peer` = three phases; `local` = local pull/commit/push only |
|
|
55
|
+
| `peerHost` / `peerUser` | empty | ssh target |
|
|
56
|
+
| `peerKeyPath` | empty | private key; empty = ssh default |
|
|
57
|
+
| `peerCommand` | `/usr/local/bin/dsh-mem-sync` | command run on the peer |
|
|
58
|
+
| `peerEnvName` | `cloud` | `DSH_SYNC_NAME` for that command |
|
|
59
|
+
| `retryDelaySec` | `60` | first retry delay; 0 disables |
|
|
60
|
+
| `healIntervalSec` | `600` | silent self-heal interval; 0 disables |
|
|
61
|
+
| `notifyOnFailure` | `true` | show an OS dialog on final failure |
|
|
62
|
+
| `commitPrefix` | `sync` | auto-commit prefix |
|
|
63
|
+
| `gitPath` | `git` | git executable |
|
|
64
|
+
| `localTimeoutMs` / `peerTimeoutMs` | `120000` | per-phase timeout |
|
|
65
|
+
| `statusFile` | empty | write a machine snapshot there before each sync and commit it (e.g. `~/.dsh/memories/pc-status.md`); empty = off |
|
|
66
|
+
|
|
67
|
+
State lives in `$DSH_HOME/memory-sync/`: `last-run.json`, `sync.log`, `lock/sync.lock`.
|
|
68
|
+
|
|
69
|
+
## How it runs
|
|
70
|
+
|
|
71
|
+
On mount (which is one Harness start) the plugin performs three phases:
|
|
72
|
+
|
|
73
|
+
1. **Local push** — `git pull --rebase --autostash`, `git add -A`, commit if dirty, `git push`. A rejected push is retried once after `fetch` + `rebase`.
|
|
74
|
+
2. **Peer commit** — over ssh, run a command on the peer (default `DSH_SYNC_NAME=cloud /usr/local/bin/dsh-mem-sync`), so the peer commits and pushes its own side into the same bare repo.
|
|
75
|
+
3. **Local pull** — run the local sync again to fetch the peer's fresh commit.
|
|
76
|
+
|
|
77
|
+
All three must succeed. On failure the plugin retries once after 60 s; if it still fails it writes `last-run.json`, raises an OS dialog (Windows / macOS / Linux), and then retries silently every 10 minutes until it succeeds.
|
|
78
|
+
|
|
79
|
+
The startup run only happens when the plugin mounts within 300 s of process start; a hot reload does not trigger a sync.
|
|
80
|
+
|
|
81
|
+
## Peer setup
|
|
82
|
+
|
|
83
|
+
The peer only needs a bare repo plus a command that commits and pushes:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
git init --bare -b main /srv/dsh-memory.git
|
|
87
|
+
chown -R dsh:dsh /srv/dsh-memory.git
|
|
88
|
+
git --git-dir=/srv/dsh-memory.git symbolic-ref HEAD refs/heads/main
|
|
89
|
+
git -C /home/dsh/.dsh/memories remote add origin /srv/dsh-memory.git
|
|
90
|
+
chmod 755 /usr/local/bin/dsh-mem-sync
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Add this machine's public key to the peer user's `~/.ssh/authorized_keys` and point the local remote at `ssh://dsh@host/srv/dsh-memory.git`. The plugin only runs the phases in order; it does not care how the repo is laid out.
|
|
94
|
+
|
|
95
|
+
## Tool
|
|
96
|
+
|
|
97
|
+
`memory_sync`:
|
|
98
|
+
|
|
99
|
+
- `{"action":"run"}` — sync now; returns per-phase exit/duration/head.
|
|
100
|
+
- `{"action":"status"}` — last run, HEAD, dirty, ahead/behind, peer configured.
|
|
101
|
+
|
|
102
|
+
## Security and privacy
|
|
103
|
+
|
|
104
|
+
- The plugin only runs local `git` and `ssh`; it opens no other network connections and sends no telemetry.
|
|
105
|
+
- It commits and pushes whatever is in `repoPath`; the `statusFile` snapshot is committed too. Check the content before syncing to a public remote.
|
|
106
|
+
- The failure dialog contains the failing phase and git/ssh errors, not repository contents; the log is at `$DSH_HOME/memory-sync/sync.log`.
|
|
107
|
+
- Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md); please do not open a public issue.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm test
|
|
113
|
+
node --check index.js
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Tests use a temporary bare repo and two clones: dirty-file push, rebase over a peer commit, non-repo error, lock exclusivity/staleness, config normalization.
|
|
117
|
+
|
|
118
|
+
## License and contributing
|
|
119
|
+
|
|
120
|
+
MIT. Issues and PRs are welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) first; changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
package/README.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-memory-sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-memory-sync)
|
|
4
|
+
[](https://github.com/QIC-9821/dsh-memory-sync/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
8
|
+
> 每次打开 DeepSeek Harness,自动把本机的 git 记忆目录和另一台机器双向同步一次——开机就有最新记忆,不用常驻后台。
|
|
9
|
+
|
|
10
|
+
English: [README.en.md](README.en.md)
|
|
11
|
+
|
|
12
|
+
适合把 `~/.dsh/memories`(或任意 git 目录)放在一台常开的机器上,另一台电脑每次打开 Harness 时自动对齐;平时不做周期同步,也不需要常驻进程。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
dsh plugin add dsh-memory-sync
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
固定版本或从源码安装:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
dsh plugin add github:QIC-9821/dsh-memory-sync#v0.1.1
|
|
24
|
+
dsh plugin add /absolute/path/to/dsh-memory-sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
要求 Node.js ≥ 22(用 Harness 自带运行时也可以)。
|
|
28
|
+
|
|
29
|
+
安装后在 Harness 设置里找到「启动时同步记忆」并填写配置。配置也可以直接写在 profile 的插槽里:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
- id: dsh-memory-sync
|
|
33
|
+
name: 'dsh-memory-sync'
|
|
34
|
+
config:
|
|
35
|
+
repoPath: '' # 留空 = $DSH_HOME/memories
|
|
36
|
+
remote: cloud
|
|
37
|
+
branch: main
|
|
38
|
+
mode: peer
|
|
39
|
+
peerHost: 203.0.113.10
|
|
40
|
+
peerUser: dsh
|
|
41
|
+
peerKeyPath: C:/Users/me/.ssh/id_ed25519
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 配置
|
|
45
|
+
|
|
46
|
+
| 键 | 默认 | 说明 |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `repoPath` | `$DSH_HOME/memories` | 要同步的 git 工作目录 |
|
|
49
|
+
| `remote` | `cloud` | git remote 名;不存在时依次尝试 `origin`、`cloud` |
|
|
50
|
+
| `branch` | `main` | 分支名 |
|
|
51
|
+
| `enabled` | `true` | 总开关 |
|
|
52
|
+
| `runOnStartup` | `true` | 启动时自动跑 |
|
|
53
|
+
| `startupGraceSec` | `300` | 启动后多少秒内挂载才算「启动」;超过视为热重载 |
|
|
54
|
+
| `mode` | `peer` | `peer` = 三段式;`local` = 只在本机 pull/commit/push |
|
|
55
|
+
| `peerHost` / `peerUser` | 空 | ssh 目标 |
|
|
56
|
+
| `peerKeyPath` | 空 | 私钥路径;空 = 用 ssh 默认密钥 |
|
|
57
|
+
| `peerCommand` | `/usr/local/bin/dsh-mem-sync` | 对端执行的同步脚本 |
|
|
58
|
+
| `peerEnvName` | `cloud` | 执行时设置的 `DSH_SYNC_NAME` |
|
|
59
|
+
| `retryDelaySec` | `60` | 启动失败后第一次重试间隔;0 关闭 |
|
|
60
|
+
| `healIntervalSec` | `600` | 失败后静默自愈间隔;0 关闭 |
|
|
61
|
+
| `notifyOnFailure` | `true` | 失败弹系统窗口 |
|
|
62
|
+
| `commitPrefix` | `sync` | 自动提交信息前缀 |
|
|
63
|
+
| `gitPath` | `git` | git 可执行文件 |
|
|
64
|
+
| `localTimeoutMs` / `peerTimeoutMs` | `120000` | 单段超时 |
|
|
65
|
+
| `statusFile` | 空 | 每次同步前写一份机器快照到该文件(如 `~/.dsh/memories/pc-status.md`),随同步一起提交;空 = 不写 |
|
|
66
|
+
|
|
67
|
+
状态、日志、锁都在 `$DSH_HOME/memory-sync/`:`last-run.json`、`sync.log`、`lock/sync.lock`。
|
|
68
|
+
|
|
69
|
+
## 工作方式
|
|
70
|
+
|
|
71
|
+
插件在 Harness 挂载时(即一次启动)依次执行三段:
|
|
72
|
+
|
|
73
|
+
1. **本机推送**:`git pull --rebase --autostash` → `git add -A` → 有改动就提交 → `git push`(push 被拒时 fetch + rebase 再试一次)。
|
|
74
|
+
2. **对端提交**:通过 ssh 在远端执行一条命令(默认 `DSH_SYNC_NAME=cloud /usr/local/bin/dsh-mem-sync`),让对端把它的改动提交并推回同一个裸仓。
|
|
75
|
+
3. **本机拉回**:再本地执行一次同样的 pull/commit/push,拿到对端刚推上来的提交。
|
|
76
|
+
|
|
77
|
+
三段都成功才算成功。失败会 60 秒后重试一次;仍失败则写 `last-run.json`,弹系统窗口提醒(Windows/macOS/Linux 各自实现),之后每 10 分钟静默重试,成功后停止。
|
|
78
|
+
|
|
79
|
+
> 插件只在进程启动后 300 秒内挂载时执行启动同步;热重载/开发时重新加载插件不会触发。
|
|
80
|
+
|
|
81
|
+
## 对端准备
|
|
82
|
+
|
|
83
|
+
对端只需要一个**裸仓**和一条能提交的同步命令,例如:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# 裸仓
|
|
87
|
+
git init --bare -b main /srv/dsh-memory.git
|
|
88
|
+
chown -R dsh:dsh /srv/dsh-memory.git
|
|
89
|
+
git --git-dir=/srv/dsh-memory.git symbolic-ref HEAD refs/heads/main
|
|
90
|
+
|
|
91
|
+
# 对端工作副本(/home/dsh/.dsh/memories)的 remote 指向裸仓
|
|
92
|
+
git -C /home/dsh/.dsh/memories remote add origin /srv/dsh-memory.git
|
|
93
|
+
|
|
94
|
+
# /usr/local/bin/dsh-mem-sync:pull --rebase --autostash → add/commit → push
|
|
95
|
+
chmod 755 /usr/local/bin/dsh-mem-sync
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
把本机的 ssh 公钥加到对端用户的 `~/.ssh/authorized_keys`,本机 remote 用 `ssh://dsh@host/srv/dsh-memory.git`。插件只负责「启动时按顺序跑」,不理解仓库布局。
|
|
99
|
+
|
|
100
|
+
## 工具
|
|
101
|
+
|
|
102
|
+
Agent 可调用 `memory_sync`:
|
|
103
|
+
|
|
104
|
+
- `{"action":"run"}`:立刻同步一次,返回三段的 exit/耗时/head。
|
|
105
|
+
- `{"action":"status"}`:上次结果、HEAD、dirty、ahead/behind、peer 是否配置。
|
|
106
|
+
|
|
107
|
+
## 安全与隐私
|
|
108
|
+
|
|
109
|
+
- 插件只运行本机 `git` 和 `ssh`,不发起其它网络请求、不上传遥测。
|
|
110
|
+
- 它会把 `repoPath` 里的内容提交并推送;`statusFile` 生成的机器快照也会一起进入仓库。同步到公开仓库前请自行确认内容。
|
|
111
|
+
- 失败弹窗包含出错的阶段和 git/ssh 报错,不含仓库文件内容;日志在 `$DSH_HOME/memory-sync/sync.log`。
|
|
112
|
+
- 安全漏洞请走 [SECURITY.md](SECURITY.md) 里的私密渠道,不要开公开 issue。
|
|
113
|
+
|
|
114
|
+
## 开发
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm test # node --test test/*.test.mjs
|
|
118
|
+
node --check index.js
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
测试用临时裸仓 + 两次 clone 覆盖:脏文件提交推送、对端先提交的 rebase、非 git 目录报错、锁互斥与过期回收、配置归一化。
|
|
122
|
+
|
|
123
|
+
## 许可与贡献
|
|
124
|
+
|
|
125
|
+
MIT。欢迎提 issue / PR,先看 [CONTRIBUTING.md](CONTRIBUTING.md);变更记录在 [CHANGELOG.md](CHANGELOG.md)。
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#1f2a44"/>
|
|
3
|
+
<circle cx="20" cy="20" r="6" fill="#7ee0c5"/>
|
|
4
|
+
<circle cx="44" cy="44" r="6" fill="#7ee0c5"/>
|
|
5
|
+
<path d="M20 26v6a6 6 0 0 0 6 6h12" fill="none" stroke="#7ee0c5" stroke-width="4" stroke-linecap="round"/>
|
|
6
|
+
<path d="M44 38v-6a6 6 0 0 0-6-6H26" fill="none" stroke="#4f7cff" stroke-width="4" stroke-linecap="round" stroke-dasharray="2 7"/>
|
|
7
|
+
<path d="M36 14l6 4-6 4" fill="none" stroke="#4f7cff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
8
|
+
<path d="M28 50l-6-4 6-4" fill="none" stroke="#7ee0c5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { normalizeConfig } from './lib/config.mjs'
|
|
4
|
+
import { ensureDir, createLogger, rotateLog } from './lib/state.mjs'
|
|
5
|
+
import { createSyncRuntime } from './lib/sync.mjs'
|
|
6
|
+
import { memorySyncToolSpec } from './lib/tool.mjs'
|
|
7
|
+
|
|
8
|
+
export const name = 'dsh-memory-sync'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Config is optional: hosts without @deepseek-ai/schemastery must still load
|
|
12
|
+
* the plugin (they just lose the generated settings form).
|
|
13
|
+
*/
|
|
14
|
+
let z = null
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import('@deepseek-ai/schemastery')
|
|
17
|
+
z = mod?.default ?? mod ?? null
|
|
18
|
+
} catch {
|
|
19
|
+
z = null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const field = (schema) => (typeof schema?.extra === 'function' ? schema.extra('volatile', true) : schema)
|
|
23
|
+
|
|
24
|
+
export const Config = z
|
|
25
|
+
? z.object({
|
|
26
|
+
repoPath: field(z.string().default('')),
|
|
27
|
+
remote: field(z.string().default('cloud')),
|
|
28
|
+
branch: field(z.string().default('main')),
|
|
29
|
+
enabled: field(z.boolean().default(true)),
|
|
30
|
+
runOnStartup: field(z.boolean().default(true)),
|
|
31
|
+
startupGraceSec: field(z.natural().default(300)),
|
|
32
|
+
mode: field(z.string().default('peer')),
|
|
33
|
+
peerHost: field(z.string().default('')),
|
|
34
|
+
peerUser: field(z.string().default('')),
|
|
35
|
+
peerKeyPath: field(z.string().default('')),
|
|
36
|
+
peerCommand: field(z.string().default('/usr/local/bin/dsh-mem-sync')),
|
|
37
|
+
peerEnvName: field(z.string().default('cloud')),
|
|
38
|
+
retryDelaySec: field(z.natural().default(60)),
|
|
39
|
+
healIntervalSec: field(z.natural().default(600)),
|
|
40
|
+
notifyOnFailure: field(z.boolean().default(true)),
|
|
41
|
+
commitPrefix: field(z.string().default('sync')),
|
|
42
|
+
gitPath: field(z.string().default('git')),
|
|
43
|
+
localTimeoutMs: field(z.natural().default(120000)),
|
|
44
|
+
peerTimeoutMs: field(z.natural().default(120000)),
|
|
45
|
+
statusFile: field(z.string().default('')),
|
|
46
|
+
})
|
|
47
|
+
: undefined
|
|
48
|
+
|
|
49
|
+
export const inject = ['tools']
|
|
50
|
+
|
|
51
|
+
function pickLogger(logger) {
|
|
52
|
+
if (typeof logger?.info === 'function') return logger
|
|
53
|
+
return {
|
|
54
|
+
info: (...args) => console.log('[memory-sync]', ...args),
|
|
55
|
+
warn: (...args) => console.warn('[memory-sync]', ...args),
|
|
56
|
+
error: (...args) => console.error('[memory-sync]', ...args),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function apply(ctx, config = {}) {
|
|
61
|
+
const cfg = normalizeConfig(config, process.env)
|
|
62
|
+
await ensureDir(cfg.stateDir).catch(() => {})
|
|
63
|
+
await rotateLog(join(cfg.stateDir, 'sync.log')).catch(() => {})
|
|
64
|
+
|
|
65
|
+
const logger = pickLogger(createLogger({ dir: cfg.stateDir, tag: 'memory-sync' }))
|
|
66
|
+
const runtime = createSyncRuntime({ cfg, logger })
|
|
67
|
+
|
|
68
|
+
const disposers = []
|
|
69
|
+
if (typeof ctx?.tools?.register === 'function') {
|
|
70
|
+
try {
|
|
71
|
+
disposers.push(ctx.tools.register(memorySyncToolSpec(runtime)))
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger.warn(`tool registration failed: ${error?.message || error}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
runtime.scheduleStartup()
|
|
78
|
+
logger.info(`ready: repo=${cfg.repoPath} mode=${cfg.mode} remote=${cfg.remote}/${cfg.branch}`)
|
|
79
|
+
|
|
80
|
+
const dispose = async () => {
|
|
81
|
+
runtime.dispose()
|
|
82
|
+
for (const disposer of disposers.reverse()) {
|
|
83
|
+
try {
|
|
84
|
+
disposer?.()
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore teardown errors
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ctx.effect?.(() => () => dispose(), 'dsh-memory-sync: teardown')
|
|
92
|
+
return dispose
|
|
93
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Defaults for every config key. Empty strings mean "not set yet". */
|
|
5
|
+
export const DEFAULTS = Object.freeze({
|
|
6
|
+
repoPath: '',
|
|
7
|
+
remote: 'cloud',
|
|
8
|
+
branch: 'main',
|
|
9
|
+
enabled: true,
|
|
10
|
+
runOnStartup: true,
|
|
11
|
+
startupGraceSec: 300,
|
|
12
|
+
mode: 'peer',
|
|
13
|
+
peerHost: '',
|
|
14
|
+
peerUser: '',
|
|
15
|
+
peerKeyPath: '',
|
|
16
|
+
peerCommand: '/usr/local/bin/dsh-mem-sync',
|
|
17
|
+
peerEnvName: 'cloud',
|
|
18
|
+
retryDelaySec: 60,
|
|
19
|
+
healIntervalSec: 600,
|
|
20
|
+
notifyOnFailure: true,
|
|
21
|
+
commitPrefix: 'sync',
|
|
22
|
+
gitPath: 'git',
|
|
23
|
+
localTimeoutMs: 120000,
|
|
24
|
+
peerTimeoutMs: 120000,
|
|
25
|
+
statusFile: '',
|
|
26
|
+
stateDir: '',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function isPlainObject(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hosts expose config fields marked `.extra('volatile', true)` as live
|
|
35
|
+
* references (`{ get() }`) instead of raw scalars. Unwrap them so both a
|
|
36
|
+
* plain object and a reference map normalize to the same values.
|
|
37
|
+
*/
|
|
38
|
+
function unwrapRef(value) {
|
|
39
|
+
let current = value
|
|
40
|
+
for (let i = 0; i < 4; i += 1) {
|
|
41
|
+
if (current === null || typeof current !== 'object') return current
|
|
42
|
+
// Live config references look like `{ get() }` (method) or `{ get value() }`
|
|
43
|
+
// (accessor). Support both, plus a plain `{ value }` wrapper.
|
|
44
|
+
if (typeof current.get === 'function') {
|
|
45
|
+
try {
|
|
46
|
+
current = current.get()
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if ('get' in current) {
|
|
53
|
+
current = current.get
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if ('value' in current) {
|
|
57
|
+
current = current.value
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
return current
|
|
61
|
+
}
|
|
62
|
+
return current
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toInt(value, fallback, min, max) {
|
|
66
|
+
const n = Number(value)
|
|
67
|
+
if (!Number.isFinite(n)) return fallback
|
|
68
|
+
const i = Math.trunc(n)
|
|
69
|
+
if (i < min || i > max) return fallback
|
|
70
|
+
return i
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toBool(value, fallback) {
|
|
74
|
+
if (typeof value === 'boolean') return value
|
|
75
|
+
if (value === 'true' || value === 1) return true
|
|
76
|
+
if (value === 'false' || value === 0) return false
|
|
77
|
+
return fallback
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function dshHome(env) {
|
|
81
|
+
const home = String(env?.DSH_HOME || '').trim()
|
|
82
|
+
return home ? resolve(home) : join(homedir(), '.dsh')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function expandHome(value, env) {
|
|
86
|
+
const text = String(value || '').trim()
|
|
87
|
+
if (!text) return ''
|
|
88
|
+
if (text === '~') return homedir()
|
|
89
|
+
if (text.startsWith('~/') || text.startsWith('~\\')) {
|
|
90
|
+
return join(homedir(), text.slice(2))
|
|
91
|
+
}
|
|
92
|
+
if (isAbsolute(text)) return text
|
|
93
|
+
return resolve(env?.DSH_HOME ? dshHome(env) : process.cwd(), text)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalize a Loader config object into a frozen, fully-resolved config.
|
|
98
|
+
* Empty strings are treated as "not set" so a user can clear a field and
|
|
99
|
+
* fall back to the default.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizeConfig(raw = {}, env = process.env) {
|
|
102
|
+
const input = unwrapRef(raw)
|
|
103
|
+
const src = isPlainObject(input) ? input : {}
|
|
104
|
+
const merged = { ...DEFAULTS }
|
|
105
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
106
|
+
const value = unwrapRef(src[key])
|
|
107
|
+
if (value === undefined || value === null) continue
|
|
108
|
+
if (typeof value === 'string' && value.trim() === '') continue
|
|
109
|
+
merged[key] = value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const home = dshHome(env)
|
|
113
|
+
const stateDir = resolve(
|
|
114
|
+
expandHome(String(merged.stateDir || ''), env) || join(home, 'memory-sync'),
|
|
115
|
+
)
|
|
116
|
+
const repoPath = resolve(
|
|
117
|
+
expandHome(String(merged.repoPath || ''), env) || join(home, 'memories'),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
repoPath,
|
|
122
|
+
stateDir,
|
|
123
|
+
remote: String(merged.remote || DEFAULTS.remote),
|
|
124
|
+
branch: String(merged.branch || DEFAULTS.branch),
|
|
125
|
+
enabled: toBool(merged.enabled, DEFAULTS.enabled),
|
|
126
|
+
runOnStartup: toBool(merged.runOnStartup, DEFAULTS.runOnStartup),
|
|
127
|
+
startupGraceSec: toInt(merged.startupGraceSec, DEFAULTS.startupGraceSec, 0, 86400),
|
|
128
|
+
mode: merged.mode === 'local' ? 'local' : 'peer',
|
|
129
|
+
peerHost: String(merged.peerHost || '').trim(),
|
|
130
|
+
peerUser: String(merged.peerUser || '').trim(),
|
|
131
|
+
peerKeyPath: expandHome(String(merged.peerKeyPath || ''), env),
|
|
132
|
+
peerCommand: String(merged.peerCommand || DEFAULTS.peerCommand).trim(),
|
|
133
|
+
peerEnvName: String(merged.peerEnvName || DEFAULTS.peerEnvName).trim(),
|
|
134
|
+
retryDelaySec: toInt(merged.retryDelaySec, DEFAULTS.retryDelaySec, 0, 3600),
|
|
135
|
+
healIntervalSec: toInt(merged.healIntervalSec, DEFAULTS.healIntervalSec, 0, 86400),
|
|
136
|
+
notifyOnFailure: toBool(merged.notifyOnFailure, DEFAULTS.notifyOnFailure),
|
|
137
|
+
commitPrefix: String(merged.commitPrefix || DEFAULTS.commitPrefix).trim() || 'sync',
|
|
138
|
+
gitPath: String(merged.gitPath || DEFAULTS.gitPath).trim() || 'git',
|
|
139
|
+
localTimeoutMs: toInt(merged.localTimeoutMs, DEFAULTS.localTimeoutMs, 5000, 600000),
|
|
140
|
+
peerTimeoutMs: toInt(merged.peerTimeoutMs, DEFAULTS.peerTimeoutMs, 5000, 600000),
|
|
141
|
+
// Optional machine snapshot committed with the sync (empty = disabled).
|
|
142
|
+
statusFile: expandHome(String(merged.statusFile || ''), env),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The config has a usable repo path; the runtime checks whether it is a git repo. */
|
|
147
|
+
export function isConfigured(cfg) {
|
|
148
|
+
return Boolean(cfg?.repoPath)
|
|
149
|
+
}
|