dsh-memory-tiers 0.5.1 → 0.8.2
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/ARCHITECTURE.md +2460 -1679
- package/README.md +268 -206
- package/client.mjs +608 -425
- package/compaction.mjs +184 -177
- package/dshllm.mjs +141 -0
- package/index.mjs +826 -670
- package/memory5.mjs +259 -42
- package/memory6.mjs +1553 -0
- package/package.json +63 -58
- package/reindex.mjs +3 -3
- package/settings.mjs +327 -241
- package/silicon.mjs +56 -8
package/memory5.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 四层记忆 v5 —— 二次重构骨架
|
|
3
3
|
*
|
|
4
|
-
* 与 memory4 的区别(详见
|
|
4
|
+
* 与 memory4 的区别(详见 ARCHITECTURE.md):
|
|
5
5
|
* 1. 三层活跃流水线:及时层 → 巩固层 → 长久层
|
|
6
6
|
* 2. 层用单一 `layer` 字段表达,不再用 weight × tier 交叉
|
|
7
7
|
* 3. 新增 heat(热度)+ 按天去重的提及计数,替代"一次性判定 importance"
|
|
@@ -58,7 +58,7 @@ const ACTIVE_ORDER = [LAYER.HOT, LAYER.CONSOLIDATE, LAYER.LONG]
|
|
|
58
58
|
// ============================================================
|
|
59
59
|
// 配置
|
|
60
60
|
//
|
|
61
|
-
// heat 相关参数由 tune.mjs 扫描 32
|
|
61
|
+
// heat 相关参数由 tune.mjs 扫描 32 组得出,结论见 ARCHITECTURE.md 第 4 节。
|
|
62
62
|
// ============================================================
|
|
63
63
|
|
|
64
64
|
export const DEFAULTS = {
|
|
@@ -79,6 +79,12 @@ export const DEFAULTS = {
|
|
|
79
79
|
// 这个参数留着只是为了兼容旧配置,改成任何值都不影响行为。
|
|
80
80
|
consolidateDays: 0, // 已弃用(0 = 不按时间触发)
|
|
81
81
|
longMaxChars: 2000, // 长久层上限,超了往 DL3 沉
|
|
82
|
+
|
|
83
|
+
// ★ L3 视图(DL3 → L3 的排序)重算间隔,默认 30 分钟。
|
|
84
|
+
// 这是全局唯一需要节流的东西,原因见 l3View 的注释:
|
|
85
|
+
// L3 在上下文里排最前,它一变,后面 L2 / L1 的前缀缓存全断;
|
|
86
|
+
// 而 L1 / L2 本来每轮都在变且排在末尾,节流它们没有意义。
|
|
87
|
+
viewRefreshMs: 30 * 60 * 1000,
|
|
82
88
|
dl2MaxChars: 12000, // DL2(重要原文库)上限,超了就压缩字数
|
|
83
89
|
dl2CompressTarget: 400, // 单条超过这么多字才值得压缩
|
|
84
90
|
allowModelCompress: false, // ★ 默认不许用模型压缩:实测一个字都减不掉,纯烧钱
|
|
@@ -192,7 +198,11 @@ export function dayKey(ts) {
|
|
|
192
198
|
|
|
193
199
|
let __seq = 0
|
|
194
200
|
let __idn = 0
|
|
195
|
-
|
|
201
|
+
// 尾部那 4 位随机是给「多进程共享一个库」用的:__idn 是进程内计数器,
|
|
202
|
+
// 两个进程(dsh + astrbot)都从 1 开始,纯计数器必然撞 id。
|
|
203
|
+
// 加了随机数后同毫秒同序号撞上的概率约 1/1.7e6,可忽略。
|
|
204
|
+
const nextId = () =>
|
|
205
|
+
`m5_${(++__idn).toString(36)}_${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`
|
|
196
206
|
|
|
197
207
|
export function makeItem({
|
|
198
208
|
text,
|
|
@@ -481,7 +491,7 @@ export function decideConsolidateRoute(item, now = Date.now(), cfg = DEFAULTS) {
|
|
|
481
491
|
// audit.log 每次写入/迁移/遗忘一行
|
|
482
492
|
// ============================================================
|
|
483
493
|
|
|
484
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, renameSync } from 'node:fs'
|
|
494
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, renameSync, statSync } from 'node:fs'
|
|
485
495
|
import { join, dirname } from 'node:path'
|
|
486
496
|
|
|
487
497
|
// ★ 向量分片(2026-10-31)
|
|
@@ -498,37 +508,116 @@ import { join, dirname } from 'node:path'
|
|
|
498
508
|
// (写分片 + 旧文件改名 .bak,保留回退,下次不再读)。
|
|
499
509
|
// 这条是给已在跑的 .big / 线上库用的,不要删。
|
|
500
510
|
const VEC_SHARD_SIZE = 5000
|
|
501
|
-
const VEC_SHARD_RE = /^vectors-part-(\d{4})\.json$/
|
|
511
|
+
const VEC_SHARD_RE = /^vectors-part-(\d{4})\.json$/ // 旧版无前缀
|
|
502
512
|
const vecShardName = (i) => `vectors-part-${String(i).padStart(4, '0')}.json`
|
|
503
513
|
|
|
514
|
+
// ★ 多写者(2026-11-01):每个写者只写自己那份文件,读的时候把所有人的合并起来看。
|
|
515
|
+
// 这样两个进程同时跑也不需要锁——谁也不覆盖谁。
|
|
516
|
+
// writer 名字只留 [a-z0-9_-],因为它会进文件名。
|
|
517
|
+
const VEC_ANY_RE = /^vectors-(.+)-part-(\d{4})\.json$/
|
|
518
|
+
const vecOwnRe = (w) => new RegExp(`^vectors-${w}-part-(\\d{4})\\.json$`)
|
|
519
|
+
const vecOwnName = (w, i) => `vectors-${w}-part-${String(i).padStart(4, '0')}.json`
|
|
520
|
+
const sanitizeWriter = (w) =>
|
|
521
|
+
String(w || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || 'default'
|
|
522
|
+
const STORE_RE = /^store-([A-Za-z0-9_-]+)\.json$/
|
|
523
|
+
const storeOwnName = (w) => `store-${w}.json`
|
|
524
|
+
|
|
504
525
|
export function createStore(dir, opts = {}) {
|
|
505
526
|
// ★ 向量指纹:记录这批向量是哪个 embedding 模型生成的。
|
|
506
527
|
//
|
|
507
|
-
// 为什么必须有:
|
|
528
|
+
// 为什么必须有:Qwen3-Embedding-0.6B、bge-m3 **都是 1024 维**。
|
|
508
529
|
// 换模型时 cosine() 里 `a.length !== b.length` 这个检查不会触发,
|
|
509
530
|
// 旧向量会被当成合法向量参与计算,静默算出垃圾相似度——
|
|
510
531
|
// 不报错、不崩,只是"检索结果越来越怪",最难查的那种坏。
|
|
511
532
|
//
|
|
512
533
|
// 指纹不符 → 隔离旧向量 + 从零重建,宁可暂时没有语义检索也不给错的结果。
|
|
513
534
|
const signature = typeof opts.signature === 'string' ? opts.signature : null
|
|
535
|
+
const writer = sanitizeWriter(opts.writer)
|
|
514
536
|
mkdirSync(dir, { recursive: true })
|
|
515
537
|
mkdirSync(join(dir, 'archive'), { recursive: true })
|
|
516
538
|
mkdirSync(join(dir, 'graveyard'), { recursive: true })
|
|
517
539
|
|
|
518
|
-
const storePath = join(dir,
|
|
519
|
-
const
|
|
520
|
-
const
|
|
540
|
+
const storePath = join(dir, storeOwnName(writer)) // 自己的分片
|
|
541
|
+
const legacyStorePath = join(dir, 'store.json') // 旧版单文件:只迁移一次
|
|
542
|
+
const vecPath = join(dir, 'vectors.json') // 旧版单文件向量
|
|
543
|
+
const metaPath = join(dir, `vectors-${writer}.meta.json`)
|
|
544
|
+
const legacyMetaPath = join(dir, 'vectors.meta.json')
|
|
521
545
|
|
|
522
546
|
let state = { version: 5, seq: 0, items: [] }
|
|
523
547
|
let vectors = new Map()
|
|
524
548
|
|
|
525
|
-
|
|
549
|
+
const listDir = () => { try { return readdirSync(dir) } catch { return [] } }
|
|
550
|
+
|
|
551
|
+
function readShard(p) {
|
|
526
552
|
try {
|
|
527
|
-
const raw = JSON.parse(readFileSync(
|
|
528
|
-
|
|
529
|
-
} catch {
|
|
553
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'))
|
|
554
|
+
return raw && Array.isArray(raw.items) ? raw : null
|
|
555
|
+
} catch { return null } // 单个分片坏了跳过,不让别的写者连累自己
|
|
530
556
|
}
|
|
531
557
|
|
|
558
|
+
/** 别人的分片:只读,标上 __foreign,写回时会被排除 */
|
|
559
|
+
function loadForeign() {
|
|
560
|
+
const out = []
|
|
561
|
+
for (const f of listDir()) {
|
|
562
|
+
const m = f.match(STORE_RE)
|
|
563
|
+
if (!m || m[1] === writer) continue
|
|
564
|
+
const raw = readShard(join(dir, f))
|
|
565
|
+
if (!raw) continue
|
|
566
|
+
for (const it of raw.items) { it.__foreign = true; out.push(it) }
|
|
567
|
+
}
|
|
568
|
+
return out
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 把别人的最新内容合并进来(他们可能刚写了新东西)。
|
|
573
|
+
*
|
|
574
|
+
* ★ 为什么需要:对方的条目是**启动时和他每次落盘时**才读进来的,
|
|
575
|
+
* 不刷新的话 dsh 会一直看不见 astrbot 刚记的东西。
|
|
576
|
+
* ★ 为什么带 TTL:全量 re-read 别人的分片有磁盘开销,
|
|
577
|
+
* 检索/注入每次都刷太浪费;15 秒一次足够"感觉是实时的"。
|
|
578
|
+
*/
|
|
579
|
+
// 别人的分片"指纹":文件名 + 修改时间 + 大小。
|
|
580
|
+
// 只要没变就不重读——比定时刷新准(对方一写就能看见),也比每次都读便宜。
|
|
581
|
+
let foreignSig = ''
|
|
582
|
+
let gen = 0 // 条目集合换过一代就 +1,上层缓存(分层索引)据此失效
|
|
583
|
+
function foreignSignature() {
|
|
584
|
+
let sig = ''
|
|
585
|
+
try {
|
|
586
|
+
for (const f of readdirSync(dir)) {
|
|
587
|
+
const m = f.match(STORE_RE)
|
|
588
|
+
if (!m || m[1] === writer) continue
|
|
589
|
+
try {
|
|
590
|
+
const st = statSync(join(dir, f))
|
|
591
|
+
sig += `${f}:${st.mtimeMs}:${st.size};`
|
|
592
|
+
} catch { sig += `${f}:?;` }
|
|
593
|
+
}
|
|
594
|
+
} catch { /* 列不出目录就当没变 */ }
|
|
595
|
+
return sig
|
|
596
|
+
}
|
|
597
|
+
function refreshForeign() {
|
|
598
|
+
const own = state.items.filter((it) => !it.__foreign)
|
|
599
|
+
state.items = own.concat(loadForeign())
|
|
600
|
+
foreignSig = foreignSignature()
|
|
601
|
+
gen++
|
|
602
|
+
}
|
|
603
|
+
/** 别人的文件动过才重读。检索 / 注入 / 统计前调一下就行,代价是几次 stat */
|
|
604
|
+
function maybeRefreshForeign() {
|
|
605
|
+
const sig = foreignSignature()
|
|
606
|
+
if (sig === foreignSig) return false
|
|
607
|
+
refreshForeign()
|
|
608
|
+
return true
|
|
609
|
+
}
|
|
610
|
+
foreignSig = foreignSignature()
|
|
611
|
+
|
|
612
|
+
// 自己的数据:优先自己的分片;没有就认领旧版 store.json(只在还没有任何
|
|
613
|
+
// store-*.json 时认领一次,否则第二个写者会把同一份数据领走变成双份)。
|
|
614
|
+
let own = readShard(storePath)
|
|
615
|
+
if (!own && existsSync(legacyStorePath) && !listDir().some((f) => STORE_RE.test(f))) {
|
|
616
|
+
own = readShard(legacyStorePath)
|
|
617
|
+
}
|
|
618
|
+
if (own) state = own
|
|
619
|
+
state.items = (state.items || []).concat(loadForeign())
|
|
620
|
+
|
|
532
621
|
function readVecMeta() {
|
|
533
622
|
try { return JSON.parse(readFileSync(metaPath, 'utf8')) } catch { return null }
|
|
534
623
|
}
|
|
@@ -549,12 +638,13 @@ export function createStore(dir, opts = {}) {
|
|
|
549
638
|
/** 指纹不符时把旧向量整体挪走(不删,留给用户回退/核对) */
|
|
550
639
|
function quarantineVectors(reason) {
|
|
551
640
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
552
|
-
const bak = join(dir, `vectors.stale-${stamp}`)
|
|
641
|
+
const bak = join(dir, `vectors.stale-${writer}-${stamp}`)
|
|
553
642
|
let moved = 0
|
|
554
643
|
try {
|
|
555
644
|
mkdirSync(bak, { recursive: true })
|
|
556
|
-
for (const f of
|
|
557
|
-
|
|
645
|
+
for (const f of listDir()) {
|
|
646
|
+
// 只隔离自己的:别人的向量我们没有资格动
|
|
647
|
+
if (!vecOwnRe(writer).test(f) && f !== `vectors-${writer}.meta.json`) continue
|
|
558
648
|
try { renameSync(join(dir, f), join(bak, f)); moved++ } catch { /* 单个失败不阻塞 */ }
|
|
559
649
|
}
|
|
560
650
|
} catch { /* 建不了备份目录就原地不动,下面也不加载 */ }
|
|
@@ -562,11 +652,26 @@ export function createStore(dir, opts = {}) {
|
|
|
562
652
|
return bak
|
|
563
653
|
}
|
|
564
654
|
|
|
565
|
-
/**
|
|
655
|
+
/** 读自己的分片;没有任何分片返回 null(交给旧文件兼容逻辑) */
|
|
566
656
|
function readVecShards() {
|
|
567
|
-
let files
|
|
568
|
-
|
|
569
|
-
|
|
657
|
+
let files = listDir()
|
|
658
|
+
const re = vecOwnRe(writer)
|
|
659
|
+
let shards = files.filter((f) => re.test(f)).sort()
|
|
660
|
+
if (!shards.length) {
|
|
661
|
+
// 首次迁移:目录里只有旧的无前缀分片,且还没有别的写者 → 认领为自己的
|
|
662
|
+
const legacy = files.filter((f) => VEC_SHARD_RE.test(f))
|
|
663
|
+
const others = files.filter((f) => VEC_ANY_RE.test(f) && !re.test(f))
|
|
664
|
+
if (legacy.length && !others.length) {
|
|
665
|
+
for (const f of legacy) {
|
|
666
|
+
try { renameSync(join(dir, f), join(dir, vecOwnName(writer, Number(f.match(VEC_SHARD_RE)[1])))) } catch { /* 认领失败就跳过 */ }
|
|
667
|
+
}
|
|
668
|
+
if (!existsSync(metaPath) && existsSync(legacyMetaPath)) {
|
|
669
|
+
try { renameSync(legacyMetaPath, metaPath) } catch { /* 同上 */ }
|
|
670
|
+
}
|
|
671
|
+
files = listDir()
|
|
672
|
+
shards = files.filter((f) => re.test(f)).sort()
|
|
673
|
+
}
|
|
674
|
+
}
|
|
570
675
|
if (!shards.length) return null
|
|
571
676
|
const out = new Map()
|
|
572
677
|
for (const f of shards) {
|
|
@@ -583,6 +688,9 @@ export function createStore(dir, opts = {}) {
|
|
|
583
688
|
|
|
584
689
|
/** 全量重写分片;条目变少时把多余旧片删掉 */
|
|
585
690
|
function writeVecShards() {
|
|
691
|
+
// 没有模型时不碰任何向量文件:现有向量可能是真模型生成的,
|
|
692
|
+
// 用户接回模型后还能直接用,不能被一次空表覆盖掉。
|
|
693
|
+
if (vectorsDisabled) return 0
|
|
586
694
|
const entries = [...vectors.entries()]
|
|
587
695
|
const shardCount = Math.ceil(entries.length / VEC_SHARD_SIZE)
|
|
588
696
|
for (let i = 0; i < shardCount; i++) {
|
|
@@ -590,11 +698,12 @@ export function createStore(dir, opts = {}) {
|
|
|
590
698
|
for (const [k, v] of entries.slice(i * VEC_SHARD_SIZE, (i + 1) * VEC_SHARD_SIZE)) {
|
|
591
699
|
out[k] = Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString('base64')
|
|
592
700
|
}
|
|
593
|
-
writeFileSync(join(dir,
|
|
701
|
+
writeFileSync(join(dir, vecOwnName(writer, i)), JSON.stringify(out), 'utf8')
|
|
594
702
|
}
|
|
595
703
|
try {
|
|
596
|
-
|
|
597
|
-
|
|
704
|
+
const re = vecOwnRe(writer)
|
|
705
|
+
for (const f of listDir()) {
|
|
706
|
+
const m = f.match(re)
|
|
598
707
|
if (m && Number(m[1]) >= shardCount) unlinkSync(join(dir, f))
|
|
599
708
|
}
|
|
600
709
|
} catch { /* 清不掉不影响正确性,下次再清 */ }
|
|
@@ -602,7 +711,12 @@ export function createStore(dir, opts = {}) {
|
|
|
602
711
|
return shardCount
|
|
603
712
|
}
|
|
604
713
|
|
|
605
|
-
|
|
714
|
+
// 没有模型('none')→ 语义层整体关闭:不读、不写向量文件,检索只走关键词。
|
|
715
|
+
// 不读是因为查询侧没有 embed,qVec 恒为 null,读了也用不上;
|
|
716
|
+
// 不写是为了保住现有的向量,等用户接上模型还能续用。
|
|
717
|
+
const vectorsDisabled = signature === 'none'
|
|
718
|
+
|
|
719
|
+
const sharded = vectorsDisabled ? null : readVecShards()
|
|
606
720
|
if (sharded) {
|
|
607
721
|
const meta = readVecMeta()
|
|
608
722
|
// 只在新旧都明确时才判定;老库没有 meta(sig 缺失)按兼容处理,直接沿用
|
|
@@ -614,7 +728,7 @@ export function createStore(dir, opts = {}) {
|
|
|
614
728
|
vectors = sharded
|
|
615
729
|
if (!meta) writeVecMeta(sharded.size) // 老库补写指纹
|
|
616
730
|
}
|
|
617
|
-
} else if (existsSync(vecPath)) {
|
|
731
|
+
} else if (!vectorsDisabled && existsSync(vecPath)) { // 旧版单文件,仅首次
|
|
618
732
|
try {
|
|
619
733
|
const raw = JSON.parse(readFileSync(vecPath, 'utf8'))
|
|
620
734
|
for (const [k, v] of Object.entries(raw || {})) {
|
|
@@ -627,8 +741,24 @@ export function createStore(dir, opts = {}) {
|
|
|
627
741
|
} catch { /* 同上 */ }
|
|
628
742
|
}
|
|
629
743
|
|
|
744
|
+
/** 原子落盘:先写临时文件再改名。两个进程同时 save 不会互相撕坏文件 */
|
|
745
|
+
function atomicWrite(p, body) {
|
|
746
|
+
const tmp = `${p}.tmp-${process.pid}`
|
|
747
|
+
try {
|
|
748
|
+
writeFileSync(tmp, body, 'utf8')
|
|
749
|
+
renameSync(tmp, p)
|
|
750
|
+
} catch {
|
|
751
|
+
try { unlinkSync(tmp) } catch { /* 临时文件清不掉不影响 */ }
|
|
752
|
+
writeFileSync(p, body, 'utf8') // 改名失败就退回直接写,别把数据丢了
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
630
756
|
function save() {
|
|
631
|
-
|
|
757
|
+
// 落盘前先看一眼别人有没有新东西,合并进内存视图
|
|
758
|
+
refreshForeign()
|
|
759
|
+
// 只写自己的条目:别人的数据在他们自己的分片里,我们没有资格替他们存
|
|
760
|
+
const mine = state.items.filter((it) => !it.__foreign)
|
|
761
|
+
atomicWrite(storePath, JSON.stringify({ ...state, items: mine }, null, 1))
|
|
632
762
|
writeVecShards()
|
|
633
763
|
}
|
|
634
764
|
|
|
@@ -659,9 +789,16 @@ export function createStore(dir, opts = {}) {
|
|
|
659
789
|
get items() { return state.items },
|
|
660
790
|
set items(v) { state.items = v },
|
|
661
791
|
get vectors() { return vectors },
|
|
662
|
-
save, saveVectors, audit,
|
|
792
|
+
save, saveVectors, audit, refreshForeign, maybeRefreshForeign,
|
|
793
|
+
/** 条目集合的代数:变了说明别人的数据合并进来了,上层缓存要重算 */
|
|
794
|
+
get gen() { return gen },
|
|
663
795
|
dumpVectors,
|
|
664
796
|
dir,
|
|
797
|
+
writer,
|
|
798
|
+
/** 自己的条目(不含别人的) */
|
|
799
|
+
get ownItems() { return state.items.filter((it) => !it.__foreign) },
|
|
800
|
+
/** 是不是自己的条目——不是就不能改、不能删 */
|
|
801
|
+
isOwn: (it) => !it.__foreign,
|
|
665
802
|
}
|
|
666
803
|
}
|
|
667
804
|
|
|
@@ -743,7 +880,7 @@ export function createMemory5(o = {}) {
|
|
|
743
880
|
const cfg = { ...DEFAULTS, ...(o.cfg || {}) }
|
|
744
881
|
const dir = o.dir || '.memory5'
|
|
745
882
|
const tick = o.now || (() => Date.now())
|
|
746
|
-
const store = createStore(dir, { signature: o.embedSignature })
|
|
883
|
+
const store = createStore(dir, { signature: o.embedSignature, writer: o.writer })
|
|
747
884
|
|
|
748
885
|
// ============================================================
|
|
749
886
|
// 模型调用统计
|
|
@@ -811,10 +948,16 @@ export function createMemory5(o = {}) {
|
|
|
811
948
|
// 待摘要队列(summarizeMode === 'deferred' 时用)
|
|
812
949
|
const summaryQueue = []
|
|
813
950
|
|
|
814
|
-
|
|
951
|
+
// ★ 默认只返回**自己的**条目:分流、遗忘、压缩、融合这些会改数据的操作,
|
|
952
|
+
// 只能动自己写的(别人的数据在别人的分片里,我们改了也存不回去)。
|
|
953
|
+
// 要看全库(检索、注入上下文、统计、列目录)就传 { all: true }。
|
|
954
|
+
const byLayer = (layer, opts) =>
|
|
955
|
+
store.items.filter((it) => it.layer === layer && ((opts && opts.all) || !it.__foreign))
|
|
815
956
|
const chars = (list) => list.reduce((s, it) => s + String(it.text || '').length, 0)
|
|
816
957
|
const find = (id) => store.items.find((it) => it.id === id)
|
|
817
958
|
const removeItem = (id) => {
|
|
959
|
+
const it = store.items.find((x) => x.id === id)
|
|
960
|
+
if (it && it.__foreign) return false // 别人的条目:删了也会被他的分片带回来
|
|
818
961
|
store.items = store.items.filter((it) => it.id !== id)
|
|
819
962
|
invalidateLayerIndex() // 条目少了,桶要重建
|
|
820
963
|
}
|
|
@@ -1008,13 +1151,15 @@ export function createMemory5(o = {}) {
|
|
|
1008
1151
|
// Map.get 是 O(1),比重建索引便宜几个数量级。
|
|
1009
1152
|
// ============================================================
|
|
1010
1153
|
let _layerIndex = null
|
|
1154
|
+
let _layerIndexGen = -1
|
|
1011
1155
|
|
|
1012
1156
|
// 用 function 声明而不是箭头函数:让它们能提升,removeItem / moveTo
|
|
1013
1157
|
// 这些在 createMemory5 体里更早定义的函数就能安全调用,不会有 TDZ 风险。
|
|
1014
1158
|
function invalidateLayerIndex() { _layerIndex = null }
|
|
1015
1159
|
|
|
1016
1160
|
function getLayerIndex() {
|
|
1017
|
-
|
|
1161
|
+
// 别人的数据合并进来后,索引必须重建,否则会漏掉他们新写的条目
|
|
1162
|
+
if (_layerIndex && _layerIndexGen === store.gen) return _layerIndex
|
|
1018
1163
|
const m = new Map()
|
|
1019
1164
|
for (const it of store.items) {
|
|
1020
1165
|
let arr = m.get(it.layer)
|
|
@@ -1022,6 +1167,7 @@ export function createMemory5(o = {}) {
|
|
|
1022
1167
|
arr.push(it)
|
|
1023
1168
|
}
|
|
1024
1169
|
_layerIndex = m
|
|
1170
|
+
_layerIndexGen = store.gen
|
|
1025
1171
|
return m
|
|
1026
1172
|
}
|
|
1027
1173
|
|
|
@@ -1345,6 +1491,11 @@ export function createMemory5(o = {}) {
|
|
|
1345
1491
|
// 迁移
|
|
1346
1492
|
// ============================================================
|
|
1347
1493
|
function moveTo(item, layer, reason, now) {
|
|
1494
|
+
// ★ 别人的条目不搬层:它在别人的分片里,我们改了也存不回去,
|
|
1495
|
+
// 下次读盘就被他自己的版本覆盖掉;更糟的是本次会话里它会被
|
|
1496
|
+
// 当成"我方数据"参与注入和容量计算,重启前后行为不一致。
|
|
1497
|
+
// 别人的层、热度、作废标记一律只读。
|
|
1498
|
+
if (!item || item.__foreign) return
|
|
1348
1499
|
const from = item.layer
|
|
1349
1500
|
if (from === layer) return
|
|
1350
1501
|
|
|
@@ -1496,6 +1647,8 @@ export function createMemory5(o = {}) {
|
|
|
1496
1647
|
const affected = []
|
|
1497
1648
|
for (const it of store.items) {
|
|
1498
1649
|
if (it.kind !== 'summary' || it.supersededBy) continue
|
|
1650
|
+
// 别人的摘要不在我们的分片里,打不上作废标记(打了也存不回去)
|
|
1651
|
+
if (it.__foreign) continue
|
|
1499
1652
|
if ((it.sources || []).includes(oldItem.id)) {
|
|
1500
1653
|
it.supersededBy = newItem.id
|
|
1501
1654
|
affected.push(it.id)
|
|
@@ -1532,9 +1685,16 @@ export function createMemory5(o = {}) {
|
|
|
1532
1685
|
if (!nv) return null
|
|
1533
1686
|
|
|
1534
1687
|
// 只和 DL3 里的摘要比(摘要 vs 摘要),不碰原文
|
|
1688
|
+
//
|
|
1689
|
+
// ★ 只跟**自己的**摘要比。别人的摘要在别人的分片里,一旦被判成"重复"
|
|
1690
|
+
// 就会被合并进来、而我们这一条会被删掉——删掉的写进了自己的分片
|
|
1691
|
+
// (永久生效),合并的结果却落在别人的对象上(存不回去,重启即失效)。
|
|
1692
|
+
// 一合一删之后,这条内容两边都不剩,等于凭空丢一条记忆。
|
|
1693
|
+
// 所以跨写者不做判重 / 取代 / 融合,只做检索和注入。
|
|
1535
1694
|
const cands = []
|
|
1536
1695
|
for (const it of store.items) {
|
|
1537
1696
|
if (it.id === newItem.id) continue
|
|
1697
|
+
if (it.__foreign) continue
|
|
1538
1698
|
if (it.layer !== LAYER.DL_LONG || it.kind !== 'summary') continue
|
|
1539
1699
|
if (it.supersededBy) continue
|
|
1540
1700
|
const v = store.vectors.get(it.id)
|
|
@@ -2474,6 +2634,7 @@ export function createMemory5(o = {}) {
|
|
|
2474
2634
|
* @returns 带 confidence / advice 的结果,advice 是给模型看的下一步建议
|
|
2475
2635
|
*/
|
|
2476
2636
|
async function search(query, { depth = 'auto', ...opt } = {}) {
|
|
2637
|
+
store.maybeRefreshForeign() // 别人可能刚写了新东西,15 秒内刷一次
|
|
2477
2638
|
const q = String(query || '').trim()
|
|
2478
2639
|
const limit = opt.limit || 6
|
|
2479
2640
|
|
|
@@ -2566,6 +2727,9 @@ export function createMemory5(o = {}) {
|
|
|
2566
2727
|
for (const id of list) {
|
|
2567
2728
|
const it = find(id)
|
|
2568
2729
|
if (!it) continue
|
|
2730
|
+
// 别人的条目:热度/关注次数存在他的分片里,我们加的这一下重启就没了,
|
|
2731
|
+
// 反而让本次会话的排序和重启后不一致(同一条记忆两次查询排位不同)。
|
|
2732
|
+
if (it.__foreign) continue
|
|
2569
2733
|
touchHeat(it, now, cfg)
|
|
2570
2734
|
it.recallCount = (Number(it.recallCount) || 0) + 1
|
|
2571
2735
|
n++
|
|
@@ -2586,6 +2750,7 @@ export function createMemory5(o = {}) {
|
|
|
2586
2750
|
const win = parseTime(q, now)
|
|
2587
2751
|
const kw = keywords(win ? q.replace(win.word, '') : q)
|
|
2588
2752
|
const qVec = await embedOne(q)
|
|
2753
|
+
const noSem = !qVec // 没连模型(或本条查询取向量失败)→ 纯关键词/热度打分
|
|
2589
2754
|
|
|
2590
2755
|
const scored = []
|
|
2591
2756
|
// ★ 只扫目标层(2026-10-31 修)。原来这里是 `for (const it of store.items)`
|
|
@@ -2631,8 +2796,12 @@ export function createMemory5(o = {}) {
|
|
|
2631
2796
|
// 没有任何一条记忆会含这个词——按老规则整段时间的记忆全被排除,
|
|
2632
2797
|
// 结果就是"昨天"搜出 0 条,明明那天的东西都在库里。
|
|
2633
2798
|
// 时间窗本身就是强约束,这里只需要排掉语义上明显无关的。
|
|
2799
|
+
// 没有 embedding 时(qVec 为空):时间窗查询不能被"零命中"筛掉。
|
|
2800
|
+
// 「昨天发生了什么」去掉时间词只剩"发生什么",没有任何一条会含这个词,
|
|
2801
|
+
// 语义又用不上,老规则会把整段时间的记忆全排掉——明明东西都在库里。
|
|
2802
|
+
// 时间窗本身就是强约束,这时按热度/重要度排就够了。
|
|
2634
2803
|
if (win) {
|
|
2635
|
-
if (hit === 0 && sem < 0.2) continue
|
|
2804
|
+
if (!noSem && hit === 0 && sem < 0.2) continue
|
|
2636
2805
|
} else if (kw.size && hit === 0 && sem < 0.45) {
|
|
2637
2806
|
continue
|
|
2638
2807
|
}
|
|
@@ -2769,8 +2938,31 @@ export function createMemory5(o = {}) {
|
|
|
2769
2938
|
// 好处:老摘要热度掉了,分数自动掉下去,**不用搬数据**就退出上下文。
|
|
2770
2939
|
// 不需要"L3 满了挤出去"这种逻辑,也就不会出现"冷掉的摘要赖着不走"。
|
|
2771
2940
|
// ============================================================
|
|
2772
|
-
|
|
2773
|
-
|
|
2941
|
+
// L3 是「视图」不是「层」:每次都从 DL3 现算,条目本身没有副本。
|
|
2942
|
+
//
|
|
2943
|
+
// ★ 这个排序是全局唯一需要节流的地方(2026-11-01 改)。
|
|
2944
|
+
// 以前节流的是「整个记忆块」(L3+L2+L1 一起),那是错的:
|
|
2945
|
+
// ① L1 每轮都有新数据写入,压着不刷新 = 模型看不到刚说的话;
|
|
2946
|
+
// ② 快照是追加在末尾的,前面的历史缓存根本不受影响,没有可省的。
|
|
2947
|
+
//
|
|
2948
|
+
// 真正会打断缓存的只有 L3:它在注入块里排最前,L3 一变,
|
|
2949
|
+
// 后面 L2 / L1 的整段前缀全部失效。而 heat 随时间连续衰减,
|
|
2950
|
+
// 边界条目的分数此消彼长,入选集合时不时会变一条——必须压住。
|
|
2951
|
+
//
|
|
2952
|
+
// 所以:排序结果缓存 viewRefreshMs(默认 30 分钟),期间复用同一批入选条目。
|
|
2953
|
+
// L1 / L2 不缓存,每轮现算。
|
|
2954
|
+
let l3Cache = null
|
|
2955
|
+
|
|
2956
|
+
function l3View(now = tick(), opts = {}) {
|
|
2957
|
+
const refreshMs = Number(cfg.viewRefreshMs) || 0
|
|
2958
|
+
// 容量变了要立刻重算:否则把 longMaxChars 调大要等一个周期才见效
|
|
2959
|
+
const key = cfg.longMaxChars
|
|
2960
|
+
if (!opts.force && l3Cache && l3Cache.key === key && refreshMs > 0 && now - l3Cache.at < refreshMs) {
|
|
2961
|
+
return l3Cache.items
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// all:全库。别人沉淀的摘要也该出现在 L3 里,这才是共享的意义
|
|
2965
|
+
const pool = byLayer(LAYER.DL_LONG, { all: true }).filter((it) => !it.supersededBy)
|
|
2774
2966
|
|
|
2775
2967
|
const scored = pool.map((it) => ({
|
|
2776
2968
|
it,
|
|
@@ -2786,6 +2978,7 @@ export function createMemory5(o = {}) {
|
|
|
2786
2978
|
out.push(it)
|
|
2787
2979
|
used += len
|
|
2788
2980
|
}
|
|
2981
|
+
l3Cache = { at: now, key, items: out }
|
|
2789
2982
|
return out
|
|
2790
2983
|
}
|
|
2791
2984
|
|
|
@@ -2798,17 +2991,24 @@ export function createMemory5(o = {}) {
|
|
|
2798
2991
|
* L3←longMaxChars、L2←consolidateMaxChars、L1←hotMaxChars。
|
|
2799
2992
|
* > 0 时才作为最后一道闸门截断(给不信任容量的调用方兜底)。
|
|
2800
2993
|
* @param {{includeHot?: boolean}} [opts]
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
2994
|
+
* @param {{includeHot?: boolean}} [opts]
|
|
2995
|
+
* includeHot 恒为 true:三层都注入,没有开关(2026-11-01 移除该开关)。
|
|
2996
|
+
* 要调的是各层容量,不是注不注入。参数留着只为兼容旧调用。
|
|
2804
2997
|
*/
|
|
2805
2998
|
function buildBlock(budget = 0, opts = {}) {
|
|
2999
|
+
store.maybeRefreshForeign() // 上下文里要带上别人刚记的东西
|
|
2806
3000
|
const includeHot = opts.includeHot !== false
|
|
2807
3001
|
// ★ L3 是视图:从 DL3 按 importance×热度 截取。
|
|
2808
3002
|
// 截出来之后按 seq 升序排——新增加末尾,前面逐字节不变,前缀缓存能命中。
|
|
2809
3003
|
const long = l3View().sort((a, b) => a.seq - b.seq)
|
|
2810
|
-
|
|
2811
|
-
|
|
3004
|
+
// ★ 共享目录里别人的条目也进上下文,但排在自己人后面:
|
|
3005
|
+
// 容量是我们按自己的条目维护的(enforceLimits 只动自己的),
|
|
3006
|
+
// 先到先得的话,对方写得越多,我们自己的条目被挤掉得越多。
|
|
3007
|
+
const ownFirst = (a, b) => (a.__foreign ? 1 : 0) - (b.__foreign ? 1 : 0)
|
|
3008
|
+
const cons = byLayer(LAYER.CONSOLIDATE, { all: true })
|
|
3009
|
+
.sort((a, b) => ownFirst(a, b) || (a.seq - b.seq))
|
|
3010
|
+
const hot = byLayer(LAYER.HOT, { all: true })
|
|
3011
|
+
.sort((a, b) => ownFirst(a, b) || (b.seq - a.seq)) // 最新的在前
|
|
2812
3012
|
|
|
2813
3013
|
// 作废条不进上下文:新的那条已经在里面了,再放旧的只会让模型看到两条相反的指令。
|
|
2814
3014
|
// 想查历史可以走 search(那里会改写成"(已作废)…")。
|
|
@@ -2852,8 +3052,17 @@ export function createMemory5(o = {}) {
|
|
|
2852
3052
|
// L1 ← hotMaxChars(同理)
|
|
2853
3053
|
// 以前是 bootMaxChars 当总闸门、L3 吃光后 L2 只剩零头(实测 351 字只进 55 字),
|
|
2854
3054
|
// 结果「刚沉淀的对话」优先级反而低于「一周前的群聊」——不合理。
|
|
2855
|
-
|
|
2856
|
-
|
|
3055
|
+
// ★ 单写者时下面等价于直接 pack(..., Infinity):容量本来就是硬上限,
|
|
3056
|
+
// 多写者时才生效——别人的条目只能"借"剩下的空间,
|
|
3057
|
+
// 自己那份已经占满时仍留 30% 给共享方(不然对方一条都进不来,共享就没意义了)。
|
|
3058
|
+
const packShared = (list, limit) => {
|
|
3059
|
+
const mine = pack(list.filter((it) => !it.__foreign), Infinity)
|
|
3060
|
+
const used = mine.reduce((s, l) => s + l.length + 1, 0)
|
|
3061
|
+
return mine.concat(pack(list.filter((it) => it.__foreign), Math.max(Math.round(limit * 0.3), limit - used)))
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
const hotLines = includeHot ? packShared(visible(hot), cfg.hotMaxChars) : []
|
|
3065
|
+
const consLines = packShared(visible(cons), cfg.consolidateMaxChars)
|
|
2857
3066
|
|
|
2858
3067
|
const parts = []
|
|
2859
3068
|
if (longLines.length) parts.push('## 一直记得的', ...longLines)
|
|
@@ -2869,11 +3078,12 @@ export function createMemory5(o = {}) {
|
|
|
2869
3078
|
// 观测 / 维护
|
|
2870
3079
|
// ============================================================
|
|
2871
3080
|
function stats() {
|
|
3081
|
+
store.maybeRefreshForeign()
|
|
2872
3082
|
const count = {}
|
|
2873
3083
|
const charsBy = {}
|
|
2874
3084
|
for (const l of Object.values(LAYER)) {
|
|
2875
3085
|
// L3 是视图,按"当前能进上下文的条数"算,不是 DL3 的存储量
|
|
2876
|
-
const list = l === LAYER.LONG ? l3View(tick()) : byLayer(l)
|
|
3086
|
+
const list = l === LAYER.LONG ? l3View(tick()) : byLayer(l, { all: true })
|
|
2877
3087
|
count[l] = list.length
|
|
2878
3088
|
charsBy[l] = chars(list)
|
|
2879
3089
|
}
|
|
@@ -2901,6 +3111,8 @@ export function createMemory5(o = {}) {
|
|
|
2901
3111
|
function pin(id, v = true) {
|
|
2902
3112
|
const it = find(id)
|
|
2903
3113
|
if (!it) return { ok: false, error: '找不到' }
|
|
3114
|
+
// 别人的条目改了也存不回去(save 只写自己的分片),直接拒绝,别假装成功。
|
|
3115
|
+
if (!store.isOwn(it)) return { ok: false, error: '别人的条目,不能钉住' }
|
|
2904
3116
|
it.pinned = v
|
|
2905
3117
|
store.save()
|
|
2906
3118
|
return { ok: true, id, pinned: v }
|
|
@@ -2909,6 +3121,9 @@ export function createMemory5(o = {}) {
|
|
|
2909
3121
|
function forgetById(id) {
|
|
2910
3122
|
const it = find(id)
|
|
2911
3123
|
if (!it) return { ok: false, error: '找不到' }
|
|
3124
|
+
// 别人的条目:删不掉条目本身,但 forget() 会写台账、删归档——
|
|
3125
|
+
// 等于"没删掉还删了对方的正文"。这里必须拦。
|
|
3126
|
+
if (!store.isOwn(it)) return { ok: false, error: '别人的条目,不能删' }
|
|
2912
3127
|
const ok = forget(it, '手动删除', tick())
|
|
2913
3128
|
store.save()
|
|
2914
3129
|
return { ok, id }
|
|
@@ -2916,7 +3131,7 @@ export function createMemory5(o = {}) {
|
|
|
2916
3131
|
|
|
2917
3132
|
function list(layer = null) {
|
|
2918
3133
|
if (layer === LAYER.LONG) return l3View(tick()) // L3 是视图
|
|
2919
|
-
return (layer ? byLayer(layer) : store.items).slice()
|
|
3134
|
+
return (layer ? byLayer(layer, { all: true }) : store.items).slice()
|
|
2920
3135
|
}
|
|
2921
3136
|
|
|
2922
3137
|
/** 手动快进时间(测试用) */
|
|
@@ -2952,6 +3167,8 @@ export function createMemory5(o = {}) {
|
|
|
2952
3167
|
flushSummaries,
|
|
2953
3168
|
pendingSummaries,
|
|
2954
3169
|
dumpVectors: (p) => store.dumpVectors(p), // 合并导出向量快照(调试/分析用)
|
|
3170
|
+
/** 底层 store:测试和多写者调试用 */
|
|
3171
|
+
_store: store,
|
|
2955
3172
|
fuse: consolidateFusion, // 手动跑一次全量融合
|
|
2956
3173
|
fuseDL3: fuseIntoDL3, // 摘要入 DL3 时的融合(判重的新位置)
|
|
2957
3174
|
_seed: (item, vec) => { // 直接注入(测试用)
|