memory-eternal 0.9.23 → 0.10.2
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/.claude-plugin/plugin.json +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +27 -7
- package/README.md +40 -7
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +187 -32
- package/lib/api.js +592 -536
- package/lib/capture-log.js +65 -59
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +278 -14
- package/lib/client.js +20 -17
- package/lib/config-sync.js +161 -89
- package/lib/db.js +359 -212
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -470
- package/lib/sse.js +51 -51
- package/lib/stall.js +30 -30
- package/lib/sweep.js +56 -56
- package/lib/vault-resolve.js +84 -84
- package/lib/vault.js +347 -106
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -7
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +20 -17
- package/web/index.html +46 -46
package/lib/vault.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import fs from 'node:fs'
|
|
8
|
-
import { getDb, enforceAudit } from './db.js'
|
|
8
|
+
import { getDb, enforceAudit, nextCardId, MAIN_STATUS, QUARANTINE_STATUSES, tableForStatus } from './db.js'
|
|
9
9
|
import { minhashSignature, lshCandidates } from './minhash.js'
|
|
10
10
|
|
|
11
11
|
/** 主题目录根。 */
|
|
@@ -134,14 +134,34 @@ export function queryTerms(query) {
|
|
|
134
134
|
return tokens
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* 在目标目录中找与候选文本最相似的现有卡(去重守卫)。比较正文。
|
|
139
|
+
*
|
|
140
|
+
* 只比对 **approved**(P0 修复):命中后调用方会 `appendUpdate` 往这张卡里追加内容,
|
|
141
|
+
* 而 pending / rejected 是不该再长内容的状态——旧实现只按 `deleted_at IS NULL` 过滤,
|
|
142
|
+
* 会把新知识追加进待审垃圾卡或已被驳回的卡,等于绕过审核往废弃卡里灌数据。
|
|
143
|
+
*/
|
|
138
144
|
export async function dedupCheck(root, text, threshold = 0.62) {
|
|
145
|
+
return bestDuplicate(await mainStoreBodies(root), text, threshold)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 主库已审核卡的 `(path, body)` 快照。
|
|
150
|
+
*
|
|
151
|
+
* 批量写入(导入备份)必须用它做**导入前**基线:否则同一份备份里互相近似的卡会在写入
|
|
152
|
+
* 过程中互相判重,把备份当成「重复」丢掉一批(备份是真相源,不能被自己人吞)。
|
|
153
|
+
*/
|
|
154
|
+
export async function mainStoreBodies(root) {
|
|
139
155
|
const db = getDb(root)
|
|
140
|
-
|
|
156
|
+
return db.prepare("SELECT path, body FROM cards WHERE status = 'approved' AND deleted_at IS NULL").all()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 在一组候选里找与给定文本最相似的一张(≥ 阈值)。候选集合由调用方提供,供快照比对复用。 */
|
|
160
|
+
export function bestDuplicate(candidates, text, threshold = 0.62) {
|
|
141
161
|
let best = null
|
|
142
|
-
for (const row of
|
|
143
|
-
const score = textSimilarity(text, row
|
|
144
|
-
if (score >= threshold && (!best || score > best.score)) best = { path: row
|
|
162
|
+
for (const row of Array.isArray(candidates) ? candidates : []) {
|
|
163
|
+
const score = textSimilarity(text, row?.body || '')
|
|
164
|
+
if (score >= threshold && (!best || score > best.score)) best = { path: row?.path, score }
|
|
145
165
|
}
|
|
146
166
|
return best
|
|
147
167
|
}
|
|
@@ -220,21 +240,72 @@ function agentWhere(agent, args) {
|
|
|
220
240
|
return "LOWER(COALESCE(submitted_by,'')) = ?"
|
|
221
241
|
}
|
|
222
242
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
243
|
+
// ---- 两套存储:主库(cards,只存 approved)/ 隔离区(quarantine,pending·rejected·deleted)--
|
|
244
|
+
//
|
|
245
|
+
// 下面三个函数负责「把状态白名单翻译成对两张表的查询」。读路径默认只碰主库:
|
|
246
|
+
// 主库物理上不含非 approved 行,所以查主库**不需要写状态条件**,也就没有「漏写状态条件」这种 bug 面。
|
|
247
|
+
|
|
248
|
+
/** 状态白名单 → 需要读的表清单。 */
|
|
249
|
+
function storesFor(want) {
|
|
250
|
+
const hasMain = want.includes(MAIN_STATUS)
|
|
251
|
+
const hasQuarantine = want.some((s) => QUARANTINE_STATUSES.includes(s))
|
|
252
|
+
if (hasMain && !hasQuarantine) return [{ table: 'cards', statuses: null }]
|
|
253
|
+
if (hasQuarantine && !hasMain) return [{ table: 'quarantine', statuses: want.filter((s) => QUARANTINE_STATUSES.includes(s)) }]
|
|
254
|
+
return [
|
|
255
|
+
{ table: 'cards', statuses: null },
|
|
256
|
+
{ table: 'quarantine', statuses: want.filter((s) => QUARANTINE_STATUSES.includes(s)) },
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 生成一条「跨两套存储」的查询。
|
|
262
|
+
* @param sel 选择列表(如 '*' 或 'path, title')
|
|
263
|
+
* @param stores storesFor() 的结果
|
|
264
|
+
* @param extra { kind, agent, tail } —— tail 是 ORDER BY / LIMIT 片段(默认按 updated_at 倒序)
|
|
265
|
+
*/
|
|
266
|
+
function multiStoreQuery(sel, stores, { kind = '', agent = '', tail = 'ORDER BY updated_at DESC' } = {}) {
|
|
267
|
+
const parts = []
|
|
268
|
+
const args = []
|
|
269
|
+
for (const store of stores) {
|
|
270
|
+
const where = []
|
|
271
|
+
const wargs = []
|
|
272
|
+
if (store.statuses) {
|
|
273
|
+
where.push(`status IN (${store.statuses.map(() => '?').join(',')})`)
|
|
274
|
+
wargs.push(...store.statuses)
|
|
275
|
+
}
|
|
276
|
+
if (kind) { where.push('kind = ?'); wargs.push(kind) }
|
|
277
|
+
if (agent) where.push(agentWhere(agent, wargs))
|
|
278
|
+
parts.push(`SELECT ${sel} FROM ${store.table}${where.length ? ' WHERE ' + where.join(' AND ') : ''}`)
|
|
279
|
+
args.push(...wargs)
|
|
280
|
+
}
|
|
281
|
+
return { sql: parts.join(' UNION ALL ') + ' ' + tail, args }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 在两张表里按 path 找一行(跨存储读取用,返回 { table, row })。 */
|
|
285
|
+
function findCardRow(db, rel) {
|
|
286
|
+
const inMain = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
|
|
287
|
+
if (inMain) return { table: 'cards', row: inMain }
|
|
288
|
+
const inQuarantine = db.prepare('SELECT * FROM quarantine WHERE path = ?').get(rel)
|
|
289
|
+
if (inQuarantine) return { table: 'quarantine', row: inQuarantine }
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 某个 path 是否已被占用(两张表任意一张里有,就算占用)。 */
|
|
294
|
+
function pathTaken(db, rel) {
|
|
295
|
+
if (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) return true
|
|
296
|
+
return !!db.prepare('SELECT 1 FROM quarantine WHERE path = ?').get(rel)
|
|
230
297
|
}
|
|
231
298
|
|
|
232
299
|
/** 标题排序用的中文排序器(拼音 + 数字感知);固定 zh-Hans-CN,避免随进程 locale 漂移。 */
|
|
233
300
|
export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
|
234
301
|
|
|
235
302
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
303
|
+
* 列出卡片。
|
|
304
|
+
*
|
|
305
|
+
* 两套存储下 status 的语义:'approved' → 只查主库(**不加状态条件**,主库物理上只有 approved);
|
|
306
|
+
* 'pending'/'rejected'/'deleted' → 查隔离区;'all' → 两张表 UNION(审核中心用)。
|
|
307
|
+
*
|
|
308
|
+
* @param opts.status 状态白名单(默认 approved = 主库)
|
|
238
309
|
* @param opts.kind 类型过滤(SQL)
|
|
239
310
|
* @param opts.agent 署名过滤(服务端归一,如 deepseek-harness / claude)
|
|
240
311
|
* @param opts.limit 返回条数上限(缺省=全部,供内部调用保持原语义)
|
|
@@ -242,35 +313,49 @@ export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, s
|
|
|
242
313
|
* @param opts.sort 'recent'(updated_at DESC,默认)| 'title'(拼音升序,跨页全局有序)
|
|
243
314
|
*/
|
|
244
315
|
export async function listCards(root, { status, kind, agent, limit, offset = 0, sort = 'recent' } = {}) {
|
|
245
|
-
const want = status ?? [
|
|
316
|
+
const want = status ?? [MAIN_STATUS]
|
|
246
317
|
const db = getDb(root)
|
|
247
|
-
const
|
|
318
|
+
const stores = storesFor(want)
|
|
319
|
+
|
|
248
320
|
if (sort === 'title') {
|
|
249
321
|
// SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
|
|
250
322
|
// 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
|
|
251
|
-
const
|
|
323
|
+
const { sql, args } = multiStoreQuery('path, title', stores, { kind, agent, tail: '' })
|
|
324
|
+
const light = db.prepare(sql).all(...args)
|
|
252
325
|
light.sort((a, b) => TITLE_COLLATOR.compare(a.title || '', b.title || ''))
|
|
253
326
|
const start = Math.max(0, offset | 0)
|
|
254
327
|
const page = Number.isInteger(limit) && limit > 0 ? light.slice(start, start + limit) : light.slice(start)
|
|
255
328
|
if (page.length === 0) return []
|
|
256
|
-
const
|
|
257
|
-
const rows =
|
|
329
|
+
const paths = page.map((r) => r.path)
|
|
330
|
+
const rows = []
|
|
331
|
+
for (const store of stores) {
|
|
332
|
+
const ph = paths.map(() => '?').join(',')
|
|
333
|
+
rows.push(...db.prepare(`SELECT * FROM ${store.table} WHERE path IN (${ph})`).all(...paths))
|
|
334
|
+
}
|
|
258
335
|
const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
|
|
259
336
|
return page.map((r) => byPath.get(r.path)).filter(Boolean)
|
|
260
337
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
338
|
+
|
|
339
|
+
const tail = Number.isInteger(limit) && limit > 0
|
|
340
|
+
? `ORDER BY updated_at DESC LIMIT ? OFFSET ?`
|
|
341
|
+
: 'ORDER BY updated_at DESC'
|
|
342
|
+
// 注意:两张表的列数不同(quarantine 多 quarantined_at / quarantine_reason),
|
|
343
|
+
// UNION ALL 必须显式列出公共列,不能用 `*`(否则列会错位)。
|
|
344
|
+
const { sql, args } = multiStoreQuery(
|
|
345
|
+
'id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at',
|
|
346
|
+
stores,
|
|
347
|
+
{ kind, agent, tail },
|
|
348
|
+
)
|
|
349
|
+
const finalArgs = Number.isInteger(limit) && limit > 0 ? [...args, limit, Math.max(0, offset | 0)] : args
|
|
350
|
+
return db.prepare(sql).all(...finalArgs).map(rowToCard)
|
|
267
351
|
}
|
|
268
352
|
|
|
269
353
|
/** 计数(分页接口要用 total 判断还有没有下一页)。 */
|
|
270
354
|
export async function countCards(root, { status, kind, agent } = {}) {
|
|
271
355
|
const db = getDb(root)
|
|
272
|
-
const
|
|
273
|
-
|
|
356
|
+
const stores = storesFor(status ?? [MAIN_STATUS])
|
|
357
|
+
const { sql, args } = multiStoreQuery('COUNT(*) n', stores, { kind, agent, tail: '' })
|
|
358
|
+
return db.prepare(`SELECT SUM(n) n FROM (${sql})`).get(...args).n || 0
|
|
274
359
|
}
|
|
275
360
|
|
|
276
361
|
/** 审核队列:status=pending 或 rejected。 */
|
|
@@ -279,10 +364,58 @@ export async function auditQueue(root) {
|
|
|
279
364
|
return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
|
|
280
365
|
}
|
|
281
366
|
|
|
282
|
-
/**
|
|
367
|
+
/** 同一个事务里执行 fn(失败自动回滚)。跨表搬家必须原子,否则会丢卡。 */
|
|
368
|
+
function inTransaction(db, fn) {
|
|
369
|
+
db.exec('BEGIN')
|
|
370
|
+
try {
|
|
371
|
+
const out = fn()
|
|
372
|
+
db.exec('COMMIT')
|
|
373
|
+
return out
|
|
374
|
+
} catch (e) {
|
|
375
|
+
try { db.exec('ROLLBACK') } catch { /* 已回滚 */ }
|
|
376
|
+
throw e
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 把一张卡从一张表搬到另一张表(重建行、保住内容 / 时间戳 / 更新记录)。
|
|
382
|
+
*
|
|
383
|
+
* 为什么不用同一个 id:两张表共享 `path` 命名空间,但 id 由 `card_sequence` 单一发号器
|
|
384
|
+
* 保证**全库唯一**;跨表插入时旧 id 可能已被目标表占用(实测 `UNIQUE constraint failed`)。
|
|
385
|
+
* 所以搬家时重新领号,并把 `card_updates.card_id` 一起改绑 —— 用户视角「更新记录还在」。
|
|
386
|
+
* 之所以要原子:INSERT 成功而 DELETE 失败会留下重复卡;反过来则直接丢卡。
|
|
387
|
+
*/
|
|
388
|
+
function moveCardBetweenStores(db, from, to, rel, { status, deletedAt = null, quarantineReason = '', updatedAt } = {}) {
|
|
389
|
+
const now = updatedAt || new Date().toISOString()
|
|
390
|
+
return inTransaction(db, () => {
|
|
391
|
+
const src = db.prepare(`SELECT * FROM ${from} WHERE path = ?`).get(rel)
|
|
392
|
+
if (!src) throw new Error(`卡片不在 ${from}: ${rel}`)
|
|
393
|
+
const newId = nextCardId(db)
|
|
394
|
+
|
|
395
|
+
if (to === 'quarantine') {
|
|
396
|
+
db.prepare(`
|
|
397
|
+
INSERT INTO quarantine
|
|
398
|
+
(id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
|
|
399
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
|
|
400
|
+
`).run(newId, src.path, src.kind, src.title, src.tags, src.body, src.summary, status, src.source, src.submitted_by, src.severity, src.reason, src.created_at, now, deletedAt, quarantineReason)
|
|
401
|
+
} else {
|
|
402
|
+
db.prepare(`
|
|
403
|
+
INSERT INTO cards
|
|
404
|
+
(id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
405
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
406
|
+
`).run(newId, src.path, src.kind, src.title, src.tags, src.body, src.summary, status, src.source, src.submitted_by, src.severity, src.reason, src.created_at, now, deletedAt)
|
|
407
|
+
}
|
|
408
|
+
// 更新记录跟着卡走(id 变了,按新 id 改绑)
|
|
409
|
+
db.prepare('UPDATE card_updates SET card_id = ? WHERE card_id = ?').run(newId, src.id)
|
|
410
|
+
db.prepare(`DELETE FROM ${from} WHERE path = ?`).run(rel)
|
|
411
|
+
return { ok: true, status, table: to, oldId: src.id, newId }
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 回收站:隔离区里 status=deleted,按 deleted_at 倒序。 */
|
|
283
416
|
export async function recycleList(root) {
|
|
284
417
|
const db = getDb(root)
|
|
285
|
-
const rows = db.prepare('SELECT * FROM
|
|
418
|
+
const rows = db.prepare('SELECT * FROM quarantine WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
|
|
286
419
|
return rows.map(rowToCard)
|
|
287
420
|
}
|
|
288
421
|
|
|
@@ -290,7 +423,7 @@ export async function recycleList(root) {
|
|
|
290
423
|
export async function purgeExpired(root, retentionDays = 30) {
|
|
291
424
|
const db = getDb(root)
|
|
292
425
|
const cut = new Date(Date.now() - retentionDays * 86400000).toISOString()
|
|
293
|
-
const rows = db.prepare('SELECT path FROM
|
|
426
|
+
const rows = db.prepare('SELECT path FROM quarantine WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
|
|
294
427
|
let purged = 0
|
|
295
428
|
for (const row of rows) {
|
|
296
429
|
try { await deleteCard(root, row.path, { permanent: true }); purged++ } catch {}
|
|
@@ -298,113 +431,193 @@ export async function purgeExpired(root, retentionDays = 30) {
|
|
|
298
431
|
return { ok: true, purged }
|
|
299
432
|
}
|
|
300
433
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
434
|
+
/**
|
|
435
|
+
* 读取一张卡(返回 Markdown 文本)。
|
|
436
|
+
*
|
|
437
|
+
* 两套存储下的语义:
|
|
438
|
+
* - 默认**只读主库**(主库物理上只含 approved)→ 想读未审核内容,必须显式 `allowUnapproved`。
|
|
439
|
+
* - `allowUnapproved: true` 时(审核中心 / 回收站预览)走两张表。
|
|
440
|
+
* 「不存在」与「存在但未审核」是两种不同的错误码,方便上层区分。
|
|
441
|
+
*/
|
|
442
|
+
export async function readCard(root, rel, { allowUnapproved = false } = {}) {
|
|
303
443
|
const db = getDb(root)
|
|
304
|
-
const
|
|
305
|
-
|
|
444
|
+
const mainRow = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
|
|
445
|
+
let row = mainRow
|
|
446
|
+
if (!row && allowUnapproved) row = db.prepare('SELECT * FROM quarantine WHERE path = ?').get(rel)
|
|
447
|
+
if (!row) {
|
|
448
|
+
// path 可能在隔离区而不在主库 —— 这种情况不能当成「不存在」,否则审核中心会误判
|
|
449
|
+
const inQuarantine = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
|
|
450
|
+
const err = new Error(inQuarantine
|
|
451
|
+
? `卡片在隔离区(${statusLabel(inQuarantine.status)}),未通过审核不能读取正文:${rel}`
|
|
452
|
+
: `卡片不存在: ${rel}`)
|
|
453
|
+
err.code = inQuarantine ? 'CARD_NOT_APPROVED' : 'CARD_NOT_FOUND'
|
|
454
|
+
if (inQuarantine) err.status = inQuarantine.status
|
|
455
|
+
throw err
|
|
456
|
+
}
|
|
457
|
+
const status = String(row.status || '')
|
|
306
458
|
const { text } = rowToMarkdown(row)
|
|
307
|
-
// 附加更新记录
|
|
308
459
|
const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
|
|
309
460
|
if (updates.length > 0) {
|
|
310
461
|
const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
|
|
311
|
-
return text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n'
|
|
462
|
+
return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text: text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n' }
|
|
312
463
|
}
|
|
313
|
-
return text
|
|
464
|
+
return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 状态中文标签(错误信息与日志用)。 */
|
|
468
|
+
function statusLabel(status) {
|
|
469
|
+
const s = String(status || '')
|
|
470
|
+
return s === 'pending' ? '待审核' : s === 'rejected' ? '已驳回' : s === 'deleted' ? '回收站中' : s
|
|
314
471
|
}
|
|
315
472
|
|
|
316
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* 删除卡片。
|
|
475
|
+
* - 永久删除:从**任一张表**里抹掉(含更新记录,靠外键 CASCADE)。
|
|
476
|
+
* - 软删(默认):主库卡 → 搬进隔离区并置 deleted;隔离区卡 → 原地置 deleted。
|
|
477
|
+
*/
|
|
317
478
|
export async function deleteCard(root, rel, { permanent = false } = {}) {
|
|
318
479
|
const db = getDb(root)
|
|
319
480
|
if (permanent) {
|
|
320
|
-
|
|
481
|
+
// 先按 path 取 id(两张表共享 id 空间),再清理按 id 关联的更新记录 —— card_updates
|
|
482
|
+
// 不带外键(卡会在两表间搬家),所以级联必须在这里手工做。
|
|
483
|
+
const ids = []
|
|
484
|
+
const mainRow = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
|
|
485
|
+
if (mainRow) ids.push(mainRow.id)
|
|
486
|
+
const qRow = db.prepare('SELECT id FROM quarantine WHERE path = ?').get(rel)
|
|
487
|
+
if (qRow) ids.push(qRow.id)
|
|
488
|
+
if (!ids.length) throw new Error('卡片不存在: ' + rel)
|
|
489
|
+
inTransaction(db, () => {
|
|
490
|
+
for (const id of ids) {
|
|
491
|
+
db.prepare('DELETE FROM card_updates WHERE card_id = ?').run(id)
|
|
492
|
+
db.prepare('DELETE FROM feedback WHERE card_path = ?').run(rel)
|
|
493
|
+
}
|
|
494
|
+
db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
|
|
495
|
+
db.prepare('DELETE FROM quarantine WHERE path = ?').run(rel)
|
|
496
|
+
})
|
|
321
497
|
return { ok: true, permanent: true }
|
|
322
498
|
}
|
|
499
|
+
const existing = findCardRow(db, rel)
|
|
500
|
+
if (!existing) throw new Error('卡片不存在: ' + rel)
|
|
501
|
+
if (existing.table === 'cards') {
|
|
502
|
+
const r = moveCardBetweenStores(db, 'cards', 'quarantine', rel, { status: 'deleted', deletedAt: new Date().toISOString(), quarantineReason: '移入回收站' })
|
|
503
|
+
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, 'deleted', 'delete', '软删进回收站')
|
|
504
|
+
return { ok: true, soft: true, ...r }
|
|
505
|
+
}
|
|
323
506
|
const now = new Date().toISOString()
|
|
324
|
-
|
|
325
|
-
|
|
507
|
+
db.prepare('UPDATE quarantine SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
|
|
508
|
+
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, 'deleted', 'delete', '软删进回收站')
|
|
326
509
|
return { ok: true, soft: true }
|
|
327
510
|
}
|
|
328
511
|
|
|
329
|
-
/**
|
|
512
|
+
/** 恢复回收站的卡:从隔离区搬回主库,status=approved。写审计日志。 */
|
|
330
513
|
export async function restoreCard(root, rel) {
|
|
331
514
|
const db = getDb(root)
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, 'approved', 'restore', '回收站恢复')
|
|
338
|
-
return { ok: true, restored: true }
|
|
515
|
+
const existing = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
|
|
516
|
+
if (!existing) throw new Error('卡片不在回收站/隔离区: ' + rel)
|
|
517
|
+
const r = moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: MAIN_STATUS, deletedAt: null })
|
|
518
|
+
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, MAIN_STATUS, 'restore', '回收站恢复')
|
|
519
|
+
return { ok: true, restored: true, ...r }
|
|
339
520
|
}
|
|
340
521
|
|
|
341
|
-
/**
|
|
522
|
+
/**
|
|
523
|
+
* 更新卡状态(审核操作,唯一入口)。写审计日志。
|
|
524
|
+
*
|
|
525
|
+
* 两套存储的关键点:status 决定这张卡**住在哪张表**。
|
|
526
|
+
* → approved:搬进主库(agent 可召回);pending/rejected/deleted:留在/搬进隔离区。
|
|
527
|
+
*/
|
|
342
528
|
export async function setCardStatus(root, rel, status, { changedBy = 'system', reason = '' } = {}) {
|
|
343
529
|
const db = getDb(root)
|
|
344
|
-
const
|
|
345
|
-
const existing = db
|
|
530
|
+
const target = String(status)
|
|
531
|
+
const existing = findCardRow(db, rel)
|
|
346
532
|
if (!existing) throw new Error('卡片不存在: ' + rel)
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
533
|
+
|
|
534
|
+
if (target === MAIN_STATUS) {
|
|
535
|
+
if (existing.table === 'cards') {
|
|
536
|
+
db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(target, new Date().toISOString(), rel)
|
|
537
|
+
} else {
|
|
538
|
+
moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: target, deletedAt: null })
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
if (existing.table === 'cards') {
|
|
542
|
+
moveCardBetweenStores(db, 'cards', 'quarantine', rel, { status: target, deletedAt: target === 'deleted' ? new Date().toISOString() : null, quarantineReason: reason || '审核流转' })
|
|
543
|
+
} else {
|
|
544
|
+
db.prepare('UPDATE quarantine SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?')
|
|
545
|
+
.run(target, target === 'deleted' ? new Date().toISOString() : null, new Date().toISOString(), rel)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, target, changedBy, reason)
|
|
549
|
+
return { ok: true, status: target, store: tableForStatus(target) }
|
|
352
550
|
}
|
|
353
551
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
552
|
+
/**
|
|
553
|
+
* 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。
|
|
554
|
+
*
|
|
555
|
+
* 两套存储:enforceAudit 判定出的 status 决定**写进哪张表** ——
|
|
556
|
+
* approved → 主库(可被召回);pending/rejected/deleted → 隔离区(只有审核中心能看)。
|
|
557
|
+
*
|
|
558
|
+
* `dedupAgainst` 传入候选集合时,去重只跟**这些**卡比(导入备份用:基线取导入前的快照,
|
|
559
|
+
* 让同一批里互相近似的卡不会互吞)。
|
|
560
|
+
*/
|
|
561
|
+
export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true, dedupAgainst = null } = {}) {
|
|
356
562
|
const db = getDb(root)
|
|
357
563
|
const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
|
|
358
564
|
|
|
359
565
|
// 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
|
|
360
566
|
const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
|
|
567
|
+
const targetTable = tableForStatus(enforcedStatus)
|
|
361
568
|
|
|
569
|
+
// 去重只在主库(approved)里找 —— 把新知识追加进隔离区里的待审/驳回卡是绕过审核
|
|
362
570
|
if (dedup) {
|
|
363
|
-
const hit = await dedupCheck(root, body, threshold)
|
|
571
|
+
const hit = dedupAgainst ? bestDuplicate(dedupAgainst, body, threshold) : await dedupCheck(root, body, threshold)
|
|
364
572
|
if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
|
|
365
573
|
}
|
|
366
574
|
|
|
367
575
|
const slug = safeSlug(title)
|
|
368
576
|
let rel = `${kindRoot}/${slug}.md`
|
|
369
577
|
let index = 2
|
|
370
|
-
while (db
|
|
578
|
+
while (pathTaken(db, rel)) {
|
|
371
579
|
rel = `${kindRoot}/${slug}-${index}.md`
|
|
372
580
|
index++
|
|
373
581
|
}
|
|
374
582
|
|
|
375
583
|
const now = new Date().toISOString()
|
|
376
584
|
const summary = body.trim().slice(0, 200)
|
|
377
|
-
db
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return { ok: true, path: rel, kind }
|
|
585
|
+
const id = nextCardId(db) // 跨两张表唯一的 id,由 card_sequence 单一发号器分配
|
|
586
|
+
if (targetTable === 'cards') {
|
|
587
|
+
db.prepare(`
|
|
588
|
+
INSERT INTO cards (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
|
|
589
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
590
|
+
`).run(
|
|
591
|
+
id, rel, kind, title, JSON.stringify(tags), body.trim(), summary,
|
|
592
|
+
enforcedStatus, source, submittedBy, severity, reason, now, now, deletedAt || null,
|
|
593
|
+
)
|
|
594
|
+
} else {
|
|
595
|
+
db.prepare(`
|
|
596
|
+
INSERT INTO quarantine (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
|
|
597
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
|
|
598
|
+
`).run(
|
|
599
|
+
id, rel, kind, title, JSON.stringify(tags), body.trim(), summary,
|
|
600
|
+
enforcedStatus, source, submittedBy, severity, reason, now, now, deletedAt || null,
|
|
601
|
+
'新卡待审核',
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
return { ok: true, path: rel, kind, status: enforcedStatus, store: targetTable }
|
|
397
605
|
}
|
|
398
606
|
|
|
399
|
-
/**
|
|
607
|
+
/**
|
|
608
|
+
* 向已存在的卡追加更新记录(跨两张表找,因为审核流转会把卡搬表,但 id 不变)。
|
|
609
|
+
*
|
|
610
|
+
* 注意:调用方(沉淀管线)只在**主库命中去重**时才追加,所以这里不会把新知识灌进隔离区卡。
|
|
611
|
+
*/
|
|
400
612
|
export async function appendUpdate(root, rel, updateText) {
|
|
401
613
|
const db = getDb(root)
|
|
402
|
-
const
|
|
403
|
-
if (!
|
|
614
|
+
const found = findCardRow(db, rel)
|
|
615
|
+
if (!found) throw new Error('卡片不存在: ' + rel)
|
|
616
|
+
const row = found.row
|
|
404
617
|
const now = new Date().toISOString()
|
|
405
618
|
db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
|
|
406
|
-
db.prepare(
|
|
407
|
-
return { ok: true, path: rel, updated: now }
|
|
619
|
+
db.prepare(`UPDATE ${found.table} SET updated_at = ? WHERE id = ?`).run(now, row.id)
|
|
620
|
+
return { ok: true, path: rel, updated: now, store: found.table }
|
|
408
621
|
}
|
|
409
622
|
|
|
410
623
|
/** 合并多张卡为一张。 */
|
|
@@ -415,8 +628,8 @@ export async function mergeCards(root, paths) {
|
|
|
415
628
|
|
|
416
629
|
const rows = []
|
|
417
630
|
for (const p of list) {
|
|
418
|
-
const
|
|
419
|
-
if (
|
|
631
|
+
const found = findCardRow(db, p)
|
|
632
|
+
if (found) rows.push(found.row)
|
|
420
633
|
}
|
|
421
634
|
if (rows.length < 2) return { ok: false, error: '读取失败' }
|
|
422
635
|
|
|
@@ -441,7 +654,8 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
|
|
|
441
654
|
const q = String(query || '').trim()
|
|
442
655
|
if (!q) return []
|
|
443
656
|
const db = getDb(root)
|
|
444
|
-
|
|
657
|
+
// 两套存储后这里**不再需要状态条件**:主库物理上只含 approved。
|
|
658
|
+
const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
|
|
445
659
|
const wanted = queryTerms(q)
|
|
446
660
|
const out = []
|
|
447
661
|
for (const row of rows) {
|
|
@@ -493,13 +707,17 @@ export async function dailyCounts(root, days = 30) {
|
|
|
493
707
|
return out
|
|
494
708
|
}
|
|
495
709
|
|
|
496
|
-
/**
|
|
710
|
+
/**
|
|
711
|
+
* 导出全部卡片原始 Markdown(**含隔离区**:用户做备份/迁移时不应该只拿到主库)。
|
|
712
|
+
* 顺带给出 store 字段,导入方可以据此判断这张卡该回主库还是回隔离区。
|
|
713
|
+
*/
|
|
497
714
|
export async function exportCards(root) {
|
|
498
715
|
const db = getDb(root)
|
|
499
|
-
const
|
|
500
|
-
|
|
716
|
+
const main = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all().map((r) => ({ ...r, __store: 'main' }))
|
|
717
|
+
const quarantined = db.prepare('SELECT * FROM quarantine ORDER BY updated_at DESC').all().map((r) => ({ ...r, __store: 'quarantine' }))
|
|
718
|
+
return [...main, ...quarantined].map((row) => {
|
|
501
719
|
const { text } = rowToMarkdown(row)
|
|
502
|
-
return { path: row.path, title: row.title, kind: row.kind, text }
|
|
720
|
+
return { path: row.path, title: row.title, kind: row.kind, status: row.status, store: row.__store, text }
|
|
503
721
|
})
|
|
504
722
|
}
|
|
505
723
|
|
|
@@ -560,10 +778,10 @@ export async function graph(root) {
|
|
|
560
778
|
if (process.env.MEMORY_GRAPH_PROFILE === '1') console.error('[memory-eternal] graph: 命中磁盘缓存')
|
|
561
779
|
return onDisk
|
|
562
780
|
}
|
|
563
|
-
// 只选图谱需要的列,不读 body
|
|
564
|
-
const rows = db.prepare(
|
|
781
|
+
// 只选图谱需要的列,不读 body(大幅减少内存和传输量)。主库即 approved,无需状态条件。
|
|
782
|
+
const rows = db.prepare('SELECT id, path, kind, title, tags, summary, status, updated_at FROM cards ORDER BY updated_at DESC').all()
|
|
565
783
|
// body 单独按需读取(只在需要 wikilink 匹配时)
|
|
566
|
-
const bodyRows = db.prepare(
|
|
784
|
+
const bodyRows = db.prepare('SELECT path, body FROM cards').all()
|
|
567
785
|
const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
|
|
568
786
|
const cards = rows.map((r) => ({
|
|
569
787
|
path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
|
|
@@ -751,25 +969,30 @@ export async function graph(root) {
|
|
|
751
969
|
return value
|
|
752
970
|
}
|
|
753
971
|
|
|
754
|
-
/**
|
|
972
|
+
/** 统计(主库用量 + 隔离区计数,供侧栏徽标用)。 */
|
|
755
973
|
export async function stats(root) {
|
|
756
974
|
const db = getDb(root)
|
|
757
|
-
const totalRow = db.prepare(
|
|
758
|
-
const byKindRows = db.prepare(
|
|
975
|
+
const totalRow = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
|
|
976
|
+
const byKindRows = db.prepare('SELECT kind, COUNT(*) as cnt FROM cards GROUP BY kind').all()
|
|
759
977
|
const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
|
|
760
978
|
|
|
761
979
|
const now = new Date()
|
|
762
980
|
const dayAgo = new Date(now.getTime() - 86400000).toISOString()
|
|
763
981
|
const weekAgo = new Date(now.getTime() - 7 * 86400000).toISOString()
|
|
764
|
-
const todayRow = db.prepare(
|
|
765
|
-
const weekRow = db.prepare(
|
|
766
|
-
const tagRows = db.prepare(
|
|
982
|
+
const todayRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(dayAgo)
|
|
983
|
+
const weekRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(weekAgo)
|
|
984
|
+
const tagRows = db.prepare('SELECT tags FROM cards').all()
|
|
767
985
|
const tagSet = new Set()
|
|
768
986
|
for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
|
|
769
987
|
|
|
770
|
-
const todayCardsRows = db.prepare(
|
|
988
|
+
const todayCardsRows = db.prepare('SELECT * FROM cards WHERE updated_at >= ? ORDER BY updated_at DESC LIMIT 30').all(dayAgo)
|
|
771
989
|
const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
|
|
772
990
|
|
|
991
|
+
// 隔离区:审核排队情况(侧栏「审核中心」徽标直接读这里)
|
|
992
|
+
const quarantineRows = db.prepare('SELECT status, COUNT(*) n FROM quarantine GROUP BY status').all()
|
|
993
|
+
const quarantine = { pending: 0, rejected: 0, deleted: 0 }
|
|
994
|
+
for (const r of quarantineRows) if (r.status in quarantine) quarantine[r.status] = r.n
|
|
995
|
+
|
|
773
996
|
return {
|
|
774
997
|
total: totalRow.cnt,
|
|
775
998
|
today: todayRow.cnt,
|
|
@@ -777,6 +1000,7 @@ export async function stats(root) {
|
|
|
777
1000
|
tags: tagSet.size,
|
|
778
1001
|
byKind,
|
|
779
1002
|
todayCards,
|
|
1003
|
+
quarantine,
|
|
780
1004
|
}
|
|
781
1005
|
}
|
|
782
1006
|
|
|
@@ -895,10 +1119,15 @@ export async function addFeedback(root, rec) {
|
|
|
895
1119
|
return { ok: true }
|
|
896
1120
|
}
|
|
897
1121
|
|
|
898
|
-
/**
|
|
1122
|
+
/**
|
|
1123
|
+
* 概览:主库(正常区)+ 隔离区(异常区)两份统计。
|
|
1124
|
+
*
|
|
1125
|
+
* `total` / `byKind` / `recent` / `tags` 描述**主库**(agent 能看到的那部分);
|
|
1126
|
+
* `status` / `quarantine` 描述隔离区(审核中心与回收站)。
|
|
1127
|
+
*/
|
|
899
1128
|
export async function overview(root) {
|
|
900
1129
|
const db = getDb(root)
|
|
901
|
-
const cards = await listCards(root, { status: [
|
|
1130
|
+
const cards = await listCards(root, { status: [MAIN_STATUS] })
|
|
902
1131
|
const byKind = {}
|
|
903
1132
|
const statusCounts = {}
|
|
904
1133
|
for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
|
|
@@ -906,12 +1135,24 @@ export async function overview(root) {
|
|
|
906
1135
|
const recent = cards.filter((c) => c.mtime > week).length
|
|
907
1136
|
const tagSet = new Set()
|
|
908
1137
|
for (const c of cards) for (const t of c.tags) tagSet.add(t)
|
|
1138
|
+
|
|
1139
|
+
// 隔离区统计:按状态 + 按 kind
|
|
1140
|
+
const quarantined = await listCards(root, { status: [...QUARANTINE_STATUSES] })
|
|
1141
|
+
const quarantine = { pending: 0, rejected: 0, deleted: 0, total: quarantined.length }
|
|
1142
|
+
const quarantineByKind = {}
|
|
1143
|
+
for (const c of quarantined) {
|
|
1144
|
+
if (c.status in quarantine) quarantine[c.status] += 1
|
|
1145
|
+
quarantineByKind[c.kind] = (quarantineByKind[c.kind] || 0) + 1
|
|
1146
|
+
}
|
|
1147
|
+
|
|
909
1148
|
return {
|
|
910
1149
|
total: cards.length,
|
|
911
1150
|
byKind,
|
|
912
1151
|
recent,
|
|
913
1152
|
tags: tagSet.size,
|
|
914
1153
|
status: statusCounts,
|
|
1154
|
+
quarantine,
|
|
1155
|
+
quarantineByKind,
|
|
915
1156
|
roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
|
|
916
1157
|
}
|
|
917
1158
|
}
|