memory-eternal 0.9.0 → 0.9.1
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 -0
- package/README.md +9 -0
- package/index.js +12 -3
- package/lib/api.js +77 -11
- package/lib/client.js +5 -5
- package/lib/vault.js +132 -25
- package/lib/watchdog.js +28 -4
- package/lib/web.js +8 -3
- package/package.json +1 -1
- package/web/app.js +9 -9
package/lib/vault.js
CHANGED
|
@@ -64,25 +64,38 @@ export function safeSlug(name) {
|
|
|
64
64
|
return base || 'card'
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
export function
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
67
|
+
/** 字符 bigram 集合(textSimilarity 的预处理部分,单独暴露供 O(n²) 场景复用)。 */
|
|
68
|
+
export function bigramSet(text) {
|
|
69
|
+
const cleaned = String(text)
|
|
70
|
+
.replace(/[\s#*_`|[\]()()\-—・]+/g, '')
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
const out = new Set()
|
|
73
|
+
for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
|
|
74
|
+
if (cleaned.length === 1) out.add(cleaned)
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 用预算好的 bigram 集合算 Jaccard(结果与 textSimilarity 完全一致)。
|
|
80
|
+
*
|
|
81
|
+
* `minRatio` 是数学上界剪枝:J ≤ min(|A|,|B|)/max(|A|,|B|),所以长度比低于阈值时
|
|
82
|
+
* 必然 `J < 阈值`,可直接返回 0 **不迭代**(对「J ≥ 阈值」的判定无损,不是近似)。
|
|
83
|
+
* 图谱的 O(n²) 配对就靠这一步从「每次重新分词两段正文」变成「集合求交」。
|
|
84
|
+
*/
|
|
85
|
+
export function jaccardSets(ba, bb, minRatio = 0) {
|
|
86
|
+
if (!ba || !bb || ba.size === 0 || bb.size === 0) return 0
|
|
87
|
+
const [small, large] = ba.size <= bb.size ? [ba, bb] : [bb, ba]
|
|
88
|
+
if (minRatio > 0 && small.size / large.size < minRatio) return 0
|
|
81
89
|
let inter = 0
|
|
82
|
-
for (const g of
|
|
90
|
+
for (const g of small) if (large.has(g)) inter++
|
|
83
91
|
return inter / (ba.size + bb.size - inter)
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/** Jaccard-like 相似度:字符 bigram 集合。 */
|
|
95
|
+
export function textSimilarity(a, b) {
|
|
96
|
+
return jaccardSets(bigramSet(a), bigramSet(b))
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
/** CJK 感知查询词:整词 + 中文字符 bigram。 */
|
|
87
100
|
export function queryTerms(query) {
|
|
88
101
|
const tokens = new Set(String(query).split(/[^\w\u4e00-\u9fff]+/).filter(Boolean))
|
|
@@ -165,15 +178,74 @@ function rowToMarkdown(row) {
|
|
|
165
178
|
return { text: lines.join('\n'), tags }
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
/**
|
|
169
|
-
|
|
181
|
+
/**
|
|
182
|
+
* 智能体署名归一(与客户端 normAgent 同一套规则,供 SQL 过滤用):
|
|
183
|
+
* 空 / agent / unknown / session:* / DeepSeek Harness → deepseek-harness;claude-code* → claude;其余精确匹配。
|
|
184
|
+
*/
|
|
185
|
+
function agentWhere(agent, args) {
|
|
186
|
+
const a = String(agent || '').toLowerCase()
|
|
187
|
+
if (a === 'deepseek-harness') {
|
|
188
|
+
args.push('', 'agent', 'unknown', 'deepseek-harness', 'deepseek harness')
|
|
189
|
+
return "(LOWER(COALESCE(submitted_by,'')) IN (?,?,?,?,?) OR submitted_by LIKE 'session:%')"
|
|
190
|
+
}
|
|
191
|
+
if (a === 'claude') { args.push('claude', 'claude-code%'); return "(LOWER(COALESCE(submitted_by,'')) = ? OR submitted_by LIKE ?)" }
|
|
192
|
+
args.push(a)
|
|
193
|
+
return "LOWER(COALESCE(submitted_by,'')) = ?"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** WHERE 组装:状态白名单 + 可选 kind / agent。 */
|
|
197
|
+
function cardsWhere({ want, kind, agent }) {
|
|
198
|
+
const args = [...want]
|
|
199
|
+
let where = `status IN (${want.map(() => '?').join(',')})`
|
|
200
|
+
if (kind) { where += ' AND kind = ?'; args.push(kind) }
|
|
201
|
+
if (agent) where += ' AND ' + agentWhere(agent, args)
|
|
202
|
+
return { where, args }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 标题排序用的中文排序器(拼音 + 数字感知);固定 zh-Hans-CN,避免随进程 locale 漂移。 */
|
|
206
|
+
export const TITLE_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 列出卡片(从 SQL)。
|
|
210
|
+
* @param opts.status 状态白名单(默认 approved)
|
|
211
|
+
* @param opts.kind 类型过滤(SQL)
|
|
212
|
+
* @param opts.agent 署名过滤(服务端归一,如 deepseek-harness / claude)
|
|
213
|
+
* @param opts.limit 返回条数上限(缺省=全部,供内部调用保持原语义)
|
|
214
|
+
* @param opts.offset 偏移(配合 limit 做真分页)
|
|
215
|
+
* @param opts.sort 'recent'(updated_at DESC,默认)| 'title'(拼音升序,跨页全局有序)
|
|
216
|
+
*/
|
|
217
|
+
export async function listCards(root, { status, kind, agent, limit, offset = 0, sort = 'recent' } = {}) {
|
|
170
218
|
const want = status ?? ['approved']
|
|
171
219
|
const db = getDb(root)
|
|
172
|
-
const
|
|
173
|
-
|
|
220
|
+
const { where, args } = cardsWhere({ want, kind, agent })
|
|
221
|
+
if (sort === 'title') {
|
|
222
|
+
// SQLite 的 ORDER BY 是 UTF-8 字节序,中文标题看着就是乱的 → 只取 path/title 用拼音排好,
|
|
223
|
+
// 再回表取这一页的正文(避免为了排序把所有正文读进内存)。
|
|
224
|
+
const light = db.prepare(`SELECT path, title FROM cards WHERE ${where}`).all(...args)
|
|
225
|
+
light.sort((a, b) => TITLE_COLLATOR.compare(a.title || '', b.title || ''))
|
|
226
|
+
const start = Math.max(0, offset | 0)
|
|
227
|
+
const page = Number.isInteger(limit) && limit > 0 ? light.slice(start, start + limit) : light.slice(start)
|
|
228
|
+
if (page.length === 0) return []
|
|
229
|
+
const ph = page.map(() => '?').join(',')
|
|
230
|
+
const rows = db.prepare(`SELECT * FROM cards WHERE path IN (${ph})`).all(...page.map((r) => r.path))
|
|
231
|
+
const byPath = new Map(rows.map((r) => [r.path, rowToCard(r)]))
|
|
232
|
+
return page.map((r) => byPath.get(r.path)).filter(Boolean)
|
|
233
|
+
}
|
|
234
|
+
if (Number.isInteger(limit) && limit > 0) {
|
|
235
|
+
const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`).all(...args, limit, Math.max(0, offset | 0))
|
|
236
|
+
return rows.map(rowToCard)
|
|
237
|
+
}
|
|
238
|
+
const rows = db.prepare(`SELECT * FROM cards WHERE ${where} ORDER BY updated_at DESC`).all(...args)
|
|
174
239
|
return rows.map(rowToCard)
|
|
175
240
|
}
|
|
176
241
|
|
|
242
|
+
/** 计数(分页接口要用 total 判断还有没有下一页)。 */
|
|
243
|
+
export async function countCards(root, { status, kind, agent } = {}) {
|
|
244
|
+
const db = getDb(root)
|
|
245
|
+
const { where, args } = cardsWhere({ want: status ?? ['approved'], kind, agent })
|
|
246
|
+
return db.prepare(`SELECT COUNT(*) n FROM cards WHERE ${where}`).get(...args).n
|
|
247
|
+
}
|
|
248
|
+
|
|
177
249
|
/** 审核队列:status=pending 或 rejected。 */
|
|
178
250
|
export async function auditQueue(root) {
|
|
179
251
|
const cards = await listCards(root, { status: ['pending', 'rejected'] })
|
|
@@ -404,9 +476,28 @@ export async function exportCards(root) {
|
|
|
404
476
|
})
|
|
405
477
|
}
|
|
406
478
|
|
|
479
|
+
// 图谱缓存:一次生成约 300ms(301 卡),重复请求(多客户端/切页重开)直接命中。
|
|
480
|
+
// 失效用「指纹」而不是 TTL 兜底:卡数 + 更新时间 + 状态直方图 + 审计日志/更新记录水位,
|
|
481
|
+
// 任何写卡、审核、删卡、追加更新都会改变指纹 → 不存在脏读。
|
|
482
|
+
const GRAPH_CACHE_MAX = 4
|
|
483
|
+
const GRAPH_CACHE_TTL_MS = 5 * 60 * 1000
|
|
484
|
+
const graphCache = new Map() // root -> { fp, value, at }
|
|
485
|
+
|
|
486
|
+
function graphFingerprint(db) {
|
|
487
|
+
const c = db.prepare('SELECT COUNT(*) n, MAX(updated_at) m, SUM(id) s FROM cards').get()
|
|
488
|
+
const st = db.prepare('SELECT status, COUNT(*) n FROM cards GROUP BY status').all()
|
|
489
|
+
const log = db.prepare('SELECT MAX(id) id FROM audit_log').get()
|
|
490
|
+
let upd = 0
|
|
491
|
+
try { upd = db.prepare('SELECT MAX(id) id FROM card_updates').get().id || 0 } catch { /* 老库无此表 */ }
|
|
492
|
+
return [c.n, c.m || '', c.s || 0, st.map((r) => `${r.status}:${r.n}`).join(','), log.id || 0, upd].join('|')
|
|
493
|
+
}
|
|
494
|
+
|
|
407
495
|
/** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
|
|
408
496
|
export async function graph(root) {
|
|
409
497
|
const db = getDb(root)
|
|
498
|
+
const fp = graphFingerprint(db)
|
|
499
|
+
const hit = graphCache.get(root)
|
|
500
|
+
if (hit && hit.fp === fp && Date.now() - hit.at < GRAPH_CACHE_TTL_MS) return hit.value
|
|
410
501
|
// 只选图谱需要的列,不读 body(大幅减少内存和传输量)
|
|
411
502
|
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()
|
|
412
503
|
// body 单独按需读取(只在需要 wikilink 匹配时)
|
|
@@ -462,23 +553,39 @@ export async function graph(root) {
|
|
|
462
553
|
}
|
|
463
554
|
}
|
|
464
555
|
}
|
|
556
|
+
}
|
|
557
|
+
// 标签倒排(tag → paths,按 cards 顺序):原先每个标签都要全表扫一遍,是 O(卡数²)
|
|
558
|
+
const tagIndex = new Map()
|
|
559
|
+
for (const c of cards) for (const t of c.tags) {
|
|
560
|
+
const arr = tagIndex.get(t)
|
|
561
|
+
if (arr) arr.push(c.path)
|
|
562
|
+
else tagIndex.set(t, [c.path])
|
|
563
|
+
}
|
|
564
|
+
for (const card of cards) {
|
|
465
565
|
for (const tag of card.tags) {
|
|
466
|
-
for (const other of
|
|
467
|
-
if (other
|
|
566
|
+
for (const other of tagIndex.get(tag) || []) {
|
|
567
|
+
if (other !== card.path) addEdge(card.path, other, `tag:${tag}`)
|
|
468
568
|
}
|
|
469
569
|
}
|
|
470
570
|
}
|
|
471
571
|
|
|
472
|
-
//
|
|
572
|
+
// 相似度关联:bigram 集合只算一次(原先每对都重新分词两段正文=指数级白烧),
|
|
573
|
+
// 再用数学上界剪枝(J ≤ min/max)跳过长度比过小的配对。
|
|
574
|
+
const SIM_THRESHOLD = 0.42
|
|
575
|
+
const bigramsByPath = new Map()
|
|
576
|
+
for (const c of cards) bigramsByPath.set(c.path, bigramSet(bodyMap.get(c.path) || ''))
|
|
473
577
|
const similarCount = {}
|
|
474
578
|
for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
|
|
475
579
|
const a = cards[i], b = cards[j]
|
|
476
580
|
if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
|
|
477
581
|
if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
|
|
478
|
-
const sim =
|
|
479
|
-
if (sim >=
|
|
582
|
+
const sim = jaccardSets(bigramsByPath.get(a.path), bigramsByPath.get(b.path), SIM_THRESHOLD)
|
|
583
|
+
if (sim >= SIM_THRESHOLD) { addEdge(a.path, b.path, 'similar'); addDegree(a.path); addDegree(b.path); similarCount[a.path] = (similarCount[a.path] || 0) + 1; similarCount[b.path] = (similarCount[b.path] || 0) + 1 }
|
|
480
584
|
}
|
|
481
|
-
|
|
585
|
+
const value = { nodes, edges }
|
|
586
|
+
graphCache.set(root, { fp, value, at: Date.now() })
|
|
587
|
+
if (graphCache.size > GRAPH_CACHE_MAX) graphCache.delete(graphCache.keys().next().value)
|
|
588
|
+
return value
|
|
482
589
|
}
|
|
483
590
|
|
|
484
591
|
/** 统计。 */
|
package/lib/watchdog.js
CHANGED
|
@@ -71,7 +71,8 @@ export async function probeTcp(port, timeoutMs = 600) {
|
|
|
71
71
|
* @param {boolean} [opts.autoStart=true] // 首次探测到死时是否自动拉起
|
|
72
72
|
*/
|
|
73
73
|
export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRestart = 10, autoStart = true } = {}) {
|
|
74
|
-
let child = null // 我们 spawn 的 web
|
|
74
|
+
let child = null // 我们 spawn 的 web 进程(最近一次)
|
|
75
|
+
const spawned = new Map() // child -> 它监听的端口(用于回收多余实例)
|
|
75
76
|
let ownedByUs = false
|
|
76
77
|
let restartCount = 0
|
|
77
78
|
let stopped = false
|
|
@@ -100,14 +101,33 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
100
101
|
windowsHide: true,
|
|
101
102
|
})
|
|
102
103
|
c.on('exit', (code, sig) => {
|
|
104
|
+
spawned.delete(c)
|
|
103
105
|
if (!stopped && code !== 0) log(`web exited code=${code} sig=${sig}`)
|
|
104
106
|
})
|
|
105
107
|
c.unref()
|
|
108
|
+
spawned.set(c, targetPort)
|
|
106
109
|
child = c
|
|
107
110
|
ownedByUs = true
|
|
108
111
|
return targetPort
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 回收「跑到备用端口上的多余实例」。
|
|
116
|
+
*
|
|
117
|
+
* findFreePort 会在目标端口被占时退到 port+1…(曾经因此留下一个 8000 的重复 web
|
|
118
|
+
* 进程常驻 66MB,而 tick 只探目标端口,永远发现不了)。目标端口确认可用后,
|
|
119
|
+
* 把不在目标端口上的自家子进程收掉即可(只杀我们自己 spawn 的,不动用户手起的)。
|
|
120
|
+
*/
|
|
121
|
+
const reapExtraChildren = () => {
|
|
122
|
+
for (const [c, p] of spawned) {
|
|
123
|
+
if (p === port) continue
|
|
124
|
+
if (c.exitCode !== null || c.killed) { spawned.delete(c); continue }
|
|
125
|
+
try { c.kill('SIGTERM') } catch {}
|
|
126
|
+
spawned.delete(c)
|
|
127
|
+
log(`目标端口 ${port} 已可用 → 回收多余实例(端口 ${p},pid ${c.pid})`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
const tick = async () => {
|
|
112
132
|
if (stopped) return
|
|
113
133
|
const alive = await probe(port)
|
|
@@ -117,6 +137,7 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
117
137
|
log(`port ${port} 已存活(外部实例)→ 切换为保活模式(不接管)`)
|
|
118
138
|
ownedByUs = true // 至少逻辑上"知道"它活了
|
|
119
139
|
}
|
|
140
|
+
reapExtraChildren()
|
|
120
141
|
return
|
|
121
142
|
}
|
|
122
143
|
// 端口死
|
|
@@ -153,10 +174,13 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
153
174
|
if (stopped) return
|
|
154
175
|
stopped = true
|
|
155
176
|
if (timer) { clearInterval(timer); timer = null }
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
for (const c of spawned.keys()) {
|
|
178
|
+
if (c.exitCode === null && !c.killed) {
|
|
179
|
+
try { c.kill('SIGTERM') } catch {}
|
|
180
|
+
setTimeout(() => { try { c.kill('SIGKILL') } catch {} }, 1000).unref()
|
|
181
|
+
}
|
|
159
182
|
}
|
|
183
|
+
spawned.clear()
|
|
160
184
|
log('看门狗退出')
|
|
161
185
|
process.exit(0)
|
|
162
186
|
}
|
package/lib/web.js
CHANGED
|
@@ -14,7 +14,7 @@ import http from 'node:http'
|
|
|
14
14
|
import { promises as fs, readFileSync } from 'node:fs'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
17
|
-
import { createApi, API_PREFIX } from './api.js'
|
|
17
|
+
import { createApi, API_PREFIX, encodeBody } from './api.js'
|
|
18
18
|
import { defaultVaultDir, configFilePath } from './capture-run.js'
|
|
19
19
|
|
|
20
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -50,8 +50,13 @@ export function startWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVau
|
|
|
50
50
|
}
|
|
51
51
|
if (p === '/app.js') {
|
|
52
52
|
const buf = await fs.readFile(path.join(WEB_ROOT, 'app.js'))
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// 自包含 bundle 约 270KB,每次打开独立 Web 页都要重下 —— 协商 gzip 时压缩传输
|
|
54
|
+
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-store', 'Vary': 'Accept-Encoding' }
|
|
55
|
+
const { body: out, encoding } = encodeBody(res, buf)
|
|
56
|
+
if (encoding) headers['Content-Encoding'] = encoding
|
|
57
|
+
headers['Content-Length'] = out.length
|
|
58
|
+
res.writeHead(200, headers)
|
|
59
|
+
return res.end(out)
|
|
55
60
|
}
|
|
56
61
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
57
62
|
res.end(JSON.stringify({ ok: false, error: 'not found' }))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|