memory-eternal 0.9.13 → 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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/lib/client.js +17 -17
- package/lib/minhash.js +99 -0
- package/lib/vault.js +122 -27
- package/package.json +2 -2
- package/web/app.js +19 -19
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
|
-
//
|
|
593
|
-
//
|
|
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 =
|
|
603
|
-
|
|
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
|
|
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 (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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)
|
|
720
|
+
if (other !== card.path) merged.set(other, (merged.get(other) || 0) + 1)
|
|
631
721
|
}
|
|
632
722
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
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 =
|
|
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.
|
|
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/graph-lod.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"
|