dsh-memu-memory 1.2.0 → 1.2.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/README.md +8 -4
- package/README.zh.md +9 -5
- package/docs/contributing.md +2 -2
- package/docs/env-and-commands.md +8 -0
- package/docs/troubleshooting.md +4 -3
- package/lib/activity-log.js +22 -5
- package/lib/binding-diagnostics.js +1 -1
- package/lib/client.js +302 -24
- package/lib/consolidation-worker.js +43 -14
- package/lib/curator.js +86 -31
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/memory.js +314 -70
- package/lib/private.js +98 -6
- package/lib/rpc.js +45 -4
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +64 -8
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/package.json +3 -2
- package/scripts/replay.mjs +573 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +142 -10
- package/tests/consolidation-snapshot.test.mjs +43 -4
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph.test.mjs +17 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui.test.mjs +3 -2
package/lib/memory.js
CHANGED
|
@@ -15,10 +15,11 @@ import { record } from './activity-log.js'
|
|
|
15
15
|
import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
|
|
16
16
|
import { commit, retrieve, listFiles, forget } from './memu.js'
|
|
17
17
|
import { bumpStats } from './stats.js'
|
|
18
|
-
import { recallRelations } from './graph.js'
|
|
18
|
+
import { recallRelations, pruneOrphanEntities } from './graph.js'
|
|
19
19
|
import { attachmentRefs } from './media.js'
|
|
20
20
|
import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
|
|
21
|
-
import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
|
|
21
|
+
import { activeEntryStamp, assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
|
|
22
|
+
import { bigrams, dice, similarTexts, DUP_DICE, DUP_REJECT_DICE, TOPIC_DICE } from './text-similarity.js'
|
|
22
23
|
|
|
23
24
|
const HALF_LIFE_DAYS = 14
|
|
24
25
|
// A short-term entry is meant to live for the length of a conversation, not a
|
|
@@ -38,6 +39,11 @@ const HABITUATION_FLOOR = 0.5
|
|
|
38
39
|
// library with three variants of the same decision would spend the whole block on it.
|
|
39
40
|
// The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
|
|
40
41
|
const MMR_LAMBDA = 0.72
|
|
42
|
+
// Absolute floor on the retrieval share itself . The relative
|
|
43
|
+
// relevanceThreshold cannot stop a near-empty result set from being padded to topK with
|
|
44
|
+
// junk that only matched a duplicate cluster; a floor cuts those before selection.
|
|
45
|
+
// 0 turns it off entirely — the calibrated default until §13.6 has run.
|
|
46
|
+
const RETRIEVAL_FLOOR = 0
|
|
41
47
|
// Semantic fusion uses RRF: each route contributes a
|
|
42
48
|
// rank term and the merged score is normalised to the best fused hit before the
|
|
43
49
|
// existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
|
|
@@ -555,20 +561,8 @@ export function isTipOfTongue(entry) {
|
|
|
555
561
|
// Not semantic entailment: two bigram sets and a negation test. It only has to be
|
|
556
562
|
// good enough to stop "he likes tea" and "he hates tea" from sitting side by side
|
|
557
563
|
// as equally true, and quiet enough that ordinary rewordings do not trip it.
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const out = new Set()
|
|
561
|
-
for (let i = 0; i < clean.length - 1; i += 1) out.add(clean.slice(i, i + 2))
|
|
562
|
-
return out
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function dice(a, b) {
|
|
566
|
-
if (a.size === 0 || b.size === 0) return 0
|
|
567
|
-
let shared = 0
|
|
568
|
-
for (const gram of a) if (b.has(gram)) shared += 1
|
|
569
|
-
return (2 * shared) / (a.size + b.size)
|
|
570
|
-
}
|
|
571
|
-
|
|
564
|
+
// The bigrams/dice measure lives in lib/text-similarity.js so recall-side folding
|
|
565
|
+
// and the write-side duplicate gate measure with the exact same ruler.
|
|
572
566
|
const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
|
|
573
567
|
|
|
574
568
|
/**
|
|
@@ -770,27 +764,47 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
770
764
|
if (automatic && entry.visibility === 'secret') return null
|
|
771
765
|
return { id, entry, file, route, rank }
|
|
772
766
|
}
|
|
773
|
-
const take = (files, route) => {
|
|
767
|
+
const take = (files, route, collapsed) => {
|
|
774
768
|
const picked = []
|
|
775
769
|
// One route can name the same canonical entry more than once: a caller may
|
|
776
770
|
// pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
|
|
777
771
|
// canonical id before scoring and truncating, keeping the first (best) rank.
|
|
778
772
|
// Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
|
|
779
773
|
const seen = new Set()
|
|
774
|
+
// Fact-level folding : id dedupe cannot see "same fact,
|
|
775
|
+
// different id", so a paraphrase cluster would spend one candidate slot per
|
|
776
|
+
// member and crowd real memories out before any ranking runs. A candidate that
|
|
777
|
+
// a accepted candidate already speaks for (dice ≥ DUP_DICE on title + body)
|
|
778
|
+
// is recorded and skipped: one cluster, one candidate slot.
|
|
779
|
+
const gramsCache = new Map()
|
|
780
|
+
const gramsOf = (item) => {
|
|
781
|
+
let set = gramsCache.get(item.id)
|
|
782
|
+
if (!set) {
|
|
783
|
+
set = bigrams(String(item.entry?.title || '') + ' ' + String(item.entry?.content || ''))
|
|
784
|
+
gramsCache.set(item.id, set)
|
|
785
|
+
}
|
|
786
|
+
return set
|
|
787
|
+
}
|
|
780
788
|
for (const file of Array.isArray(files) ? files : []) {
|
|
781
789
|
const item = eligible(file, route, picked.length + 1)
|
|
782
790
|
if (!item || seen.has(item.id)) continue
|
|
783
791
|
seen.add(item.id)
|
|
792
|
+
const twin = picked.find((accepted) => dice(gramsOf(item), gramsOf(accepted)) >= DUP_DICE)
|
|
793
|
+
if (twin) {
|
|
794
|
+
collapsed.push({ kept: twin.id, dropped: item.id })
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
784
797
|
picked.push(item)
|
|
785
798
|
if (picked.length >= limit) break
|
|
786
799
|
}
|
|
787
800
|
return picked
|
|
788
801
|
}
|
|
789
|
-
const
|
|
790
|
-
const
|
|
802
|
+
const routeCollapsed = []
|
|
803
|
+
const localPicked = take(local, 'local', routeCollapsed)
|
|
804
|
+
const semanticPicked = take(semantic, 'semantic', routeCollapsed)
|
|
791
805
|
const merged = new Map()
|
|
792
806
|
const add = (item) => {
|
|
793
|
-
const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
|
|
807
|
+
const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [], mergedTogether: [] }
|
|
794
808
|
current.rrf += 1 / (HYBRID_RRF_K + item.rank)
|
|
795
809
|
current.bestRank = Math.min(current.bestRank, item.rank)
|
|
796
810
|
if (item.route === 'local') current.localRank = item.rank
|
|
@@ -804,14 +818,51 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
804
818
|
for (const item of localPicked) add(item)
|
|
805
819
|
for (const item of semanticPicked) add(item)
|
|
806
820
|
const rows = [...merged.values()]
|
|
821
|
+
// Cross-leg folding . Each leg folded within itself, but the
|
|
822
|
+
// two legs can still meet the same fact under two ids. Keep the highest-rrf member
|
|
823
|
+
// of each cross-leg cluster and let the rest ride along as mergedTogether metadata
|
|
824
|
+
// instead of spending recall slots; this must happen BEFORE mmrSelect, so the
|
|
825
|
+
// diversity ceiling is set by a real spread of memories rather than a cluster.
|
|
826
|
+
const fusionCollapsed = []
|
|
827
|
+
const absorbed = new Set()
|
|
828
|
+
const rowGrams = new Map()
|
|
829
|
+
const gramsOfRow = (row) => {
|
|
830
|
+
let set = rowGrams.get(row.id)
|
|
831
|
+
if (!set) {
|
|
832
|
+
set = bigrams(String(row.entry?.title || '') + ' ' + String(row.entry?.content || ''))
|
|
833
|
+
rowGrams.set(row.id, set)
|
|
834
|
+
}
|
|
835
|
+
return set
|
|
836
|
+
}
|
|
837
|
+
const hosts = []
|
|
838
|
+
for (const row of rows.slice().sort((a, b) => b.rrf - a.rrf || a.id.localeCompare(b.id))) {
|
|
839
|
+
const host = hosts.find((kept) => dice(gramsOfRow(row), gramsOfRow(kept)) >= DUP_DICE)
|
|
840
|
+
if (host) {
|
|
841
|
+
host.mergedTogether.push(row.id)
|
|
842
|
+
absorbed.add(row.id)
|
|
843
|
+
fusionCollapsed.push({ kept: host.id, dropped: row.id })
|
|
844
|
+
} else {
|
|
845
|
+
hosts.push(row)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const collapsedGroups = new Map()
|
|
849
|
+
for (const { kept, dropped } of [...routeCollapsed, ...fusionCollapsed]) {
|
|
850
|
+
if (!collapsedGroups.has(kept)) collapsedGroups.set(kept, { kept, dropped: [] })
|
|
851
|
+
const group = collapsedGroups.get(kept)
|
|
852
|
+
// The same pair can meet on both routes; report it once.
|
|
853
|
+
if (!group.dropped.includes(dropped)) group.dropped.push(dropped)
|
|
854
|
+
}
|
|
855
|
+
const collapsed = [...collapsedGroups.values()]
|
|
807
856
|
const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
|
|
808
857
|
const files = rows
|
|
858
|
+
.filter((row) => !absorbed.has(row.id))
|
|
809
859
|
.map((row) => ({
|
|
810
860
|
...row.file,
|
|
811
861
|
name: row.id,
|
|
812
862
|
entry: row.entry,
|
|
813
863
|
retrievalScore: row.rrf / maxRrf,
|
|
814
864
|
fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
|
|
865
|
+
...(row.mergedTogether.length ? { mergedTogether: row.mergedTogether.slice() } : {}),
|
|
815
866
|
}))
|
|
816
867
|
.sort((a, b) => b.retrievalScore - a.retrievalScore
|
|
817
868
|
|| a.fusion.bestRank - b.fusion.bestRank
|
|
@@ -830,10 +881,21 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
830
881
|
localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
|
|
831
882
|
semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
|
|
832
883
|
shared: [...localIds].filter((id) => semanticIds.has(id)).length,
|
|
884
|
+
collapsed,
|
|
885
|
+
clusterCount: collapsed.length,
|
|
833
886
|
},
|
|
834
887
|
}
|
|
835
888
|
}
|
|
836
889
|
|
|
890
|
+
// The retrieval component of recallScore: a fused file carries its own normalised
|
|
891
|
+
// RRF share; falling back to the old rank decay keeps every lexical caller
|
|
892
|
+
// byte-for-byte compatible. Shared with the retrieval-floor cut so the two can
|
|
893
|
+
// never disagree about what "retrieval" means.
|
|
894
|
+
function retrievalShare(file, rank) {
|
|
895
|
+
const fused = Number(file?.retrievalScore)
|
|
896
|
+
return Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
|
|
897
|
+
}
|
|
898
|
+
|
|
837
899
|
// Human-like recall ordering. Retrieval answers first (semantic match is still
|
|
838
900
|
// the primary signal, which is why it stays a rank decay), then these biases
|
|
839
901
|
// re-order within that set:
|
|
@@ -843,10 +905,7 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
843
905
|
// Every factor is bounded so no bias can outrank a good semantic hit by luck.
|
|
844
906
|
function recallScore(file, rank, context) {
|
|
845
907
|
const entry = file.entry || {}
|
|
846
|
-
|
|
847
|
-
// rank decay keeps every lexical caller byte-for-byte compatible.
|
|
848
|
-
const fused = Number(file.retrievalScore)
|
|
849
|
-
const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
|
|
908
|
+
const retrieval = retrievalShare(file, rank)
|
|
850
909
|
const strengthTerm = 0.6 + 0.4 * file.strength
|
|
851
910
|
const marginal = entry.marginalized ? 0.35 : 1
|
|
852
911
|
|
|
@@ -882,8 +941,13 @@ function recallScore(file, rank, context) {
|
|
|
882
941
|
// a trump card: 1.25 still loses to a clearly better semantic hit.
|
|
883
942
|
const openLoopBias = entry.openLoop ? 1.25 : 1
|
|
884
943
|
const habituation = habituationFactor(entry, context.now)
|
|
944
|
+
// Fact-level dedup down-weight . A near-copy that arrived
|
|
945
|
+
// later is tagged duplicateOf at write time; without this factor the copy would
|
|
946
|
+
// ride the same touch/decay loop as the original and could outlive it in rank —
|
|
947
|
+
// the "duplicates age better than the truth" loop this closes.
|
|
948
|
+
const duplicateFactor = entry.duplicateOf ? 0.7 : 1
|
|
885
949
|
|
|
886
|
-
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm
|
|
950
|
+
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm * duplicateFactor
|
|
887
951
|
}
|
|
888
952
|
|
|
889
953
|
/**
|
|
@@ -940,12 +1004,19 @@ function habituationFactor(entry, now = Date.now()) {
|
|
|
940
1004
|
// bounded biases, so its scale depends on the library and a fixed cutoff would mean
|
|
941
1005
|
// different things in different conversations. "Keep what scores within N of the best
|
|
942
1006
|
// hit" is scale-free, and 0 turns the filter off entirely.
|
|
943
|
-
|
|
1007
|
+
// `retrievalFloor` is the absolute complement : whatever the
|
|
1008
|
+
// library looks like, a hit whose retrieval share never cleared the floor must not
|
|
1009
|
+
// pad a topK that a duplicate cluster emptied. 0 turns it off entirely.
|
|
1010
|
+
function cutWeakScores(files, threshold, retrievalFloor = 0) {
|
|
1011
|
+
let out = files
|
|
944
1012
|
const cut = Number(threshold)
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1013
|
+
if (Number.isFinite(cut) && cut > 0) {
|
|
1014
|
+
const best = Number(out[0]?.score || 0)
|
|
1015
|
+
if (best > 0) out = out.filter((f) => Number(f.score || 0) >= best * cut)
|
|
1016
|
+
}
|
|
1017
|
+
const floor = Number(retrievalFloor)
|
|
1018
|
+
if (Number.isFinite(floor) && floor > 0) out = out.filter((f) => Number(f.retrieval ?? retrievalShare(f, 0)) >= floor)
|
|
1019
|
+
return out
|
|
949
1020
|
}
|
|
950
1021
|
|
|
951
1022
|
function privateWeighted(result, max, context = {}) {
|
|
@@ -959,6 +1030,7 @@ function privateWeighted(result, max, context = {}) {
|
|
|
959
1030
|
lastCreatedAt: null,
|
|
960
1031
|
sessionId: null,
|
|
961
1032
|
now: Date.now(),
|
|
1033
|
+
retrievalFloor: RETRIEVAL_FLOOR,
|
|
962
1034
|
...context,
|
|
963
1035
|
}
|
|
964
1036
|
const items = (result.files || [])
|
|
@@ -969,55 +1041,79 @@ function privateWeighted(result, max, context = {}) {
|
|
|
969
1041
|
// memories. Filtering here keeps the anchor and the recall block in agreement.
|
|
970
1042
|
.filter((f) => visibleToSession(f.entry, ctx.sessionId))
|
|
971
1043
|
.filter((f) => !ctx.automatic || f.entry?.visibility !== 'secret')
|
|
972
|
-
.map((f, i) => ({ ...f, score: recallScore(f, i, ctx) }))
|
|
1044
|
+
.map((f, i) => ({ ...f, score: recallScore(f, i, ctx), retrieval: retrievalShare(f, i) }))
|
|
973
1045
|
.sort((a, b) => b.score - a.score)
|
|
974
|
-
|
|
1046
|
+
const { chosen, skipped } = mmrSelect(cutWeakScores(items, ctx.relevanceThreshold, ctx.retrievalFloor), max)
|
|
1047
|
+
// Observation for the dedup thresholds : candidate folding
|
|
1048
|
+
// (①②) reports through result.fusion, the ranking-level hard skips (③) come back
|
|
1049
|
+
// from mmrSelect. One merged list — without it, a mistuned threshold eats real
|
|
1050
|
+
// memories invisibly. Fail-open: metadata only, never a recall failure.
|
|
1051
|
+
const fusionCollapsed = Array.isArray(result.fusion?.collapsed) ? result.fusion.collapsed : []
|
|
1052
|
+
const collapsed = [...fusionCollapsed, ...skipped]
|
|
1053
|
+
return { ...result, files: chosen, dedup: { collapsed, clusterCount: collapsed.length } }
|
|
975
1054
|
}
|
|
976
1055
|
|
|
977
1056
|
/**
|
|
978
1057
|
* Greedy maximal marginal relevance over already-scored files.
|
|
979
1058
|
*
|
|
980
|
-
* Similarity is lexical
|
|
981
|
-
*
|
|
982
|
-
*
|
|
1059
|
+
* Similarity is lexical on purpose (dice over title+body bigrams, the same shared
|
|
1060
|
+
* ruler the write-side duplicate gate uses): it must be cheap enough to run on every
|
|
1061
|
+
* recall and predictable enough to explain. An embedding version would need a second
|
|
1062
|
+
* engine round trip just to de-duplicate four lines.
|
|
1063
|
+
*
|
|
1064
|
+
* Two bands :
|
|
1065
|
+
* · dice >= DUP_DICE — the candidate describes a fact something already chosen
|
|
1066
|
+
* describes: it is never selected, only reported as `skipped`. A soft penalty
|
|
1067
|
+
* cannot bound how many seats one fact takes; this can.
|
|
1068
|
+
* · TOPIC_DICE <= dice < DUP_DICE — same topic, different fact: the existing soft
|
|
1069
|
+
* penalty (lambda x overlap x ceiling) still applies.
|
|
1070
|
+
* Below TOPIC_DICE the two memories do not interact.
|
|
983
1071
|
*/
|
|
984
1072
|
function mmrSelect(items, max, lambda = MMR_LAMBDA) {
|
|
985
1073
|
const limit = Number(max || 0)
|
|
986
|
-
if (!(limit > 0)) return items
|
|
987
|
-
if (items.length <= 1) return items.slice(0, limit)
|
|
1074
|
+
if (!(limit > 0)) return { chosen: items, skipped: [] }
|
|
1075
|
+
if (items.length <= 1) return { chosen: items.slice(0, limit), skipped: [] }
|
|
988
1076
|
const chosen = []
|
|
989
1077
|
const pool = items.slice()
|
|
990
|
-
const
|
|
991
|
-
const
|
|
1078
|
+
const gramsCache = new Map()
|
|
1079
|
+
const gramsOf = (file) => {
|
|
992
1080
|
const key = file.name
|
|
993
|
-
if (
|
|
994
|
-
const set =
|
|
995
|
-
|
|
1081
|
+
if (gramsCache.has(key)) return gramsCache.get(key)
|
|
1082
|
+
const set = bigrams(String(file.entry?.title || '') + ' ' + String(file.entry?.content || ''))
|
|
1083
|
+
gramsCache.set(key, set)
|
|
996
1084
|
return set
|
|
997
1085
|
}
|
|
998
1086
|
// Anchor the penalty to the best raw score so the diversity term is on the same
|
|
999
1087
|
// scale as relevance instead of being dominated by it.
|
|
1000
1088
|
const ceiling = Number(items[0]?.score || 0) || 1
|
|
1089
|
+
// dropped id -> kept id. Recorded across rounds; an item hard-blocked in one round
|
|
1090
|
+
// stays blocked (chosen only grows), and the map keeps it reported exactly once.
|
|
1091
|
+
const blocked = new Map()
|
|
1001
1092
|
while (pool.length > 0 && chosen.length < limit) {
|
|
1002
|
-
let bestIdx =
|
|
1093
|
+
let bestIdx = -1
|
|
1003
1094
|
let bestScore = -Infinity
|
|
1004
1095
|
for (let i = 0; i < pool.length; i += 1) {
|
|
1005
1096
|
let overlap = 0
|
|
1006
|
-
|
|
1097
|
+
let twin = null
|
|
1098
|
+
for (const picked of chosen) {
|
|
1099
|
+
const similarity = dice(gramsOf(pool[i]), gramsOf(picked))
|
|
1100
|
+
if (similarity >= DUP_DICE) { twin = picked.name; break }
|
|
1101
|
+
if (similarity >= TOPIC_DICE) overlap = Math.max(overlap, similarity)
|
|
1102
|
+
}
|
|
1103
|
+
if (twin) { blocked.set(pool[i].name, twin); continue }
|
|
1007
1104
|
const adjusted = Number(pool[i].score || 0) - lambda * overlap * ceiling
|
|
1008
1105
|
if (adjusted > bestScore) { bestScore = adjusted; bestIdx = i }
|
|
1009
1106
|
}
|
|
1107
|
+
if (bestIdx === -1) break // nothing selectable is left: only hard duplicates remain
|
|
1010
1108
|
chosen.push(pool.splice(bestIdx, 1)[0])
|
|
1011
1109
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
for (const token of a) if (b.has(token)) shared += 1
|
|
1020
|
-
return shared / (a.size + b.size - shared)
|
|
1110
|
+
const skipped = []
|
|
1111
|
+
for (const [dropped, kept] of blocked) {
|
|
1112
|
+
let group = skipped.find((g) => g.kept === kept)
|
|
1113
|
+
if (!group) { group = { kept, dropped: [] }; skipped.push(group) }
|
|
1114
|
+
group.dropped.push(dropped)
|
|
1115
|
+
}
|
|
1116
|
+
return { chosen, skipped }
|
|
1021
1117
|
}
|
|
1022
1118
|
|
|
1023
1119
|
/**
|
|
@@ -1110,6 +1206,10 @@ async function privateRememberImpl({
|
|
|
1110
1206
|
emotion = undefined, dominance = undefined, emotionSource = null,
|
|
1111
1207
|
attachments, track = 'memory',
|
|
1112
1208
|
context,
|
|
1209
|
+
// Escape hatch for the duplicate gate : two genuine
|
|
1210
|
+
// observations of the same fact on the same day are a legal write. Off by
|
|
1211
|
+
// default — the model must say it looked, not default past the gate.
|
|
1212
|
+
allowDuplicate = false,
|
|
1113
1213
|
}) {
|
|
1114
1214
|
const scope = resolveLibrary({ exec, libraryId })
|
|
1115
1215
|
const binding = scope.binding
|
|
@@ -1258,6 +1358,35 @@ async function privateRememberImpl({
|
|
|
1258
1358
|
? `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」同题但内容不同`
|
|
1259
1359
|
: `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」说法相反`
|
|
1260
1360
|
}
|
|
1361
|
+
// Fact-level duplicate gate . Strictly AFTER the conflict
|
|
1362
|
+
// check — in the same-title band a contradictory rewording must land as
|
|
1363
|
+
// pending_review, not be swallowed as a duplicate — and strictly AFTER
|
|
1364
|
+
// redactSecrets, so key material cannot leak into the similarity metric. Measures
|
|
1365
|
+
// with the shared dice the recall side folds with: one ruler for "same fact".
|
|
1366
|
+
// Never deletes or rewrites anything: a hit either refuses the new entry, or tags
|
|
1367
|
+
// it duplicateOf so recall can keep one seat per fact.
|
|
1368
|
+
let duplicate = null
|
|
1369
|
+
if (!conflict && allowDuplicate !== true) {
|
|
1370
|
+
const text = `${entry.title || ''} ${content || ''}`
|
|
1371
|
+
const grams = bigrams(text)
|
|
1372
|
+
for (const [otherId, other] of Object.entries(index.entries)) {
|
|
1373
|
+
// Same filter set the recall side uses: only an entry a recall could actually
|
|
1374
|
+
// surface can be "the same fact" — and a duplicateOf tag must point at one.
|
|
1375
|
+
if (otherId === id || !isMemoryEligible(other)) continue
|
|
1376
|
+
if (!sameFactContext(other.context, entry.context)) continue
|
|
1377
|
+
const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
|
|
1378
|
+
if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
|
|
1382
|
+
return {
|
|
1383
|
+
deduped: true,
|
|
1384
|
+
existingId: duplicate.id,
|
|
1385
|
+
similarity: duplicate.similarity,
|
|
1386
|
+
hint: '库内已有一条几乎相同的记忆;改用 memu_private_update 合并内容。若确属两次独立观测,带 allowDuplicate=true 重写',
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (duplicate && duplicate.similarity >= TOPIC_DICE) entry.duplicateOf = duplicate.id
|
|
1261
1390
|
const payload = {
|
|
1262
1391
|
recall_files: [{
|
|
1263
1392
|
name: id,
|
|
@@ -1280,16 +1409,7 @@ async function privateRememberImpl({
|
|
|
1280
1409
|
// 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
|
|
1281
1410
|
// 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
|
|
1282
1411
|
if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
|
|
1283
|
-
|
|
1284
|
-
if (active) {
|
|
1285
|
-
entry.vector = {
|
|
1286
|
-
route,
|
|
1287
|
-
model: active.profile?.catalogId || null,
|
|
1288
|
-
generation: active.generation,
|
|
1289
|
-
fingerprint: String(active.fingerprint || '').slice(0, 8),
|
|
1290
|
-
at: stamp(),
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1412
|
+
entry.vector = activeEntryStamp(route) || entry.vector
|
|
1293
1413
|
}
|
|
1294
1414
|
// Re-read after the await: a curator job or another turn may have written in
|
|
1295
1415
|
// the meantime, and saving the pre-await snapshot would erase it.
|
|
@@ -1478,6 +1598,12 @@ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now =
|
|
|
1478
1598
|
engineWarning = String(error?.message || error)
|
|
1479
1599
|
}
|
|
1480
1600
|
}
|
|
1601
|
+
// 批量恢复与单条写入同口径:提交成功就在条目上落"已入向量库"戳,否则这些条目
|
|
1602
|
+
// 在向量计数(membership 的戳腿)里永远缺失。受管提交失败直接抛,不会走到这里。
|
|
1603
|
+
if (route !== 'lexical' && engineWarning === null) {
|
|
1604
|
+
const vector = activeEntryStamp(route)
|
|
1605
|
+
if (vector) for (const item of entries) item.entry.vector = vector
|
|
1606
|
+
}
|
|
1481
1607
|
const idMap = Object.fromEntries(entries.filter(item => item.entry.importedFrom).map(item => [item.entry.importedFrom, item.id]))
|
|
1482
1608
|
await retryStoreWrite(() => updatePrivateIndex(libraryId, (fresh) => {
|
|
1483
1609
|
for (const { id, entry } of entries) {
|
|
@@ -1536,6 +1662,21 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1536
1662
|
// like one nobody ever touches. Batched into a single index write.
|
|
1537
1663
|
if (purpose !== 'automatic') touchAccessMany({ libraryId: binding.libraryId, ids: (scored.files || []).map((f) => f.name) })
|
|
1538
1664
|
bumpStats(binding.libraryId, 'recalled', (scored.files || []).length)
|
|
1665
|
+
// Fact-level dedup observation . Fail-open signal, not
|
|
1666
|
+
// bookkeeping: without it a mistuned DUP_DICE silently eats real memories and
|
|
1667
|
+
// nobody can tell "recalled N" from "recalled N members of one fact".
|
|
1668
|
+
if (scored.dedup?.collapsed?.length) {
|
|
1669
|
+
const folded = scored.dedup.collapsed.reduce((n, group) => n + group.dropped.length, 0)
|
|
1670
|
+
record({
|
|
1671
|
+
kind: 'recall',
|
|
1672
|
+
scope: 'private',
|
|
1673
|
+
sessionId: memorySessionId(exec),
|
|
1674
|
+
libraryId: binding.libraryId,
|
|
1675
|
+
message: `去重折叠 ${folded} 条候选(${scored.dedup.clusterCount} 簇)`,
|
|
1676
|
+
detail: { collapsed: scored.dedup.collapsed, clusterCount: scored.dedup.clusterCount },
|
|
1677
|
+
refs: scored.dedup.collapsed.map((group) => group.kept).slice(0, 20),
|
|
1678
|
+
})
|
|
1679
|
+
}
|
|
1539
1680
|
for (const file of scored.files || []) {
|
|
1540
1681
|
if (file.entry?.attachments?.length) {
|
|
1541
1682
|
file.entry = { ...file.entry, attachments: file.entry.attachments.map(item => {
|
|
@@ -1626,10 +1767,39 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1626
1767
|
// the same way. Capture before the update: `entry` is the pre-update snapshot.
|
|
1627
1768
|
const wasArchived = entry.status === 'archived'
|
|
1628
1769
|
const nextContent = content === undefined ? undefined : redactSecrets(content)
|
|
1629
|
-
|
|
1770
|
+
// 改写文本(title 或 content 任一变化)要过与 remember 写入门同口径的两道门
|
|
1771
|
+
// :先矛盾、后重复,度量是同一把 dice 尺。此前只有新建
|
|
1772
|
+
// 被拦——把一条记忆改写成与另一条几乎相同就能从更新路径绕开门,这里补上。
|
|
1773
|
+
// 门在 backend commit 之前跑:拒绝时引擎里什么都没写,compensation 路径不受影响。
|
|
1774
|
+
const textChanged = nextContent !== undefined || title !== undefined
|
|
1775
|
+
const nextTitle = title !== undefined ? clampText(title, 120) : (entry.title || '')
|
|
1776
|
+
const nextContext = context === undefined ? entry.context : normalizeMemoryContext(context)
|
|
1777
|
+
const scanned = textChanged && entry.status !== 'pending_review'
|
|
1778
|
+
let duplicate = null
|
|
1779
|
+
let text = null
|
|
1780
|
+
if (scanned) {
|
|
1630
1781
|
const others = Object.fromEntries(Object.entries(index.entries).filter(([key]) => key !== id_))
|
|
1631
|
-
const conflict = findConflict(others, { ...entry, title:
|
|
1782
|
+
const conflict = findConflict(others, { ...entry, title: nextTitle, content: nextContent ?? entry.content })
|
|
1632
1783
|
if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
|
|
1784
|
+
text = `${nextTitle} ${nextContent ?? entry.content}`
|
|
1785
|
+
for (const [otherId, other] of Object.entries(others)) {
|
|
1786
|
+
// 与 remember 的门同一过滤集(isMemoryEligible + sameFactContext),再排除它原本的
|
|
1787
|
+
// duplicateOf 关系——那对条目是写入时就获准共存的,向它继续靠拢不该被拦。
|
|
1788
|
+
if (!isMemoryEligible(other)) continue
|
|
1789
|
+
if (otherId === entry.duplicateOf) continue
|
|
1790
|
+
if (!sameFactContext(other.context, nextContext)) continue
|
|
1791
|
+
const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
|
|
1792
|
+
if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
|
|
1793
|
+
}
|
|
1794
|
+
if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
|
|
1795
|
+
record({
|
|
1796
|
+
kind: 'write', scope: 'private', sessionId: sessionIdOf(exec) || null, libraryId: scope.libraryId,
|
|
1797
|
+
message: '拒绝重复更新:' + id_ + ' 改写后与 ' + duplicate.id + ' 几乎相同',
|
|
1798
|
+
detail: { id: id_, deduped: true, existingId: duplicate.id, similarity: duplicate.similarity },
|
|
1799
|
+
refs: [id_, duplicate.id],
|
|
1800
|
+
})
|
|
1801
|
+
throw new Error(`update refused: the rewritten text is nearly identical to ${duplicate.id} (dice ${duplicate.similarity.toFixed(2)}); merge into ${duplicate.id} or archive one of the two instead`)
|
|
1802
|
+
}
|
|
1633
1803
|
}
|
|
1634
1804
|
const route = engineRoute()
|
|
1635
1805
|
const managed = route === 'managed-local'
|
|
@@ -1663,6 +1833,18 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1663
1833
|
if (currentBinding) assertScopeEnabled(currentBinding)
|
|
1664
1834
|
if (title !== undefined) target.title = clampText(title, 120)
|
|
1665
1835
|
if (nextContent !== undefined) target.content = nextContent
|
|
1836
|
+
// 打标带与 remember 同口径:[0.55, 0.90) 改写后仍与另一条同主题 → duplicateOf。
|
|
1837
|
+
// 旧打标的去留也按同一把尺重新裁定:文本改过、且与新文本最相似的条目不在打标带,
|
|
1838
|
+
// 但原 duplicateOf 对象仍与改写后文本同主题(它被排除在拒绝扫描之外,不代表相似
|
|
1839
|
+
// 消失)→ 保留;否则(相似依据消失,或原指向对象已不可召回)摘除——留着会错误
|
|
1840
|
+
// 压低这条记忆的召回权重。未扫(无文本变化)不动。
|
|
1841
|
+
if (scanned) {
|
|
1842
|
+
const previousHost = target.duplicateOf ? fresh.entries[target.duplicateOf] : null
|
|
1843
|
+
const stillHosted = previousHost && isMemoryEligible(previousHost)
|
|
1844
|
+
&& similarTexts(text, `${previousHost.title || ''} ${previousHost.content || ''}`) >= TOPIC_DICE
|
|
1845
|
+
if (duplicate && duplicate.similarity >= TOPIC_DICE) target.duplicateOf = duplicate.id
|
|
1846
|
+
else if (!stillHosted) delete target.duplicateOf
|
|
1847
|
+
}
|
|
1666
1848
|
if (tags !== undefined) target.tags = Array.isArray(tags) ? tags.slice(0, 20) : target.tags
|
|
1667
1849
|
if (status !== undefined) target.status = status
|
|
1668
1850
|
if (openLoop !== undefined) target.openLoop = !!openLoop
|
|
@@ -1712,6 +1894,12 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1712
1894
|
}
|
|
1713
1895
|
if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
|
|
1714
1896
|
if (nextAttachments !== undefined) target.attachments = nextAttachments
|
|
1897
|
+
// 受管路由下这次更新把这一行重新提交进了当前向量空间(managed 时 commit 无条件发生)。
|
|
1898
|
+
// 戳要跟着刷新:它现在描述的是"这条在当前代际的空间里",membership 的戳腿靠它计数。
|
|
1899
|
+
if (committedDb) {
|
|
1900
|
+
const vector = activeEntryStamp(route)
|
|
1901
|
+
if (vector) target.vector = vector
|
|
1902
|
+
}
|
|
1715
1903
|
// Vividness and accuracy are derived, not typed in: correcting the peak flag or
|
|
1716
1904
|
// the arousal reading has to move `accuracy` too, otherwise the repair would only
|
|
1717
1905
|
// change how the entry is stored and not how much it is trusted when recalled.
|
|
@@ -1771,6 +1959,30 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
|
|
|
1771
1959
|
if (action === 'accept') {
|
|
1772
1960
|
if (typeof content === 'string' && content.trim()) next.content = content
|
|
1773
1961
|
if (typeof title === 'string' && title.trim()) next.title = title
|
|
1962
|
+
// 接受即写入(accept 会把这条提交进向量库并转为 active),所以它与 remember /
|
|
1963
|
+
// privateUpdate 过同一道重复门:≥ 0.90 拒绝接受(复核保持
|
|
1964
|
+
// 暂扣,不丢数据——换内容或先归档对方后可重试),[0.55, 0.90) 接受但打 duplicateOf。
|
|
1965
|
+
// 排除它自己和 conflictingWith:后者正是这次复核要替代/共存的对象,与它相似是
|
|
1966
|
+
// 复核的本来含义,不是重复。
|
|
1967
|
+
const text = `${next.title || ''} ${next.content || ''}`
|
|
1968
|
+
let duplicate = null
|
|
1969
|
+
for (const [otherId, other] of Object.entries(index.entries || {})) {
|
|
1970
|
+
if (otherId === id || otherId === entry.conflictingWith) continue
|
|
1971
|
+
if (!isMemoryEligible(other)) continue
|
|
1972
|
+
if (!sameFactContext(other.context, next.context)) continue
|
|
1973
|
+
const similarity = similarTexts(text, `${other.title || ''} ${other.content || ''}`)
|
|
1974
|
+
if (similarity > (duplicate?.similarity ?? 0)) duplicate = { id: otherId, similarity }
|
|
1975
|
+
}
|
|
1976
|
+
if (duplicate && duplicate.similarity >= DUP_REJECT_DICE) {
|
|
1977
|
+
record({
|
|
1978
|
+
kind: 'write', scope: libraryId ? 'private' : 'global', libraryId: libraryId || null,
|
|
1979
|
+
message: '拒绝复核接受:' + id + ' 与 ' + duplicate.id + ' 几乎相同',
|
|
1980
|
+
detail: { id, deduped: true, existingId: duplicate.id, similarity: duplicate.similarity },
|
|
1981
|
+
refs: [id, duplicate.id],
|
|
1982
|
+
})
|
|
1983
|
+
throw new Error(`review accept refused: the accepted text is nearly identical to ${duplicate.id} (dice ${duplicate.similarity.toFixed(2)}); pass different content or archive ${duplicate.id} first`)
|
|
1984
|
+
}
|
|
1985
|
+
if (duplicate && duplicate.similarity >= TOPIC_DICE) next.duplicateOf = duplicate.id
|
|
1774
1986
|
const reviewRoute = engineRoute()
|
|
1775
1987
|
if (reviewRoute !== 'lexical') {
|
|
1776
1988
|
await commit({
|
|
@@ -1778,6 +1990,9 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
|
|
|
1778
1990
|
route: reviewRoute,
|
|
1779
1991
|
db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
|
|
1780
1992
|
})
|
|
1993
|
+
// pending_review 从不提交,所以被接受的那一刻才第一次真正进向量库:补上戳,
|
|
1994
|
+
// 否则这条在向量计数里永远缺失。
|
|
1995
|
+
next.vector = activeEntryStamp(reviewRoute) || next.vector
|
|
1781
1996
|
}
|
|
1782
1997
|
}
|
|
1783
1998
|
const mutate = fresh => {
|
|
@@ -1897,7 +2112,27 @@ async function privateDeleteImpl({ exec, libraryId, id }) {
|
|
|
1897
2112
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1898
2113
|
if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
|
|
1899
2114
|
delete fresh.entries[id_]
|
|
2115
|
+
// Cascade: inbound edges live in *other* entries' links[], so dropping the
|
|
2116
|
+
// row without sweeping them leaves links that point at a memory nothing can
|
|
2117
|
+
// resolve any more (outbound links die with the row itself). In place, O(N
|
|
2118
|
+
// over links) — same style as the unlink mutator, no deep clone. Archived /
|
|
2119
|
+
// suppressed entries keep their links on purpose: only this permanent path
|
|
2120
|
+
// cascades, so archiving stays reversible.
|
|
2121
|
+
for (const other of Object.values(fresh.entries)) {
|
|
2122
|
+
if (Array.isArray(other.links) && other.links.some(link => link.to === id_)) {
|
|
2123
|
+
other.links = other.links.filter(link => link.to !== id_)
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
1900
2126
|
}))
|
|
2127
|
+
// Collect entities this row was the last thing keeping alive. Cleanup after
|
|
2128
|
+
// the fact, not a correctness step: if it fails (lock contention, a store
|
|
2129
|
+
// another writer holds) the delete stands, and the startup sweep gets the
|
|
2130
|
+
// orphans on the next launch.
|
|
2131
|
+
try {
|
|
2132
|
+
pruneOrphanEntities(scope.libraryId)
|
|
2133
|
+
} catch (error) {
|
|
2134
|
+
warn(`orphan entity prune after permanent delete skipped: ${error?.message || error}`)
|
|
2135
|
+
}
|
|
1901
2136
|
return { id: id_, libraryId: scope.libraryId, deleted: true, backend, engine }
|
|
1902
2137
|
}
|
|
1903
2138
|
|
|
@@ -1964,14 +2199,19 @@ export function privateUnpin({ exec, id, reason }) {
|
|
|
1964
2199
|
export async function privateRemember(args) {
|
|
1965
2200
|
const libraryId = resolveLibrary(args).libraryId
|
|
1966
2201
|
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
|
|
2202
|
+
const deduped = result?.deduped === true
|
|
1967
2203
|
record({
|
|
1968
2204
|
kind: 'write',
|
|
1969
2205
|
scope: 'private',
|
|
1970
2206
|
sessionId: sessionIdOf(args.exec) || null,
|
|
1971
2207
|
libraryId,
|
|
1972
|
-
message:
|
|
1973
|
-
|
|
1974
|
-
|
|
2208
|
+
message: deduped
|
|
2209
|
+
? '拒绝重复写入:与 ' + (result.existingId || '?') + ' 几乎相同'
|
|
2210
|
+
: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
|
|
2211
|
+
detail: deduped
|
|
2212
|
+
? { deduped: true, existingId: result.existingId || null, similarity: result.similarity ?? null }
|
|
2213
|
+
: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
|
|
2214
|
+
refs: deduped ? [result.existingId] : (result?.id ? [result.id] : []),
|
|
1975
2215
|
})
|
|
1976
2216
|
return result
|
|
1977
2217
|
}
|
|
@@ -2086,15 +2326,19 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold, retr
|
|
|
2086
2326
|
...f,
|
|
2087
2327
|
// Local lexical rank is already meaningful; confidence still nudges ties, and
|
|
2088
2328
|
// a cross-session fact that has been injected three turns running gets the same
|
|
2089
|
-
// wear penalty as a private one.
|
|
2329
|
+
// wear penalty as a private one. `retrieval` mirrors the retrieval term of the
|
|
2330
|
+
// score so the absolute floor cut sees the same number the score was built from.
|
|
2331
|
+
retrieval: Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1),
|
|
2090
2332
|
score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
2091
2333
|
* (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
|
|
2092
2334
|
* habituationFactor(f.entry),
|
|
2093
2335
|
}))
|
|
2094
2336
|
.sort((a, b) => b.score - a.score)
|
|
2337
|
+
const ranked = mmrSelect(cutWeakScores(filtered, threshold, RETRIEVAL_FLOOR), Number(topK || settings.globalTopK || 3))
|
|
2095
2338
|
return {
|
|
2096
2339
|
...enriched,
|
|
2097
|
-
files:
|
|
2340
|
+
files: ranked.chosen,
|
|
2341
|
+
dedup: { collapsed: ranked.skipped, clusterCount: ranked.skipped.length },
|
|
2098
2342
|
domain: domainOf(binding),
|
|
2099
2343
|
unbound: !peeked,
|
|
2100
2344
|
}
|