@summerscar/dsh-mnemon-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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Memory space sync backup",
4
+ "description": "Archive the whole Mnemon data directory — every memory space database — to S3-compatible storage or WebDAV, with minute/hour/day/clock scheduling and retention."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "记忆空间同步备份",
4
+ "description": "把整个 Mnemon data 目录(含全部记忆空间数据库)打包备份到 S3 兼容存储或 WebDAV,支持按分钟/小时/天/固定时刻调度与保留份数。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@summerscar/dsh-mnemon-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "source.js",
16
+ "client.js",
17
+ "store.js",
18
+ "snapshot.js",
19
+ "scheduler.js",
20
+ "targets.js",
21
+ "zip.js",
22
+ "cordis.patch.yml",
23
+ "locale/*.json",
24
+ "icon.svg",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "keywords": [
29
+ "dsh-plugin",
30
+ "deepseek-harness",
31
+ "deepseek-harness-plugin",
32
+ "dsh-mnemon",
33
+ "mnemon",
34
+ "backup",
35
+ "restore",
36
+ "memory",
37
+ "webdav",
38
+ "s3"
39
+ ],
40
+ "license": "MIT",
41
+ "engines": {
42
+ "node": ">=22.16",
43
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
44
+ },
45
+ "peerDependencies": {
46
+ "dsh-mnemon": ">=0.5.40"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "dsh-mnemon": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "dsh": {
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web",
59
+ "immediately": true,
60
+ "inject": [
61
+ "@deepseek-ai/dsh-client-locale",
62
+ "dsh-mnemon/client"
63
+ ]
64
+ }
65
+ },
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/summerscar/dsh-mnemon-sync.git"
69
+ },
70
+ "bugs": {
71
+ "url": "https://github.com/summerscar/dsh-mnemon-sync/issues"
72
+ },
73
+ "homepage": "https://github.com/summerscar/dsh-mnemon-sync#readme",
74
+ "publishConfig": {
75
+ "access": "public"
76
+ }
77
+ }
package/scheduler.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Mnemon Sync — the schedule and the run bookkeeping.
3
+ *
4
+ * The cadence model is copied from dsh-mnemon's own Git auto-backup, because
5
+ * that design is already proven against this runtime and both surfaces should
6
+ * read the same to a user:
7
+ *
8
+ * - One interval, a whole number of minutes, read from settings. Zero means
9
+ * "only when the button is pressed".
10
+ * - One `setTimeout` for the whole Host, re-armed from `finally`, never an
11
+ * interval. Two timers can never overlap into a double upload.
12
+ * - The interval is re-read from disk on every tick, so saving settings
13
+ * changes the cadence without restarting the process.
14
+ * - A tick that fires while a run is in flight is dropped, not queued: a slow
15
+ * target must not build a backlog of pending uploads.
16
+ * - Failures are recorded and swallowed. A timer is the last place that should
17
+ * raise an exception nobody is watching.
18
+ */
19
+
20
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ export const MINUTE_MS = 60_000
24
+ const HISTORY_LIMIT = 20
25
+ const STATE_DIRECTORY = 'state'
26
+ const HISTORY_FILE = 'mnemon-sync-history.json'
27
+
28
+ /** Present a run in a form the tab can render without extra shaping. */
29
+ function toRunRecord(entry) {
30
+ return {
31
+ id: entry.id,
32
+ startedAt: entry.startedAt,
33
+ finishedAt: entry.finishedAt,
34
+ ok: entry.ok === true,
35
+ trigger: entry.trigger,
36
+ durationMs: entry.durationMs,
37
+ ...entry.spaces === undefined ? {} : { spaces: entry.spaces },
38
+ ...entry.bytes === undefined ? {} : { bytes: entry.bytes },
39
+ ...entry.key === undefined ? {} : { key: entry.key },
40
+ ...entry.location === undefined ? {} : { location: entry.location },
41
+ ...entry.error === undefined ? {} : { error: entry.error },
42
+ ...entry.removed === undefined ? {} : { removed: entry.removed },
43
+ }
44
+ }
45
+
46
+ export class SyncHistory {
47
+ constructor(home) {
48
+ this.root = home
49
+ }
50
+
51
+ path() {
52
+ return join(this.root, STATE_DIRECTORY, HISTORY_FILE)
53
+ }
54
+
55
+ read() {
56
+ try {
57
+ const stored = JSON.parse(readFileSync(this.path(), 'utf8'))
58
+ const runs = Array.isArray(stored?.runs) ? stored.runs : []
59
+ return runs.map(toRunRecord)
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ append(record) {
66
+ const runs = [toRunRecord(record), ...this.read()].slice(0, HISTORY_LIMIT)
67
+ mkdirSync(join(this.root, STATE_DIRECTORY), { recursive: true, mode: 0o700 })
68
+ writeFileSync(this.path(), `${JSON.stringify({ version: 1, runs }, null, 2)}\n`, { mode: 0o600 })
69
+ return runs
70
+ }
71
+
72
+ latest() {
73
+ return this.read()[0]
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The Host-wide timer. `run()` is injected so the timer itself has no opinion
79
+ * about what a backup does, which keeps it testable and keeps the wiring in
80
+ * index.js.
81
+ */
82
+ export class SyncScheduler {
83
+ /**
84
+ * @param {object} options
85
+ * @param {() => object} options.readSettings current settings, read fresh each tick
86
+ * @param {(trigger: string, settings: object, signal: AbortSignal) => Promise<object>} options.run performs one backup
87
+ * @param {() => Date} [options.now]
88
+ * @param {(error: unknown) => void} [options.onError]
89
+ */
90
+ constructor(options) {
91
+ this.readSettings = options.readSettings
92
+ this.run = options.run
93
+ this.now = options.now ?? (() => new Date())
94
+ this.onError = options.onError
95
+ this.timer = undefined
96
+ this.running = false
97
+ this.stopped = false
98
+ this.controller = undefined
99
+ this.lastAttemptAt = undefined
100
+ this.lastRunAt = undefined
101
+ this.lastAt = undefined
102
+ this.lastError = undefined
103
+ this.lastFailure = undefined
104
+ }
105
+
106
+ start() {
107
+ this.schedule()
108
+ return () => {
109
+ this.stopped = true
110
+ this.cancel()
111
+ }
112
+ }
113
+
114
+ /** Re-arm without running; called after the settings page saves. */
115
+ refresh() {
116
+ if (this.stopped) return
117
+ this.cancel()
118
+ this.schedule()
119
+ }
120
+
121
+ cancel() {
122
+ if (this.timer !== undefined) {
123
+ clearTimeout(this.timer)
124
+ this.timer = undefined
125
+ }
126
+ this.controller?.abort()
127
+ this.controller = undefined
128
+ }
129
+
130
+ /**
131
+ * What the settings block shows, mirroring the Git channel's shape so the two
132
+ * read alike: whether the timer is armed, and when it ran and will run again.
133
+ */
134
+ snapshot(settings) {
135
+ const minutes = minutesOf(settings)
136
+ const available = minutes > 0 && !this.stopped && settings.enabled === true
137
+ return {
138
+ available,
139
+ autoSyncMinutes: minutes,
140
+ ...this.lastAt === undefined ? {} : { lastAt: this.lastAt },
141
+ ...this.lastAttemptAt === undefined || !available
142
+ ? {}
143
+ : { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
144
+ ...this.lastError === undefined ? {} : { lastError: this.lastError },
145
+ ...this.lastError === undefined || this.lastFailure === undefined
146
+ ? {}
147
+ : { lastFailure: this.lastFailure },
148
+ ...!this.running ? {} : { running: true },
149
+ }
150
+ }
151
+
152
+ schedule() {
153
+ if (this.stopped || this.timer !== undefined) return
154
+ let settings
155
+ try {
156
+ settings = this.readSettings()
157
+ } catch {
158
+ return
159
+ }
160
+ const minutes = minutesOf(settings)
161
+ if (minutes <= 0 || settings.enabled !== true) return
162
+ this.lastAttemptAt = this.now().getTime()
163
+ this.timer = setTimeout(() => {
164
+ this.timer = undefined
165
+ void this.tick('schedule').catch(() => {
166
+ // `tick` already recorded the failure; the timer must not rethrow.
167
+ })
168
+ }, minutes * MINUTE_MS)
169
+ }
170
+
171
+ /**
172
+ * Run one backup now. Shared by the timer and the tab's button so both paths
173
+ * record history and both refuse to overlap.
174
+ */
175
+ async runNow(trigger = 'manual') {
176
+ return this.tick(trigger)
177
+ }
178
+
179
+ async tick(trigger) {
180
+ if (this.stopped) return { skipped: 'stopped' }
181
+ if (this.running) return { skipped: 'busy' }
182
+ this.running = true
183
+ const controller = new AbortController()
184
+ this.controller = controller
185
+ const startedAt = this.now().toISOString()
186
+ const startedMs = this.now().getTime()
187
+ try {
188
+ const settings = this.readSettings()
189
+ if (trigger === 'schedule' && settings.enabled !== true) {
190
+ return { skipped: 'disabled' }
191
+ }
192
+ const result = await this.run(trigger, settings, controller.signal)
193
+ this.lastRunAt = this.now().toISOString()
194
+ this.lastAt = this.lastRunAt
195
+ this.lastError = undefined
196
+ this.lastFailure = undefined
197
+ return { ...result, startedAt, durationMs: this.now().getTime() - startedMs, trigger }
198
+ } catch (error) {
199
+ const message = error instanceof Error ? error.message : String(error)
200
+ // An aborted run is the expected outcome of disable/refresh, not a fault.
201
+ if (!this.stopped && !controller.signal.aborted) {
202
+ this.lastError = message
203
+ this.lastFailure = error instanceof Error ? error.reason : undefined
204
+ try {
205
+ this.onError?.(error)
206
+ } catch {
207
+ // the error hook must not become a second failure
208
+ }
209
+ }
210
+ throw error
211
+ } finally {
212
+ this.running = false
213
+ if (this.controller === controller) this.controller = undefined
214
+ this.schedule()
215
+ }
216
+ }
217
+ }
218
+
219
+ /** The saved interval, or zero when it cannot be read at all. */
220
+ function minutesOf(settings) {
221
+ const minutes = settings?.autoSyncMinutes
222
+ return typeof minutes === 'number' && Number.isInteger(minutes) && minutes > 0 ? minutes : 0
223
+ }
224
+
225
+ export { HISTORY_LIMIT }