@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,650 @@
1
+ /**
2
+ * memory/store.ts — MaestroMemoryStore for five tracks (memory/user/daily/project/key)
3
+ * with date/content query, unique replace/remove, archive-before-delete,
4
+ * branch filter, summary/expand. Uses storage/layout + storage/atomic-store.
5
+ */
6
+
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { join, dirname } from 'node:path'
10
+ import {
11
+ parseEntries,
12
+ serializeEntries,
13
+ isDuplicate,
14
+ stripEntryId,
15
+ findExactIndex,
16
+ readEntriesSync,
17
+ appendEntryAtomicSync,
18
+ writeEntriesAtomicSync,
19
+ readEntries,
20
+ appendEntryAtomic,
21
+ writeEntriesAtomic,
22
+ withLockSync,
23
+ } from '../storage/atomic-store.ts'
24
+ import {
25
+ resolveMemoryRoot,
26
+ globalMemoryPath,
27
+ userMemoryPath,
28
+ dailyPath,
29
+ dailyDir,
30
+ projectMemoryPath,
31
+ projectKeyPath,
32
+ globalArchivePath,
33
+ userArchivePath,
34
+ projectKeyArchivePath,
35
+ maestroMetaDir,
36
+ } from '../storage/layout.ts'
37
+ import {
38
+ parseEntryBranches,
39
+ parseEntrySummary,
40
+ stripEntrySummary,
41
+ autoSummary,
42
+ extractEntryDate,
43
+ BRANCH_TAG_RE,
44
+ } from '../storage/legacy-format.ts'
45
+
46
+ export type MemoryTarget = 'memory' | 'global' | 'user' | 'project' | 'key' | 'daily'
47
+ export type MemoryAction = 'add' | 'list' | 'replace' | 'remove' | 'archive' | 'expand'
48
+
49
+ export interface ListOpts {
50
+ filter?: string
51
+ since?: string
52
+ until?: string
53
+ limit?: number
54
+ recent?: boolean
55
+ branch?: string
56
+ archived?: boolean
57
+ // A single day for the daily track (YYYY-MM-DD); takes precedence over the
58
+ // since/until cross-file sweep for one specific date.
59
+ date?: string
60
+ }
61
+
62
+ function normalizeTarget(t: string): MemoryTarget {
63
+ if (t === 'global') return 'memory'
64
+ return t as MemoryTarget
65
+ }
66
+
67
+ function genId(): string {
68
+ return randomUUID().replace(/-/g, '').slice(0, 8)
69
+ }
70
+
71
+ function todayStamp(): string {
72
+ // Local calendar date (matching TodoStore.todayStamp) so daily memory and
73
+ // daily todos land on the same "today" — UTC here drifted a day for
74
+ // timezones east of UTC around midnight.
75
+ const d = new Date()
76
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
77
+ const dd = String(d.getDate()).padStart(2, '0')
78
+ return `${d.getFullYear()}-${mm}-${dd}`
79
+ }
80
+
81
+ function timeStamp(): string {
82
+ const d = new Date()
83
+ const hh = String(d.getHours()).padStart(2, '0')
84
+ const mm = String(d.getMinutes()).padStart(2, '0')
85
+ return `${hh}:${mm}`
86
+ }
87
+
88
+ function isWriteBlockedSync(root: string): boolean {
89
+ try {
90
+ const p = join(maestroMetaDir(root), 'write-block.json')
91
+ if (!existsSync(p)) return false
92
+ const data = JSON.parse(readFileSync(p, 'utf8'))
93
+ return data.blocked === true
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ export class MaestroMemoryStore {
100
+ constructor(private readonly memoryDir: string | null = null) {}
101
+
102
+ private root(): string {
103
+ return resolveMemoryRoot(this.memoryDir)
104
+ }
105
+
106
+ private assertNotBlocked(): void {
107
+ const r = this.root()
108
+ if (isWriteBlockedSync(r)) {
109
+ throw new Error(`write blocked: migration verify mismatch (see ${join(maestroMetaDir(r), 'write-block.json')})`)
110
+ }
111
+ }
112
+
113
+ private fileFor(target: MemoryTarget, cwd?: string, date?: string): string {
114
+ const r = this.root()
115
+ const t = normalizeTarget(target)
116
+ switch (t) {
117
+ case 'memory':
118
+ return globalMemoryPath(r)
119
+ case 'user':
120
+ return userMemoryPath(r)
121
+ case 'daily': {
122
+ const d = date ?? todayStamp()
123
+ return dailyPath(r, d)
124
+ }
125
+ case 'project': {
126
+ if (!cwd) throw new Error('project track requires cwd')
127
+ return projectMemoryPath(r, cwd)
128
+ }
129
+ case 'key': {
130
+ if (!cwd) throw new Error('key track requires cwd')
131
+ return projectKeyPath(r, cwd)
132
+ }
133
+ default:
134
+ throw new Error(`unknown target ${target}`)
135
+ }
136
+ }
137
+
138
+ private archiveFileFor(target: MemoryTarget, cwd?: string): string {
139
+ const r = this.root()
140
+ const t = normalizeTarget(target)
141
+ if (t === 'memory') return globalArchivePath(r)
142
+ if (t === 'user') return userArchivePath(r)
143
+ if (t === 'key') {
144
+ if (!cwd) throw new Error('key archive requires cwd')
145
+ return projectKeyArchivePath(r, cwd)
146
+ }
147
+ throw new Error(`archive only for memory/user/key (got ${target})`)
148
+ }
149
+
150
+ // -------------------------------------------------------------------------
151
+ // Internal helpers: branch tag, summary tag, stamping
152
+ // -------------------------------------------------------------------------
153
+
154
+ private applyBranchTag(entry: string, branches?: string): string {
155
+ if (!branches) return entry
156
+ const list = branches
157
+ .split(',')
158
+ .map((b) => b.trim())
159
+ .filter(Boolean)
160
+ if (list.length === 0) return entry
161
+ // Insert [branch:...] after date prefix if present, else prepend
162
+ const dateMatch = /^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*/.exec(entry)
163
+ if (dateMatch) {
164
+ const prefix = dateMatch[0]
165
+ const rest = entry.slice(prefix.length)
166
+ // remove existing branch tag to avoid duplication
167
+ const cleaned = rest.replace(/^\[branch:[^\]]*\]\s*/, '')
168
+ return `${prefix}[branch:${list.join(',')}] ${cleaned}`
169
+ }
170
+ // No date, check for id prefix
171
+ const idMatch = /^\[id:\s*[0-9a-f]{8}\]\s*/i.exec(entry)
172
+ if (idMatch) {
173
+ const idPrefix = idMatch[0]
174
+ const rest = entry.slice(idPrefix.length)
175
+ const date2 = /^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*/.exec(rest)
176
+ if (date2) {
177
+ return `${idPrefix}${date2[0]}[branch:${list.join(',')}] ${rest.slice(date2[0].length).replace(/^\[branch:[^\]]*\]\s*/, '')}`
178
+ }
179
+ return `${idPrefix}[branch:${list.join(',')}] ${rest.replace(/^\[branch:[^\]]*\]\s*/, '')}`
180
+ }
181
+ return `[branch:${list.join(',')}] ${entry.replace(/^\[branch:[^\]]*\]\s*/, '')}`
182
+ }
183
+
184
+ private applySummaryTag(entry: string, summary?: string): string {
185
+ if (!summary) return entry
186
+ const sanitized = String(summary).replace(/[\n\r\t\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
187
+ if (!sanitized) return entry
188
+ const tag = `[summary:${sanitized}] `
189
+ // Insert after header: [id] [date] [branch] [dsh-only] etc.
190
+ // Simplify: find position after date/branch
191
+ // Reuse logic: try to place after branch if present, else after date
192
+ const idMatch = /^\[id:\s*[0-9a-f]{8}\]\s*/i.exec(entry)
193
+ let offset = 0
194
+ let prefix = ''
195
+ if (idMatch) {
196
+ prefix += idMatch[0]
197
+ offset += idMatch[0].length
198
+ }
199
+ const dateMatch = /^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*/.exec(entry.slice(offset))
200
+ if (dateMatch) {
201
+ prefix += dateMatch[0]
202
+ offset += dateMatch[0].length
203
+ } else {
204
+ // daily time: [HH:MM]
205
+ const timeMatch = /^\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*/.exec(entry.slice(offset))
206
+ if (timeMatch) {
207
+ prefix += timeMatch[0]
208
+ offset += timeMatch[0].length
209
+ }
210
+ }
211
+ // branch
212
+ const branchMatch = /^\[branch:[^\]]*\]\s*/.exec(entry.slice(offset))
213
+ if (branchMatch) {
214
+ prefix += branchMatch[0]
215
+ offset += branchMatch[0].length
216
+ }
217
+ // dsh-only
218
+ const dshMatch = /^\[dsh-only\]\s*/.exec(entry.slice(offset))
219
+ if (dshMatch) {
220
+ prefix += dshMatch[0]
221
+ offset += dshMatch[0].length
222
+ }
223
+ // Insert summary before remaining body
224
+ const rest = entry.slice(offset)
225
+ // remove existing summary to avoid duplication
226
+ const cleanedRest = rest.replace(/^\[summary:[^\]]*\]\s*/, '')
227
+ return `${prefix}${tag}${cleanedRest}`
228
+ }
229
+
230
+ private ensureId(entry: string): string {
231
+ if (/^\[id:\s*[0-9a-f]{8}\]\s*/i.test(entry)) return entry
232
+ return `[id:${genId()}] ${entry}`
233
+ }
234
+
235
+ // -------------------------------------------------------------------------
236
+ // Public API: add / list / replace / remove / archive / expand / snapshot
237
+ // -------------------------------------------------------------------------
238
+
239
+ /** List entries with optional query filters */
240
+ list(target: MemoryTarget, cwd?: string, opts: ListOpts = {}): string[] {
241
+ const t = normalizeTarget(target)
242
+ if (opts.archived) {
243
+ const af = this.archiveFileFor(t, cwd)
244
+ let entries = readEntriesSync(af)
245
+ return this.filterEntries(entries, opts)
246
+ }
247
+
248
+ // daily cross-file when since/until present; a single explicit date wins
249
+ if (t === 'daily' && opts.date !== undefined) {
250
+ let p: string
251
+ try {
252
+ p = this.fileFor('daily', undefined, opts.date)
253
+ } catch {
254
+ // invalid date guards the tool's traversal surface; list returns empty
255
+ return []
256
+ }
257
+ return this.filterEntries(readEntriesSync(p), { ...opts, date: undefined })
258
+ }
259
+ if (t === 'daily' && (opts.since !== undefined || opts.until !== undefined)) {
260
+ const dir = dailyDir(this.root())
261
+ let days: string[] = []
262
+ try {
263
+ days = readdirSync(dir)
264
+ .filter((n) => /^\d{4}-\d{2}-\d{2}\.md$/.test(n))
265
+ .map((n) => n.slice(0, 10))
266
+ .sort()
267
+ } catch {
268
+ days = []
269
+ }
270
+ let rows: { date: string | null; text: string }[] = []
271
+ for (const day of days) {
272
+ if (opts.since !== undefined && day < opts.since) continue
273
+ if (opts.until !== undefined && day > opts.until) continue
274
+ const p = dailyPath(this.root(), day)
275
+ let entries: string[] = []
276
+ try {
277
+ entries = readEntriesSync(p)
278
+ } catch {
279
+ continue
280
+ }
281
+ for (const e of entries) rows.push({ date: day, text: e })
282
+ }
283
+ // If no daily files matched but we still have today file that wasn't enumerated? Already covered via readdir.
284
+ // Apply filter/recent/limit/branch (branch irrelevant for daily but keep)
285
+ let texts = rows.map((r) => r.text)
286
+ // Filter by content
287
+ if (opts.filter) {
288
+ const q = opts.filter.toLowerCase()
289
+ texts = texts.filter((e) => e.toLowerCase().includes(q))
290
+ rows = rows.filter((r) => r.text.toLowerCase().includes(q))
291
+ }
292
+ // Since/until already applied via file date, but also filter entries whose internal date extracted differs? Keep simple: already file-based.
293
+ // Branch filter only for key; skip
294
+ if (opts.recent) texts = [...texts].reverse()
295
+ if (opts.limit !== undefined && Number.isFinite(opts.limit) && opts.limit > 0) {
296
+ texts = texts.slice(0, Math.floor(opts.limit))
297
+ }
298
+ return texts
299
+ }
300
+
301
+ const file = this.fileFor(t, cwd)
302
+ let entries = readEntriesSync(file)
303
+
304
+ // Branch filter for key (only when branch param provided and not empty)
305
+ if (t === 'key' && opts.branch !== undefined && String(opts.branch).trim() !== '') {
306
+ const b = String(opts.branch).trim()
307
+ entries = entries.filter((e) => {
308
+ const scope = parseEntryBranches(e)
309
+ return scope === null || scope.includes(b)
310
+ })
311
+ }
312
+
313
+ return this.filterEntries(entries, opts)
314
+ }
315
+
316
+ private filterEntries(entries: string[], opts: ListOpts): string[] {
317
+ let out = [...entries]
318
+ if (opts.filter !== undefined && String(opts.filter).trim() !== '') {
319
+ const q = String(opts.filter).toLowerCase()
320
+ out = out.filter((e) => e.toLowerCase().includes(q))
321
+ }
322
+ if (opts.since !== undefined || opts.until !== undefined) {
323
+ out = out.filter((e) => {
324
+ const d = extractEntryDate(e)
325
+ if (d === null) return true // undated survives date filters (per spec: not filtered out)
326
+ if (opts.since !== undefined && d < opts.since) return false
327
+ if (opts.until !== undefined && d > opts.until) return false
328
+ return true
329
+ })
330
+ }
331
+ if (opts.recent) out = [...out].reverse()
332
+ if (opts.limit !== undefined && Number.isFinite(opts.limit) && opts.limit > 0) {
333
+ out = out.slice(0, Math.floor(opts.limit))
334
+ }
335
+ return out
336
+ }
337
+
338
+ /** Add entry (with optional branches/summary for key) */
339
+ add(
340
+ target: MemoryTarget,
341
+ entry: string,
342
+ cwd?: string,
343
+ opts: { branches?: string; summary?: string; date?: string } = {},
344
+ ): { ok: true; duplicate?: boolean; id?: string } | { ok: false; error: string } {
345
+ try {
346
+ this.assertNotBlocked()
347
+ } catch (e: any) {
348
+ return { ok: false, error: e?.message ?? String(e) }
349
+ }
350
+ const t = normalizeTarget(target)
351
+ let content = String(entry ?? '').trim()
352
+ if (!content) return { ok: false, error: 'empty content' }
353
+ // For key, handle branches and summary before id generation
354
+ if (t === 'key') {
355
+ if (opts.branches) content = this.applyBranchTag(content, opts.branches)
356
+ if (opts.summary) {
357
+ content = this.applySummaryTag(content, opts.summary)
358
+ }
359
+ content = this.ensureId(content)
360
+ }
361
+ // For daily/project, strip hand-written date prefix? Keep simple: no stamping, store verbatim
362
+ let file: string
363
+ try {
364
+ file = this.fileFor(t, cwd, opts.date)
365
+ } catch (e: any) {
366
+ return { ok: false, error: e?.message ?? String(e) }
367
+ }
368
+ const res = appendEntryAtomicSync(file, content)
369
+ if (!res.ok) return { ok: false, error: res.error }
370
+ if (res.duplicate) return { ok: true, duplicate: true }
371
+ // Extract generated id if any
372
+ const m = /^\[id:\s*([0-9a-f]{8})\]/i.exec(content)
373
+ return { ok: true, id: m ? m[1].toLowerCase() : undefined }
374
+ }
375
+
376
+ /** Replace unique entry matching substring */
377
+ replace(
378
+ target: MemoryTarget,
379
+ match: string,
380
+ newContent: string,
381
+ cwd?: string,
382
+ opts: { date?: string } = {},
383
+ ): { ok: true } | { ok: false; error: string; matches?: string[] } {
384
+ try {
385
+ this.assertNotBlocked()
386
+ } catch (e: any) {
387
+ return { ok: false, error: e?.message ?? String(e) }
388
+ }
389
+ const t = normalizeTarget(target)
390
+ const oldText = String(match ?? '').trim()
391
+ const newText = String(newContent ?? '').trim()
392
+ if (!oldText) return { ok: false, error: 'empty match' }
393
+ if (!newText) return { ok: false, error: 'empty new content' }
394
+ let file: string
395
+ try {
396
+ file = this.fileFor(t, cwd, opts.date)
397
+ } catch (e: any) {
398
+ return { ok: false, error: e?.message ?? String(e) }
399
+ }
400
+ // Read + modify + write inside the directory lock so a concurrent append by
401
+ // another process is never clobbered by a stale read (cross-process safety).
402
+ return withLockSync(dirname(file), () => {
403
+ // Read current entries
404
+ const entries = readEntriesSync(file)
405
+ const matches = entries.filter((e) => e.includes(oldText))
406
+ if (matches.length === 0) return { ok: false, error: `no match for "${oldText}"` }
407
+ if (matches.length > 1) return { ok: false, error: `ambiguous match for "${oldText}" (${matches.length} hits)`, matches }
408
+ // Preserve id if existing entry had one (for key)
409
+ let replacement = newText
410
+ const oldEntry = matches[0]
411
+ const oldId = /^\[id:\s*([0-9a-f]{8})\]\s*/i.exec(oldEntry)?.[1]
412
+ // If replacement already has summary handling? Not for replace; keep simple
413
+ if (oldId) {
414
+ // If replacement doesn't already have id, prepend it
415
+ if (!/^\[id:\s*[0-9a-f]{8}\]\s*/i.test(replacement)) {
416
+ replacement = `[id:${oldId.toLowerCase()}] ${replacement}`
417
+ }
418
+ }
419
+ const idx = entries.indexOf(oldEntry)
420
+ const next = [...entries]
421
+ next[idx] = replacement
422
+ const res = writeEntriesAtomicSync(file, next)
423
+ if (!res.ok) return { ok: false, error: res.error }
424
+ return { ok: true }
425
+ })
426
+ }
427
+
428
+ /** Remove unique entry matching substring */
429
+ remove(
430
+ target: MemoryTarget,
431
+ match: string,
432
+ cwd?: string,
433
+ opts: { date?: string } = {},
434
+ ): { ok: true; removed?: string } | { ok: false; error: string; matches?: string[] } {
435
+ try {
436
+ this.assertNotBlocked()
437
+ } catch (e: any) {
438
+ return { ok: false, error: e?.message ?? String(e) }
439
+ }
440
+ const t = normalizeTarget(target)
441
+ const oldText = String(match ?? '').trim()
442
+ if (!oldText) return { ok: false, error: 'empty match' }
443
+ let file: string
444
+ try {
445
+ file = this.fileFor(t, cwd, opts.date)
446
+ } catch (e: any) {
447
+ return { ok: false, error: e?.message ?? String(e) }
448
+ }
449
+ return withLockSync(dirname(file), () => {
450
+ const entries = readEntriesSync(file)
451
+ // Use ID-immune exact? But remove is substring unique, not exact. Follow legacy: filter includes.
452
+ const matches = entries.filter((e) => e.includes(oldText))
453
+ if (matches.length === 0) return { ok: false, error: `no match for "${oldText}"` }
454
+ if (matches.length > 1) return { ok: false, error: `ambiguous match for "${oldText}" (${matches.length} hits)`, matches }
455
+ const idx = entries.indexOf(matches[0])
456
+ const next = [...entries]
457
+ next.splice(idx, 1)
458
+ const res = writeEntriesAtomicSync(file, next)
459
+ if (!res.ok) return { ok: false, error: res.error }
460
+ return { ok: true, removed: matches[0] }
461
+ })
462
+ }
463
+
464
+ /** Archive-before-delete: move entry to archive then remove from main */
465
+ archive(
466
+ target: MemoryTarget,
467
+ match: string,
468
+ cwd?: string,
469
+ ): { ok: true } | { ok: false; error: string } {
470
+ try {
471
+ this.assertNotBlocked()
472
+ } catch (e: any) {
473
+ return { ok: false, error: e?.message ?? String(e) }
474
+ }
475
+ const t = normalizeTarget(target)
476
+ if (t !== 'memory' && t !== 'user' && t !== 'key') {
477
+ return { ok: false, error: 'archive only for memory/user/key' }
478
+ }
479
+ const m = String(match ?? '').trim()
480
+ if (!m) return { ok: false, error: 'empty match' }
481
+ const mainFile = this.fileFor(t, cwd)
482
+ return withLockSync(dirname(mainFile), () => {
483
+ const entries = readEntriesSync(mainFile)
484
+ const matches = entries.filter((e) => e.includes(m))
485
+ if (matches.length === 0) return { ok: false, error: `no match for "${m}"` }
486
+ if (matches.length > 1) return { ok: false, error: `ambiguous match for "${m}" (${matches.length} hits)` }
487
+ const toArchive = matches[0]
488
+ // Step 1: append to archive
489
+ const archiveFile = this.archiveFileFor(t, cwd)
490
+ const archRes = appendEntryAtomicSync(archiveFile, toArchive)
491
+ if (!archRes.ok) return { ok: false, error: `archive append failed: ${archRes.error}` }
492
+ // Step 2: remove from main only after archive succeeds
493
+ const idx = entries.indexOf(toArchive)
494
+ const next = [...entries]
495
+ next.splice(idx, 1)
496
+ const writeRes = writeEntriesAtomicSync(mainFile, next)
497
+ if (!writeRes.ok) {
498
+ // Archive already written, but main delete failed; report partial (archive succeeded)
499
+ return { ok: false, error: `archive succeeded but remove failed: ${writeRes.error}` }
500
+ }
501
+ return { ok: true }
502
+ })
503
+ }
504
+
505
+ /** List archive entries (with optional query) */
506
+ listArchive(target: MemoryTarget, cwd?: string, opts: ListOpts = {}): string[] {
507
+ const t = normalizeTarget(target)
508
+ const af = this.archiveFileFor(t, cwd)
509
+ let entries = readEntriesSync(af)
510
+ return this.filterEntries(entries, opts)
511
+ }
512
+
513
+ /** Expand: load full entry by id for key (summary/expand) */
514
+ expand(target: MemoryTarget, id: string, cwd?: string): { ok: true; entry: string } | { ok: false; error: string } {
515
+ const t = normalizeTarget(target)
516
+ if (t !== 'key') return { ok: false, error: 'expand only for key' }
517
+ const cleanId = String(id ?? '').trim().toLowerCase()
518
+ if (!cleanId) return { ok: false, error: 'empty id' }
519
+ if (!cwd) return { ok: false, error: 'key expand requires cwd' }
520
+ const file = this.fileFor(t, cwd)
521
+ const entries = readEntriesSync(file)
522
+ // Branch filter: if current entries are branch-scoped, expand should respect branch? For simplicity, search all
523
+ const found = entries.find((e) => e.toLowerCase().includes(`[id:${cleanId}]`) || e.toLowerCase().includes(cleanId))
524
+ if (!found) return { ok: false, error: `no entry with id ${cleanId}` }
525
+ return { ok: true, entry: found }
526
+ }
527
+
528
+ /** Get summary for display (explicit summary or auto) */
529
+ summaryFor(entry: string): string {
530
+ const explicit = parseEntrySummary(entry)
531
+ if (explicit !== null) return explicit
532
+ return autoSummary(entry)
533
+ }
534
+
535
+ // -------------------------------------------------------------------------
536
+ // Legacy helpers for snapshot + simple lists
537
+ // -------------------------------------------------------------------------
538
+
539
+ listGlobal(): string[] {
540
+ return this.list('memory')
541
+ }
542
+
543
+ listUser(): string[] {
544
+ return this.list('user')
545
+ }
546
+
547
+ listKey(cwd: string, opts: ListOpts = {}): string[] {
548
+ return this.list('key', cwd, opts)
549
+ }
550
+
551
+ listDaily(cwd?: string, date?: string, opts: ListOpts = {}): string[] {
552
+ // For backward compat, daily list without date uses today
553
+ if (date) {
554
+ const p = dailyPath(this.root(), date)
555
+ return this.filterEntries(readEntriesSync(p), opts)
556
+ }
557
+ return this.list('daily', undefined, opts)
558
+ }
559
+
560
+ listProject(cwd: string, opts: ListOpts = {}): string[] {
561
+ return this.list('project', cwd, opts)
562
+ }
563
+
564
+ addGlobal(entry: string): { ok: true; duplicate?: boolean } | { ok: false; error: string } {
565
+ return this.add('memory', entry)
566
+ }
567
+
568
+ addUser(entry: string): { ok: true; duplicate?: boolean } | { ok: false; error: string } {
569
+ return this.add('user', entry)
570
+ }
571
+
572
+ addKey(cwd: string, entry: string, opts: { branches?: string; summary?: string } = {}): { ok: true; duplicate?: boolean; id?: string } | { ok: false; error: string } {
573
+ return this.add('key', entry, cwd, opts)
574
+ }
575
+
576
+ addDaily(entry: string, date?: string): { ok: true; duplicate?: boolean } | { ok: false; error: string } {
577
+ const d = date ?? todayStamp()
578
+ const file = dailyPath(this.root(), d)
579
+ const res = appendEntryAtomicSync(file, entry)
580
+ if (!res.ok) return { ok: false, error: res.error }
581
+ return { ok: true, duplicate: res.duplicate }
582
+ }
583
+
584
+ addProject(cwd: string, entry: string): { ok: true; duplicate?: boolean } | { ok: false; error: string } {
585
+ return this.add('project', entry, cwd)
586
+ }
587
+
588
+ /** Bounded snapshot: memory + user + key (branch-filtered), excludes project/daily */
589
+ snapshot(cwd: string | null, opts: { branch?: string } = {}): string {
590
+ const mem = this.list('memory')
591
+ const user = this.list('user')
592
+ const key = cwd ? this.list('key', cwd, opts.branch ? { branch: opts.branch } : {}) : []
593
+ const parts: string[] = []
594
+ if (mem.length) parts.push(`# Global Memory\n${mem.join('\n---\n')}`)
595
+ if (user.length) parts.push(`# User Memory\n${user.join('\n---\n')}`)
596
+ if (key.length) parts.push(`# Project Key Memory\n${key.join('\n---\n')}`)
597
+ return parts.join('\n\n')
598
+ }
599
+
600
+ /** Snapshot that respects branch filter (same as above, explicit) */
601
+ snapshotForBranch(cwd: string | null, branch?: string): string {
602
+ return this.snapshot(cwd, { branch })
603
+ }
604
+ }
605
+
606
+ // Re-export ArchiveStore for direct use (matches legacy)
607
+ export class MaestroArchiveStore {
608
+ constructor(private readonly memoryDir: string | null = null) {}
609
+
610
+ private root(): string {
611
+ return resolveMemoryRoot(this.memoryDir)
612
+ }
613
+
614
+ fileFor(target: MemoryTarget, cwd?: string): string {
615
+ const r = this.root()
616
+ const t = normalizeTarget(target)
617
+ if (t === 'memory') return globalArchivePath(r)
618
+ if (t === 'user') return userArchivePath(r)
619
+ if (t === 'key') {
620
+ if (!cwd) throw new Error('key archive requires cwd')
621
+ return projectKeyArchivePath(r, cwd)
622
+ }
623
+ throw new Error(`archive only for memory/user/key`)
624
+ }
625
+
626
+ entries(target: MemoryTarget, cwd?: string): string[] {
627
+ return readEntriesSync(this.fileFor(target, cwd))
628
+ }
629
+
630
+ append(target: MemoryTarget, content: string, cwd?: string): { ok: true } | { ok: false; error: string } {
631
+ const p = this.fileFor(target, cwd)
632
+ const res = appendEntryAtomicSync(p, content)
633
+ if (!res.ok) return { ok: false, error: res.error }
634
+ return { ok: true }
635
+ }
636
+
637
+ remove(target: MemoryTarget, match: string, cwd?: string): { ok: true } | { ok: false; error: string } {
638
+ const p = this.fileFor(target, cwd)
639
+ const entries = readEntriesSync(p)
640
+ const matches = entries.filter((e) => e.includes(match))
641
+ if (matches.length === 0) return { ok: false, error: `no archive match for "${match}"` }
642
+ if (matches.length > 1) return { ok: false, error: `ambiguous archive match` }
643
+ const idx = entries.indexOf(matches[0])
644
+ const next = [...entries]
645
+ next.splice(idx, 1)
646
+ const res = writeEntriesAtomicSync(p, next)
647
+ if (!res.ok) return { ok: false, error: res.error }
648
+ return { ok: true }
649
+ }
650
+ }