memory-eternal 0.8.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/lib/vault.js CHANGED
@@ -64,25 +64,38 @@ export function safeSlug(name) {
64
64
  return base || 'card'
65
65
  }
66
66
 
67
- /** Jaccard-like 相似度:字符 bigram 集合。 */
68
- export function textSimilarity(a, b) {
69
- const bigrams = (text) => {
70
- const cleaned = String(text)
71
- .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
72
- .toLowerCase()
73
- const out = new Set()
74
- for (let i = 0; i < cleaned.length - 1; i++) out.add(cleaned.slice(i, i + 2))
75
- if (cleaned.length === 1) out.add(cleaned)
76
- return out
77
- }
78
- const ba = bigrams(a)
79
- const bb = bigrams(b)
80
- if (ba.size === 0 || bb.size === 0) return 0
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 ba) if (bb.has(g)) inter++
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
- /** 列出卡片(从 SQL)。 */
169
- export async function listCards(root, { status } = {}) {
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 placeholders = want.map(() => '?').join(',')
173
- const rows = db.prepare(`SELECT * FROM cards WHERE status IN (${placeholders}) ORDER BY updated_at DESC`).all(...want)
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'] })
@@ -379,46 +451,6 @@ export async function search(root, query, { limit = 30, minScore = 0, semantic =
379
451
  return out.slice(0, limit)
380
452
  }
381
453
 
382
- /** 当日简报。 */
383
- export function dailyBrief(cardsToday) {
384
- if (!cardsToday || cardsToday.length === 0) return '今天还没有新的记忆沉淀。'
385
- return cardsToday.map((c) => `- ${c.kind}|${c.title}`).join('\n')
386
- }
387
-
388
- /** 生成今日简报卡(INSERT into cards)。 */
389
- export async function generateDailyBrief(root) {
390
- const d = new Date()
391
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
392
- try {
393
- const db = getDb(root)
394
- // 幂等:检查是否已存在
395
- const existing = db.prepare("SELECT 1 FROM cards WHERE title = ? AND status = 'approved'").get(`每日回顾 ${date}`)
396
- if (existing) return { ok: true, existed: true }
397
-
398
- const s = await stats(root)
399
- const body = `今日新增 ${s.today} · 近 7 天 ${s.week} · 总 ${s.total}\n\n## 今日沉淀\n${dailyBrief(s.todayCards)}`
400
- const now = new Date().toISOString()
401
- db.prepare(`
402
- INSERT INTO cards (path, kind, title, tags, body, summary, status, source, created_at, updated_at)
403
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
404
- `).run(
405
- `00-System/daily-${date}.md`,
406
- 'prompt',
407
- `每日回顾 ${date}`,
408
- JSON.stringify(['每日回顾', '统计']),
409
- body,
410
- body.slice(0, 200),
411
- 'approved',
412
- 'daily-brief',
413
- now,
414
- now,
415
- )
416
- return { ok: true, existed: false }
417
- } catch (e) {
418
- return { ok: false, error: String(e.message || e) }
419
- }
420
- }
421
-
422
454
  /** 用量趋势:最近 N 天每天新增卡片数。 */
423
455
  export async function dailyCounts(root, days = 30) {
424
456
  const db = getDb(root)
@@ -444,9 +476,28 @@ export async function exportCards(root) {
444
476
  })
445
477
  }
446
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
+
447
495
  /** 图谱:节点 = 卡片;边 = [[wikilink]] 或共享标签。 */
448
496
  export async function graph(root) {
449
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
450
501
  // 只选图谱需要的列,不读 body(大幅减少内存和传输量)
451
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()
452
503
  // body 单独按需读取(只在需要 wikilink 匹配时)
@@ -502,23 +553,39 @@ export async function graph(root) {
502
553
  }
503
554
  }
504
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) {
505
565
  for (const tag of card.tags) {
506
- for (const other of cards) {
507
- if (other.path !== card.path && other.tags.includes(tag)) addEdge(card.path, other.path, `tag:${tag}`)
566
+ for (const other of tagIndex.get(tag) || []) {
567
+ if (other !== card.path) addEdge(card.path, other, `tag:${tag}`)
508
568
  }
509
569
  }
510
570
  }
511
571
 
512
- // 相似度关联
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) || ''))
513
577
  const similarCount = {}
514
578
  for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
515
579
  const a = cards[i], b = cards[j]
516
580
  if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
517
581
  if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
518
- const sim = textSimilarity(bodyMap.get(a.path) || '', bodyMap.get(b.path) || '')
519
- if (sim >= 0.42) { 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 }
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 }
520
584
  }
521
- return { nodes, edges }
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
522
589
  }
523
590
 
524
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
- if (child && !child.killed) {
157
- try { child.kill('SIGTERM') } catch {}
158
- setTimeout(() => { try { child && child.kill('SIGKILL') } catch {} }, 1000).unref()
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
- res.writeHead(200, { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-store' })
54
- return res.end(buf)
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.8.0",
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": {