@ddtcorex/dsh-maestro-memory 1.0.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * git.ts — Git adapter abstraction. Disabled => zero spawn.
3
+ * Real adapter uses child_process git with file:// remotes via --git-dir show.
4
+ * Mock adapter is in-memory for tests.
5
+ */
6
+
7
+ import { spawnSync } from 'node:child_process'
8
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { randomUUID } from 'node:crypto'
12
+
13
+ export interface GitAdapter {
14
+ /** Fetch remote branch, return ok/error */
15
+ fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }>
16
+ /** Push files to remote branch (commit & push). files keys are branch-root paths e.g. KEY.md */
17
+ push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }>
18
+ /** Get remote branch files (read). */
19
+ getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }>
20
+ }
21
+
22
+ /**
23
+ * MockGitAdapter — in-memory remote, tracks calls for disabled assertions.
24
+ */
25
+ export class MockGitAdapter implements GitAdapter {
26
+ // remoteUrl -> branch -> files
27
+ public remotes = new Map<string, Map<string, Record<string, string>>>()
28
+ public calls: Array<{ method: string; remoteUrl: string; branch: string }> = []
29
+ public failNextFetch: string | null = null
30
+ public failNextPush: string | null = null
31
+ public failNextGet: string | null = null
32
+
33
+ async fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }> {
34
+ this.calls.push({ method: 'fetch', remoteUrl, branch })
35
+ if (this.failNextFetch) {
36
+ const e = this.failNextFetch
37
+ this.failNextFetch = null
38
+ return { ok: false, error: e }
39
+ }
40
+ return { ok: true }
41
+ }
42
+
43
+ async push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
44
+ this.calls.push({ method: 'push', remoteUrl, branch })
45
+ if (this.failNextPush) {
46
+ const e = this.failNextPush
47
+ this.failNextPush = null
48
+ return { ok: false, error: e }
49
+ }
50
+ let br = this.remotes.get(remoteUrl)
51
+ if (!br) {
52
+ br = new Map()
53
+ this.remotes.set(remoteUrl, br)
54
+ }
55
+ br.set(branch, { ...files, '.meta.json': JSON.stringify({ message, at: new Date().toISOString() }) })
56
+ return { ok: true }
57
+ }
58
+
59
+ async getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
60
+ this.calls.push({ method: 'getRemoteFiles', remoteUrl, branch })
61
+ if (this.failNextGet) {
62
+ const e = this.failNextGet
63
+ this.failNextGet = null
64
+ return { ok: false, error: e }
65
+ }
66
+ const br = this.remotes.get(remoteUrl)?.get(branch)
67
+ if (!br) return { ok: true, files: {} }
68
+ // strip meta
69
+ const { '.meta.json': _m, ...files } = br as any
70
+ return { ok: true, files }
71
+ }
72
+
73
+ // helper to directly set remote files (simulate other machine push)
74
+ setRemoteFiles(remoteUrl: string, branch: string, files: Record<string, string>) {
75
+ let br = this.remotes.get(remoteUrl)
76
+ if (!br) {
77
+ br = new Map()
78
+ this.remotes.set(remoteUrl, br)
79
+ }
80
+ br.set(branch, { ...files })
81
+ }
82
+
83
+ getRemoteFilesSync(remoteUrl: string, branch: string): Record<string, string> | undefined {
84
+ return this.remotes.get(remoteUrl)?.get(branch)
85
+ }
86
+
87
+ reset() {
88
+ this.calls = []
89
+ this.failNextFetch = null
90
+ this.failNextPush = null
91
+ this.failNextGet = null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * RealGitAdapter — uses git CLI for file:// remotes.
97
+ * For http/ssh remotes it delegates to git fetch/push via temp clone.
98
+ * Minimal implementation sufficient for integration tests with file:// bare remotes.
99
+ */
100
+ export class RealGitAdapter implements GitAdapter {
101
+ async fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }> {
102
+ // For file:// bare remote, check existence via ls-remote; no real fetch needed.
103
+ // We treat fetch as ls-remote to validate connectivity.
104
+ const res = spawnSync('git', ['ls-remote', remoteUrl, `refs/heads/${branch}`], { encoding: 'utf8', timeout: 5000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
105
+ if (res.error) return { ok: false, error: (res.error as any).message ?? String(res.error) }
106
+ if (res.status !== 0) {
107
+ const stderr = String(res.stderr ?? '')
108
+ const lower = stderr.toLowerCase()
109
+ // Only a reachable repo where this exact branch/ref does not exist yet is
110
+ // acceptable (means "first push"). Everything else — unreachable remote,
111
+ // not a repo, or an auth/connectivity failure — is a hard error so we
112
+ // never silently treat an offline remote as "no data".
113
+ if (lower.includes("couldn't find remote ref") || lower.includes('no such ref') || lower.includes('unknown revision')) {
114
+ return { ok: true }
115
+ }
116
+ return { ok: false, error: stderr.trim() || `ls-remote failed (status ${res.status})` }
117
+ }
118
+ return { ok: true }
119
+ }
120
+
121
+ async getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
122
+ // Use git --git-dir=<remote> show branch:file if remote is file:// path
123
+ const filePath = this.resolveFileUrl(remoteUrl)
124
+ if (filePath) {
125
+ // file:// bare remote
126
+ return this.getFilesFromBare(filePath, branch)
127
+ }
128
+ // For non-file remotes, clone to temp and read
129
+ return this.getFilesViaClone(remoteUrl, branch)
130
+ }
131
+
132
+ async push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
133
+ const filePath = this.resolveFileUrl(remoteUrl)
134
+ if (filePath) {
135
+ return this.pushToBare(filePath, branch, files, message)
136
+ }
137
+ return this.pushViaClone(remoteUrl, branch, files, message)
138
+ }
139
+
140
+ private resolveFileUrl(url: string): string | null {
141
+ if (url.startsWith('file://')) return url.slice('file://'.length)
142
+ if (url.startsWith('/tmp/') || url.startsWith('/')) {
143
+ // Treat absolute path as file remote for tests (implicit file://)
144
+ if (existsSync(url)) return url
145
+ // also allow /tmp/xxx without existence check
146
+ if (url.startsWith('/tmp/')) return url
147
+ }
148
+ return null
149
+ }
150
+
151
+ private getFilesFromBare(barePath: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
152
+ return new Promise((resolve) => {
153
+ const expected = ['KEY.md', 'MEMORY.md', 'KEY-archive.md', 'TODOS.md']
154
+ const files: Record<string, string> = {}
155
+ for (const f of expected) {
156
+ const res = spawnSync('git', ['--git-dir', barePath, 'show', `${branch}:${f}`], { encoding: 'utf8', timeout: 5000 })
157
+ if (res.status === 0) {
158
+ files[f] = res.stdout
159
+ } else {
160
+ // file not on branch or branch missing -> treat as missing
161
+ // if branch missing, files empty
162
+ const stderr = String(res.stderr ?? '')
163
+ if (stderr.includes('not found') || stderr.includes('does not exist') || stderr.includes('invalid object')) {
164
+ continue
165
+ }
166
+ // other error -> treat as empty for now
167
+ continue
168
+ }
169
+ }
170
+ // Check if branch exists at all: if no files and branch missing, return empty (not error)
171
+ const ls = spawnSync('git', ['--git-dir', barePath, 'rev-parse', '--verify', `refs/heads/${branch}`], { encoding: 'utf8' })
172
+ if (ls.status !== 0 && Object.keys(files).length === 0) {
173
+ // branch not existent yet
174
+ resolve({ ok: true, files: {} })
175
+ return
176
+ }
177
+ resolve({ ok: true, files })
178
+ })
179
+ }
180
+
181
+ private pushToBare(barePath: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
182
+ return new Promise((resolve) => {
183
+ const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-'))
184
+ try {
185
+ // init temp repo
186
+ let r = spawnSync('git', ['init'], { cwd: tmp, encoding: 'utf8' })
187
+ if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'init failed' })
188
+ r = spawnSync('git', ['remote', 'add', 'origin', barePath], { cwd: tmp, encoding: 'utf8' })
189
+ if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'remote add failed' })
190
+ // try fetch existing branch
191
+ spawnSync('git', ['fetch', 'origin', branch], { cwd: tmp, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
192
+ // Check out the branch ON TOP of the fetched remote tip (FETCH_HEAD) so a
193
+ // subsequent commit is a descendant of the remote history and can be
194
+ // pushed as a fast-forward. On the first push FETCH_HEAD is absent, so
195
+ // checkout falls through to the orphan branch path below.
196
+ const checkout = spawnSync('git', ['checkout', '-B', branch, 'FETCH_HEAD'], { cwd: tmp, encoding: 'utf8' })
197
+ if (checkout.status !== 0) {
198
+ // try orphan
199
+ spawnSync('git', ['checkout', '--orphan', branch], { cwd: tmp, encoding: 'utf8' })
200
+ spawnSync('git', ['rm', '-rf', '.'], { cwd: tmp, encoding: 'utf8' })
201
+ }
202
+ // write files
203
+ for (const [name, content] of Object.entries(files)) {
204
+ const p = join(tmp, name)
205
+ mkdirSync(join(p, '..'), { recursive: true })
206
+ writeFileSync(p, content, 'utf8')
207
+ }
208
+ spawnSync('git', ['add', '.'], { cwd: tmp, encoding: 'utf8' })
209
+ // need user identity
210
+ const env = { ...process.env, GIT_AUTHOR_NAME: 'maestro', GIT_AUTHOR_EMAIL: 'maestro@local', GIT_COMMITTER_NAME: 'maestro', GIT_COMMITTER_EMAIL: 'maestro@local' }
211
+ const commit = spawnSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: tmp, encoding: 'utf8', env })
212
+ if (commit.status !== 0) return resolve({ ok: false, error: commit.stderr?.toString() ?? 'commit failed' })
213
+ const push = spawnSync('git', ['push', 'origin', `${branch}:${branch}`], { cwd: tmp, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
214
+ if (push.status !== 0) return resolve({ ok: false, error: push.stderr?.toString() ?? 'push failed' })
215
+ resolve({ ok: true })
216
+ } finally {
217
+ try { rmSync(tmp, { recursive: true, force: true }) } catch {}
218
+ }
219
+ })
220
+ }
221
+
222
+ private getFilesViaClone(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
223
+ return new Promise((resolve) => {
224
+ const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-clone-'))
225
+ try {
226
+ const clone = spawnSync('git', ['clone', '--branch', branch, '--single-branch', remoteUrl, tmp + '/repo'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
227
+ if (clone.status !== 0) {
228
+ // branch may not exist yet
229
+ resolve({ ok: true, files: {} })
230
+ return
231
+ }
232
+ const repo = join(tmp, 'repo')
233
+ const files: Record<string, string> = {}
234
+ for (const f of ['KEY.md', 'MEMORY.md', 'KEY-archive.md', 'TODOS.md']) {
235
+ const p = join(repo, f)
236
+ if (existsSync(p)) files[f] = readFileSync(p, 'utf8')
237
+ }
238
+ resolve({ ok: true, files })
239
+ } finally {
240
+ try { rmSync(tmp, { recursive: true, force: true }) } catch {}
241
+ }
242
+ })
243
+ }
244
+
245
+ private pushViaClone(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
246
+ return new Promise((resolve) => {
247
+ const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-push-'))
248
+ try {
249
+ // shallow clone or init
250
+ let repo = join(tmp, 'repo')
251
+ mkdirSync(repo, { recursive: true })
252
+ let r = spawnSync('git', ['init'], { cwd: repo, encoding: 'utf8' })
253
+ if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'init failed' })
254
+ r = spawnSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repo, encoding: 'utf8' })
255
+ if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'remote add failed' })
256
+ spawnSync('git', ['fetch', 'origin', branch], { cwd: repo, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
257
+ // Base the branch on the remote tip (FETCH_HEAD) so we fast-forward
258
+ // instead of producing an orphan root commit that the second push
259
+ // rejects as non-fast-forward.
260
+ const checkout = spawnSync('git', ['checkout', '-B', branch, 'FETCH_HEAD'], { cwd: repo, encoding: 'utf8' })
261
+ if (checkout.status !== 0) {
262
+ spawnSync('git', ['checkout', '--orphan', branch], { cwd: repo, encoding: 'utf8' })
263
+ spawnSync('git', ['rm', '-rf', '.'], { cwd: repo, encoding: 'utf8' })
264
+ }
265
+ for (const [name, content] of Object.entries(files)) {
266
+ writeFileSync(join(repo, name), content, 'utf8')
267
+ }
268
+ spawnSync('git', ['add', '.'], { cwd: repo, encoding: 'utf8' })
269
+ const env = { ...process.env, GIT_AUTHOR_NAME: 'maestro', GIT_AUTHOR_EMAIL: 'maestro@local', GIT_COMMITTER_NAME: 'maestro', GIT_COMMITTER_EMAIL: 'maestro@local' }
270
+ const commit = spawnSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: repo, encoding: 'utf8', env })
271
+ if (commit.status !== 0) return resolve({ ok: false, error: commit.stderr?.toString() ?? 'commit failed' })
272
+ const push = spawnSync('git', ['push', 'origin', `${branch}:${branch}`], { cwd: repo, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
273
+ if (push.status !== 0) return resolve({ ok: false, error: push.stderr?.toString() ?? 'push failed' })
274
+ resolve({ ok: true })
275
+ } finally {
276
+ try { rmSync(tmp, { recursive: true, force: true }) } catch {}
277
+ }
278
+ })
279
+ }
280
+ }
@@ -0,0 +1,5 @@
1
+ export * from './layout.ts'
2
+ export * from './merge.ts'
3
+ export * from './git.ts'
4
+ export * from './config.ts'
5
+ export * from './service.ts'
@@ -0,0 +1,39 @@
1
+ import { join } from 'node:path'
2
+ import { maestroMetaDir, projectHash } from '../storage/layout.ts'
3
+
4
+ export function syncDir(root: string, hash: string): string {
5
+ return join(maestroMetaDir(root), 'sync', hash)
6
+ }
7
+
8
+ export function syncConfigPath(root: string, hash: string): string {
9
+ return join(syncDir(root, hash), 'config.json')
10
+ }
11
+
12
+ export function syncMetaPath(root: string, hash: string): string {
13
+ return join(syncDir(root, hash), 'lastSync.json')
14
+ }
15
+
16
+ export function syncConflictsPath(root: string, hash: string): string {
17
+ return join(syncDir(root, hash), 'conflicts.jsonl')
18
+ }
19
+
20
+ export function syncBranchName(hash: string): string {
21
+ return `maestro-memory/${hash}`
22
+ }
23
+
24
+ export function resolveSyncHash(cwd: string): string {
25
+ return projectHash(cwd)
26
+ }
27
+
28
+ // aggregated for tests
29
+ export function allSyncPaths(root: string, cwd: string) {
30
+ const hash = projectHash(cwd)
31
+ return {
32
+ dir: syncDir(root, hash),
33
+ config: syncConfigPath(root, hash),
34
+ meta: syncMetaPath(root, hash),
35
+ conflicts: syncConflictsPath(root, hash),
36
+ branch: syncBranchName(hash),
37
+ hash,
38
+ }
39
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * merge.ts — union merge by id, never drops either version.
3
+ * Pure functions, no I/O.
4
+ */
5
+
6
+ const MEMORY_ID_RE = /^\[id:\s*([0-9a-f]{8})\]\s*/i
7
+
8
+ export interface ConflictRecord {
9
+ id: string
10
+ track: string
11
+ localEntry: string | null
12
+ remoteEntry: string | null
13
+ reason: string
14
+ }
15
+
16
+ export interface MergeResult {
17
+ merged: string[]
18
+ conflicts: ConflictRecord[]
19
+ addedLocal: string[]
20
+ addedRemote: string[]
21
+ }
22
+
23
+ function extractMemoryId(entry: string): string | null {
24
+ const m = MEMORY_ID_RE.exec(String(entry))
25
+ return m ? m[1].toLowerCase() : null
26
+ }
27
+
28
+ function normalizeEntry(entry: string): string {
29
+ return String(entry).trim()
30
+ }
31
+
32
+ function contentHash(entry: string): string {
33
+ // strip id prefix for comparison of body? But for modifiedBoth we want body difference
34
+ // Compare full stripped id content; if id same but body differs => conflict
35
+ const withoutId = String(entry).replace(MEMORY_ID_RE, '').trim()
36
+ return withoutId
37
+ }
38
+
39
+ /**
40
+ * Parse entries into map id => entry. Entries without id are assigned a synthetic
41
+ * id derived from content hash (for safety) but flagged as missing.
42
+ */
43
+ export function mapById(entries: string[]): Map<string, string> {
44
+ const m = new Map<string, string>()
45
+ for (const e of entries) {
46
+ const id = extractMemoryId(e)
47
+ if (id) {
48
+ // keep first occurrence; duplicates by id should not happen but last wins
49
+ if (!m.has(id)) m.set(id, e)
50
+ }
51
+ }
52
+ return m
53
+ }
54
+
55
+ export function idsOf(entries: string[]): Set<string> {
56
+ const s = new Set<string>()
57
+ for (const e of entries) {
58
+ const id = extractMemoryId(e)
59
+ if (id) s.add(id)
60
+ }
61
+ return s
62
+ }
63
+
64
+ /**
65
+ * Union merge for memory-style entries (KEY, MEMORY, archive).
66
+ * - local + remote entries with distinct ids => union (both present)
67
+ * - same id, same content => one entry
68
+ * - same id, different content => conflict (neither applied, keep local)
69
+ */
70
+ export function mergeMemoryEntries(opts: {
71
+ track: string
72
+ local: string[]
73
+ remote: string[]
74
+ baseIds?: Set<string>
75
+ }): MergeResult {
76
+ const { track, local, remote, baseIds } = opts
77
+ const localMap = mapById(local)
78
+ const remoteMap = mapById(remote)
79
+
80
+ const base = baseIds ?? new Set<string>()
81
+
82
+ const localIds = new Set(localMap.keys())
83
+ const remoteIds = new Set(remoteMap.keys())
84
+
85
+ const allIds = new Set<string>([...localIds, ...remoteIds])
86
+
87
+ const merged: string[] = []
88
+ const conflicts: ConflictRecord[] = []
89
+ const addedLocal: string[] = []
90
+ const addedRemote: string[] = []
91
+
92
+ for (const id of allIds) {
93
+ const hasLocal = localMap.has(id)
94
+ const hasRemote = remoteMap.has(id)
95
+ const localEntry = localMap.get(id) ?? null
96
+ const remoteEntry = remoteMap.get(id) ?? null
97
+
98
+ if (hasLocal && !hasRemote) {
99
+ // local only
100
+ if (base.has(id)) {
101
+ // deletedRemote? base had it, remote deleted -> conflict if local modified?
102
+ // per spec: delete vs modify => conflict, delete vs delete => absent
103
+ // Our simple rule: if base has id and remote missing, but local present and content differs from base? we don't have base content, so treat as remote deletion -> keep local but flag? For MVP, keep local.
104
+ merged.push(localEntry!)
105
+ } else {
106
+ // addedLocal
107
+ addedLocal.push(id)
108
+ merged.push(localEntry!)
109
+ }
110
+ continue
111
+ }
112
+ if (!hasLocal && hasRemote) {
113
+ if (base.has(id)) {
114
+ // deletedLocal vs remote present => conflict? keep remote? per spec conflict
115
+ // For MVP, we consider this addedRemote if not in base, else conflict -> keep remote but record conflict? Simpler add.
116
+ if (localIds.has(id)) {
117
+ // unreachable
118
+ }
119
+ // If base had it and local deleted -> conflict not auto delete. We treat as conflict.
120
+ // Check if local deleted intentionally: we flag conflict
121
+ conflicts.push({ id, track, localEntry: null, remoteEntry, reason: 'deletedLocal vs modifiedRemote' })
122
+ // do not auto-merge; keep local deletion (i.e., not in merged) — but per never-drop, we should keep remote as conflict.
123
+ // For now, push remote to merged but also mark conflict, so union still contains remote? Spec says delete not applied when other side modified => conflict.
124
+ // We'll keep remote out of merged until resolve, to avoid auto-drop of delete.
125
+ // Decision: do not push to merged, leave conflict.
126
+ continue
127
+ } else {
128
+ addedRemote.push(id)
129
+ merged.push(remoteEntry!)
130
+ }
131
+ continue
132
+ }
133
+ if (hasLocal && hasRemote) {
134
+ const l = localEntry!
135
+ const r = remoteEntry!
136
+ if (normalizeEntry(l) === normalizeEntry(r)) {
137
+ merged.push(l)
138
+ } else if (contentHash(l) === contentHash(r)) {
139
+ // ids same, content same after stripping id? Actually already compared normalized; if equal after stripping id they are same body
140
+ merged.push(l)
141
+ } else {
142
+ // same id, different body => conflict
143
+ conflicts.push({ id, track, localEntry: l, remoteEntry: r, reason: 'modifiedBoth' })
144
+ // keep local in merged, remote not auto-applied
145
+ merged.push(l)
146
+ }
147
+ }
148
+ }
149
+
150
+ // Also handle local entries without id (legacy) — they are not in map; append them verbatim to merged if not duplicated
151
+ for (const e of local) {
152
+ if (!extractMemoryId(e)) {
153
+ if (!merged.includes(e)) merged.push(e)
154
+ }
155
+ }
156
+ for (const e of remote) {
157
+ if (!extractMemoryId(e) && !merged.includes(e)) {
158
+ // remote entry without id — treat as addedRemote but can't dedupe; add if not duplicate body
159
+ const body = contentHash(e)
160
+ const exists = merged.some((m) => contentHash(m) === body)
161
+ if (!exists) {
162
+ merged.push(e)
163
+ if (!extractMemoryId(e)) {
164
+ // synthetic id absent, still count as added
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return { merged, conflicts, addedLocal, addedRemote }
171
+ }
172
+
173
+ // Todo merge: same id logic but todos are already stamped with id; reuse same function with track='TODOS'
174
+ export function mergeTodoEntries(opts: { local: string[]; remote: string[]; baseIds?: Set<string> }): MergeResult {
175
+ // Todos use same id regex: \[id: xxxxxxxx\]
176
+ // But todo entries are multiline: first line contains id; our extract works on full entry (first line)
177
+ return mergeMemoryEntries({ track: 'TODOS', local: opts.local, remote: opts.remote, baseIds: opts.baseIds })
178
+ }
179
+
180
+ // Helpers for sync service: compute ids snapshot for meta
181
+ export function snapshotIds(entriesByTrack: Record<string, string[]>): Record<string, string[]> {
182
+ const out: Record<string, string[]> = {}
183
+ for (const [track, entries] of Object.entries(entriesByTrack)) {
184
+ const ids = [...idsOf(entries)].sort()
185
+ out[track] = ids
186
+ }
187
+ return out
188
+ }