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/ARCHITECTURE.md +132 -0
- package/CHANGELOG.md +46 -0
- package/LICENSE +201 -0
- package/README.es.md +166 -0
- package/README.hi.md +166 -0
- package/README.md +166 -0
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/client/client.d.ts +6 -0
- package/client/client.js +240 -0
- package/cordis.patch.yml +4 -0
- package/index.mjs +1849 -0
- package/lib/budget.mjs +110 -0
- package/lib/constants.mjs +58 -0
- package/lib/errors.mjs +143 -0
- package/lib/extract.mjs +54 -0
- package/lib/gate.mjs +131 -0
- package/lib/match.mjs +43 -0
- package/lib/snapshot.mjs +115 -0
- package/lib/store.mjs +654 -0
- package/lib/strings.mjs +43 -0
- package/lib/workspace.mjs +31 -0
- package/package.json +93 -0
- package/types.d.ts +191 -0
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
|
+
}
|