@summerscar/dsh-mnemon-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/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.1",
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,241 @@
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
+ * Drop the whole run history.
78
+ *
79
+ * History is a display convenience, not a record of truth: the archives
80
+ * themselves live on the target, and retention keys off the same list, so
81
+ * clearing it simply stops the page from showing past runs. Retention then
82
+ * has nothing to delete, which is the honest outcome — after clearing, this
83
+ * plugin no longer knows which keys it uploaded.
84
+ */
85
+ clear() {
86
+ const path = this.path()
87
+ mkdirSync(join(this.root, STATE_DIRECTORY), { recursive: true, mode: 0o700 })
88
+ writeFileSync(path, `${JSON.stringify({ version: 1, runs: [] }, null, 2)}\n`, { mode: 0o600 })
89
+ return []
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The Host-wide timer. `run()` is injected so the timer itself has no opinion
95
+ * about what a backup does, which keeps it testable and keeps the wiring in
96
+ * index.js.
97
+ */
98
+ export class SyncScheduler {
99
+ /**
100
+ * @param {object} options
101
+ * @param {() => object} options.readSettings current settings, read fresh each tick
102
+ * @param {(trigger: string, settings: object, signal: AbortSignal) => Promise<object>} options.run performs one backup
103
+ * @param {() => Date} [options.now]
104
+ * @param {(error: unknown) => void} [options.onError]
105
+ */
106
+ constructor(options) {
107
+ this.readSettings = options.readSettings
108
+ this.run = options.run
109
+ this.now = options.now ?? (() => new Date())
110
+ this.onError = options.onError
111
+ this.timer = undefined
112
+ this.running = false
113
+ this.stopped = false
114
+ this.controller = undefined
115
+ this.lastAttemptAt = undefined
116
+ this.lastRunAt = undefined
117
+ this.lastAt = undefined
118
+ this.lastError = undefined
119
+ this.lastFailure = undefined
120
+ }
121
+
122
+ start() {
123
+ this.schedule()
124
+ return () => {
125
+ this.stopped = true
126
+ this.cancel()
127
+ }
128
+ }
129
+
130
+ /** Re-arm without running; called after the settings page saves. */
131
+ refresh() {
132
+ if (this.stopped) return
133
+ this.cancel()
134
+ this.schedule()
135
+ }
136
+
137
+ cancel() {
138
+ if (this.timer !== undefined) {
139
+ clearTimeout(this.timer)
140
+ this.timer = undefined
141
+ }
142
+ this.controller?.abort()
143
+ this.controller = undefined
144
+ }
145
+
146
+ /**
147
+ * What the settings block shows, mirroring the Git channel's shape so the two
148
+ * read alike: whether the timer is armed, and when it ran and will run again.
149
+ */
150
+ snapshot(settings) {
151
+ const minutes = minutesOf(settings)
152
+ const available = minutes > 0 && !this.stopped && settings.enabled === true
153
+ return {
154
+ available,
155
+ autoSyncMinutes: minutes,
156
+ ...this.lastAt === undefined ? {} : { lastAt: this.lastAt },
157
+ ...this.lastAttemptAt === undefined || !available
158
+ ? {}
159
+ : { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
160
+ ...this.lastError === undefined ? {} : { lastError: this.lastError },
161
+ ...this.lastError === undefined || this.lastFailure === undefined
162
+ ? {}
163
+ : { lastFailure: this.lastFailure },
164
+ ...!this.running ? {} : { running: true },
165
+ }
166
+ }
167
+
168
+ schedule() {
169
+ if (this.stopped || this.timer !== undefined) return
170
+ let settings
171
+ try {
172
+ settings = this.readSettings()
173
+ } catch {
174
+ return
175
+ }
176
+ const minutes = minutesOf(settings)
177
+ if (minutes <= 0 || settings.enabled !== true) return
178
+ this.lastAttemptAt = this.now().getTime()
179
+ this.timer = setTimeout(() => {
180
+ this.timer = undefined
181
+ void this.tick('schedule').catch(() => {
182
+ // `tick` already recorded the failure; the timer must not rethrow.
183
+ })
184
+ }, minutes * MINUTE_MS)
185
+ }
186
+
187
+ /**
188
+ * Run one backup now. Shared by the timer and the tab's button so both paths
189
+ * record history and both refuse to overlap.
190
+ */
191
+ async runNow(trigger = 'manual') {
192
+ return this.tick(trigger)
193
+ }
194
+
195
+ async tick(trigger) {
196
+ if (this.stopped) return { skipped: 'stopped' }
197
+ if (this.running) return { skipped: 'busy' }
198
+ this.running = true
199
+ const controller = new AbortController()
200
+ this.controller = controller
201
+ const startedAt = this.now().toISOString()
202
+ const startedMs = this.now().getTime()
203
+ try {
204
+ const settings = this.readSettings()
205
+ if (trigger === 'schedule' && settings.enabled !== true) {
206
+ return { skipped: 'disabled' }
207
+ }
208
+ const result = await this.run(trigger, settings, controller.signal)
209
+ this.lastRunAt = this.now().toISOString()
210
+ this.lastAt = this.lastRunAt
211
+ this.lastError = undefined
212
+ this.lastFailure = undefined
213
+ return { ...result, startedAt, durationMs: this.now().getTime() - startedMs, trigger }
214
+ } catch (error) {
215
+ const message = error instanceof Error ? error.message : String(error)
216
+ // An aborted run is the expected outcome of disable/refresh, not a fault.
217
+ if (!this.stopped && !controller.signal.aborted) {
218
+ this.lastError = message
219
+ this.lastFailure = error instanceof Error ? error.reason : undefined
220
+ try {
221
+ this.onError?.(error)
222
+ } catch {
223
+ // the error hook must not become a second failure
224
+ }
225
+ }
226
+ throw error
227
+ } finally {
228
+ this.running = false
229
+ if (this.controller === controller) this.controller = undefined
230
+ this.schedule()
231
+ }
232
+ }
233
+ }
234
+
235
+ /** The saved interval, or zero when it cannot be read at all. */
236
+ function minutesOf(settings) {
237
+ const minutes = settings?.autoSyncMinutes
238
+ return typeof minutes === 'number' && Number.isInteger(minutes) && minutes > 0 ? minutes : 0
239
+ }
240
+
241
+ export { HISTORY_LIMIT }