@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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +756 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +627 -0
- package/lib/index.js.map +1 -0
- package/lib/memory/batch.d.ts +44 -0
- package/lib/memory/batch.d.ts.map +1 -0
- package/lib/memory/batch.js +75 -0
- package/lib/memory/batch.js.map +1 -0
- package/lib/memory/feedback.d.ts +18 -0
- package/lib/memory/feedback.d.ts.map +1 -0
- package/lib/memory/feedback.js +29 -0
- package/lib/memory/feedback.js.map +1 -0
- package/lib/memory/store.d.ts +154 -0
- package/lib/memory/store.d.ts.map +1 -0
- package/lib/memory/store.js +606 -0
- package/lib/memory/store.js.map +1 -0
- package/lib/migration/cli.d.ts +18 -0
- package/lib/migration/cli.d.ts.map +1 -0
- package/lib/migration/cli.js +143 -0
- package/lib/migration/cli.js.map +1 -0
- package/lib/migration/fixture.d.ts +45 -0
- package/lib/migration/fixture.d.ts.map +1 -0
- package/lib/migration/fixture.js +177 -0
- package/lib/migration/fixture.js.map +1 -0
- package/lib/migration/service.d.ts +75 -0
- package/lib/migration/service.d.ts.map +1 -0
- package/lib/migration/service.js +713 -0
- package/lib/migration/service.js.map +1 -0
- package/lib/prompt/snapshot.d.ts +26 -0
- package/lib/prompt/snapshot.d.ts.map +1 -0
- package/lib/prompt/snapshot.js +74 -0
- package/lib/prompt/snapshot.js.map +1 -0
- package/lib/review/queue.d.ts +72 -0
- package/lib/review/queue.d.ts.map +1 -0
- package/lib/review/queue.js +306 -0
- package/lib/review/queue.js.map +1 -0
- package/lib/skills-browser.d.ts +67 -0
- package/lib/skills-browser.d.ts.map +1 -0
- package/lib/skills-browser.js +185 -0
- package/lib/skills-browser.js.map +1 -0
- package/lib/storage/atomic-store.d.ts +211 -0
- package/lib/storage/atomic-store.d.ts.map +1 -0
- package/lib/storage/atomic-store.js +582 -0
- package/lib/storage/atomic-store.js.map +1 -0
- package/lib/storage/layout.d.ts +81 -0
- package/lib/storage/layout.d.ts.map +1 -0
- package/lib/storage/layout.js +162 -0
- package/lib/storage/layout.js.map +1 -0
- package/lib/storage/legacy-format.d.ts +104 -0
- package/lib/storage/legacy-format.d.ts.map +1 -0
- package/lib/storage/legacy-format.js +262 -0
- package/lib/storage/legacy-format.js.map +1 -0
- package/lib/sync/config.d.ts +22 -0
- package/lib/sync/config.d.ts.map +1 -0
- package/lib/sync/config.js +57 -0
- package/lib/sync/config.js.map +1 -0
- package/lib/sync/git.d.ts +97 -0
- package/lib/sync/git.d.ts.map +1 -0
- package/lib/sync/git.js +282 -0
- package/lib/sync/git.js.map +1 -0
- package/lib/sync/index.d.ts +6 -0
- package/lib/sync/index.d.ts.map +1 -0
- package/lib/sync/index.js +6 -0
- package/lib/sync/index.js.map +1 -0
- package/lib/sync/layout.d.ts +15 -0
- package/lib/sync/layout.d.ts.map +1 -0
- package/lib/sync/layout.js +33 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.d.ts.map +1 -0
- package/lib/sync/merge.js +159 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/service.d.ts +70 -0
- package/lib/sync/service.d.ts.map +1 -0
- package/lib/sync/service.js +508 -0
- package/lib/sync/service.js.map +1 -0
- package/lib/todo/store.d.ts +142 -0
- package/lib/todo/store.d.ts.map +1 -0
- package/lib/todo/store.js +452 -0
- package/lib/todo/store.js.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/client/index.tsx +994 -0
- package/src/host/index.ts +591 -0
- package/src/host/memory/batch.ts +107 -0
- package/src/host/memory/feedback.ts +36 -0
- package/src/host/memory/store.ts +650 -0
- package/src/host/migration/cli.ts +139 -0
- package/src/host/migration/fixture.ts +218 -0
- package/src/host/migration/service.ts +786 -0
- package/src/host/prompt/snapshot.ts +95 -0
- package/src/host/review/queue.ts +325 -0
- package/src/host/skills-browser.ts +191 -0
- package/src/host/storage/atomic-store.ts +605 -0
- package/src/host/storage/layout.ts +191 -0
- package/src/host/storage/legacy-format.ts +308 -0
- package/src/host/sync/config.ts +67 -0
- package/src/host/sync/git.ts +280 -0
- package/src/host/sync/index.ts +5 -0
- package/src/host/sync/layout.ts +39 -0
- package/src/host/sync/merge.ts +188 -0
- package/src/host/sync/service.ts +494 -0
- 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
|
+
}
|