memory-eternal 0.9.7 → 0.9.9

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/stall.js ADDED
@@ -0,0 +1,30 @@
1
+ // 记忆核心 · 「轮次收尾事件是否真的没来」判定
2
+ //
3
+ // 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
4
+ // 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
5
+ // → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
6
+ // 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
7
+ // 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
8
+ // claimIdleMs 条件不满足 → 真故障反而不报。
9
+ //
10
+ // 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
11
+ // 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
12
+
13
+ /**
14
+ * @param {object} input
15
+ * @param {number[]} input.claimedAt 轮次开始时间戳(ms)
16
+ * @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
17
+ * @param {number} [input.now] 当前时间
18
+ * @param {number} [input.windowMs] 观察窗口,默认 20 分钟
19
+ * @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
20
+ * @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
21
+ */
22
+ export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
23
+ const from = now - windowMs
24
+ const started = claimedAt.filter((t) => t >= from).length
25
+ const stopped = stoppedAt.filter((t) => t >= from).length
26
+ return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
27
+ }
28
+
29
+ /** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
30
+ export const MAX_STAMPS = 200
package/lib/vault.js CHANGED
@@ -561,26 +561,94 @@ export async function graph(root) {
561
561
  if (arr) arr.push(c.path)
562
562
  else tagIndex.set(t, [c.path])
563
563
  }
564
+ // 标签边:同标签「全连接」会爆炸 —— 593 卡时 2.4 万条边,占总 payload 的 99%,前端每帧都要遍历。
565
+ // 改为「按共享标签数取 Top-K」:语义仍是「靠共享标签相邻」,但边数降一个数量级。
566
+ // 对每张卡用标签倒排收集候选与其共享标签数,只保留权重最高的 K 个(双向去重后约 n*K/2 条)。
567
+ const TAG_TOPK = 6
568
+ const pairWeight = new Map() // `a\u0000b`(排序后)-> 已加边的共享标签数
564
569
  for (const card of cards) {
570
+ const weights = new Map()
565
571
  for (const tag of card.tags) {
566
572
  for (const other of tagIndex.get(tag) || []) {
567
- if (other !== card.path) addEdge(card.path, other, `tag:${tag}`)
573
+ if (other === card.path) continue
574
+ weights.set(other, (weights.get(other) || 0) + 1)
568
575
  }
569
576
  }
577
+ // 候选不超过 K 时全连(小库/小标签簇),超过才截断 Top-K
578
+ const ranked = [...weights.entries()].sort((x, y) => y[1] - x[1])
579
+ const top = weights.size <= TAG_TOPK ? ranked : ranked.slice(0, TAG_TOPK)
580
+ for (const [other, w] of top) {
581
+ const [x, y] = [card.path, other].sort()
582
+ const key = `${x}\u0000${y}`
583
+ if ((pairWeight.get(key) || 0) >= w) continue // 已按更高权重加过这条边
584
+ pairWeight.set(key, w)
585
+ const xc = byPath.get(x)
586
+ const yc = byPath.get(y)
587
+ const shared = xc && yc ? xc.tags.find((t) => yc.tags.includes(t)) : ''
588
+ addEdge(x, y, shared ? `tag:${shared}` : 'tag')
589
+ }
570
590
  }
571
591
 
572
592
  // 相似度关联:bigram 集合只算一次(原先每对都重新分词两段正文=指数级白烧),
573
593
  // 再用数学上界剪枝(J ≤ min/max)跳过长度比过小的配对。
574
594
  const SIM_THRESHOLD = 0.42
575
595
  const bigramsByPath = new Map()
576
- for (const c of cards) bigramsByPath.set(c.path, bigramSet(bodyMap.get(c.path) || ''))
596
+ // df = 有多少张卡含这个 bigram。常见 bigram(df 很大)几乎均匀铺满全库,
597
+ // 撑不起 0.42 的 Jaccard;只用稀有 bigram 建倒排即可生成候选,避免 5.8 万对全量比对。
598
+ const DF_CAP = 24
599
+ const RARE_CAP = 120
600
+ const gramDf = new Map()
601
+ for (const c of cards) {
602
+ const s = bigramSet(bodyMap.get(c.path) || '')
603
+ bigramsByPath.set(c.path, s)
604
+ for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
605
+ }
606
+ const rareIndex = new Map()
607
+ const rareByPath = new Map()
608
+ for (const c of cards) {
609
+ const rare = []
610
+ for (const g of bigramsByPath.get(c.path)) if ((gramDf.get(g) || 0) <= DF_CAP) rare.push(g)
611
+ // 只取「最稀有」的 RARE_CAP 个参与候选生成:近重复卡一定共享大量稀有 gram,
612
+ // 而这一步是冷启动的真正热点(每张卡 × 每个稀有 gram 的倒排表)。
613
+ rare.sort((x, y) => (gramDf.get(x) || 0) - (gramDf.get(y) || 0))
614
+ rareByPath.set(c.path, rare.length <= RARE_CAP ? rare : rare.slice(0, RARE_CAP))
615
+ for (const g of rare) {
616
+ const arr = rareIndex.get(g)
617
+ if (arr) arr.push(c.path)
618
+ else rareIndex.set(g, [c.path])
619
+ }
620
+ }
577
621
  const similarCount = {}
578
- for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
579
- const a = cards[i], b = cards[j]
580
- if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
581
- if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
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 }
622
+ // 标签集合预先建成 Set:原先每个配对都做 tags.some(includes) 是 O(标签数²),配对 5.8 万次很可观。
623
+ const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
624
+ // 候选生成:按共享稀有 bigram 数降序,只对候选做精确 Jaccard。
625
+ // 每张卡最多保留 1 条 similar 边(与旧行为一致)。
626
+ const sharedRare = new Map()
627
+ for (const card of cards) {
628
+ for (const gram of rareByPath.get(card.path)) {
629
+ for (const other of rareIndex.get(gram) || []) {
630
+ if (other !== card.path) sharedRare.set(other, (sharedRare.get(other) || 0) + 1)
631
+ }
632
+ }
633
+ if (!sharedRare.size) continue
634
+ // 候选上限:共享稀有 gram 最多的 40 张(近重复卡一定排在最前面,召回不受影响)
635
+ const ranked = [...sharedRare.entries()].sort((x, y) => y[1] - x[1])
636
+ const cands = ranked.length <= 40 ? ranked : ranked.slice(0, 40)
637
+ sharedRare.clear()
638
+ const A = bigramsByPath.get(card.path)
639
+ for (const [other, shared] of cands) {
640
+ if ((similarCount[card.path] || 0) >= 1) break
641
+ if ((similarCount[other] || 0) >= 1) continue
642
+ const b = byPath.get(other)
643
+ if (!b) continue
644
+ if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
645
+ const B = bigramsByPath.get(other)
646
+ const lo = Math.min(A.size, B.size)
647
+ const hi = Math.max(A.size, B.size)
648
+ if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
649
+ const sim = jaccardSets(A, B, SIM_THRESHOLD)
650
+ if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
651
+ }
584
652
  }
585
653
  const value = { nodes, edges }
586
654
  graphCache.set(root, { fp, value, at: Date.now() })
package/lib/watchdog.js CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  import http from 'node:http'
17
17
  import net from 'node:net'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
18
20
  import { spawn } from 'node:child_process'
19
21
  import path from 'node:path'
20
22
  import { fileURLToPath } from 'node:url'
@@ -24,6 +26,53 @@ import { nodeBinary, childEnv } from './node-bin.js'
24
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
25
27
  const WEB_JS = path.join(__dirname, 'web.js')
26
28
  const API_PREFIX = '/memory-eternal/api'
29
+ // -- 单例锁 -------------------------------------------------------------------
30
+ // 每个 DSH 启动都会 spawn 一个 detached watchdog,而旧代码没有任何存活检查 ——
31
+ // 多次重启就堆积多个 watchdog 常驻轮询同一端口(dsh-memory-eternal#6)。
32
+ // 这里用一个 pid 锁文件:同端口已有活着的 watchdog 就让它直接退出。
33
+ const LOCK_NAME = 'memory-eternal-watchdog.json'
34
+
35
+ /** 锁文件路径($DSH_HOME 下,与共享配置同处)。 */
36
+ export function watchdogLockPath(env = process.env) {
37
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), LOCK_NAME)
38
+ }
39
+
40
+ /** 进程是否存活(Windows 下 process.kill(pid,0) 同样可用;EPERM 说明进程在但无权限)。 */
41
+ export function isPidAlive(pid) {
42
+ const n = Number(pid)
43
+ if (!n || n <= 0) return false
44
+ if (n === process.pid) return true
45
+ try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
46
+ }
47
+
48
+ /** 读取锁文件;损坏或不存在返回 null。 */
49
+ export function readWatchdogLock(env = process.env) {
50
+ try { return JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return null }
51
+ }
52
+
53
+ /**
54
+ * 尝试取得看门狗锁。
55
+ * @returns {{acquired:boolean, file:string, previous:object|null}} acquired=false 表示「同端口已有活着的看门狗」,调用方应直接退出。
56
+ */
57
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
58
+ const file = watchdogLockPath(env)
59
+ const prev = readWatchdogLock(env)
60
+ const samePortOwnerAlive = prev && prev.pid !== pid && Number(prev.port) === Number(port) && isPidAlive(prev.pid)
61
+ if (samePortOwnerAlive) return { acquired: false, file, previous: prev }
62
+ const stale = prev && !isPidAlive(prev.pid) ? prev : null
63
+ try {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true })
65
+ fs.writeFileSync(file, JSON.stringify({ pid, port: Number(port), vault, startedAt: new Date().toISOString() }))
66
+ } catch { /* 写不了锁不阻塞保活 */ }
67
+ return { acquired: true, file, previous: stale }
68
+ }
69
+
70
+ /** 释放锁(只清自己的,避免误删别人刚写的)。 */
71
+ export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
72
+ const cur = readWatchdogLock(env)
73
+ if (!cur || cur.pid !== pid) return false
74
+ try { fs.unlinkSync(watchdogLockPath(env)); return true } catch { return false }
75
+ }
27
76
 
28
77
  const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
29
78
  const log = (...args) => process.stderr.write(`[${ts()}] [watchdog] ${args.join(' ')}\n`)
@@ -226,5 +275,13 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
226
275
  const maxRestart = Number(argOf('--max-restart')) || 10
227
276
  const vaultRoot = argOf('--vault') || undefined
228
277
  const autoStart = !argv.includes('--no-restart')
278
+ const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
279
+ if (!lock.acquired) {
280
+ log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
281
+ process.exit(0)
282
+ }
283
+ if (lock.previous) log('清理陈旧看门狗记录(pid ' + lock.previous.pid + ' 已不存在)')
284
+ process.on('exit', () => releaseWatchdogLock())
229
285
  startWatchdog({ port, interval, maxRestart, vaultRoot, autoStart })
286
+ log('看门狗已启动(pid ' + process.pid + ',端口 ' + port + ')')
230
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,7 +60,7 @@
60
60
  "build": "node build.mjs",
61
61
  "build:client": "node build.mjs",
62
62
  "prepublishOnly": "node build.mjs",
63
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/api.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/markdown.test.mjs && node tests/ssr.test.mjs"
63
+ "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/api.test.mjs && node tests/graph-perf.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/ssr.test.mjs"
64
64
  },
65
65
  "dependencies": {
66
66
  "@deepseek-ai/schemastery": "^3.18.1"