dsh-memento 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.mjs ADDED
@@ -0,0 +1,654 @@
1
+ // lib/store.mjs — 本地 SQLite Provider(零 DSH 依赖,仅 node: 内置模块)。
2
+ //
3
+ // 单文件库(WAL),表结构支撑 双轨 × 双层 × 条目文本 + 元数据(来源、创建/更新
4
+ // 时间、会话 id)。replace/remove 用唯一子串匹配(instr,绕开 LIKE 转义问题),
5
+ // 不唯一/零命中时报错要求更具体。另有插件自有审计表 audit:每条记忆变更/快照
6
+ // 落一行(动作、结果、审批来源、会话 id),与审批 seam 的 approval/asked +
7
+ // approval/decided 审计对一起构成完整审计链。
8
+ //
9
+ // Provider 不做预算裁决(那是 Service 层职责),也绝不静默截断。
10
+
11
+ import { DatabaseSync } from 'node:sqlite'
12
+ import { mkdirSync, chmodSync, existsSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { randomUUID } from 'node:crypto'
15
+ import { SCHEMA_VERSION, DEFAULT_DB_NAME, TRACKS, SCOPES, ERROR_CODES, MAX_QUERY_LIMIT } from './constants.mjs'
16
+ import { findUniqueMatch, requireUniqueMatch } from './match.mjs'
17
+ import { StoreError, InvalidInputError, EntryNotFoundError, AmbiguousMatchError, ProposalNotFoundError } from './errors.mjs'
18
+
19
+ /**
20
+ * @typedef {object} EntryRow - entries 表 SELECT 行(toEntry 输入)。
21
+ * @property {string} id
22
+ * @property {'user' | 'agent'} track
23
+ * @property {'user-global' | 'workspace'} scope
24
+ * @property {string} workspace_key
25
+ * @property {string} agent_key
26
+ * @property {string} text
27
+ * @property {string} source
28
+ * @property {number} created_at
29
+ * @property {number} updated_at
30
+ * @property {number | null} last_recalled
31
+ * @property {number} recall_count
32
+ * @property {string | null} session_id
33
+ * @typedef {object} StoreInsertInput
34
+ * @property {string} track
35
+ * @property {string} scope
36
+ * @property {string} [workspaceKey]
37
+ * @property {string} [agentKey]
38
+ * @property {string} text
39
+ * @property {string} [source]
40
+ * @property {string | null} [sessionId]
41
+ * @typedef {object} StoreMatchInput
42
+ * @property {string} track
43
+ * @property {string} scope
44
+ * @property {string} match
45
+ * @typedef {object} StoreReplaceInput
46
+ * @property {string} track
47
+ * @property {string} scope
48
+ * @property {string} match
49
+ * @property {string} text
50
+ * @property {string | null} [sessionId]
51
+ * @typedef {object} StoreQueryFilter
52
+ * @property {string} [track]
53
+ * @property {string} [scope]
54
+ * @property {string} [text]
55
+ * @property {number} [limit]
56
+ * @typedef {object} AuditInput
57
+ * @property {string} action
58
+ * @property {string | null} [track]
59
+ * @property {string | null} [scope]
60
+ * @property {string | null} [entryId]
61
+ * @property {string | null} [text]
62
+ * @property {string | null} [outcome]
63
+ * @property {string | null} [source]
64
+ * @property {string | null} [sessionId]
65
+ * @typedef {object} AuditRow - audit 表 SELECT 行。
66
+ * @property {number} seq
67
+ * @property {number} ts
68
+ * @property {string} action
69
+ * @property {string | null} track
70
+ * @property {string | null} scope
71
+ * @property {string | null} entry_id
72
+ * @property {string | null} text
73
+ * @property {string | null} outcome
74
+ * @property {string | null} source
75
+ * @property {string | null} session_id
76
+ * @typedef {object} ProposalRow - proposals 表 SELECT 行。
77
+ * @property {string} id
78
+ * @property {string} kind
79
+ * @property {string} track
80
+ * @property {string} scope
81
+ * @property {string} workspace_key
82
+ * @property {string} agent_key
83
+ * @property {string} text
84
+ * @property {string} source
85
+ * @property {string | null} session_id
86
+ * @property {string} status
87
+ * @property {number} created_at
88
+ * @property {number | null} decided_at
89
+ * @typedef {import('node:sqlite').DatabaseSync} Db
90
+ * @typedef {import('../types.js').MemoryEntry} MemoryEntry
91
+ * @typedef {import('../types.js').MemoryQueryResult} MemoryQueryResult
92
+ * @typedef {object} Store - openMemoryStore 返回的 Provider 句柄。
93
+ * @property {Db} db
94
+ * @property {string} path
95
+ * @property {(input: StoreInsertInput) => MemoryEntry} insertEntry
96
+ * @property {(inputs: StoreInsertInput[]) => MemoryEntry[]} seedEntries
97
+ * @property {(input: StoreReplaceInput) => {previous: MemoryEntry, entry: MemoryEntry}} replaceEntry
98
+ * @property {(input: StoreMatchInput) => MemoryEntry} removeEntry
99
+ * @property {(input: {track: string, scope: string, matches: string[], text: string, source?: string, workspaceKey?: string, sessionId?: string | null}) => {removed: MemoryEntry[], entry: MemoryEntry}} consolidateEntries
100
+ * @property {(filter?: StoreQueryFilter) => MemoryQueryResult} queryEntries
101
+ * @property {() => MemoryEntry[]} listEntries
102
+ * @property {(track: string, scope: string, match: string) => MemoryEntry[]} matchCandidates
103
+ * @property {(track: string, scope: string) => number} usage
104
+ * @property {(row: AuditInput) => object} auditAppend
105
+ * @property {(limit?: number) => object[]} auditList
106
+ * @property {(input: object) => object | null} proposalUpsert
107
+ * @property {(status?: string, limit?: number) => object[]} proposalList
108
+ * @property {(id: string, status: 'approved' | 'dismissed') => object} proposalDecide
109
+ * @property {() => void} close
110
+ */
111
+
112
+ /** WAL 之外的 PRAGMA:串行写 + 等待锁上限。 */
113
+ const PRAGMAS = 'PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;'
114
+
115
+ /**
116
+ * POSIX 上把记忆库主文件与已存在的 WAL/-shm 边车收紧为属主读写(0600)。
117
+ * 边车由 SQLite 惰性创建,因此在 PRAGMA WAL 生效之后调用、只 chmod 已存在的
118
+ * 文件(尽力而为,避免为 chmod 触发边车创建);Windows 无 POSIX 权限位,跳过。
119
+ * @param {string} dbPath - 主库绝对路径。
120
+ * @param {{platform?: string, chmod?: (path: string, mode: number) => void, exists?: (path: string) => boolean}} [io] - 测试注入用。
121
+ */
122
+ export function chmodOwned(dbPath, io = {}) {
123
+ const platform = io.platform ?? process.platform
124
+ const chmod = io.chmod ?? chmodSync
125
+ const exists = io.exists ?? existsSync
126
+ if (platform === 'win32') return
127
+ chmod(dbPath, 0o600)
128
+ for (const suffix of ['-wal', '-shm']) {
129
+ const sidecar = `${dbPath}${suffix}`
130
+ if (exists(sidecar)) chmod(sidecar, 0o600)
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 解析记忆库绝对路径。
136
+ * - 显式绝对路径:原样规范化。
137
+ * - 显式相对路径:相对 $DSH_HOME(有则),否则相对进程 cwd。
138
+ * - 空值:$DSH_HOME/dsh-memento/memory.db;$DSH_HOME 缺失时响亮失败(S5)。
139
+ * @param {string} [dbPath] - Config.dbPath。
140
+ * @param {string|undefined} [dshHome] - 环境 $DSH_HOME(测试注入)。
141
+ * @returns {string} 绝对路径。
142
+ */
143
+ export function resolveDbPath(dbPath, dshHome = process.env.DSH_HOME) {
144
+ if (dbPath) {
145
+ const base = dshHome ?? process.cwd()
146
+ return path.isAbsolute(dbPath) ? path.normalize(dbPath) : path.resolve(base, dbPath)
147
+ }
148
+ if (!dshHome) {
149
+ throw new StoreError(
150
+ ERROR_CODES.MISSING_DSH_HOME,
151
+ 'dbPath is not configured and $DSH_HOME is not set; run under dsh or set dbPath explicitly',
152
+ )
153
+ }
154
+ return path.join(dshHome, 'dsh-memento', DEFAULT_DB_NAME)
155
+ }
156
+
157
+ /**
158
+ * 校验 track/scope 词汇(写路径入口)。非法值响亮失败,绝不落到 SQL。
159
+ * @param {string} track - 轨道。
160
+ * @param {string} scope - 作用域。
161
+ * @returns {{track: 'user' | 'agent', scope: 'user-global' | 'workspace'}} 原值(已校验)。
162
+ */
163
+ export function assertScope(track, scope) {
164
+ if (!/** @type {readonly string[]} */ (TRACKS).includes(track) || !/** @type {readonly string[]} */ (SCOPES).includes(scope)) {
165
+ throw new InvalidInputError(`invalid memory scope: track=${JSON.stringify(track)} scope=${JSON.stringify(scope)} (track ∈ ${TRACKS.join('|')}, scope ∈ ${SCOPES.join('|')})`)
166
+ }
167
+ return { track: /** @type {'user' | 'agent'} */ (track), scope: /** @type {'user-global' | 'workspace'} */ (scope) }
168
+ }
169
+
170
+ /** 把 SELECT 行映射为稳定条目形状。 */
171
+ function toEntry(/** @type {EntryRow} */ row) {
172
+ return {
173
+ id: row.id,
174
+ track: row.track,
175
+ scope: row.scope,
176
+ workspaceKey: row.workspace_key,
177
+ agentKey: row.agent_key,
178
+ text: row.text,
179
+ source: row.source,
180
+ createdAt: row.created_at,
181
+ updatedAt: row.updated_at,
182
+ lastRecalled: row.last_recalled,
183
+ recallCount: row.recall_count,
184
+ sessionId: row.session_id,
185
+ }
186
+ }
187
+
188
+ /** 把 proposals SELECT 行映射为稳定提案形状。 */
189
+ function toProposal(/** @type {ProposalRow} */ row) {
190
+ return {
191
+ id: row.id,
192
+ kind: row.kind,
193
+ track: row.track,
194
+ scope: row.scope,
195
+ workspaceKey: row.workspace_key,
196
+ agentKey: row.agent_key,
197
+ text: row.text,
198
+ source: row.source,
199
+ sessionId: row.session_id,
200
+ status: row.status,
201
+ createdAt: row.created_at,
202
+ decidedAt: row.decided_at,
203
+ }
204
+ }
205
+
206
+ /**
207
+ * 打开(或创建)记忆库并迁移到当前 schema。库损坏/版本过新在打开点响亮抛出。
208
+ * @param {string} dbPath - 绝对路径。
209
+ * @param {{retentionDays?: number}} [options] - {retentionDays}:>0 时裁剪超过保留天数的审计行。
210
+ * @returns {Store} store:插入/更新/删除/查询/审计/关闭。
211
+ */
212
+ export function openMemoryStore(dbPath, options = {}) {
213
+ mkdirSync(path.dirname(dbPath), { recursive: true })
214
+ let db
215
+ try {
216
+ db = new DatabaseSync(dbPath)
217
+ } catch (error) {
218
+ throw new StoreError(
219
+ ERROR_CODES.STORE_CORRUPT,
220
+ `cannot open memory database at ${dbPath}: ${error instanceof Error ? error.message : String(error)}`,
221
+ { path: dbPath },
222
+ )
223
+ }
224
+ try {
225
+ db.exec(PRAGMAS)
226
+ // S4:边车(-wal/-shm)在 PRAGMA 生效后才被惰性创建,此处统一收紧权限。
227
+ chmodOwned(dbPath)
228
+ migrate(db, dbPath)
229
+ pruneAudit(db, options.retentionDays ?? 0)
230
+ } catch (error) {
231
+ closeDb(db)
232
+ if (error instanceof StoreError) throw error
233
+ throw new StoreError(
234
+ ERROR_CODES.STORE_CORRUPT,
235
+ `memory database at ${dbPath} failed schema validation: ${error instanceof Error ? error.message : String(error)}`,
236
+ { path: dbPath },
237
+ )
238
+ }
239
+
240
+ const store = {
241
+ db,
242
+ path: dbPath,
243
+
244
+ /**
245
+ * 新增一条记忆。
246
+ * @param {StoreInsertInput} input - {track, scope, workspaceKey, text, source, sessionId}。
247
+ * @returns {MemoryEntry} 已落盘条目。
248
+ */
249
+ insertEntry(input) {
250
+ return insertOne(db, input)
251
+ },
252
+
253
+ /**
254
+ * 批量插入(事务内原子:任一条失败整体回滚,绝无部分写入)。
255
+ * @param {StoreInsertInput[]} inputs - 与 insertEntry 相同的输入数组。
256
+ * @returns {MemoryEntry[]} 已落盘条目(与输入同序)。
257
+ */
258
+ seedEntries(inputs) {
259
+ if (!Array.isArray(inputs) || inputs.length === 0) {
260
+ throw new InvalidInputError('seed requires a non-empty entry list')
261
+ }
262
+ return withTransaction(db, () => inputs.map((input) => insertOne(db, input)))
263
+ },
264
+
265
+ /**
266
+ * 按唯一子串匹配并替换文本(事务内匹配+更新,原子)。
267
+ * @param {StoreReplaceInput} input - {track, scope, match, text, sessionId}。
268
+ * @returns {{previous: MemoryEntry, entry: MemoryEntry}} 旧条目与更新后的条目。
269
+ */
270
+ replaceEntry(input) {
271
+ const { track, scope } = assertScope(input.track, input.scope)
272
+ if (typeof input.match !== 'string' || input.match.length === 0) {
273
+ throw new InvalidInputError('replace/remove match must be a non-empty string')
274
+ }
275
+ if (typeof input.text !== 'string' || input.text.length === 0) {
276
+ throw new InvalidInputError('entry text must be a non-empty string')
277
+ }
278
+ return withTransaction(db, () => {
279
+ const found = matchEntries(db, track, scope, input.match)
280
+ const target = requireUniqueMatch(findUniqueMatch(found, input.match), { track, scope, match: input.match }, { EntryNotFoundError, AmbiguousMatchError })
281
+ db.prepare('UPDATE entries SET text = ?, updated_at = ?, session_id = ? WHERE id = ?')
282
+ .run(input.text, Date.now(), input.sessionId ?? null, target.id)
283
+ return { previous: target, entry: getEntry(db, target.id) }
284
+ })
285
+ },
286
+
287
+ /**
288
+ * 按唯一子串匹配并删除(事务内匹配+删除,原子)。
289
+ * @param {StoreMatchInput} input - {track, scope, match}。
290
+ * @returns {MemoryEntry} 被删除的条目。
291
+ */
292
+ removeEntry(input) {
293
+ const { track, scope } = assertScope(input.track, input.scope)
294
+ if (typeof input.match !== 'string' || input.match.length === 0) {
295
+ throw new InvalidInputError('replace/remove match must be a non-empty string')
296
+ }
297
+ return withTransaction(db, () => {
298
+ const found = matchEntries(db, track, scope, input.match)
299
+ const target = requireUniqueMatch(findUniqueMatch(found, input.match), { track, scope, match: input.match }, { EntryNotFoundError, AmbiguousMatchError })
300
+ db.prepare('DELETE FROM entries WHERE id = ?').run(target.id)
301
+ return target
302
+ })
303
+ },
304
+
305
+ /**
306
+ * 事务内按多个唯一子串整合:逐一定位(零/多命中响亮报错)→ 全部删除 → 插入新条目。
307
+ * 任一步失败整体回滚(原子,绝无部分写入)。
308
+ * @param {{track: string, scope: string, matches: string[], text: string, source?: string, workspaceKey?: string, agentKey?: string, sessionId?: string | null}} input - 整合方案。
309
+ * @returns {{removed: MemoryEntry[], entry: MemoryEntry}} 被删除的旧条目与新条目。
310
+ */
311
+ consolidateEntries(input) {
312
+ const { track, scope } = assertScope(input.track, input.scope)
313
+ if (!Array.isArray(input.matches) || input.matches.length === 0 || input.matches.length > 20) {
314
+ throw new InvalidInputError('consolidate matches must be an array of 1..20 non-empty strings')
315
+ }
316
+ if (typeof input.text !== 'string' || input.text.length === 0) {
317
+ throw new InvalidInputError('entry text must be a non-empty string')
318
+ }
319
+ for (const match of input.matches) {
320
+ if (typeof match !== 'string' || match.length === 0) {
321
+ throw new InvalidInputError('consolidate matches must be an array of 1..20 non-empty strings')
322
+ }
323
+ }
324
+ return withTransaction(db, () => {
325
+ const removed = []
326
+ for (const match of input.matches) {
327
+ const found = matchEntries(db, track, scope, match)
328
+ const target = requireUniqueMatch(findUniqueMatch(found, match), { track, scope, match }, { EntryNotFoundError, AmbiguousMatchError })
329
+ db.prepare('DELETE FROM entries WHERE id = ?').run(target.id)
330
+ removed.push(target)
331
+ }
332
+ const entry = insertOne(db, {
333
+ track, scope, text: input.text,
334
+ workspaceKey: input.workspaceKey,
335
+ agentKey: input.agentKey,
336
+ source: input.source,
337
+ sessionId: input.sessionId ?? null,
338
+ })
339
+ return { removed, entry }
340
+ })
341
+ },
342
+
343
+ /**
344
+ * 查询条目:子串过滤(大小写不敏感,ASCII 折叠;CJK 无大小写不受影响)+ 数量上限。
345
+ * 排序按召回频次(高频即重要):recall_count DESC, updated_at DESC, id;命中页的条目
346
+ * 召回计数 +1(last_recalled 同步)。快照用 listEntries 保持创建序(冻结块稳定优先)。
347
+ * @param {StoreQueryFilter} [filter] - {track, scope, text, limit}。
348
+ * @returns {MemoryQueryResult}。
349
+ */
350
+ queryEntries(filter = {}) {
351
+ const conditions = []
352
+ const params = []
353
+ if (filter.track !== undefined) { conditions.push('track = ?'); params.push(filter.track) }
354
+ if (filter.scope !== undefined) { conditions.push('scope = ?'); params.push(filter.scope) }
355
+ if (typeof filter.text === 'string' && filter.text.length > 0) {
356
+ conditions.push('instr(lower(text), lower(?)) > 0'); params.push(filter.text)
357
+ }
358
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
359
+ const total = /** @type {number} */ (db.prepare(`SELECT COUNT(*) AS n FROM entries ${where}`).get(...params).n)
360
+ const requested = Number.isInteger(filter.limit) && filter.limit > 0 ? filter.limit : MAX_QUERY_LIMIT
361
+ const limit = Math.min(requested, MAX_QUERY_LIMIT)
362
+ const rows = /** @type {EntryRow[]} */ (db.prepare(`SELECT * FROM entries ${where} ORDER BY recall_count DESC, updated_at DESC, id LIMIT ?`)
363
+ .all(...params, limit))
364
+ if (rows.length > 0) {
365
+ const placeholders = rows.map(() => '?').join(', ')
366
+ db.prepare(`UPDATE entries SET recall_count = recall_count + 1, last_recalled = ? WHERE id IN (${placeholders})`)
367
+ .run(Date.now(), ...rows.map((row) => row.id))
368
+ }
369
+ return { entries: rows.map(toEntry), total, truncated: total > rows.length }
370
+ },
371
+
372
+ /** @returns {MemoryEntry[]} 全部条目(快照/报表用)。 */
373
+ listEntries() {
374
+ return /** @type {EntryRow[]} */ (db.prepare('SELECT * FROM entries ORDER BY created_at, id').all()).map(toEntry)
375
+ },
376
+
377
+ /**
378
+ * 无界候选匹配(replace/remove/consolidate 的定位语义;大小写不敏感)。
379
+ * 不带 limit——定位必须覆盖该层全部条目,绝不静默截断。
380
+ * @param {string} track - 轨道。
381
+ * @param {string} scope - 作用域。
382
+ * @param {string} match - 目标子串。
383
+ * @returns {MemoryEntry[]} 候选条目(创建序)。
384
+ */
385
+ matchCandidates(track, scope, match) {
386
+ return matchEntries(db, track, scope, match)
387
+ },
388
+
389
+ /**
390
+ * (track, scope) 当前字符用量(JS 字符数,与 lib/budget.mjs 一致)。
391
+ * @param {string} track - 轨道。
392
+ * @param {string} scope - 作用域。
393
+ * @returns {number} 用量。
394
+ */
395
+ usage(track, scope) {
396
+ let used = 0
397
+ const rows = /** @type {Array<{text: string}>} */ (db.prepare('SELECT text FROM entries WHERE track = ? AND scope = ?').all(track, scope))
398
+ for (const row of rows) {
399
+ used += row.text.length
400
+ }
401
+ return used
402
+ },
403
+
404
+ /**
405
+ * 追加一条审计记录(插件自有审计账本,独立于会话日志)。
406
+ * @param {AuditInput} row - {action, track, scope, entryId, text, outcome, source, sessionId}。
407
+ * @returns {object} 审计行(含 seq 与 ts)。
408
+ */
409
+ auditAppend(row) {
410
+ const ts = Date.now()
411
+ db.prepare(`INSERT INTO audit (ts, action, track, scope, entry_id, text, outcome, source, session_id)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
413
+ .run(ts, row.action, row.track ?? null, row.scope ?? null, row.entryId ?? null,
414
+ row.text ?? null, row.outcome ?? null, row.source ?? null, row.sessionId ?? null)
415
+ return { seq: Number(db.prepare('SELECT last_insert_rowid() AS seq').get().seq), ts, ...row }
416
+ },
417
+
418
+ /**
419
+ * 最近审计行(面板/审计用)。
420
+ * @param {number} [limit] - 上限。
421
+ * @returns {object[]} 按时间倒序。
422
+ */
423
+ auditList(limit = 100) {
424
+ return /** @type {AuditRow[]} */ (db.prepare('SELECT * FROM audit ORDER BY seq DESC LIMIT ?').all(limit))
425
+ .map((row) => ({
426
+ seq: row.seq,
427
+ ts: row.ts,
428
+ action: row.action,
429
+ track: row.track,
430
+ scope: row.scope,
431
+ entryId: row.entry_id,
432
+ text: row.text,
433
+ outcome: row.outcome,
434
+ source: row.source,
435
+ sessionId: row.session_id,
436
+ }))
437
+ },
438
+
439
+ /** 关闭连接(幂等)。 */
440
+ close() {
441
+ closeDb(db)
442
+ },
443
+
444
+ /**
445
+ * 幂等插入提案:同 (session_id, kind) 已存在则跳过并返回 null(INSERT OR IGNORE)。
446
+ * @param {{kind: string, track: string, scope: string, workspaceKey?: string, agentKey?: string, text: string, source?: string, sessionId?: string | null}} input - 提案内容。
447
+ * @returns {object | null} 已落盘提案;幂等命中返回 null。
448
+ */
449
+ proposalUpsert(input) {
450
+ const { track, scope } = assertScope(input.track, input.scope)
451
+ if (typeof input.text !== 'string' || input.text.length === 0) {
452
+ throw new InvalidInputError('proposal text must be a non-empty string')
453
+ }
454
+ const id = randomUUID()
455
+ const ts = Date.now()
456
+ const result = db.prepare(`INSERT OR IGNORE INTO proposals
457
+ (id, kind, track, scope, workspace_key, agent_key, text, source, session_id, status, created_at)
458
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`)
459
+ .run(id, input.kind, track, scope, input.workspaceKey ?? '', input.agentKey ?? '', input.text,
460
+ input.source ?? 'dsh-memento', input.sessionId ?? null, ts)
461
+ if (result.changes === 0) return null
462
+ return toProposal(/** @type {ProposalRow} */ (db.prepare('SELECT * FROM proposals WHERE id = ?').get(id)))
463
+ },
464
+
465
+ /**
466
+ * 提案列表(按创建时间升序;可按状态过滤)。
467
+ * @param {string} [status] - pending/approved/dismissed;省略返回全部。
468
+ * @param {number} [limit] - 上限(默认 100)。
469
+ * @returns {object[]} 提案数组。
470
+ */
471
+ proposalList(status, limit = 100) {
472
+ const where = status === undefined ? '' : 'WHERE status = ?'
473
+ const params = status === undefined ? [limit] : [status, limit]
474
+ const rows = /** @type {ProposalRow[]} */ (db.prepare(`SELECT * FROM proposals ${where} ORDER BY created_at, id LIMIT ?`).all(...params))
475
+ return rows.map(toProposal)
476
+ },
477
+
478
+ /**
479
+ * 裁决提案:pending → approved/dismissed(幂等约束:非 pending 响亮报错)。
480
+ * @param {string} id - 提案 id。
481
+ * @param {'approved' | 'dismissed'} status - 目标状态。
482
+ * @returns {object} 已裁决提案。
483
+ */
484
+ proposalDecide(id, status) {
485
+ if (status !== 'approved' && status !== 'dismissed') {
486
+ throw new InvalidInputError(`proposal decision must be approved or dismissed, got ${JSON.stringify(status)}`)
487
+ }
488
+ const row = /** @type {ProposalRow | undefined} */ (db.prepare('SELECT * FROM proposals WHERE id = ?').get(id))
489
+ if (row === undefined) throw new ProposalNotFoundError(id)
490
+ if (row.status !== 'pending') throw new ProposalNotFoundError(id, `already ${row.status}`)
491
+ db.prepare('UPDATE proposals SET status = ?, decided_at = ? WHERE id = ?').run(status, Date.now(), id)
492
+ return toProposal(/** @type {ProposalRow} */ (db.prepare('SELECT * FROM proposals WHERE id = ?').get(id)))
493
+ },
494
+ }
495
+ return store
496
+ }
497
+
498
+ /** 按 id 读取单条。 */
499
+ function getEntry(/** @type {Db} */ db, /** @type {string} */ id) {
500
+ const row = /** @type {EntryRow | undefined} */ (db.prepare('SELECT * FROM entries WHERE id = ?').get(id))
501
+ if (row === undefined) throw new StoreError(ERROR_CODES.STORE_CORRUPT, `entry ${id} vanished mid-transaction`)
502
+ return toEntry(row)
503
+ }
504
+
505
+ /** 校验并插入单条(insertEntry 与 seedEntries 共用;调用方决定是否在事务内)。 */
506
+ function insertOne(/** @type {Db} */ db, /** @type {StoreInsertInput} */ input) {
507
+ const { track, scope } = assertScope(input.track, input.scope)
508
+ if (typeof input.text !== 'string' || input.text.length === 0) {
509
+ throw new InvalidInputError('entry text must be a non-empty string')
510
+ }
511
+ const entry = {
512
+ id: randomUUID(),
513
+ track,
514
+ scope,
515
+ workspaceKey: input.workspaceKey ?? '',
516
+ agentKey: input.agentKey ?? '',
517
+ text: input.text,
518
+ source: input.source ?? 'dsh-memento',
519
+ createdAt: Date.now(),
520
+ updatedAt: Date.now(),
521
+ lastRecalled: /** @type {number | null} */ (null),
522
+ recallCount: 0,
523
+ sessionId: input.sessionId ?? null,
524
+ }
525
+ db.prepare(`INSERT INTO entries (id, track, scope, workspace_key, agent_key, text, source, created_at, updated_at, session_id)
526
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
527
+ .run(entry.id, entry.track, entry.scope, entry.workspaceKey, entry.agentKey, entry.text, entry.source,
528
+ entry.createdAt, entry.updatedAt, entry.sessionId)
529
+ return entry
530
+ }
531
+
532
+ /** 子串匹配候选(instr + lower,大小写不敏感;CJK 无大小写不受影响)。 */
533
+ function matchEntries(/** @type {Db} */ db, /** @type {string} */ track, /** @type {string} */ scope, /** @type {string} */ match) {
534
+ return /** @type {EntryRow[]} */ (db.prepare(`SELECT * FROM entries WHERE track = ? AND scope = ? AND instr(lower(text), lower(?)) > 0 ORDER BY created_at, id`)
535
+ .all(track, scope, match))
536
+ .map(toEntry)
537
+ }
538
+
539
+ /** 事务包装:失败回滚并原样重抛(空 catch 语义:只回滚,不回吞)。 */
540
+ function withTransaction(/** @type {Db} */ db, /** @type {() => any} */ fn) {
541
+ db.exec('BEGIN IMMEDIATE')
542
+ try {
543
+ const result = fn()
544
+ db.exec('COMMIT')
545
+ return result
546
+ } catch (error) {
547
+ try { db.exec('ROLLBACK') } catch { /* 连接已坏时 ROLLBACK 无意义,保留原错误 */ }
548
+ throw error
549
+ }
550
+ }
551
+
552
+ /** 建表 + 版本迁移。新库直接建当前版本;旧库逐级迁移;版本高于当前 → 响亮拒绝(防降级读坏数据)。 */
553
+ function migrate(/** @type {Db} */ db, /** @type {string} */ dbPath) {
554
+ // 新库先探测表存在性再 prepare:对不存在的表 prepare 会直接抛错。
555
+ const hasMeta = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get() !== undefined
556
+ if (!hasMeta) {
557
+ db.exec(BASE_SCHEMA_SQL)
558
+ db.exec(PROPOSALS_SCHEMA_SQL)
559
+ db.exec(V3_SCHEMA_SQL)
560
+ db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION))
561
+ return
562
+ }
563
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version')
564
+ const version = row === undefined ? 0 : Number(row.value)
565
+ if (!Number.isInteger(version) || version < 0) {
566
+ throw new StoreError(ERROR_CODES.STORE_CORRUPT, `memory database at ${dbPath} has invalid schema_version ${JSON.stringify(row?.value)}`, { path: dbPath })
567
+ }
568
+ if (version > SCHEMA_VERSION) {
569
+ throw new StoreError(
570
+ ERROR_CODES.STORE_UNSUPPORTED_VERSION,
571
+ `memory database at ${dbPath} has schema_version ${version} > supported ${SCHEMA_VERSION}; upgrade dsh-memento instead of downgrading`,
572
+ { path: dbPath },
573
+ )
574
+ }
575
+ let current = version
576
+ if (current < 2) {
577
+ db.exec(PROPOSALS_SCHEMA_SQL)
578
+ current = 2
579
+ }
580
+ if (current < 3) {
581
+ db.exec(V3_SCHEMA_SQL)
582
+ current = 3
583
+ }
584
+ db.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'").run(String(current))
585
+ }
586
+
587
+ /** v1 基础表(meta/entries/audit)——新库建库与逐级迁移共用同一梯子(BASE → v2 → v3)。 */
588
+ const BASE_SCHEMA_SQL = `
589
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
590
+ CREATE TABLE entries (
591
+ id TEXT PRIMARY KEY,
592
+ track TEXT NOT NULL CHECK (track IN ('user', 'agent')),
593
+ scope TEXT NOT NULL CHECK (scope IN ('user-global', 'workspace')),
594
+ workspace_key TEXT NOT NULL DEFAULT '',
595
+ text TEXT NOT NULL,
596
+ source TEXT NOT NULL,
597
+ created_at INTEGER NOT NULL,
598
+ updated_at INTEGER NOT NULL,
599
+ session_id TEXT
600
+ );
601
+ CREATE INDEX entries_track_scope ON entries (track, scope);
602
+ CREATE TABLE audit (
603
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
604
+ ts INTEGER NOT NULL,
605
+ action TEXT NOT NULL,
606
+ track TEXT,
607
+ scope TEXT,
608
+ entry_id TEXT,
609
+ text TEXT,
610
+ outcome TEXT,
611
+ source TEXT,
612
+ session_id TEXT
613
+ );
614
+ CREATE INDEX audit_ts ON audit (ts);
615
+ `
616
+
617
+ /** v2 proposals 提案表(auto-capture 的压缩记忆提案;(session_id, kind) 幂等;v2 形状无 agent_key)。 */
618
+ const PROPOSALS_SCHEMA_SQL = `
619
+ CREATE TABLE proposals (
620
+ id TEXT PRIMARY KEY,
621
+ kind TEXT NOT NULL,
622
+ track TEXT NOT NULL,
623
+ scope TEXT NOT NULL,
624
+ workspace_key TEXT NOT NULL DEFAULT '',
625
+ text TEXT NOT NULL,
626
+ source TEXT NOT NULL,
627
+ session_id TEXT,
628
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'dismissed')),
629
+ created_at INTEGER NOT NULL,
630
+ decided_at INTEGER,
631
+ UNIQUE (session_id, kind)
632
+ );
633
+ CREATE INDEX proposals_status ON proposals (status);
634
+ `
635
+
636
+ /** v3 增量:per-agent 作用域与召回排序列(新库建库时也走这一段)。 */
637
+ const V3_SCHEMA_SQL = `
638
+ ALTER TABLE entries ADD COLUMN agent_key TEXT NOT NULL DEFAULT '';
639
+ ALTER TABLE entries ADD COLUMN last_recalled INTEGER;
640
+ ALTER TABLE entries ADD COLUMN recall_count INTEGER NOT NULL DEFAULT 0;
641
+ ALTER TABLE proposals ADD COLUMN agent_key TEXT NOT NULL DEFAULT '';
642
+ `
643
+
644
+ /** 关闭连接(幂等,吞掉二次关闭的报错)。 */
645
+ function closeDb(/** @type {Db} */ db) {
646
+ try { db.close() } catch { /* 已关闭或关闭失败:disposer 里不抛出,避免遮蔽卸载主错误 */ }
647
+ }
648
+
649
+ /** 审计保留裁剪:retentionDays > 0 时删除早于截止时间的审计行(audit_ts 索引已存在)。 */
650
+ function pruneAudit(/** @type {Db} */ db, /** @type {number} */ retentionDays) {
651
+ if (!Number.isInteger(retentionDays) || retentionDays <= 0) return
652
+ const cutoff = Date.now() - retentionDays * 86400000
653
+ db.prepare('DELETE FROM audit WHERE ts < ?').run(cutoff)
654
+ }