dsh-memory-sync 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
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.0] - 2026-11-05
8
+
9
+ First public release.
10
+
11
+ ### Added
12
+
13
+ - Three-phase startup sync: local pull/commit/push → peer commit over ssh → local
14
+ pull again, so both machines end on the same commit.
15
+ - `peer` and `local` modes; `repoPath`, `remote`, `branch`, `peerHost`,
16
+ `peerUser`, `peerKeyPath`, `peerCommand` and timeouts are configurable.
17
+ - Startup grace window (`startupGraceSec`, default 300 s) so hot reloads do not
18
+ trigger a sync.
19
+ - Retry once after `retryDelaySec` (60 s), then silent self-heal every
20
+ `healIntervalSec` (600 s) until the sync succeeds.
21
+ - Optional OS dialog on final failure (`notifyOnFailure`).
22
+ - Optional machine snapshot written before each sync and committed with it
23
+ (`statusFile`).
24
+ - `memory_sync` tool for agents: `{"action":"run"}` and `{"action":"status"}`.
25
+ - Lock file and `last-run.json` / `sync.log` under `$DSH_HOME/memory-sync/`.
26
+ - Tests covering dirty-tree commits, rebase over a peer commit, non-repo errors,
27
+ lock exclusivity and staleness, and config normalization.
28
+
29
+ [0.1.0]: https://github.com/QIC-9821/dsh/releases/tag/v0.1.0
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
+ [![npm version](https://img.shields.io/npm/v/dsh-memory-sync.svg)](https://www.npmjs.com/package/dsh-memory-sync)
4
+ [![CI](https://github.com/QIC-9821/dsh/actions/workflows/ci.yml/badge.svg)](https://github.com/QIC-9821/dsh/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](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#v0.1.0
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
- # Temporary Holding Version
1
+ # dsh-memory-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/dsh-memory-sync.svg)](https://www.npmjs.com/package/dsh-memory-sync)
4
+ [![CI](https://github.com/QIC-9821/dsh/actions/workflows/ci.yml/badge.svg)](https://github.com/QIC-9821/dsh/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](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#v0.1.0
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)。
@@ -0,0 +1,8 @@
1
+ - insert:
2
+ - id: dsh-memory-sync
3
+ name: 'dsh-memory-sync'
4
+ config:
5
+ enabled: true
6
+ runOnStartup: true
7
+ mode: peer
8
+ repoPath: ''
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
+ }