memory-eternal 0.9.17 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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,10 +134,16 @@ 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) {
139
145
  const db = getDb(root)
140
- const rows = db.prepare('SELECT path, body FROM cards WHERE deleted_at IS NULL').all()
146
+ const rows = db.prepare("SELECT path, body FROM cards WHERE status = 'approved' AND deleted_at IS NULL").all()
141
147
  let best = null
142
148
  for (const row of rows) {
143
149
  const score = textSimilarity(text, row.body || '')
@@ -220,21 +226,72 @@ function agentWhere(agent, args) {
220
226
  return "LOWER(COALESCE(submitted_by,'')) = ?"
221
227
  }
222
228
 
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 }
229
+ // ---- 两套存储:主库(cards,只存 approved)/ 隔离区(quarantine,pending·rejected·deleted)--
230
+ //
231
+ // 下面三个函数负责「把状态白名单翻译成对两张表的查询」。读路径默认只碰主库:
232
+ // 主库物理上不含非 approved 行,所以查主库**不需要写状态条件**,也就没有「漏写状态条件」这种 bug 面。
233
+
234
+ /** 状态白名单 → 需要读的表清单。 */
235
+ function storesFor(want) {
236
+ const hasMain = want.includes(MAIN_STATUS)
237
+ const hasQuarantine = want.some((s) => QUARANTINE_STATUSES.includes(s))
238
+ if (hasMain && !hasQuarantine) return [{ table: 'cards', statuses: null }]
239
+ if (hasQuarantine && !hasMain) return [{ table: 'quarantine', statuses: want.filter((s) => QUARANTINE_STATUSES.includes(s)) }]
240
+ return [
241
+ { table: 'cards', statuses: null },
242
+ { table: 'quarantine', statuses: want.filter((s) => QUARANTINE_STATUSES.includes(s)) },
243
+ ]
244
+ }
245
+
246
+ /**
247
+ * 生成一条「跨两套存储」的查询。
248
+ * @param sel 选择列表(如 '*' 或 'path, title')
249
+ * @param stores storesFor() 的结果
250
+ * @param extra { kind, agent, tail } —— tail 是 ORDER BY / LIMIT 片段(默认按 updated_at 倒序)
251
+ */
252
+ function multiStoreQuery(sel, stores, { kind = '', agent = '', tail = 'ORDER BY updated_at DESC' } = {}) {
253
+ const parts = []
254
+ const args = []
255
+ for (const store of stores) {
256
+ const where = []
257
+ const wargs = []
258
+ if (store.statuses) {
259
+ where.push(`status IN (${store.statuses.map(() => '?').join(',')})`)
260
+ wargs.push(...store.statuses)
261
+ }
262
+ if (kind) { where.push('kind = ?'); wargs.push(kind) }
263
+ if (agent) where.push(agentWhere(agent, wargs))
264
+ parts.push(`SELECT ${sel} FROM ${store.table}${where.length ? ' WHERE ' + where.join(' AND ') : ''}`)
265
+ args.push(...wargs)
266
+ }
267
+ return { sql: parts.join(' UNION ALL ') + ' ' + tail, args }
268
+ }
269
+
270
+ /** 在两张表里按 path 找一行(跨存储读取用,返回 { table, row })。 */
271
+ function findCardRow(db, rel) {
272
+ const inMain = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
273
+ if (inMain) return { table: 'cards', row: inMain }
274
+ const inQuarantine = db.prepare('SELECT * FROM quarantine WHERE path = ?').get(rel)
275
+ if (inQuarantine) return { table: 'quarantine', row: inQuarantine }
276
+ return null
277
+ }
278
+
279
+ /** 某个 path 是否已被占用(两张表任意一张里有,就算占用)。 */
280
+ function pathTaken(db, rel) {
281
+ if (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) return true
282
+ return !!db.prepare('SELECT 1 FROM quarantine WHERE path = ?').get(rel)
230
283
  }
231
284
 
232
285
  /** 标题排序用的中文排序器(拼音 + 数字感知);固定 zh-Hans-CN,避免随进程 locale 漂移。 */
233
286
  export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
234
287
 
235
288
  /**
236
- * 列出卡片(从 SQL)。
237
- * @param opts.status 状态白名单(默认 approved)
289
+ * 列出卡片。
290
+ *
291
+ * 两套存储下 status 的语义:'approved' → 只查主库(**不加状态条件**,主库物理上只有 approved);
292
+ * 'pending'/'rejected'/'deleted' → 查隔离区;'all' → 两张表 UNION(审核中心用)。
293
+ *
294
+ * @param opts.status 状态白名单(默认 approved = 主库)
238
295
  * @param opts.kind 类型过滤(SQL)
239
296
  * @param opts.agent 署名过滤(服务端归一,如 deepseek-harness / claude)
240
297
  * @param opts.limit 返回条数上限(缺省=全部,供内部调用保持原语义)
@@ -242,35 +299,49 @@ export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, s
242
299
  * @param opts.sort 'recent'(updated_at DESC,默认)| 'title'(拼音升序,跨页全局有序)
243
300
  */
244
301
  export async function listCards(root, { status, kind, agent, limit, offset = 0, sort = 'recent' } = {}) {
245
- const want = status ?? ['approved']
302
+ const want = status ?? [MAIN_STATUS]
246
303
  const db = getDb(root)
247
- const { where, args } = cardsWhere({ want, kind, agent })
304
+ const stores = storesFor(want)
305
+
248
306
  if (sort === 'title') {
249
307
  // SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
250
308
  // 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
251
- const light = db.prepare(`SELECT path, title FROM cards WHERE ${where}`).all(...args)
309
+ const { sql, args } = multiStoreQuery('path, title', stores, { kind, agent, tail: '' })
310
+ const light = db.prepare(sql).all(...args)
252
311
  light.sort((a, b) => TITLE_COLLATOR.compare(a.title || '', b.title || ''))
253
312
  const start = Math.max(0, offset | 0)
254
313
  const page = Number.isInteger(limit) && limit > 0 ? light.slice(start, start + limit) : light.slice(start)
255
314
  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))
315
+ const paths = page.map((r) => r.path)
316
+ const rows = []
317
+ for (const store of stores) {
318
+ const ph = paths.map(() => '?').join(',')
319
+ rows.push(...db.prepare(`SELECT * FROM ${store.table} WHERE path IN (${ph})`).all(...paths))
320
+ }
258
321
  const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
259
322
  return page.map((r) => byPath.get(r.path)).filter(Boolean)
260
323
  }
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)
324
+
325
+ const tail = Number.isInteger(limit) && limit > 0
326
+ ? `ORDER BY updated_at DESC LIMIT ? OFFSET ?`
327
+ : 'ORDER BY updated_at DESC'
328
+ // 注意:两张表的列数不同(quarantine 多 quarantined_at / quarantine_reason),
329
+ // UNION ALL 必须显式列出公共列,不能用 `*`(否则列会错位)。
330
+ const { sql, args } = multiStoreQuery(
331
+ 'id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at',
332
+ stores,
333
+ { kind, agent, tail },
334
+ )
335
+ const finalArgs = Number.isInteger(limit) && limit > 0 ? [...args, limit, Math.max(0, offset | 0)] : args
336
+ return db.prepare(sql).all(...finalArgs).map(rowToCard)
267
337
  }
268
338
 
269
339
  /** 计数(分页接口要用 total 判断还有没有下一页)。 */
270
340
  export async function countCards(root, { status, kind, agent } = {}) {
271
341
  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
342
+ const stores = storesFor(status ?? [MAIN_STATUS])
343
+ const { sql, args } = multiStoreQuery('COUNT(*) n', stores, { kind, agent, tail: '' })
344
+ return db.prepare(`SELECT SUM(n) n FROM (${sql})`).get(...args).n || 0
274
345
  }
275
346
 
276
347
  /** 审核队列:status=pending 或 rejected。 */
@@ -279,10 +350,58 @@ export async function auditQueue(root) {
279
350
  return { pending: cards.filter((c) => c.status === 'pending'), rejected: cards.filter((c) => c.status === 'rejected') }
280
351
  }
281
352
 
282
- /** 回收站:status=deleted,按 deleted_at 倒序。 */
353
+ /** 同一个事务里执行 fn(失败自动回滚)。跨表搬家必须原子,否则会丢卡。 */
354
+ function inTransaction(db, fn) {
355
+ db.exec('BEGIN')
356
+ try {
357
+ const out = fn()
358
+ db.exec('COMMIT')
359
+ return out
360
+ } catch (e) {
361
+ try { db.exec('ROLLBACK') } catch { /* 已回滚 */ }
362
+ throw e
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 把一张卡从一张表搬到另一张表(重建行、保住内容 / 时间戳 / 更新记录)。
368
+ *
369
+ * 为什么不用同一个 id:两张表共享 `path` 命名空间,但 id 由 `card_sequence` 单一发号器
370
+ * 保证**全库唯一**;跨表插入时旧 id 可能已被目标表占用(实测 `UNIQUE constraint failed`)。
371
+ * 所以搬家时重新领号,并把 `card_updates.card_id` 一起改绑 —— 用户视角「更新记录还在」。
372
+ * 之所以要原子:INSERT 成功而 DELETE 失败会留下重复卡;反过来则直接丢卡。
373
+ */
374
+ function moveCardBetweenStores(db, from, to, rel, { status, deletedAt = null, quarantineReason = '', updatedAt } = {}) {
375
+ const now = updatedAt || new Date().toISOString()
376
+ return inTransaction(db, () => {
377
+ const src = db.prepare(`SELECT * FROM ${from} WHERE path = ?`).get(rel)
378
+ if (!src) throw new Error(`卡片不在 ${from}: ${rel}`)
379
+ const newId = nextCardId(db)
380
+
381
+ if (to === 'quarantine') {
382
+ db.prepare(`
383
+ INSERT INTO quarantine
384
+ (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at, quarantined_at, quarantine_reason)
385
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
386
+ `).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)
387
+ } else {
388
+ db.prepare(`
389
+ INSERT INTO cards
390
+ (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
391
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
392
+ `).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)
393
+ }
394
+ // 更新记录跟着卡走(id 变了,按新 id 改绑)
395
+ db.prepare('UPDATE card_updates SET card_id = ? WHERE card_id = ?').run(newId, src.id)
396
+ db.prepare(`DELETE FROM ${from} WHERE path = ?`).run(rel)
397
+ return { ok: true, status, table: to, oldId: src.id, newId }
398
+ })
399
+ }
400
+
401
+ /** 回收站:隔离区里 status=deleted,按 deleted_at 倒序。 */
283
402
  export async function recycleList(root) {
284
403
  const db = getDb(root)
285
- const rows = db.prepare('SELECT * FROM cards WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
404
+ const rows = db.prepare('SELECT * FROM quarantine WHERE status = ? ORDER BY deleted_at DESC').all('deleted')
286
405
  return rows.map(rowToCard)
287
406
  }
288
407
 
@@ -290,7 +409,7 @@ export async function recycleList(root) {
290
409
  export async function purgeExpired(root, retentionDays = 30) {
291
410
  const db = getDb(root)
292
411
  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)
412
+ const rows = db.prepare('SELECT path FROM quarantine WHERE status = ? AND deleted_at IS NOT NULL AND deleted_at < ?').all('deleted', cut)
294
413
  let purged = 0
295
414
  for (const row of rows) {
296
415
  try { await deleteCard(root, row.path, { permanent: true }); purged++ } catch {}
@@ -298,67 +417,139 @@ export async function purgeExpired(root, retentionDays = 30) {
298
417
  return { ok: true, purged }
299
418
  }
300
419
 
301
- /** 读取一张卡(返回 Markdown 文本,兼容旧调用方)。 */
302
- export async function readCard(root, rel) {
420
+ /**
421
+ * 读取一张卡(返回 Markdown 文本)。
422
+ *
423
+ * 两套存储下的语义:
424
+ * - 默认**只读主库**(主库物理上只含 approved)→ 想读未审核内容,必须显式 `allowUnapproved`。
425
+ * - `allowUnapproved: true` 时(审核中心 / 回收站预览)走两张表。
426
+ * 「不存在」与「存在但未审核」是两种不同的错误码,方便上层区分。
427
+ */
428
+ export async function readCard(root, rel, { allowUnapproved = false } = {}) {
303
429
  const db = getDb(root)
304
- const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
305
- if (!row) throw new Error('卡片不存在: ' + rel)
430
+ const mainRow = db.prepare('SELECT * FROM cards WHERE path = ?').get(rel)
431
+ let row = mainRow
432
+ if (!row && allowUnapproved) row = db.prepare('SELECT * FROM quarantine WHERE path = ?').get(rel)
433
+ if (!row) {
434
+ // path 可能在隔离区而不在主库 —— 这种情况不能当成「不存在」,否则审核中心会误判
435
+ const inQuarantine = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
436
+ const err = new Error(inQuarantine
437
+ ? `卡片在隔离区(${statusLabel(inQuarantine.status)}),未通过审核不能读取正文:${rel}`
438
+ : `卡片不存在: ${rel}`)
439
+ err.code = inQuarantine ? 'CARD_NOT_APPROVED' : 'CARD_NOT_FOUND'
440
+ if (inQuarantine) err.status = inQuarantine.status
441
+ throw err
442
+ }
443
+ const status = String(row.status || '')
306
444
  const { text } = rowToMarkdown(row)
307
- // 附加更新记录
308
445
  const updates = db.prepare('SELECT content, created_at FROM card_updates WHERE card_id = ? ORDER BY created_at ASC').all(row.id)
309
446
  if (updates.length > 0) {
310
447
  const updateLines = updates.map((u) => `- ${u.created_at.slice(0, 10)}:${u.content}`)
311
- return text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n'
448
+ return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text: text + '\n## 更新记录\n\n' + updateLines.join('\n') + '\n' }
312
449
  }
313
- return text
450
+ return { path: rel, status, store: mainRow ? 'main' : 'quarantine', text }
451
+ }
452
+
453
+ /** 状态中文标签(错误信息与日志用)。 */
454
+ function statusLabel(status) {
455
+ const s = String(status || '')
456
+ return s === 'pending' ? '待审核' : s === 'rejected' ? '已驳回' : s === 'deleted' ? '回收站中' : s
314
457
  }
315
458
 
316
- /** 软删卡片:status=deleted + deleted_at。 */
459
+ /**
460
+ * 删除卡片。
461
+ * - 永久删除:从**任一张表**里抹掉(含更新记录,靠外键 CASCADE)。
462
+ * - 软删(默认):主库卡 → 搬进隔离区并置 deleted;隔离区卡 → 原地置 deleted。
463
+ */
317
464
  export async function deleteCard(root, rel, { permanent = false } = {}) {
318
465
  const db = getDb(root)
319
466
  if (permanent) {
320
- db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
467
+ // 先按 path 取 id(两张表共享 id 空间),再清理按 id 关联的更新记录 —— card_updates
468
+ // 不带外键(卡会在两表间搬家),所以级联必须在这里手工做。
469
+ const ids = []
470
+ const mainRow = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
471
+ if (mainRow) ids.push(mainRow.id)
472
+ const qRow = db.prepare('SELECT id FROM quarantine WHERE path = ?').get(rel)
473
+ if (qRow) ids.push(qRow.id)
474
+ if (!ids.length) throw new Error('卡片不存在: ' + rel)
475
+ inTransaction(db, () => {
476
+ for (const id of ids) {
477
+ db.prepare('DELETE FROM card_updates WHERE card_id = ?').run(id)
478
+ db.prepare('DELETE FROM feedback WHERE card_path = ?').run(rel)
479
+ }
480
+ db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
481
+ db.prepare('DELETE FROM quarantine WHERE path = ?').run(rel)
482
+ })
321
483
  return { ok: true, permanent: true }
322
484
  }
485
+ const existing = findCardRow(db, rel)
486
+ if (!existing) throw new Error('卡片不存在: ' + rel)
487
+ if (existing.table === 'cards') {
488
+ const r = moveCardBetweenStores(db, 'cards', 'quarantine', rel, { status: 'deleted', deletedAt: new Date().toISOString(), quarantineReason: '移入回收站' })
489
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, 'deleted', 'delete', '软删进回收站')
490
+ return { ok: true, soft: true, ...r }
491
+ }
323
492
  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)
493
+ db.prepare('UPDATE quarantine SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?').run('deleted', now, now, rel)
494
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, 'deleted', 'delete', '软删进回收站')
326
495
  return { ok: true, soft: true }
327
496
  }
328
497
 
329
- /** 恢复软删卡:status=approved,清除 deleted_at。写审计日志。 */
498
+ /** 恢复回收站的卡:从隔离区搬回主库,status=approved。写审计日志。 */
330
499
  export async function restoreCard(root, rel) {
331
500
  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 }
501
+ const existing = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
502
+ if (!existing) throw new Error('卡片不在回收站/隔离区: ' + rel)
503
+ const r = moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: MAIN_STATUS, deletedAt: null })
504
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, MAIN_STATUS, 'restore', '回收站恢复')
505
+ return { ok: true, restored: true, ...r }
339
506
  }
340
507
 
341
- /** 更新卡状态(审核操作)。写审计日志,确保可追溯。 */
508
+ /**
509
+ * 更新卡状态(审核操作,唯一入口)。写审计日志。
510
+ *
511
+ * 两套存储的关键点:status 决定这张卡**住在哪张表**。
512
+ * → approved:搬进主库(agent 可召回);pending/rejected/deleted:留在/搬进隔离区。
513
+ */
342
514
  export async function setCardStatus(root, rel, status, { changedBy = 'system', reason = '' } = {}) {
343
515
  const db = getDb(root)
344
- const now = new Date().toISOString()
345
- const existing = db.prepare('SELECT status FROM cards WHERE path = ?').get(rel)
516
+ const target = String(status)
517
+ const existing = findCardRow(db, rel)
346
518
  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 }
519
+
520
+ if (target === MAIN_STATUS) {
521
+ if (existing.table === 'cards') {
522
+ db.prepare('UPDATE cards SET status = ?, updated_at = ? WHERE path = ?').run(target, new Date().toISOString(), rel)
523
+ } else {
524
+ moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: target, deletedAt: null })
525
+ }
526
+ } else {
527
+ if (existing.table === 'cards') {
528
+ moveCardBetweenStores(db, 'cards', 'quarantine', rel, { status: target, deletedAt: target === 'deleted' ? new Date().toISOString() : null, quarantineReason: reason || '审核流转' })
529
+ } else {
530
+ db.prepare('UPDATE quarantine SET status = ?, deleted_at = ?, updated_at = ? WHERE path = ?')
531
+ .run(target, target === 'deleted' ? new Date().toISOString() : null, new Date().toISOString(), rel)
532
+ }
533
+ }
534
+ db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.row.status, target, changedBy, reason)
535
+ return { ok: true, status: target, store: tableForStatus(target) }
352
536
  }
353
537
 
354
- /** 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。 */
538
+ /**
539
+ * 原子写卡;先去重,命中返回 {duplicate}。审核守卫在数据库层强制。
540
+ *
541
+ * 两套存储:enforceAudit 判定出的 status 决定**写进哪张表** ——
542
+ * approved → 主库(可被召回);pending/rejected/deleted → 隔离区(只有审核中心能看)。
543
+ */
355
544
  export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
356
545
  const db = getDb(root)
357
546
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
358
547
 
359
548
  // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
360
549
  const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
550
+ const targetTable = tableForStatus(enforcedStatus)
361
551
 
552
+ // 去重只在主库(approved)里找 —— 把新知识追加进隔离区里的待审/驳回卡是绕过审核
362
553
  if (dedup) {
363
554
  const hit = await dedupCheck(root, body, threshold)
364
555
  if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
@@ -367,44 +558,49 @@ export async function writeCard(root, { kind, title, tags = [], body, source = '
367
558
  const slug = safeSlug(title)
368
559
  let rel = `${kindRoot}/${slug}.md`
369
560
  let index = 2
370
- while (db.prepare('SELECT 1 FROM cards WHERE path = ?').get(rel)) {
561
+ while (pathTaken(db, rel)) {
371
562
  rel = `${kindRoot}/${slug}-${index}.md`
372
563
  index++
373
564
  }
374
565
 
375
566
  const now = new Date().toISOString()
376
567
  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 }
568
+ const id = nextCardId(db) // 跨两张表唯一的 id,由 card_sequence 单一发号器分配
569
+ if (targetTable === 'cards') {
570
+ db.prepare(`
571
+ INSERT INTO cards (id, path, kind, title, tags, body, summary, status, source, submitted_by, severity, reason, created_at, updated_at, deleted_at)
572
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
573
+ `).run(
574
+ id, rel, kind, title, JSON.stringify(tags), body.trim(), summary,
575
+ enforcedStatus, source, submittedBy, severity, reason, now, now, deletedAt || null,
576
+ )
577
+ } else {
578
+ db.prepare(`
579
+ 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)
580
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
581
+ `).run(
582
+ id, rel, kind, title, JSON.stringify(tags), body.trim(), summary,
583
+ enforcedStatus, source, submittedBy, severity, reason, now, now, deletedAt || null,
584
+ '新卡待审核',
585
+ )
586
+ }
587
+ return { ok: true, path: rel, kind, status: enforcedStatus, store: targetTable }
397
588
  }
398
589
 
399
- /** 向已存在的卡追加更新记录。 */
590
+ /**
591
+ * 向已存在的卡追加更新记录(跨两张表找,因为审核流转会把卡搬表,但 id 不变)。
592
+ *
593
+ * 注意:调用方(沉淀管线)只在**主库命中去重**时才追加,所以这里不会把新知识灌进隔离区卡。
594
+ */
400
595
  export async function appendUpdate(root, rel, updateText) {
401
596
  const db = getDb(root)
402
- const row = db.prepare('SELECT id FROM cards WHERE path = ?').get(rel)
403
- if (!row) throw new Error('卡片不存在: ' + rel)
597
+ const found = findCardRow(db, rel)
598
+ if (!found) throw new Error('卡片不存在: ' + rel)
599
+ const row = found.row
404
600
  const now = new Date().toISOString()
405
601
  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 }
602
+ db.prepare(`UPDATE ${found.table} SET updated_at = ? WHERE id = ?`).run(now, row.id)
603
+ return { ok: true, path: rel, updated: now, store: found.table }
408
604
  }
409
605
 
410
606
  /** 合并多张卡为一张。 */
@@ -415,8 +611,8 @@ export async function mergeCards(root, paths) {
415
611
 
416
612
  const rows = []
417
613
  for (const p of list) {
418
- const row = db.prepare('SELECT * FROM cards WHERE path = ?').get(p)
419
- if (row) rows.push(row)
614
+ const found = findCardRow(db, p)
615
+ if (found) rows.push(found.row)
420
616
  }
421
617
  if (rows.length < 2) return { ok: false, error: '读取失败' }
422
618
 
@@ -441,7 +637,8 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
441
637
  const q = String(query || '').trim()
442
638
  if (!q) return []
443
639
  const db = getDb(root)
444
- const rows = db.prepare("SELECT * FROM cards WHERE status = 'approved' ORDER BY updated_at DESC").all()
640
+ // 两套存储后这里**不再需要状态条件**:主库物理上只含 approved。
641
+ const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
445
642
  const wanted = queryTerms(q)
446
643
  const out = []
447
644
  for (const row of rows) {
@@ -493,13 +690,17 @@ export async function dailyCounts(root, days = 30) {
493
690
  return out
494
691
  }
495
692
 
496
- /** 导出全部卡片原始 Markdown。 */
693
+ /**
694
+ * 导出全部卡片原始 Markdown(**含隔离区**:用户做备份/迁移时不应该只拿到主库)。
695
+ * 顺带给出 store 字段,导入方可以据此判断这张卡该回主库还是回隔离区。
696
+ */
497
697
  export async function exportCards(root) {
498
698
  const db = getDb(root)
499
- const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
500
- return rows.map((row) => {
699
+ const main = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all().map((r) => ({ ...r, __store: 'main' }))
700
+ const quarantined = db.prepare('SELECT * FROM quarantine ORDER BY updated_at DESC').all().map((r) => ({ ...r, __store: 'quarantine' }))
701
+ return [...main, ...quarantined].map((row) => {
501
702
  const { text } = rowToMarkdown(row)
502
- return { path: row.path, title: row.title, kind: row.kind, text }
703
+ return { path: row.path, title: row.title, kind: row.kind, status: row.status, store: row.__store, text }
503
704
  })
504
705
  }
505
706
 
@@ -560,10 +761,10 @@ export async function graph(root) {
560
761
  if (process.env.MEMORY_GRAPH_PROFILE === '1') console.error('[memory-eternal] graph: 命中磁盘缓存')
561
762
  return onDisk
562
763
  }
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()
764
+ // 只选图谱需要的列,不读 body(大幅减少内存和传输量)。主库即 approved,无需状态条件。
765
+ const rows = db.prepare('SELECT id, path, kind, title, tags, summary, status, updated_at FROM cards ORDER BY updated_at DESC').all()
565
766
  // body 单独按需读取(只在需要 wikilink 匹配时)
566
- const bodyRows = db.prepare("SELECT path, body FROM cards WHERE status = 'approved'").all()
767
+ const bodyRows = db.prepare('SELECT path, body FROM cards').all()
567
768
  const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
568
769
  const cards = rows.map((r) => ({
569
770
  path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
@@ -751,25 +952,30 @@ export async function graph(root) {
751
952
  return value
752
953
  }
753
954
 
754
- /** 统计。 */
955
+ /** 统计(主库用量 + 隔离区计数,供侧栏徽标用)。 */
755
956
  export async function stats(root) {
756
957
  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()
958
+ const totalRow = db.prepare('SELECT COUNT(*) as cnt FROM cards').get()
959
+ const byKindRows = db.prepare('SELECT kind, COUNT(*) as cnt FROM cards GROUP BY kind').all()
759
960
  const byKind = Object.fromEntries(byKindRows.map((r) => [r.kind, r.cnt]))
760
961
 
761
962
  const now = new Date()
762
963
  const dayAgo = new Date(now.getTime() - 86400000).toISOString()
763
964
  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()
965
+ const todayRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(dayAgo)
966
+ const weekRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(weekAgo)
967
+ const tagRows = db.prepare('SELECT tags FROM cards').all()
767
968
  const tagSet = new Set()
768
969
  for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
769
970
 
770
- const todayCardsRows = db.prepare("SELECT * FROM cards WHERE status = 'approved' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 30").all(dayAgo)
971
+ const todayCardsRows = db.prepare('SELECT * FROM cards WHERE updated_at >= ? ORDER BY updated_at DESC LIMIT 30').all(dayAgo)
771
972
  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
973
 
974
+ // 隔离区:审核排队情况(侧栏「审核中心」徽标直接读这里)
975
+ const quarantineRows = db.prepare('SELECT status, COUNT(*) n FROM quarantine GROUP BY status').all()
976
+ const quarantine = { pending: 0, rejected: 0, deleted: 0 }
977
+ for (const r of quarantineRows) if (r.status in quarantine) quarantine[r.status] = r.n
978
+
773
979
  return {
774
980
  total: totalRow.cnt,
775
981
  today: todayRow.cnt,
@@ -777,6 +983,7 @@ export async function stats(root) {
777
983
  tags: tagSet.size,
778
984
  byKind,
779
985
  todayCards,
986
+ quarantine,
780
987
  }
781
988
  }
782
989
 
@@ -895,10 +1102,15 @@ export async function addFeedback(root, rec) {
895
1102
  return { ok: true }
896
1103
  }
897
1104
 
898
- /** 统计:按 kind + status。 */
1105
+ /**
1106
+ * 概览:主库(正常区)+ 隔离区(异常区)两份统计。
1107
+ *
1108
+ * `total` / `byKind` / `recent` / `tags` 描述**主库**(agent 能看到的那部分);
1109
+ * `status` / `quarantine` 描述隔离区(审核中心与回收站)。
1110
+ */
899
1111
  export async function overview(root) {
900
1112
  const db = getDb(root)
901
- const cards = await listCards(root, { status: ['approved', 'pending', 'rejected'] })
1113
+ const cards = await listCards(root, { status: [MAIN_STATUS] })
902
1114
  const byKind = {}
903
1115
  const statusCounts = {}
904
1116
  for (const c of cards) { byKind[c.kind] = (byKind[c.kind] || 0) + 1; statusCounts[c.status] = (statusCounts[c.status] || 0) + 1 }
@@ -906,12 +1118,24 @@ export async function overview(root) {
906
1118
  const recent = cards.filter((c) => c.mtime > week).length
907
1119
  const tagSet = new Set()
908
1120
  for (const c of cards) for (const t of c.tags) tagSet.add(t)
1121
+
1122
+ // 隔离区统计:按状态 + 按 kind
1123
+ const quarantined = await listCards(root, { status: [...QUARANTINE_STATUSES] })
1124
+ const quarantine = { pending: 0, rejected: 0, deleted: 0, total: quarantined.length }
1125
+ const quarantineByKind = {}
1126
+ for (const c of quarantined) {
1127
+ if (c.status in quarantine) quarantine[c.status] += 1
1128
+ quarantineByKind[c.kind] = (quarantineByKind[c.kind] || 0) + 1
1129
+ }
1130
+
909
1131
  return {
910
1132
  total: cards.length,
911
1133
  byKind,
912
1134
  recent,
913
1135
  tags: tagSet.size,
914
1136
  status: statusCounts,
1137
+ quarantine,
1138
+ quarantineByKind,
915
1139
  roots: Object.fromEntries(Object.entries(KIND_ROOTS).map(([k, v]) => [k, `${v}/`])),
916
1140
  }
917
1141
  }