memory-eternal 0.9.23 → 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/README.en.md +11 -2
- package/README.md +20 -2
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- package/index.js +11 -3
- package/lib/api.js +558 -536
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +166 -7
- package/lib/client.js +17 -14
- package/lib/config-sync.js +5 -4
- package/lib/db.js +150 -3
- package/lib/vault.js +325 -101
- package/lib/web.js +3 -3
- package/package.json +3 -2
- package/web/app.js +20 -17
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(
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
*
|
|
237
|
-
*
|
|
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 ?? [
|
|
302
|
+
const want = status ?? [MAIN_STATUS]
|
|
246
303
|
const db = getDb(root)
|
|
247
|
-
const
|
|
304
|
+
const stores = storesFor(want)
|
|
305
|
+
|
|
248
306
|
if (sort === 'title') {
|
|
249
307
|
// SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
|
|
250
308
|
// 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
|
|
251
|
-
const
|
|
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
|
|
257
|
-
const rows =
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
273
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
302
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
/**
|
|
498
|
+
/** 恢复回收站的卡:从隔离区搬回主库,status=approved。写审计日志。 */
|
|
330
499
|
export async function restoreCard(root, rel) {
|
|
331
500
|
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 }
|
|
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
|
|
345
|
-
const existing = db
|
|
516
|
+
const target = String(status)
|
|
517
|
+
const existing = findCardRow(db, rel)
|
|
346
518
|
if (!existing) throw new Error('卡片不存在: ' + rel)
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 }
|
|
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
|
|
403
|
-
if (!
|
|
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(
|
|
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
|
|
419
|
-
if (
|
|
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
|
-
|
|
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
|
-
/**
|
|
693
|
+
/**
|
|
694
|
+
* 导出全部卡片原始 Markdown(**含隔离区**:用户做备份/迁移时不应该只拿到主库)。
|
|
695
|
+
* 顺带给出 store 字段,导入方可以据此判断这张卡该回主库还是回隔离区。
|
|
696
|
+
*/
|
|
497
697
|
export async function exportCards(root) {
|
|
498
698
|
const db = getDb(root)
|
|
499
|
-
const
|
|
500
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
758
|
-
const byKindRows = db.prepare(
|
|
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(
|
|
765
|
-
const weekRow = db.prepare(
|
|
766
|
-
const tagRows = db.prepare(
|
|
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(
|
|
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
|
-
/**
|
|
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: [
|
|
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
|
}
|
package/lib/web.js
CHANGED
|
@@ -41,10 +41,10 @@ export function startWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVau
|
|
|
41
41
|
|
|
42
42
|
// 共享配置文件变化 → 即时推给已打开的独立页(DSH 端一保存,这边不用刷新就能看到)
|
|
43
43
|
try {
|
|
44
|
+
// 监听**文件本身**而不是父目录(同 lib/config-sync.js 的说明):
|
|
45
|
+
// 父目录被删除时 Windows 上 libuv 会断言崩溃,监听文件只会得到 ENOENT。
|
|
44
46
|
const cfgFile = configFilePath(process.env)
|
|
45
|
-
const
|
|
46
|
-
const w = watch(path.dirname(cfgFile), (_event, name) => {
|
|
47
|
-
if (name && !String(name).startsWith(cfgName)) return
|
|
47
|
+
const w = watch(cfgFile, () => {
|
|
48
48
|
broadcast('config', { at: Date.now(), source: 'file' })
|
|
49
49
|
})
|
|
50
50
|
if (typeof w.unref === 'function') w.unref()
|