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/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
- const rows = db.prepare('SELECT path, body FROM cards WHERE deleted_at IS NULL').all()
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 rows) {
143
- const score = textSimilarity(text, row.body || '')
144
- if (score >= threshold && (!best || score > best.score)) best = { path: row.path, score }
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
- /** WHERE 组装:状态白名单 + 可选 kind / agent。 */
224
- function cardsWhere({ want, kind, agent }) {
225
- const args = [...want]
226
- let where = `status IN (${want.map(() => '?').join(',')})`
227
- if (kind) { where += ' AND kind = ?'; args.push(kind) }
228
- if (agent) where += ' AND ' + agentWhere(agent, args)
229
- return { where, args }
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
- * 列出卡片(从 SQL)。
237
- * @param opts.status 状态白名单(默认 approved)
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 ?? ['approved']
316
+ const want = status ?? [MAIN_STATUS]
246
317
  const db = getDb(root)
247
- const { where, args } = cardsWhere({ want, kind, agent })
318
+ const stores = storesFor(want)
319
+
248
320
  if (sort === 'title') {
249
321
  // SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
250
322
  // 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
251
- const light = db.prepare(`SELECT path, title FROM cards WHERE ${where}`).all(...args)
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 ph = page.map(() => '?').join(',')
257
- const rows = db.prepare(`SELECT * FROM cards WHERE path IN (${ph})`).all(...page.map((r) => r.path))
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
- if (Number.isInteger(limit) && limit > 0) {
262
- const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(...args, limit, Math.max(0, offset | 0))
263
- return rows.map(rowToCard)
264
- }
265
- const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC`).all(...args)
266
- return rows.map(rowToCard)
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 { where, args } = cardsWhere({ want: status ?? ['approved'], kind, agent })
273
- return db.prepare(`SELECT COUNT(*) n FROM cards WHERE ${where}`).get(...args).n
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
- /** 回收站:status=deleted,按 deleted_at 倒序。 */
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 cards WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
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 cards WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
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
- /** 读取一张卡(返回 Markdown 文本,兼容旧调用方)。 */
302
- export async function readCard(root, rel) {
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 row = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
305
- if (!row) throw new Error('卡片不存在: ' + rel)
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
- /** 软删卡片:status=deleted + deleted_at。 */
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
- db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
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
- const info = db.prepare('UPDATE cards SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
325
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
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
- /** 恢复软删卡:status=approved,清除 deleted_at。写审计日志。 */
512
+ /** 恢复回收站的卡:从隔离区搬回主库,status=approved。写审计日志。 */
330
513
  export async function restoreCard(root, rel) {
331
514
  const db = getDb(root)
332
- const now = new Date().toISOString()
333
- const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
334
- if (!existing) throw new Error('卡片不存在: ' + rel)
335
- const info = db.prepare('UPDATE cards SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('approved', now, rel)
336
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
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 now = new Date().toISOString()
345
- const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
530
+ const target = String(status)
531
+ const existing = findCardRow(db, rel)
346
532
  if (!existing) throw new Error('卡片不存在: ' + rel)
347
- const info = db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(status, now, rel)
348
- if (info.changes === 0) throw new Error('卡片不存在: ' + rel)
349
- // 审计日志:记录每次 status 变更
350
- db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, status, changedBy, reason)
351
- return { ok: true, status }
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
- /** 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。 */
355
- export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
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.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
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.prepare(`
378
- INSERT INTO cards (path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
379
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
380
- `).run(
381
- rel,
382
- kind,
383
- title,
384
- JSON.stringify(tags),
385
- body.trim(),
386
- summary,
387
- enforcedStatus,
388
- source,
389
- submittedBy,
390
- severity,
391
- reason,
392
- now,
393
- now,
394
- deletedAt || null,
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 row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
403
- if (!row) throw new Error('卡片不存在: ' + rel)
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('UPDATE cards SET updated_at = ? WHERE id = ?').run(now, row.id)
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 row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
419
- if (row) rows.push(row)
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
- const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
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
- /** 导出全部卡片原始 Markdown。 */
710
+ /**
711
+ * 导出全部卡片原始 Markdown(**含隔离区**:用户做备份/迁移时不应该只拿到主库)。
712
+ * 顺带给出 store 字段,导入方可以据此判断这张卡该回主库还是回隔离区。
713
+ */
497
714
  export async function exportCards(root) {
498
715
  const db = getDb(root)
499
- const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
500
- return rows.map((row) => {
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("SELECT id, path, kind, title, tags, summary, status, updated_at FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
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("SELECT path, body FROM cards WHERE status = 'approved'").all()
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("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved'").get()
758
- const byKindRows = db.prepare("SELECT kind, COUNT(*) as cnt FROM cards WHERE status = 'approved' GROUP BY kind").all()
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("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(dayAgo)
765
- const weekRow = db.prepare("SELECT COUNT(*) as cnt FROM cards WHERE status = 'approved' AND updated_at >= ?").get(weekAgo)
766
- const tagRows = db.prepare("SELECT tags FROM cards WHERE status = 'approved'").all()
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("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
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
- /** 统计:按 kind + status。 */
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: ['approved', 'pending', 'rejected'] })
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
  }