memory-eternal 0.22.0 → 0.24.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +133 -1
- package/README.zh-CN.md +133 -1
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/index.js +78 -0
- package/lib/api.js +156 -17
- package/lib/client.js +93 -23
- package/lib/news-digest.js +109 -0
- package/lib/news-follows.js +523 -0
- package/lib/search-index.js +212 -0
- package/lib/vault.js +90 -15
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/scripts/bench-search.mjs +64 -0
- package/web/app.js +98 -28
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// 记忆核心 · 检索索引(SQLite FTS5 + BM25/IDF)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这一层:旧的 search() 是**全表扫描 + 子串计数**——
|
|
4
|
+
// score = 整串命中 3 分 + 每个词命中 1 分
|
|
5
|
+
// 没有 IDF、没有词频、没有长度归一,于是「用户」「插件」这种高频词和稀有词同权,
|
|
6
|
+
// 含一个「用户」的原始对话快照就能挤进前五(实测:搜「审核红线」返回全是噪声)。
|
|
7
|
+
//
|
|
8
|
+
// 这里换成 FTS5:
|
|
9
|
+
// · BM25 自带 IDF 与长度归一,天然压制高频噪声词;
|
|
10
|
+
// · 多列加权(标题 8 / 正文 1 / 标签 2 / 路径 1.5),标题命中优先;
|
|
11
|
+
// · 中文用**字符 bigram 预切词**(不用 jieba、零依赖):FTS5 的 unicode61 与 trigram
|
|
12
|
+
// 对「检索」「插件」这类 2 字中文词都会失效,bigram 是唯一零依赖的正解。
|
|
13
|
+
//
|
|
14
|
+
// 维护策略(两次踩坑后的结论):
|
|
15
|
+
// 1. **写时增量**:卡片增删改后立刻调用 indexCardById / unindexCard,
|
|
16
|
+
// 检索路径不承担重建成本(否则每次搜都要 diff 全表,比旧扫描还慢)。
|
|
17
|
+
// 2. **签名只由 SQL 计算**:JS 的 String#length 是 UTF-16 码元、SQLite length() 是字符,
|
|
18
|
+
// 带 emoji 的卡两边永远对不上 —— 曾导致 279 张卡每次检索都被重灌(300ms/次)。
|
|
19
|
+
// 现在两边都用同一段 SQL 表达式,彻底消除口径差。
|
|
20
|
+
// 3. **兜底对账**:进程内每 5 分钟最多做一次全表 diff,防外部改库导致索引漂移。
|
|
21
|
+
const FTS_TABLE = 'cards_fts'
|
|
22
|
+
const ROWS_TABLE = '_fts_rows'
|
|
23
|
+
const INDEX_VERSION = '2'
|
|
24
|
+
const RECONCILE_MS = 5 * 60 * 1000
|
|
25
|
+
const lastReconcile = new WeakMap()
|
|
26
|
+
|
|
27
|
+
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/
|
|
28
|
+
|
|
29
|
+
/** 签名表达式(SQL 侧唯一口径,禁止在 JS 里另算一套)。 */
|
|
30
|
+
const SIG_SQL = "(COALESCE(updated_at,'') || '|' || length(body) || '|' || length(title) || '|' || length(tags) || '|' || length(path))"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把文本切成 FTS token 序列。
|
|
34
|
+
* 中文:连续汉字段 → 全部 bigram(长度 1 则保留单字)
|
|
35
|
+
* 其他:按非字母数字切分,小写,保留长度 ≥2 的词
|
|
36
|
+
*/
|
|
37
|
+
export function tokenize(text) {
|
|
38
|
+
const s = String(text || '').toLowerCase()
|
|
39
|
+
const out = []
|
|
40
|
+
let i = 0
|
|
41
|
+
while (i < s.length) {
|
|
42
|
+
const ch = s[i]
|
|
43
|
+
if (CJK_RE.test(ch)) {
|
|
44
|
+
let j = i
|
|
45
|
+
while (j < s.length && CJK_RE.test(s[j])) j++
|
|
46
|
+
const run = s.slice(i, j)
|
|
47
|
+
if (run.length === 1) out.push(run)
|
|
48
|
+
else for (let k = 0; k < run.length - 1; k++) out.push(run.slice(k, k + 2))
|
|
49
|
+
i = j
|
|
50
|
+
} else if (/[0-9a-z\u00c0-\u024f]/i.test(ch)) {
|
|
51
|
+
let j = i
|
|
52
|
+
while (j < s.length && /[0-9a-z\u00c0-\u024f]/i.test(s[j])) j++
|
|
53
|
+
const w = s.slice(i, j)
|
|
54
|
+
if (w.length >= 2) out.push(w)
|
|
55
|
+
i = j
|
|
56
|
+
} else {
|
|
57
|
+
i++
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...new Set(out)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 索引存储形态:空格分隔的 token 串。 */
|
|
64
|
+
export function indexText(text) { return tokenize(text).join(' ') }
|
|
65
|
+
|
|
66
|
+
/** 构造 FTS5 MATCH 表达式。mode='and' 要求全部命中,'or' 任一命中。 */
|
|
67
|
+
export function buildMatch(terms, mode = 'and') {
|
|
68
|
+
const valid = (Array.isArray(terms) ? terms : [])
|
|
69
|
+
.map((t) => String(t || '').trim())
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((t) => '"' + t.replace(/"/g, '""') + '"')
|
|
72
|
+
if (!valid.length) return ''
|
|
73
|
+
return valid.join(mode === 'or' ? ' OR ' : ' AND ')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensureTables(db) {
|
|
77
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${ROWS_TABLE} (id INTEGER PRIMARY KEY, sig TEXT NOT NULL)`)
|
|
78
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE} USING fts5(title, body, tags, path, content='', contentless_delete=1)`)
|
|
79
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _fts_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeSig(db, id) {
|
|
83
|
+
db.prepare(`INSERT OR REPLACE INTO ${ROWS_TABLE}(id, sig) SELECT id, ${SIG_SQL} FROM cards WHERE id = ?`).run(id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function insertRow(db, row) {
|
|
87
|
+
db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(row.id)
|
|
88
|
+
db.prepare(`INSERT INTO ${FTS_TABLE}(rowid, title, body, tags, path) VALUES (?, ?, ?, ?, ?)`)
|
|
89
|
+
.run(row.id, indexText(row.title), indexText(row.body), indexText(row.tags), indexText(row.path))
|
|
90
|
+
writeSig(db, row.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 单卡增量索引(写路径调用;任何错误都吞掉,索引绝不能影响业务写入)。 */
|
|
94
|
+
export function indexCardById(db, id) {
|
|
95
|
+
try {
|
|
96
|
+
ensureTables(db)
|
|
97
|
+
const row = db.prepare('SELECT id, path, title, tags, body FROM cards WHERE id = ?').get(id)
|
|
98
|
+
if (!row) return unindexCard(db, id)
|
|
99
|
+
insertRow(db, row)
|
|
100
|
+
return true
|
|
101
|
+
} catch { return false }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 单卡移出索引(删除 / 迁出主库)。 */
|
|
105
|
+
export function unindexCard(db, id) {
|
|
106
|
+
try {
|
|
107
|
+
ensureTables(db)
|
|
108
|
+
db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(id)
|
|
109
|
+
db.prepare(`DELETE FROM ${ROWS_TABLE} WHERE id = ?`).run(id)
|
|
110
|
+
return true
|
|
111
|
+
} catch { return false }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 全量重建(导出给排障 / 「重建索引」用)。 */
|
|
115
|
+
export function reindexAll(db, { force = true } = {}) {
|
|
116
|
+
ensureTables(db)
|
|
117
|
+
db.exec(`DELETE FROM ${FTS_TABLE}`)
|
|
118
|
+
db.exec(`DELETE FROM ${ROWS_TABLE}`)
|
|
119
|
+
const rows = db.prepare('SELECT id, path, title, tags, body FROM cards').all()
|
|
120
|
+
const del = db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`)
|
|
121
|
+
const ins = db.prepare(`INSERT INTO ${FTS_TABLE}(rowid, title, body, tags, path) VALUES (?, ?, ?, ?, ?)`)
|
|
122
|
+
db.exec('BEGIN')
|
|
123
|
+
try {
|
|
124
|
+
for (const r of rows) {
|
|
125
|
+
del.run(r.id)
|
|
126
|
+
ins.run(r.id, indexText(r.title), indexText(r.body), indexText(r.tags), indexText(r.path))
|
|
127
|
+
}
|
|
128
|
+
db.prepare(`INSERT OR REPLACE INTO ${ROWS_TABLE}(id, sig) SELECT id, ${SIG_SQL} FROM cards`).run()
|
|
129
|
+
db.prepare("INSERT OR REPLACE INTO _fts_meta(key, value) VALUES('version', ?)").run(INDEX_VERSION)
|
|
130
|
+
db.exec('COMMIT')
|
|
131
|
+
} catch (e) { try { db.exec('ROLLBACK') } catch {} throw e }
|
|
132
|
+
lastReconcile.set(db, Date.now())
|
|
133
|
+
return { ok: true, indexed: rows.length }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 对账(不是每次检索都跑):进程内每 5 分钟最多一次全表 diff。
|
|
138
|
+
* 写路径已经做了增量,这里只兜住「外部改库 / 漏挂的写路径」。
|
|
139
|
+
*/
|
|
140
|
+
export function ensureIndex(db, { force = false } = {}) {
|
|
141
|
+
ensureTables(db)
|
|
142
|
+
const meta = db.prepare("SELECT value FROM _fts_meta WHERE key = 'version'").get()
|
|
143
|
+
if (!meta || meta.value !== INDEX_VERSION) return reindexAll(db)
|
|
144
|
+
|
|
145
|
+
const indexedCount = db.prepare(`SELECT COUNT(*) n FROM ${ROWS_TABLE}`).get().n
|
|
146
|
+
const cardCount = db.prepare('SELECT COUNT(*) n FROM cards').get().n
|
|
147
|
+
if (!force && indexedCount === cardCount && Date.now() - (lastReconcile.get(db) || 0) < RECONCILE_MS) {
|
|
148
|
+
return { rebuilt: 'skipped' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const current = db.prepare(`SELECT id, ${SIG_SQL} AS sig FROM cards`).all()
|
|
152
|
+
const indexed = new Map(db.prepare(`SELECT id, sig FROM ${ROWS_TABLE}`).all().map((r) => [r.id, r.sig]))
|
|
153
|
+
const changed = []
|
|
154
|
+
const seen = new Set()
|
|
155
|
+
for (const row of current) {
|
|
156
|
+
seen.add(row.id)
|
|
157
|
+
if (indexed.get(row.id) !== row.sig) changed.push(row.id)
|
|
158
|
+
}
|
|
159
|
+
const removed = []
|
|
160
|
+
for (const id of indexed.keys()) if (!seen.has(id)) removed.push(id)
|
|
161
|
+
lastReconcile.set(db, Date.now())
|
|
162
|
+
|
|
163
|
+
if (!changed.length && !removed.length) return { rebuilt: 'none' }
|
|
164
|
+
const rows = db.prepare('SELECT id, path, title, tags, body FROM cards WHERE id IN (' + [...changed].map(() => '?').join(',') + ')').all(...changed)
|
|
165
|
+
const rowMap = new Map(rows.map((r) => [r.id, r]))
|
|
166
|
+
db.exec('BEGIN')
|
|
167
|
+
try {
|
|
168
|
+
for (const id of removed) unindexCard(db, id)
|
|
169
|
+
for (const id of changed) {
|
|
170
|
+
const r = rowMap.get(id)
|
|
171
|
+
if (r) insertRow(db, r)
|
|
172
|
+
else unindexCard(db, id)
|
|
173
|
+
}
|
|
174
|
+
db.exec('COMMIT')
|
|
175
|
+
} catch (e) { try { db.exec('ROLLBACK') } catch {} throw e }
|
|
176
|
+
return { rebuilt: 'partial', updated: changed.length, removed: removed.length }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* FTS5 检索。返回 [{ id, score }],score 越大越相关(bm25 是负值,已翻正)。
|
|
181
|
+
* 先按「全部词命中」查;零结果才退化成「任一命中」,避免多打一个词就全灭。
|
|
182
|
+
*/
|
|
183
|
+
export function searchIndexed(db, query, { limit = 60 } = {}) {
|
|
184
|
+
const terms = tokenize(query)
|
|
185
|
+
if (!terms.length) return []
|
|
186
|
+
ensureTables(db)
|
|
187
|
+
const stmt = db.prepare(
|
|
188
|
+
`SELECT rowid AS id, bm25(${FTS_TABLE}, 8.0, 1.0, 2.0, 1.5) AS s FROM ${FTS_TABLE} WHERE ${FTS_TABLE} MATCH ? ORDER BY s LIMIT ?`
|
|
189
|
+
)
|
|
190
|
+
let rows = []
|
|
191
|
+
const andExpr = buildMatch(terms, 'and')
|
|
192
|
+
if (andExpr) { try { rows = stmt.all(andExpr, limit) } catch { rows = [] } }
|
|
193
|
+
if (!rows.length) {
|
|
194
|
+
const orExpr = buildMatch(terms, 'or')
|
|
195
|
+
if (orExpr) { try { rows = stmt.all(orExpr, limit) } catch { rows = [] } }
|
|
196
|
+
}
|
|
197
|
+
// bm25 是「越小越相关」的负值,量级随语料规模变化(小库 1e-6、大库 1e-1)。
|
|
198
|
+
// 直接乘常数会让小库的分数 <1,与旧的 1~10 分口径不兼容(minScore=2 会误杀)。
|
|
199
|
+
// 用 log1p 归一到 ~1–20 的稳定区间:单调保序,且任何命中都 ≥1。
|
|
200
|
+
return rows.map((r) => ({ id: r.id, score: 1 + Math.log1p(-Number(r.s) * 1e6) }))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 索引统计(排障/面板用)。 */
|
|
204
|
+
export function indexStats(db) {
|
|
205
|
+
try {
|
|
206
|
+
ensureTables(db)
|
|
207
|
+
return {
|
|
208
|
+
indexed: db.prepare(`SELECT COUNT(*) n FROM ${FTS_TABLE}`).get().n,
|
|
209
|
+
cards: db.prepare('SELECT COUNT(*) n FROM cards').get().n,
|
|
210
|
+
}
|
|
211
|
+
} catch { return { indexed: 0, cards: 0 } }
|
|
212
|
+
}
|
package/lib/vault.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from 'node:path'
|
|
|
7
7
|
import fs from 'node:fs'
|
|
8
8
|
import { getDb, enforceAudit, nextCardId, MAIN_STATUS, QUARANTINE_STATUSES, tableForStatus } from './db.js'
|
|
9
9
|
import { minhashSignature, lshCandidates } from './minhash.js'
|
|
10
|
+
import { indexCardById, unindexCard } from './search-index.js'
|
|
10
11
|
|
|
11
12
|
/** 主题目录根。 */
|
|
12
13
|
export const KIND_ROOTS = {
|
|
@@ -416,17 +417,49 @@ function moveCardBetweenStores(db, from, to, rel, { status, deletedAt = null, qu
|
|
|
416
417
|
// 更新记录跟着卡走(id 变了,按新 id 改绑)
|
|
417
418
|
db.prepare('UPDATE card_updates SET card_id = ? WHERE card_id = ?').run(newId, src.id)
|
|
418
419
|
db.prepare(`DELETE FROM ${from} WHERE path = ?`).run(rel)
|
|
420
|
+
// 索引跟随搬家:进主库→加;离开主库→删(隔离区不进召回面)
|
|
421
|
+
if (to === 'cards') indexCardById(db, newId)
|
|
422
|
+
if (from === 'cards') unindexCard(db, src.id)
|
|
419
423
|
return { ok: true, status, table: to, oldId: src.id, newId }
|
|
420
424
|
})
|
|
421
425
|
}
|
|
422
426
|
|
|
423
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* 回收站:隔离区里 status ∈ {deleted, rejected}。
|
|
429
|
+
*
|
|
430
|
+
* issue #57:过去只查 deleted,于是「审核里点驳回」的卡永远进不了回收站 —— 用户在回收站
|
|
431
|
+
* 怎么等都等不到,只能一张张去审核队列里删。驳回与删除都是「这张卡不会被召回」,
|
|
432
|
+
* 对清理者而言该出现在同一处;两者用 status 区分,恢复语义不同(见 restoreCard)。
|
|
433
|
+
* 排序用 COALESCE:驳回卡没有 deleted_at。
|
|
434
|
+
*/
|
|
424
435
|
export async function recycleList(root) {
|
|
425
436
|
const db = getDb(root)
|
|
426
|
-
const rows = db.prepare(
|
|
437
|
+
const rows = db.prepare(
|
|
438
|
+
"SELECT * FROM quarantine WHERE status IN ('deleted', 'rejected') ORDER BY COALESCE(deleted_at, updated_at) DESC"
|
|
439
|
+
).all()
|
|
427
440
|
return rows.map(rowToCard)
|
|
428
441
|
}
|
|
429
442
|
|
|
443
|
+
/**
|
|
444
|
+
* 永久清空回收站(deleted + rejected)。
|
|
445
|
+
*
|
|
446
|
+
* issue #57 的直接诉求:「清空」要能真清空,而不是只清 30 天前的。
|
|
447
|
+
* 与 purgeExpired 分开:那个是保留期策略(自动),这个是用户显式点「清空」,
|
|
448
|
+
* 语义是"把我在回收站看到的东西全删掉"。逐个走 deleteCard(permanent) 才会
|
|
449
|
+
* 连带清掉 card_updates / feedback / 修改副本关联。
|
|
450
|
+
*/
|
|
451
|
+
export async function purgeAllRecycle(root) {
|
|
452
|
+
const db = getDb(root)
|
|
453
|
+
const rows = db.prepare("SELECT path FROM quarantine WHERE status IN ('deleted', 'rejected')").all()
|
|
454
|
+
let purged = 0
|
|
455
|
+
const failed = []
|
|
456
|
+
for (const row of rows) {
|
|
457
|
+
try { await deleteCard(root, row.path, { permanent: true }); purged++ }
|
|
458
|
+
catch (e) { failed.push(row.path) }
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, purged, total: rows.length, failed }
|
|
461
|
+
}
|
|
462
|
+
|
|
430
463
|
/** 清理超过 retentionDays 天的回收卡(永久删除)。 */
|
|
431
464
|
export async function purgeExpired(root, retentionDays = 30) {
|
|
432
465
|
const db = getDb(root)
|
|
@@ -510,6 +543,7 @@ export async function deleteCard(root, rel, { permanent = false } = {}) {
|
|
|
510
543
|
db.prepare('DELETE FROM cards WHERE path = ?').run(rel)
|
|
511
544
|
db.prepare('DELETE FROM quarantine WHERE path = ?').run(rel)
|
|
512
545
|
})
|
|
546
|
+
for (const id of ids) unindexCard(db, id)
|
|
513
547
|
return { ok: true, permanent: true }
|
|
514
548
|
}
|
|
515
549
|
const existing = findCardRow(db, rel)
|
|
@@ -525,13 +559,27 @@ export async function deleteCard(root, rel, { permanent = false } = {}) {
|
|
|
525
559
|
return { ok: true, soft: true }
|
|
526
560
|
}
|
|
527
561
|
|
|
528
|
-
/**
|
|
562
|
+
/**
|
|
563
|
+
* 恢复回收站里的卡。写审计日志。
|
|
564
|
+
*
|
|
565
|
+
* 两种来源语义不同(issue #57):
|
|
566
|
+
* - 已删除(deleted):搬回主库,status=approved —— 它本来就是"删了一下",恢复即可用。
|
|
567
|
+
* - 已驳回(rejected):**只能退回审核队列(pending)**。驳回意味着"内容不该入库",
|
|
568
|
+
* 若恢复即 approved,回收站就成了绕过审核批准一条被驳回内容的旁路。
|
|
569
|
+
* 主库物理上只存 approved(见 db.js MAIN_STATUS),所以这里原地改状态、不搬表。
|
|
570
|
+
*/
|
|
529
571
|
export async function restoreCard(root, rel) {
|
|
530
572
|
const db = getDb(root)
|
|
531
573
|
const existing = db.prepare('SELECT status FROM quarantine WHERE path = ?').get(rel)
|
|
532
574
|
if (!existing) throw new Error('卡片不在回收站/隔离区: ' + rel)
|
|
533
575
|
// 修改副本不能「恢复」成一张独立卡 —— 那会跟原卡重复。它只能被批准(覆盖原卡)或删除。
|
|
534
576
|
if (getRevision(db, rel)) throw new Error('这是知识卡的修改副本:请在审核中心「批准」以覆盖原卡,或直接删除')
|
|
577
|
+
const now = new Date().toISOString()
|
|
578
|
+
if (existing.status === 'rejected') {
|
|
579
|
+
db.prepare('UPDATE quarantine SET status = ?, deleted_at = NULL, updated_at = ? WHERE path = ?').run('pending', now, rel)
|
|
580
|
+
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, 'rejected', 'pending', 'restore', '驳回卡退回审核队列')
|
|
581
|
+
return { ok: true, restored: true, status: 'pending' }
|
|
582
|
+
}
|
|
535
583
|
const r = moveCardBetweenStores(db, 'quarantine', 'cards', rel, { status: MAIN_STATUS, deletedAt: null })
|
|
536
584
|
db.prepare('INSERT INTO audit_log (card_path, old_status, new_status, changed_by, reason) VALUES (?, ?, ?, ?, ?)').run(rel, existing.status, MAIN_STATUS, 'restore', '回收站恢复')
|
|
537
585
|
return { ok: true, restored: true, ...r }
|
|
@@ -665,6 +713,7 @@ export async function writeCard(root, { kind, title, tags = [], body, source = '
|
|
|
665
713
|
id, rel, kind, title, JSON.stringify(tags), body.trim(), summary,
|
|
666
714
|
enforcedStatus, source, submittedBy, severity, reason, now, now, deletedAt || null,
|
|
667
715
|
)
|
|
716
|
+
indexCardById(db, id)
|
|
668
717
|
} else {
|
|
669
718
|
db.prepare(`
|
|
670
719
|
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)
|
|
@@ -691,6 +740,7 @@ export async function appendUpdate(root, rel, updateText) {
|
|
|
691
740
|
const now = new Date().toISOString()
|
|
692
741
|
db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)').run(row.id, updateText.trim(), now)
|
|
693
742
|
db.prepare(`UPDATE ${found.table} SET updated_at = ? WHERE id = ?`).run(now, row.id)
|
|
743
|
+
if (found.table === 'cards') indexCardById(db, row.id)
|
|
694
744
|
return { ok: true, path: rel, updated: now, store: found.table }
|
|
695
745
|
}
|
|
696
746
|
|
|
@@ -715,6 +765,7 @@ export async function updateCardContent(root, rel, { title, tags, body, reason }
|
|
|
715
765
|
db.prepare(
|
|
716
766
|
`UPDATE ${found.table} SET title = ?, tags = ?, body = ?, summary = ?, reason = ?, updated_at = ? WHERE path = ?`,
|
|
717
767
|
).run(nextTitle, JSON.stringify(nextTags), nextBody, nextBody.slice(0, 200), nextReason, now, rel)
|
|
768
|
+
if (found.table === 'cards') indexCardById(db, row.id)
|
|
718
769
|
return { ok: true, path: rel, store: found.table, updated: now }
|
|
719
770
|
}
|
|
720
771
|
|
|
@@ -801,6 +852,7 @@ function applyRevision(db, revisionPath, rev, { changedBy = 'user', reason = ''
|
|
|
801
852
|
const tags = normalizeTags(revRow.tags)
|
|
802
853
|
db.prepare('UPDATE cards SET title = ?, tags = ?, body = ?, summary = ?, updated_at = ? WHERE path = ?')
|
|
803
854
|
.run(revRow.title, JSON.stringify(tags), revRow.body, String(revRow.body).trim().slice(0, 200), now, rev.target_path)
|
|
855
|
+
indexCardById(db, target.id)
|
|
804
856
|
db.prepare('INSERT INTO card_updates (card_id, content, created_at) VALUES (?, ?, ?)')
|
|
805
857
|
.run(target.id, '经审核修改:' + (reason || ('由 ' + (revRow.submitted_by || 'agent') + ' 提交,' + changedBy + ' 批准')), now)
|
|
806
858
|
db.prepare('DELETE FROM quarantine WHERE path = ?').run(revisionPath)
|
|
@@ -846,20 +898,43 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
|
|
|
846
898
|
if (!q) return []
|
|
847
899
|
const db = getDb(root)
|
|
848
900
|
// 两套存储后这里**不再需要状态条件**:主库物理上只含 approved。
|
|
849
|
-
const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
|
|
850
901
|
const wanted = queryTerms(q)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
902
|
+
let out = []
|
|
903
|
+
|
|
904
|
+
// ① 主路径:FTS5 + BM25(多列加权,自带 IDF 与长度归一)
|
|
905
|
+
// 旧实现是「全表扫描 + 子串计数」,没有 IDF —— 高频词和稀有词同权,
|
|
906
|
+
// 含一个「用户」的原始快照就能挤进前五(实测 precision@5 ≈ 12%)。
|
|
907
|
+
try {
|
|
908
|
+
const { searchIndexed } = await import('./search-index.js')
|
|
909
|
+
const hits = searchIndexed(db, q, { limit: Math.max(limit * 4, 80) })
|
|
910
|
+
if (hits.length) {
|
|
911
|
+
const scoreOf = new Map(hits.map((h) => [h.id, h.score]))
|
|
912
|
+
const ids = hits.map((h) => h.id)
|
|
913
|
+
const rows = db.prepare('SELECT * FROM cards WHERE id IN (' + ids.map(() => '?').join(',') + ')').all(...ids)
|
|
914
|
+
out = rows.map((row) => {
|
|
915
|
+
const card = rowToCard(row)
|
|
916
|
+
let score = scoreOf.get(row.id) || 0
|
|
917
|
+
if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
|
|
918
|
+
return { ...card, score, excerpt: row.body.trim().slice(0, 300) }
|
|
919
|
+
}).filter((h) => h.score > 0 && h.score >= minScore)
|
|
859
920
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
921
|
+
} catch { out = [] } // 索引异常 → 静默降级到旧路径,绝不能因为索引坏了就搜不到
|
|
922
|
+
|
|
923
|
+
// ② 兜底路径:FTS 不可用 / 索引为空时保持旧行为
|
|
924
|
+
if (!out.length) {
|
|
925
|
+
const rows = db.prepare('SELECT * FROM cards ORDER BY updated_at DESC').all()
|
|
926
|
+
for (const row of rows) {
|
|
927
|
+
const card = rowToCard(row)
|
|
928
|
+
const haystack = `${card.path}\n${card.title}\n${row.body}`.toLowerCase()
|
|
929
|
+
let score = 0
|
|
930
|
+
if (haystack.includes(q.toLowerCase())) score = 3
|
|
931
|
+
for (const term of wanted) {
|
|
932
|
+
if (haystack.includes(term.toLowerCase())) score += 1
|
|
933
|
+
}
|
|
934
|
+
if (semantic) score += Math.round(textSimilarity(q, `${card.title} ${card.summary}`) * 6)
|
|
935
|
+
if (score > 0 && score >= minScore) {
|
|
936
|
+
out.push({ ...card, score, excerpt: row.body.trim().slice(0, 300) })
|
|
937
|
+
}
|
|
863
938
|
}
|
|
864
939
|
}
|
|
865
940
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "memory-eternal",
|
|
4
4
|
"publisher": "eternalnight996",
|
|
5
5
|
"name": "memory-eternal",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.24.0",
|
|
7
7
|
"description": "Memory Atlas for Orca: open the local memory vault, pending review and knowledge graph from a sandboxed Orca panel.",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "EternalNight996",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"icon": "assets/icon.png",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"release": "node scripts/release.mjs",
|
|
71
71
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
72
72
|
"panel-check": "node scripts/panel-check.mjs",
|
|
73
|
-
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/graph-force.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/graph-warmup.test.mjs && node tests/loading-hints.test.mjs && node tests/web-entry.test.mjs && node tests/tui-scroll.test.mjs && node tests/tui-audit-scene.test.mjs && node tests/tui-plugin-boot.test.mjs && node tests/capture-session-id.test.mjs && node tests/ssr.test.mjs && node tests/window-controls.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs && node tests/host-managed.test.mjs && node tests/replace-claim.test.mjs && node tests/version-check-self.test.mjs && node tests/setup-codex.test.mjs && node tests/listener-netstat.test.mjs && node tests/issue-46-overlay.test.mjs && node tests/graph-initial-zoom.test.mjs && node tests/card-head-actions.test.mjs && node tests/card-reader-ux.test.mjs && node tests/tag-filter.test.mjs && node tests/ui-overflow.test.mjs && node tests/version-card-state.test.mjs && node tests/llm-routes-proxy.test.mjs && node tests/model-strikes.test.mjs && node tests/support-bundle.test.mjs && node tests/health-panel.test.mjs && node tests/issue-48-timeout.test.mjs && node tests/setup-node-path.test.mjs && node tests/capture-kind.test.mjs && node tests/core-memory.test.mjs && node tests/skill-cards.test.mjs && node tests/audit-search.test.mjs && node tests/news.test.mjs && node tests/news-feed.test.mjs && node tests/ledger.test.mjs && node tests/issue-55-timezone.test.mjs"
|
|
73
|
+
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/graph-force.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/graph-warmup.test.mjs && node tests/loading-hints.test.mjs && node tests/web-entry.test.mjs && node tests/tui-scroll.test.mjs && node tests/tui-audit-scene.test.mjs && node tests/tui-plugin-boot.test.mjs && node tests/capture-session-id.test.mjs && node tests/ssr.test.mjs && node tests/window-controls.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs && node tests/host-managed.test.mjs && node tests/replace-claim.test.mjs && node tests/version-check-self.test.mjs && node tests/setup-codex.test.mjs && node tests/listener-netstat.test.mjs && node tests/issue-46-overlay.test.mjs && node tests/graph-initial-zoom.test.mjs && node tests/card-head-actions.test.mjs && node tests/card-reader-ux.test.mjs && node tests/tag-filter.test.mjs && node tests/ui-overflow.test.mjs && node tests/version-card-state.test.mjs && node tests/llm-routes-proxy.test.mjs && node tests/model-strikes.test.mjs && node tests/support-bundle.test.mjs && node tests/health-panel.test.mjs && node tests/issue-48-timeout.test.mjs && node tests/setup-node-path.test.mjs && node tests/capture-kind.test.mjs && node tests/core-memory.test.mjs && node tests/skill-cards.test.mjs && node tests/audit-search.test.mjs && node tests/news.test.mjs && node tests/news-feed.test.mjs && node tests/ledger.test.mjs && node tests/issue-55-timezone.test.mjs && node tests/recycle-center.test.mjs && node tests/news-follows.test.mjs && node tests/news-digest.test.mjs && node tests/search-index.test.mjs"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 记忆核心 · 检索延迟基准(用户可自行复跑,不接受"我们很快"这种空话)
|
|
3
|
+
//
|
|
4
|
+
// 用法:
|
|
5
|
+
// node scripts/bench-search.mjs # 默认 ~/.dsh/memory-vault
|
|
6
|
+
// node scripts/bench-search.mjs --vault <DIR> # 指定库
|
|
7
|
+
// node scripts/bench-search.mjs --rounds 5 # 每个查询跑几轮(默认 5)
|
|
8
|
+
// node scripts/bench-search.mjs --rebuild # 先全量重建索引,报出重建耗时
|
|
9
|
+
//
|
|
10
|
+
// 输出:卡片数、索引条数、p50 / p95 / max 延迟、QPS 估算。
|
|
11
|
+
// 为什么要有它:README 里的性能数字必须能被人现场复核,否则就是营销。
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2)
|
|
17
|
+
const opt = (name, def = '') => { const i = args.indexOf('--' + name); return i >= 0 ? (args[i + 1] ?? def) : def }
|
|
18
|
+
const flag = (name) => args.includes('--' + name)
|
|
19
|
+
|
|
20
|
+
const vault = opt('vault') || process.env.MEMORY_VAULT_DIR
|
|
21
|
+
|| path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'memory-vault')
|
|
22
|
+
const rounds = Math.max(1, Number(opt('rounds', '5')) || 5)
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(vault)) {
|
|
25
|
+
console.error('找不到记忆库:' + vault + '(用 --vault 指定)')
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { search } = await import('../lib/vault.js')
|
|
30
|
+
const { reindexAll, indexStats } = await import('../lib/search-index.js')
|
|
31
|
+
const { getDb } = await import('../lib/db.js')
|
|
32
|
+
|
|
33
|
+
const db = getDb(vault)
|
|
34
|
+
if (flag('rebuild')) {
|
|
35
|
+
const t0 = Date.now()
|
|
36
|
+
reindexAll(db)
|
|
37
|
+
console.log('全量重建索引:' + (Date.now() - t0) + 'ms')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const st = indexStats(db)
|
|
41
|
+
// 固定查询集:混合中英、长词、短词、专有名词,避免只挑"快的那几个"
|
|
42
|
+
const queries = [
|
|
43
|
+
'检索 索引', '审核 红线', '回收站 清空', '作者语录', '新闻 关注', '发布 版本',
|
|
44
|
+
'数据库 选型', '记忆 架构', '插件 安装', '异常 排查', 'token 成本', '知识图谱 性能',
|
|
45
|
+
'审核 中心', '技能卡', 'FTS5 索引', '缓存 一致性', '配置 同步', '新闻 源', '摘要 推送', 'GitHub 热门',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const samples = []
|
|
49
|
+
for (const q of queries) {
|
|
50
|
+
for (let i = 0; i < rounds; i++) {
|
|
51
|
+
const t = process.hrtime.bigint()
|
|
52
|
+
await search(vault, q, { limit: 5 })
|
|
53
|
+
samples.push(Number(process.hrtime.bigint() - t) / 1e6)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
samples.sort((a, b) => a - b)
|
|
57
|
+
const at = (p) => samples[Math.min(samples.length - 1, Math.floor(samples.length * p))]
|
|
58
|
+
const total = samples.reduce((a, b) => a + b, 0)
|
|
59
|
+
|
|
60
|
+
console.log('库路径 : ' + vault)
|
|
61
|
+
console.log('卡片 / 索引 : ' + st.cards + ' / ' + st.indexed)
|
|
62
|
+
console.log('查询 × 轮次 : ' + queries.length + ' × ' + rounds + ' = ' + samples.length + ' 次')
|
|
63
|
+
console.log('p50 / p95 / max: ' + at(0.5).toFixed(2) + 'ms / ' + at(0.95).toFixed(2) + 'ms / ' + samples[samples.length - 1].toFixed(2) + 'ms')
|
|
64
|
+
console.log('平均 / QPS : ' + (total / samples.length).toFixed(2) + 'ms / ' + Math.round(1000 / (total / samples.length)) + ' 次/秒')
|