memory-eternal 0.9.12 → 0.9.14

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/minhash.js ADDED
@@ -0,0 +1,99 @@
1
+ // MinHash + LSH:相似卡候选生成(把 O(n²) 精确比对换成「签名分桶 → 少量精确校验」)。
2
+ //
3
+ // 用法:先给每张卡的 bigram 哈希集合算一个 k 维最小哈希签名;再按 band 分桶,
4
+ // 同一 band 撞在一起的卡互为候选;最后只对候选做**精确** Jaccard(保证判定语义不变)。
5
+ // 纯函数,方便单测。
6
+
7
+ const M1 = 0x85ebca6b
8
+ const M2 = 0xc2b2ae35
9
+
10
+ /** 32 位整数混合(murmur3 finalizer 变体)—— 用作第 seed 号哈希函数。 */
11
+ export function mix32(x, seed = 0) {
12
+ let h = (x ^ Math.imul(seed + 1, 0x9e3779b1)) >>> 0
13
+ h ^= h >>> 16
14
+ h = Math.imul(h, M1) >>> 0
15
+ h ^= h >>> 13
16
+ h = Math.imul(h, M2) >>> 0
17
+ h ^= h >>> 16
18
+ return h >>> 0
19
+ }
20
+
21
+ /**
22
+ * k 维最小哈希签名。
23
+ * @param {Iterable<number>} grams 哈希后的 bigram 集合
24
+ * @param {number} k 签名长度
25
+ * @returns {Uint32Array}
26
+ */
27
+ export function minhashSignature(grams, k = 64) {
28
+ const sig = new Uint32Array(k).fill(0xffffffff)
29
+ for (const g of grams) {
30
+ for (let i = 0; i < k; i++) {
31
+ const h = mix32(g, i)
32
+ if (h < sig[i]) sig[i] = h
33
+ }
34
+ }
35
+ return sig
36
+ }
37
+
38
+ /** 两个签名的一致位比例 = Jaccard 的无偏估计。 */
39
+ export function jaccardEstimate(a, b) {
40
+ if (!a || !b || a.length === 0 || a.length !== b.length) return 0
41
+ let same = 0
42
+ for (let i = 0; i < a.length; i++) if (a[i] === b[i]) same++
43
+ return same / a.length
44
+ }
45
+
46
+ /**
47
+ * LSH 分桶:把签名切成 bands 段,每段 rows 行,拼成桶 key。
48
+ * @returns {string[]} 长度 = bands(每段一个 key)
49
+ */
50
+ export function lshBandKeys(sig, bands = 32, rows = 2) {
51
+ const keys = []
52
+ for (let b = 0; b < bands; b++) {
53
+ let key = ''
54
+ for (let r = 0; r < rows; r++) key += sig[b * rows + r].toString(36) + '.'
55
+ keys.push(key)
56
+ }
57
+ return keys
58
+ }
59
+
60
+ /**
61
+ * 生成候选:同一 band 撞在一起的卡互为候选,按「撞上的 band 数」降序截断。
62
+ * @param {Map<string, Uint32Array>} signatures id -> 签名
63
+ * @param {{bands?:number, rows?:number, maxPerCard?:number, minHits?:number}} [opts]
64
+ * @returns {Map<string, string[]>} id -> 候选 id 列表(已按命中数排序、已截断)
65
+ */
66
+ export function lshCandidates(signatures, opts = {}) {
67
+ const bands = opts.bands || 32
68
+ const rows = opts.rows || 2
69
+ const maxPerCard = opts.maxPerCard || 40
70
+ const minHits = opts.minHits || 1
71
+ const hits = new Map() // id -> Map(otherId -> count)
72
+ const buckets = new Map() // bandKey -> id[]
73
+ for (const [id, sig] of signatures) {
74
+ const keys = lshBandKeys(sig, bands, rows)
75
+ for (const key of keys) {
76
+ const arr = buckets.get(key)
77
+ if (arr) arr.push(id)
78
+ else buckets.set(key, [id])
79
+ }
80
+ }
81
+ for (const arr of buckets.values()) {
82
+ if (arr.length < 2 || arr.length > 400) continue // 超大桶基本是「全库同质」,跳过以免退化
83
+ for (let i = 0; i < arr.length; i++) {
84
+ for (let j = i + 1; j < arr.length; j++) {
85
+ const a = arr[i], b = arr[j]
86
+ let ma = hits.get(a); if (!ma) { ma = new Map(); hits.set(a, ma) }
87
+ ma.set(b, (ma.get(b) || 0) + 1)
88
+ let mb = hits.get(b); if (!mb) { mb = new Map(); hits.set(b, mb) }
89
+ mb.set(a, (mb.get(a) || 0) + 1)
90
+ }
91
+ }
92
+ }
93
+ const out = new Map()
94
+ for (const [id, map] of hits) {
95
+ const ranked = [...map.entries()].filter(([, n]) => n >= minHits).sort((x, y) => y[1] - x[1])
96
+ out.set(id, (ranked.length <= maxPerCard ? ranked : ranked.slice(0, maxPerCard)).map(([other]) => other))
97
+ }
98
+ return out
99
+ }
package/lib/vault.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // 底层依赖 lib/db.js 的 getDb(root) → DatabaseSync 实例。
5
5
 
6
6
  import path from 'node:path'
7
+ import fs from 'node:fs'
7
8
  import { getDb, enforceAudit } from './db.js'
9
+ import { minhashSignature, lshCandidates } from './minhash.js'
8
10
 
9
11
  /** 主题目录根。 */
10
12
  export const KIND_ROOTS = {
@@ -75,6 +77,31 @@ export function bigramSet(text) {
75
77
  return out
76
78
  }
77
79
 
80
+ /**
81
+ * 与 bigramSet 同规则(同一套清洗),但把每个 bigram 压成 32 位整数再入集合。
82
+ * 为什么单独一份:Set<number> 的构建与求交都比 Set<string> 快数倍,
83
+ * 而 **去重判定** 仍必须用字符串版(语义不能变)—— 所以图谱专用、互不干扰。
84
+ */
85
+ export function bigramHashSet(text) {
86
+ const cleaned = String(text)
87
+ .replace(/[\s#*_`|[\]()()\-—・]+/g, '')
88
+ .toLowerCase()
89
+ const out = new Set()
90
+ for (let i = 0; i < cleaned.length - 1; i++) out.add(hash32(cleaned.slice(i, i + 2)))
91
+ if (cleaned.length === 1) out.add(hash32(cleaned))
92
+ return out
93
+ }
94
+
95
+ /** 32 位 FNV-1a:把 bigram 压成整数键。 */
96
+ function hash32(s) {
97
+ let h = 0x811c9dc5
98
+ for (let i = 0; i < s.length; i++) {
99
+ h ^= s.charCodeAt(i)
100
+ h = Math.imul(h, 0x01000193) >>> 0
101
+ }
102
+ return h >>> 0
103
+ }
104
+
78
105
  /**
79
106
  * 用预算好的 bigram 集合算 Jaccard(结果与 textSimilarity 完全一致)。
80
107
  *
@@ -483,6 +510,34 @@ const GRAPH_CACHE_MAX = 4
483
510
  const GRAPH_CACHE_TTL_MS = 5 * 60 * 1000
484
511
  const graphCache = new Map() // root -> { fp, value, at }
485
512
 
513
+ // ---- P0-1:建图结果落盘 ------------------------------------------------------
514
+ // 指纹一变就失效,命中直接读文件 —— 免掉整条建图管线(596 卡实测 ~600ms → ~25ms)。
515
+ // 文件放 vault 根目录、原子写(tmp + rename),写失败/超限都只是「下次重建」,不影响功能。
516
+ const GRAPH_CACHE_FILE = 'memory-eternal-graph-cache.json'
517
+ const GRAPH_CACHE_MAX_BYTES = 8 * 1024 * 1024
518
+ const graphCachePath = (root) => path.join(root, GRAPH_CACHE_FILE)
519
+
520
+ function readGraphCache(root, fp) {
521
+ try {
522
+ const data = JSON.parse(fs.readFileSync(graphCachePath(root), 'utf8'))
523
+ if (!data || data.fp !== fp) return null
524
+ const v = data.value
525
+ if (!v || !Array.isArray(v.nodes) || !Array.isArray(v.edges)) return null
526
+ return v
527
+ } catch { return null }
528
+ }
529
+
530
+ function writeGraphCache(root, fp, value) {
531
+ try {
532
+ const payload = JSON.stringify({ fp, at: Date.now(), value })
533
+ if (payload.length > GRAPH_CACHE_MAX_BYTES) return
534
+ const file = graphCachePath(root)
535
+ const tmp = file + '.' + process.pid + '.tmp'
536
+ fs.writeFileSync(tmp, payload)
537
+ fs.renameSync(tmp, file)
538
+ } catch { /* 缓存写失败不影响功能 */ }
539
+ }
540
+
486
541
  function graphFingerprint(db) {
487
542
  const c = db.prepare('SELECT COUNT(*) n, MAX(updated_at) m, SUM(id) s FROM cards').get()
488
543
  const st = db.prepare('SELECT status, COUNT(*) n FROM cards GROUP BY status').all()
@@ -498,6 +553,13 @@ export async function graph(root) {
498
553
  const fp = graphFingerprint(db)
499
554
  const hit = graphCache.get(root)
500
555
  if (hit && hit.fp === fp && Date.now() - hit.at < GRAPH_CACHE_TTL_MS) return hit.value
556
+ // P0-1:内存未命中就查磁盘缓存(指纹一致即视为有效)
557
+ const onDisk = readGraphCache(root, fp)
558
+ if (onDisk) {
559
+ graphCache.set(root, { fp, value: onDisk, at: Date.now() })
560
+ if (process.env.MEMORY_GRAPH_PROFILE === '1') console.error('[memory-eternal] graph: 命中磁盘缓存')
561
+ return onDisk
562
+ }
501
563
  // 只选图谱需要的列,不读 body(大幅减少内存和传输量)
502
564
  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()
503
565
  // body 单独按需读取(只在需要 wikilink 匹配时)
@@ -589,69 +651,102 @@ export async function graph(root) {
589
651
  }
590
652
  }
591
653
 
592
- // 相似度关联:bigram 集合只算一次(原先每对都重新分词两段正文=指数级白烧),
593
- // 再用数学上界剪枝(J ≤ min/max)跳过长度比过小的配对。
654
+ // 相似度关联(P0-3 + P1-1):
655
+ // · bigram 一律用 32 位哈希入集合(构建与求交都比字符串快数倍,碰撞率可忽略);
656
+ // · 候选来自两个互补来源:① 稀有 gram 倒排(按 df 计数排序,免每卡 sort 比较器)
657
+ // ② MinHash + LSH 分桶(补足「共享 gram 都很常见」的近重复对);
658
+ // · 只对候选做**精确** Jaccard —— 判定语义与旧实现一致,只是不再全量配对。
594
659
  const SIM_THRESHOLD = 0.42
595
- const bigramsByPath = new Map()
596
- // df = 有多少张卡含这个 bigram。常见 bigram(df 很大)几乎均匀铺满全库,
597
- // 撑不起 0.42 的 Jaccard;只用稀有 bigram 建倒排即可生成候选,避免 5.8 万对全量比对。
598
660
  const DF_CAP = 24
599
661
  const RARE_CAP = 120
662
+ const CAND_CAP = 40
663
+ const MH_K = 48
664
+ const MH_BANDS = 24
665
+ const MH_ROWS = 2
666
+ const prof = process.env.MEMORY_GRAPH_PROFILE === '1'
667
+ let tStage = Date.now()
668
+ const hashesByPath = new Map()
600
669
  const gramDf = new Map()
601
670
  for (const c of cards) {
602
- const s = bigramSet(bodyMap.get(c.path) || '')
603
- bigramsByPath.set(c.path, s)
671
+ const s = bigramHashSet(bodyMap.get(c.path) || '')
672
+ hashesByPath.set(c.path, s)
604
673
  for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
605
674
  }
606
- const rareIndex = new Map()
675
+ const tHash = Date.now() - tStage
676
+ // ① 稀有 gram 倒排:df ∈ [1, DF_CAP] 直接进桶(计数排序),再按桶取最稀的 RARE_CAP 个
677
+ tStage = Date.now()
607
678
  const rareByPath = new Map()
608
679
  for (const c of cards) {
680
+ const buckets = []
681
+ for (let df = 0; df <= DF_CAP; df++) buckets.push(null)
682
+ for (const g of hashesByPath.get(c.path)) {
683
+ const df = gramDf.get(g) || 0
684
+ if (df > DF_CAP) continue
685
+ if (!buckets[df]) buckets[df] = []
686
+ buckets[df].push(g)
687
+ }
609
688
  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) {
689
+ for (let df = 1; df <= DF_CAP && rare.length < RARE_CAP; df++) {
690
+ const b = buckets[df]
691
+ if (!b) continue
692
+ for (const g of b) { rare.push(g); if (rare.length >= RARE_CAP) break }
693
+ }
694
+ rareByPath.set(c.path, rare)
695
+ }
696
+ const rareIndex = new Map()
697
+ for (const c of cards) {
698
+ for (const g of rareByPath.get(c.path)) {
616
699
  const arr = rareIndex.get(g)
617
700
  if (arr) arr.push(c.path)
618
701
  else rareIndex.set(g, [c.path])
619
702
  }
620
703
  }
704
+ const tRare = Date.now() - tStage
705
+ // ② MinHash + LSH:签名只跑「稀有 gram」(≤120/卡),成本可控且正好覆盖近重复场景
706
+ tStage = Date.now()
707
+ const signatures = new Map()
708
+ for (const c of cards) signatures.set(c.path, minhashSignature(rareByPath.get(c.path), MH_K))
709
+ const lshCands = lshCandidates(signatures, { bands: MH_BANDS, rows: MH_ROWS, maxPerCard: CAND_CAP })
710
+ const tSig = Date.now() - tStage
711
+ tStage = Date.now()
621
712
  const similarCount = {}
622
- // 标签集合预先建成 Set:原先每个配对都做 tags.some(includes) 是 O(标签数²),配对 5.8 万次很可观。
623
713
  const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
624
- // 候选生成:按共享稀有 bigram 数降序,只对候选做精确 Jaccard。
625
- // 每张卡最多保留 1 条 similar 边(与旧行为一致)。
626
- const sharedRare = new Map()
714
+ const merged = new Map()
715
+ let jaccardCalls = 0
627
716
  for (const card of cards) {
717
+ merged.clear()
628
718
  for (const gram of rareByPath.get(card.path)) {
629
719
  for (const other of rareIndex.get(gram) || []) {
630
- if (other !== card.path) sharedRare.set(other, (sharedRare.get(other) || 0) + 1)
720
+ if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 1)
631
721
  }
632
722
  }
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) {
723
+ for (const other of lshCands.get(card.path) || []) {
724
+ if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 2)
725
+ }
726
+ if (!merged.size) continue
727
+ const ranked = [...merged.entries()].sort((x, y) => y[1] - x[1])
728
+ const cands = ranked.length <= CAND_CAP ? ranked : ranked.slice(0, CAND_CAP)
729
+ const A = hashesByPath.get(card.path)
730
+ for (const [other] of cands) {
640
731
  if ((similarCount[card.path] || 0) >= 1) break
641
732
  if ((similarCount[other] || 0) >= 1) continue
642
733
  const b = byPath.get(other)
643
734
  if (!b) continue
644
735
  if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
645
- const B = bigramsByPath.get(other)
736
+ const B = hashesByPath.get(other)
646
737
  const lo = Math.min(A.size, B.size)
647
738
  const hi = Math.max(A.size, B.size)
648
739
  if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
740
+ jaccardCalls++
649
741
  const sim = jaccardSets(A, B, SIM_THRESHOLD)
650
742
  if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
651
743
  }
652
744
  }
745
+ const tCand = Date.now() - tStage
746
+ if (prof) console.error('[memory-eternal] graph profile: hash=' + tHash + 'ms rare=' + tRare + 'ms sig=' + tSig + 'ms cand=' + tCand + 'ms jaccardCalls=' + jaccardCalls)
653
747
  const value = { nodes, edges }
654
748
  graphCache.set(root, { fp, value, at: Date.now() })
749
+ writeGraphCache(root, fp, value)
655
750
  if (graphCache.size > GRAPH_CACHE_MAX) graphCache.delete(graphCache.keys().next().value)
656
751
  return value
657
752
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
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/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.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/feedback.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/vault-resolve.test.mjs && node tests/minhash.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/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/graph-sprites.test.mjs && node tests/ssr.test.mjs"
64
64
  },
65
65
  "dependencies": {
66
66
  "@deepseek-ai/schemastery": "^3.18.1"