dsh-memu-memory 1.1.3 → 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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
package/lib/memory.js
CHANGED
|
@@ -10,14 +10,16 @@ import {
|
|
|
10
10
|
getLibrary, setSessionBinding, setRecallError, effectiveScope, listLibrariesAll,
|
|
11
11
|
} from './state.js'
|
|
12
12
|
import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
|
|
13
|
+
import { scopeGenerationOf } from './consolidation-ledger.js'
|
|
13
14
|
import { record } from './activity-log.js'
|
|
14
|
-
import { BASE_AROUSAL, moodCongruence, moodDecay } from './mood.js'
|
|
15
|
+
import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
|
|
15
16
|
import { commit, retrieve, listFiles, forget } from './memu.js'
|
|
16
17
|
import { bumpStats } from './stats.js'
|
|
17
|
-
import { recallRelations } from './graph.js'
|
|
18
|
+
import { recallRelations, pruneOrphanEntities } from './graph.js'
|
|
18
19
|
import { attachmentRefs } from './media.js'
|
|
19
20
|
import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
|
|
20
|
-
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'
|
|
21
23
|
|
|
22
24
|
const HALF_LIFE_DAYS = 14
|
|
23
25
|
// A short-term entry is meant to live for the length of a conversation, not a
|
|
@@ -37,6 +39,11 @@ const HABITUATION_FLOOR = 0.5
|
|
|
37
39
|
// library with three variants of the same decision would spend the whole block on it.
|
|
38
40
|
// The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
|
|
39
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
|
|
40
47
|
// Semantic fusion uses RRF: each route contributes a
|
|
41
48
|
// rank term and the merged score is normalised to the best fused hit before the
|
|
42
49
|
// existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
|
|
@@ -93,7 +100,59 @@ function clearRecallError(exec) {
|
|
|
93
100
|
if (sessionId) setRecallError(sessionId, null)
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
// Serialize backend-bearing private writes across every route in this module.
|
|
104
|
+
// Acquire this queue BEFORE the vector lease; neither waits while holding a
|
|
105
|
+
// synchronous file lock. This is not a cross-process/backend transaction.
|
|
106
|
+
const privateWriteTails = new Map()
|
|
96
107
|
async function withManagedPrivateWrite(libraryId, action) {
|
|
108
|
+
const previous = privateWriteTails.get(libraryId) || Promise.resolve()
|
|
109
|
+
const running = previous.then(() => withManagedPrivateWriteImpl(libraryId, action))
|
|
110
|
+
const settled = running.then(() => {}, () => {})
|
|
111
|
+
privateWriteTails.set(libraryId, settled)
|
|
112
|
+
try { return await running } finally {
|
|
113
|
+
if (privateWriteTails.get(libraryId) === settled) privateWriteTails.delete(libraryId)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertCompensationResult(result, operation) {
|
|
118
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || result.ok === false || result.error || result.parseWarning
|
|
119
|
+
|| (operation === 'backend compensation delete' && (!Number.isSafeInteger(result.deleted) || result.deleted < 0))) {
|
|
120
|
+
throw new Error(operation + ' was not acknowledged')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The index remains authoritative. Refuse destructive repair if another index
|
|
125
|
+
// writer replaced the expected pre-await entry; otherwise rebuild that old row.
|
|
126
|
+
// No transaction spans these external calls: crashes/other processes can still
|
|
127
|
+
// interrupt compensation. Never describe an uncertain repair as success.
|
|
128
|
+
async function compensatePrivateCommit(error, { libraryId, id, route, db, expectedEntry }) {
|
|
129
|
+
try {
|
|
130
|
+
const current = loadPrivateIndex(libraryId).entries[id]
|
|
131
|
+
if (JSON.stringify(current ?? null) !== JSON.stringify(expectedEntry ?? null)) {
|
|
132
|
+
throw new Error('memory changed before backend compensation')
|
|
133
|
+
}
|
|
134
|
+
const snapshot = JSON.stringify(current)
|
|
135
|
+
assertCompensationResult(await forget({ route, db, name: id }), 'backend compensation delete')
|
|
136
|
+
if (current && !['pending_review', 'superseded'].includes(current.status)) {
|
|
137
|
+
assertCompensationResult(await commit({ route, db, payload: {
|
|
138
|
+
recall_files: [{ name: id, track: current.track || 'memory', description: clampText(current.title, 180), content: current.content }],
|
|
139
|
+
resource: [], user: {},
|
|
140
|
+
} }), 'backend compensation restore')
|
|
141
|
+
}
|
|
142
|
+
if (JSON.stringify(loadPrivateIndex(libraryId).entries[id]) !== snapshot) {
|
|
143
|
+
throw new Error('memory changed during backend compensation')
|
|
144
|
+
}
|
|
145
|
+
} catch (compensationError) {
|
|
146
|
+
const failure = new Error('private memory index write failed; backend compensation uncertain: ' + String(compensationError?.message || compensationError), { cause: error })
|
|
147
|
+
failure.code = 'PRIVATE_BACKEND_COMPENSATION_FAILED'
|
|
148
|
+
failure.compensationError = compensationError
|
|
149
|
+
warn(failure.message)
|
|
150
|
+
throw failure
|
|
151
|
+
}
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function withManagedPrivateWriteImpl(libraryId, action) {
|
|
97
156
|
const intent = managedVectorIntent()
|
|
98
157
|
if (!intent.managed) return action()
|
|
99
158
|
// 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
|
|
@@ -219,6 +278,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
219
278
|
}
|
|
220
279
|
const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
|
|
221
280
|
if (!binding) throw new Error('no bound session')
|
|
281
|
+
assertConsolidationParent(binding)
|
|
222
282
|
// A binding without a library means the conversation explicitly opted out of
|
|
223
283
|
// private memory. Writing through it used to land in `index/null.json` — a
|
|
224
284
|
// "successful" write that can never be recalled. Refuse with an actionable
|
|
@@ -232,8 +292,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
232
292
|
// The sidebar switches (and the folder ceiling behind them) must constrain tool
|
|
233
293
|
// calls too, not just injection. One gate, sourced from `effectiveScope` so the
|
|
234
294
|
// folder policy is always part of the answer.
|
|
295
|
+
function assertConsolidationParent(binding) {
|
|
296
|
+
if (!binding?.consolidationFork) return
|
|
297
|
+
const parent = getSessionBinding(binding.parentSessionId)
|
|
298
|
+
if (!parent || !binding.parentScopeGeneration || scopeGenerationOf(parent) !== binding.parentScopeGeneration) {
|
|
299
|
+
throw new Error('DSH_MEMU_SCOPE_STALE: consolidation worker parent authorization changed')
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
235
303
|
function assertScopeEnabled(binding, { layer = 'library' } = {}) {
|
|
236
304
|
if (!binding) return
|
|
305
|
+
assertConsolidationParent(binding)
|
|
306
|
+
if (binding.consolidationFork && layer === 'global') throw new Error('DSH_MEMU_WORKER_RESTRICTED: private worker cannot access global memory')
|
|
237
307
|
const scope = effectiveScope(binding)
|
|
238
308
|
if (layer === 'library' && !scope.useLibrary) {
|
|
239
309
|
throw new Error(scope.libraryBlocked
|
|
@@ -491,20 +561,8 @@ export function isTipOfTongue(entry) {
|
|
|
491
561
|
// Not semantic entailment: two bigram sets and a negation test. It only has to be
|
|
492
562
|
// good enough to stop "he likes tea" and "he hates tea" from sitting side by side
|
|
493
563
|
// as equally true, and quiet enough that ordinary rewordings do not trip it.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const out = new Set()
|
|
497
|
-
for (let i = 0; i < clean.length - 1; i += 1) out.add(clean.slice(i, i + 2))
|
|
498
|
-
return out
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function dice(a, b) {
|
|
502
|
-
if (a.size === 0 || b.size === 0) return 0
|
|
503
|
-
let shared = 0
|
|
504
|
-
for (const gram of a) if (b.has(gram)) shared += 1
|
|
505
|
-
return (2 * shared) / (a.size + b.size)
|
|
506
|
-
}
|
|
507
|
-
|
|
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.
|
|
508
566
|
const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
|
|
509
567
|
|
|
510
568
|
/**
|
|
@@ -706,27 +764,47 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
706
764
|
if (automatic && entry.visibility === 'secret') return null
|
|
707
765
|
return { id, entry, file, route, rank }
|
|
708
766
|
}
|
|
709
|
-
const take = (files, route) => {
|
|
767
|
+
const take = (files, route, collapsed) => {
|
|
710
768
|
const picked = []
|
|
711
769
|
// One route can name the same canonical entry more than once: a caller may
|
|
712
770
|
// pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
|
|
713
771
|
// canonical id before scoring and truncating, keeping the first (best) rank.
|
|
714
772
|
// Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
|
|
715
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
|
+
}
|
|
716
788
|
for (const file of Array.isArray(files) ? files : []) {
|
|
717
789
|
const item = eligible(file, route, picked.length + 1)
|
|
718
790
|
if (!item || seen.has(item.id)) continue
|
|
719
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
|
+
}
|
|
720
797
|
picked.push(item)
|
|
721
798
|
if (picked.length >= limit) break
|
|
722
799
|
}
|
|
723
800
|
return picked
|
|
724
801
|
}
|
|
725
|
-
const
|
|
726
|
-
const
|
|
802
|
+
const routeCollapsed = []
|
|
803
|
+
const localPicked = take(local, 'local', routeCollapsed)
|
|
804
|
+
const semanticPicked = take(semantic, 'semantic', routeCollapsed)
|
|
727
805
|
const merged = new Map()
|
|
728
806
|
const add = (item) => {
|
|
729
|
-
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: [] }
|
|
730
808
|
current.rrf += 1 / (HYBRID_RRF_K + item.rank)
|
|
731
809
|
current.bestRank = Math.min(current.bestRank, item.rank)
|
|
732
810
|
if (item.route === 'local') current.localRank = item.rank
|
|
@@ -740,14 +818,51 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
740
818
|
for (const item of localPicked) add(item)
|
|
741
819
|
for (const item of semanticPicked) add(item)
|
|
742
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()]
|
|
743
856
|
const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
|
|
744
857
|
const files = rows
|
|
858
|
+
.filter((row) => !absorbed.has(row.id))
|
|
745
859
|
.map((row) => ({
|
|
746
860
|
...row.file,
|
|
747
861
|
name: row.id,
|
|
748
862
|
entry: row.entry,
|
|
749
863
|
retrievalScore: row.rrf / maxRrf,
|
|
750
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() } : {}),
|
|
751
866
|
}))
|
|
752
867
|
.sort((a, b) => b.retrievalScore - a.retrievalScore
|
|
753
868
|
|| a.fusion.bestRank - b.fusion.bestRank
|
|
@@ -766,10 +881,21 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
766
881
|
localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
|
|
767
882
|
semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
|
|
768
883
|
shared: [...localIds].filter((id) => semanticIds.has(id)).length,
|
|
884
|
+
collapsed,
|
|
885
|
+
clusterCount: collapsed.length,
|
|
769
886
|
},
|
|
770
887
|
}
|
|
771
888
|
}
|
|
772
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
|
+
|
|
773
899
|
// Human-like recall ordering. Retrieval answers first (semantic match is still
|
|
774
900
|
// the primary signal, which is why it stays a rank decay), then these biases
|
|
775
901
|
// re-order within that set:
|
|
@@ -779,18 +905,22 @@ export function fuseRecallCandidates({ local = [], semantic = [], index, limit =
|
|
|
779
905
|
// Every factor is bounded so no bias can outrank a good semantic hit by luck.
|
|
780
906
|
function recallScore(file, rank, context) {
|
|
781
907
|
const entry = file.entry || {}
|
|
782
|
-
|
|
783
|
-
// rank decay keeps every lexical caller byte-for-byte compatible.
|
|
784
|
-
const fused = Number(file.retrievalScore)
|
|
785
|
-
const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
|
|
908
|
+
const retrieval = retrievalShare(file, rank)
|
|
786
909
|
const strengthTerm = 0.6 + 0.4 * file.strength
|
|
787
910
|
const marginal = entry.marginalized ? 0.35 : 1
|
|
788
911
|
|
|
789
912
|
// Fading affect bias: what pulls is the tint the memory still carries today, not
|
|
790
913
|
// the one it was written with. Old grievances quieten down; old fondness does not.
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
914
|
+
//
|
|
915
|
+
// Two paths, one decision: when both sides carry a composition, compare the
|
|
916
|
+
// compositions (how much of the present shape the memory shares). Otherwise fall
|
|
917
|
+
// back to the legacy signed-valence congruence. Entries written before this model
|
|
918
|
+
// existed have no composition and must keep working unchanged.
|
|
919
|
+
const moodBias = !context.moodEnabled
|
|
920
|
+
? 1
|
|
921
|
+
: (context.emotion && entry.emotion
|
|
922
|
+
? 1 + 0.3 * affectFadeFactor(entry, context.now) * (2 * overlap(entry.emotion, context.emotion) - 1)
|
|
923
|
+
: 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence))
|
|
794
924
|
const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
|
|
795
925
|
const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
|
|
796
926
|
const peakEnd = 1 + 0.25 * Math.max(peak, end)
|
|
@@ -802,13 +932,38 @@ function recallScore(file, rank, context) {
|
|
|
802
932
|
// this asks "does its loudness match the room right now?" — different questions that
|
|
803
933
|
// only look like duplicates.
|
|
804
934
|
const arousalTerm = context.moodEnabled ? arousalBias(entry, context) : 1
|
|
935
|
+
// Dominance asks a third question: who was steering. Two equally sad memories pull
|
|
936
|
+
// differently depending on whether the present state is "I can fix this" or "nothing
|
|
937
|
+
// I do matters". Bounded to +-10% so it can only reorder near-ties.
|
|
938
|
+
const dominanceTerm = context.moodEnabled ? dominanceBias(entry.dominance, context.dominance) : 1
|
|
805
939
|
// An open loop is an unfinished thread — a promise, a question, a plan nobody
|
|
806
940
|
// closed. Those are what a person brings up unprompted, so they get a nudge and not
|
|
807
941
|
// a trump card: 1.25 still loses to a clearly better semantic hit.
|
|
808
942
|
const openLoopBias = entry.openLoop ? 1.25 : 1
|
|
809
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
|
|
810
949
|
|
|
811
|
-
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm
|
|
950
|
+
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm * duplicateFactor
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* How much of a memory's original tint is still there, 0..1.
|
|
955
|
+
*
|
|
956
|
+
* Same curve as `fadedValence`, but returned as a bare factor so the composition path
|
|
957
|
+
* applies the *fading* to the whole comparison instead of decaying the shares (shares
|
|
958
|
+
* sum to 1, so decaying them proportionally would change nothing).
|
|
959
|
+
*/
|
|
960
|
+
function affectFadeFactor(entry, now = Date.now()) {
|
|
961
|
+
const valence = entry.emotion ? derivedValence(entry.emotion) : Number(entry.moodValence || 0)
|
|
962
|
+
if (!valence) return 0
|
|
963
|
+
const updated = Date.parse(entry.updatedAt || entry.createdAt || '') || now
|
|
964
|
+
const days = Math.max(0, (now - updated) / 86_400_000)
|
|
965
|
+
const halfLife = valence < 0 ? FAB_HALF_LIFE_DAYS.negative : FAB_HALF_LIFE_DAYS.positive
|
|
966
|
+
return Math.pow(0.5, days / halfLife)
|
|
812
967
|
}
|
|
813
968
|
|
|
814
969
|
/**
|
|
@@ -822,9 +977,12 @@ function recallScore(file, rank, context) {
|
|
|
822
977
|
function arousalBias(entry, context) {
|
|
823
978
|
const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
|
|
824
979
|
const drive = Math.max(-0.35, Math.min(0.65, present - BASE_AROUSAL))
|
|
980
|
+
// A composed entry can be *very* angry while its legacy valence projection looks
|
|
981
|
+
// mild, so the largest share counts as intensity alongside the stored arousal.
|
|
825
982
|
const intensity = Math.max(
|
|
826
983
|
entry.peak === true ? 1 : 0,
|
|
827
984
|
Math.min(1, Math.max(0, Number(entry.arousal || 0))),
|
|
985
|
+
Math.min(1, maxShare(entry.emotion)),
|
|
828
986
|
Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
|
|
829
987
|
)
|
|
830
988
|
return 1 + 0.5 * drive * (intensity - 0.5)
|
|
@@ -846,23 +1004,33 @@ function habituationFactor(entry, now = Date.now()) {
|
|
|
846
1004
|
// bounded biases, so its scale depends on the library and a fixed cutoff would mean
|
|
847
1005
|
// different things in different conversations. "Keep what scores within N of the best
|
|
848
1006
|
// hit" is scale-free, and 0 turns the filter off entirely.
|
|
849
|
-
|
|
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
|
|
850
1012
|
const cut = Number(threshold)
|
|
851
|
-
if (
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
855
1020
|
}
|
|
856
1021
|
|
|
857
1022
|
function privateWeighted(result, max, context = {}) {
|
|
858
1023
|
const ctx = {
|
|
859
1024
|
moodEnabled: false,
|
|
860
1025
|
moodValence: 0,
|
|
1026
|
+
emotion: null,
|
|
861
1027
|
arousal: BASE_AROUSAL,
|
|
1028
|
+
dominance: 0,
|
|
862
1029
|
firstCreatedAt: null,
|
|
863
1030
|
lastCreatedAt: null,
|
|
864
1031
|
sessionId: null,
|
|
865
1032
|
now: Date.now(),
|
|
1033
|
+
retrievalFloor: RETRIEVAL_FLOOR,
|
|
866
1034
|
...context,
|
|
867
1035
|
}
|
|
868
1036
|
const items = (result.files || [])
|
|
@@ -873,55 +1041,79 @@ function privateWeighted(result, max, context = {}) {
|
|
|
873
1041
|
// memories. Filtering here keeps the anchor and the recall block in agreement.
|
|
874
1042
|
.filter((f) => visibleToSession(f.entry, ctx.sessionId))
|
|
875
1043
|
.filter((f) => !ctx.automatic || f.entry?.visibility !== 'secret')
|
|
876
|
-
.map((f, i) => ({ ...f, score: recallScore(f, i, ctx) }))
|
|
1044
|
+
.map((f, i) => ({ ...f, score: recallScore(f, i, ctx), retrieval: retrievalShare(f, i) }))
|
|
877
1045
|
.sort((a, b) => b.score - a.score)
|
|
878
|
-
|
|
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 } }
|
|
879
1054
|
}
|
|
880
1055
|
|
|
881
1056
|
/**
|
|
882
1057
|
* Greedy maximal marginal relevance over already-scored files.
|
|
883
1058
|
*
|
|
884
|
-
* Similarity is lexical
|
|
885
|
-
*
|
|
886
|
-
*
|
|
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.
|
|
887
1071
|
*/
|
|
888
1072
|
function mmrSelect(items, max, lambda = MMR_LAMBDA) {
|
|
889
1073
|
const limit = Number(max || 0)
|
|
890
|
-
if (!(limit > 0)) return items
|
|
891
|
-
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: [] }
|
|
892
1076
|
const chosen = []
|
|
893
1077
|
const pool = items.slice()
|
|
894
|
-
const
|
|
895
|
-
const
|
|
1078
|
+
const gramsCache = new Map()
|
|
1079
|
+
const gramsOf = (file) => {
|
|
896
1080
|
const key = file.name
|
|
897
|
-
if (
|
|
898
|
-
const set =
|
|
899
|
-
|
|
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)
|
|
900
1084
|
return set
|
|
901
1085
|
}
|
|
902
1086
|
// Anchor the penalty to the best raw score so the diversity term is on the same
|
|
903
1087
|
// scale as relevance instead of being dominated by it.
|
|
904
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()
|
|
905
1092
|
while (pool.length > 0 && chosen.length < limit) {
|
|
906
|
-
let bestIdx =
|
|
1093
|
+
let bestIdx = -1
|
|
907
1094
|
let bestScore = -Infinity
|
|
908
1095
|
for (let i = 0; i < pool.length; i += 1) {
|
|
909
1096
|
let overlap = 0
|
|
910
|
-
|
|
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 }
|
|
911
1104
|
const adjusted = Number(pool[i].score || 0) - lambda * overlap * ceiling
|
|
912
1105
|
if (adjusted > bestScore) { bestScore = adjusted; bestIdx = i }
|
|
913
1106
|
}
|
|
1107
|
+
if (bestIdx === -1) break // nothing selectable is left: only hard duplicates remain
|
|
914
1108
|
chosen.push(pool.splice(bestIdx, 1)[0])
|
|
915
1109
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
for (const token of a) if (b.has(token)) shared += 1
|
|
924
|
-
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 }
|
|
925
1117
|
}
|
|
926
1118
|
|
|
927
1119
|
/**
|
|
@@ -952,13 +1144,72 @@ function createdAtSpan(files) {
|
|
|
952
1144
|
return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
|
|
953
1145
|
}
|
|
954
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Decide what an entry's affect actually is, and which tier answered.
|
|
1149
|
+
*
|
|
1150
|
+
* Order: the model said it; the conversation is in a mood and inheritance is allowed;
|
|
1151
|
+
* the memory's own text reads as emotional; nothing. `emotionSource` keeps "neutral"
|
|
1152
|
+
* and "no idea" distinguishable — they drive the same recall multiplier, but only one
|
|
1153
|
+
* of them means the write path worked.
|
|
1154
|
+
*
|
|
1155
|
+
* Engineering facts stay neutral on purpose. A bug being hard to fix is not the
|
|
1156
|
+
* project feeling sad, and letting the turn's mood leak into a technical entry is how
|
|
1157
|
+
* "the deployment failed and we were furious" becomes a fact about the deployment.
|
|
1158
|
+
*/
|
|
1159
|
+
function resolveEntryEmotion({ emotion, dominance, emotionSource, owner, content, binding }) {
|
|
1160
|
+
const providedDominance = Number(dominance)
|
|
1161
|
+
const explicitDominance = dominance !== null && dominance !== undefined && Number.isFinite(providedDominance)
|
|
1162
|
+
? Math.max(-1, Math.min(1, providedDominance))
|
|
1163
|
+
: null
|
|
1164
|
+
// Three states, and they are not the same thing: omitted = inherit (a caller that
|
|
1165
|
+
// says nothing wants the turn's context), null = explicitly neutral (the model looked
|
|
1166
|
+
// and found nothing), an object = the model's reading. Collapsing null into "omitted"
|
|
1167
|
+
// would silently reinstate inherited mood on a memory the model called neutral.
|
|
1168
|
+
const explicitNeutral = emotion === null
|
|
1169
|
+
const fromModel = emotion !== undefined && emotion !== null
|
|
1170
|
+
let resolved = null
|
|
1171
|
+
let sourceName = 'unknown'
|
|
1172
|
+
let inheritedDominance = null
|
|
1173
|
+
if (fromModel) {
|
|
1174
|
+
resolved = normalizeEmotion(emotion)
|
|
1175
|
+
sourceName = 'model'
|
|
1176
|
+
} else if (!explicitNeutral && owner !== 'about_project') {
|
|
1177
|
+
const sessionMood = binding && binding.mood ? binding.mood : null
|
|
1178
|
+
if (sessionMood && sessionMood.emotion) {
|
|
1179
|
+
resolved = normalizeEmotion(sessionMood.emotion)
|
|
1180
|
+
sourceName = resolved ? 'session' : 'unknown'
|
|
1181
|
+
const sessionDominance = Number(sessionMood.dominance)
|
|
1182
|
+
if (Number.isFinite(sessionDominance)) inheritedDominance = Math.max(-1, Math.min(1, sessionDominance))
|
|
1183
|
+
}
|
|
1184
|
+
if (resolved === null && sourceName === 'unknown') {
|
|
1185
|
+
const read = readMood(content)
|
|
1186
|
+
if (read.emotion) {
|
|
1187
|
+
resolved = read.emotion
|
|
1188
|
+
sourceName = 'content'
|
|
1189
|
+
inheritedDominance = read.dominance
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
emotion: resolved,
|
|
1195
|
+
dominance: explicitDominance === null ? (inheritedDominance === null ? 0 : inheritedDominance) : explicitDominance,
|
|
1196
|
+
source: explicitNeutral ? 'model' : (emotionSource || sourceName),
|
|
1197
|
+
neutral: explicitNeutral,
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
955
1201
|
async function privateRememberImpl({
|
|
956
1202
|
exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
|
|
957
1203
|
sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
|
|
958
1204
|
owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
|
|
959
1205
|
moodValence = null, arousal = null, peak = false, salientDate = null,
|
|
1206
|
+
emotion = undefined, dominance = undefined, emotionSource = null,
|
|
960
1207
|
attachments, track = 'memory',
|
|
961
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,
|
|
962
1213
|
}) {
|
|
963
1214
|
const scope = resolveLibrary({ exec, libraryId })
|
|
964
1215
|
const binding = scope.binding
|
|
@@ -1036,6 +1287,12 @@ async function privateRememberImpl({
|
|
|
1036
1287
|
&& (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
|
|
1037
1288
|
: null
|
|
1038
1289
|
const id = previousHabit?.[0] || newMemoryId('mem')
|
|
1290
|
+
// A malformed share is refused here rather than clamped later: a negative or
|
|
1291
|
+
// non-finite composition is a caller bug, and repairing it silently would keep the
|
|
1292
|
+
// bug alive while the entry looks fine.
|
|
1293
|
+
if (emotion !== null && emotion !== undefined) normalizeEmotion(emotion)
|
|
1294
|
+
if (dominance !== null && dominance !== undefined && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
|
|
1295
|
+
const resolvedAffect = resolveEntryEmotion({ emotion, dominance, owner, content: String(content), binding })
|
|
1039
1296
|
const entry = {
|
|
1040
1297
|
...(previousHabit?.[1] || {}),
|
|
1041
1298
|
track,
|
|
@@ -1068,9 +1325,18 @@ async function privateRememberImpl({
|
|
|
1068
1325
|
// Retrieval lens + resurface metadata. When the agent does not state a mood,
|
|
1069
1326
|
// the current session read is stored instead, so a memory written during a
|
|
1070
1327
|
// hard conversation is tinted by it without the model having to name it.
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1328
|
+
// `moodValence` stays as the legacy projection of the four shares; the shares
|
|
1329
|
+
// themselves are what the new recall path compares.
|
|
1330
|
+
moodValence: resolvedAffect.emotion
|
|
1331
|
+
? derivedValence(resolvedAffect.emotion)
|
|
1332
|
+
: (resolvedAffect.neutral
|
|
1333
|
+
? 0
|
|
1334
|
+
: (moodValence === null || moodValence === undefined
|
|
1335
|
+
? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
|
|
1336
|
+
: Math.max(-1, Math.min(1, Number(moodValence) || 0)))),
|
|
1337
|
+
emotion: resolvedAffect.emotion,
|
|
1338
|
+
dominance: resolvedAffect.dominance,
|
|
1339
|
+
emotionSource: resolvedAffect.source,
|
|
1074
1340
|
arousal: arousal === null || arousal === undefined
|
|
1075
1341
|
? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
|
|
1076
1342
|
: Math.max(0, Math.min(1, Number(arousal) || 0)),
|
|
@@ -1092,6 +1358,35 @@ async function privateRememberImpl({
|
|
|
1092
1358
|
? `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」同题但内容不同`
|
|
1093
1359
|
: `与已有记忆「${clampText(conflict.entry.title || conflict.id, 60)}」说法相反`
|
|
1094
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
|
|
1095
1390
|
const payload = {
|
|
1096
1391
|
recall_files: [{
|
|
1097
1392
|
name: id,
|
|
@@ -1106,22 +1401,15 @@ async function privateRememberImpl({
|
|
|
1106
1401
|
// would make a contested memory retrievable before anyone decided it. Accepting
|
|
1107
1402
|
// the review later is what commits it (see resolveReview → privateUpdate).
|
|
1108
1403
|
const route = engineRoute()
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1404
|
+
const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
|
|
1405
|
+
const result = db
|
|
1406
|
+
? await commit({ payload, route, db })
|
|
1111
1407
|
: { ok: true, backend: route, held: conflict ? true : undefined }
|
|
1408
|
+
try {
|
|
1112
1409
|
// 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
|
|
1113
1410
|
// 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
|
|
1114
1411
|
if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
|
|
1115
|
-
|
|
1116
|
-
if (active) {
|
|
1117
|
-
entry.vector = {
|
|
1118
|
-
route,
|
|
1119
|
-
model: active.profile?.catalogId || null,
|
|
1120
|
-
generation: active.generation,
|
|
1121
|
-
fingerprint: String(active.fingerprint || '').slice(0, 8),
|
|
1122
|
-
at: stamp(),
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1412
|
+
entry.vector = activeEntryStamp(route) || entry.vector
|
|
1125
1413
|
}
|
|
1126
1414
|
// Re-read after the await: a curator job or another turn may have written in
|
|
1127
1415
|
// the meantime, and saving the pre-await snapshot would erase it.
|
|
@@ -1132,6 +1420,7 @@ async function privateRememberImpl({
|
|
|
1132
1420
|
if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
|
|
1133
1421
|
const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
|
|
1134
1422
|
if (latestConflict && !conflict) {
|
|
1423
|
+
if (db) throw new Error('memory conflict changed during write; retry for review')
|
|
1135
1424
|
entry.status = 'pending_review'
|
|
1136
1425
|
entry.conflictingWith = latestConflict.id
|
|
1137
1426
|
entry.reviewReason = 'concurrent conflicting memory; review required'
|
|
@@ -1142,6 +1431,10 @@ async function privateRememberImpl({
|
|
|
1142
1431
|
}
|
|
1143
1432
|
fresh.entries[id] = entry
|
|
1144
1433
|
}))
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
|
|
1436
|
+
throw error
|
|
1437
|
+
}
|
|
1145
1438
|
if (!previousHabit) bumpStats(scope.libraryId, 'written')
|
|
1146
1439
|
if (layer === 'must_remember' && pinSource === 'side_event') {
|
|
1147
1440
|
consumeCue(sessionIdOf(exec), sideEventId)
|
|
@@ -1305,6 +1598,12 @@ async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now =
|
|
|
1305
1598
|
engineWarning = String(error?.message || error)
|
|
1306
1599
|
}
|
|
1307
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
|
+
}
|
|
1308
1607
|
const idMap = Object.fromEntries(entries.filter(item => item.entry.importedFrom).map(item => [item.entry.importedFrom, item.id]))
|
|
1309
1608
|
await retryStoreWrite(() => updatePrivateIndex(libraryId, (fresh) => {
|
|
1310
1609
|
for (const { id, entry } of entries) {
|
|
@@ -1343,11 +1642,14 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1343
1642
|
const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
|
|
1344
1643
|
const mood = moodDecay(binding.mood)
|
|
1345
1644
|
const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1645
|
+
// Three independent legs: a clear composition, an arousal away from rest, or a
|
|
1646
|
+
// clear sense of control. A null composition only closes the first one — a tense
|
|
1647
|
+
// room with no clear good/bad reading still changes which memories come up.
|
|
1648
|
+
moodEnabled: emotionActive({ emotion: mood.emotion, arousal: mood.arousal, dominance: mood.dominance }),
|
|
1349
1649
|
moodValence: mood.valence,
|
|
1650
|
+
emotion: mood.emotion,
|
|
1350
1651
|
arousal: mood.arousal,
|
|
1652
|
+
dominance: mood.dominance,
|
|
1351
1653
|
firstCreatedAt: span.first,
|
|
1352
1654
|
lastCreatedAt: span.last,
|
|
1353
1655
|
relevanceThreshold: threshold,
|
|
@@ -1360,6 +1662,21 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1360
1662
|
// like one nobody ever touches. Batched into a single index write.
|
|
1361
1663
|
if (purpose !== 'automatic') touchAccessMany({ libraryId: binding.libraryId, ids: (scored.files || []).map((f) => f.name) })
|
|
1362
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
|
+
}
|
|
1363
1680
|
for (const file of scored.files || []) {
|
|
1364
1681
|
if (file.entry?.attachments?.length) {
|
|
1365
1682
|
file.entry = { ...file.entry, attachments: file.entry.attachments.map(item => {
|
|
@@ -1419,7 +1736,7 @@ export async function privateList({ exec, libraryId, preferIndex = false }) {
|
|
|
1419
1736
|
// mood lens and the attachment list all change how an entry is scored, aggregated or
|
|
1420
1737
|
// rendered, so a wrong value in any of them was a permanent mis-filing that could
|
|
1421
1738
|
// only be undone by archiving the memory and writing it again.
|
|
1422
|
-
async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, attachments, context }) {
|
|
1739
|
+
async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, status, openLoop, disclosure, confidence, layer, owner, visibility, source, salientDate, track, peak, moodValence, arousal, emotion, dominance, attachments, context }) {
|
|
1423
1740
|
// `source` is editable because it is a claim about provenance, and claims get
|
|
1424
1741
|
// corrected: a memory the model first filed as `inferred` may turn out to be
|
|
1425
1742
|
// something the user actually said. Enum-checked like the rest — a typo here
|
|
@@ -1441,6 +1758,8 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1441
1758
|
if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
|
|
1442
1759
|
if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
|
|
1443
1760
|
if (arousal !== undefined && !Number.isFinite(Number(arousal))) throw new Error('arousal must be a number between 0 and 1')
|
|
1761
|
+
if (emotion !== undefined && emotion !== null) normalizeEmotion(emotion)
|
|
1762
|
+
if (dominance !== undefined && dominance !== null && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
|
|
1444
1763
|
// Throws for anything the library never received: an attachment list you cannot
|
|
1445
1764
|
// correct would leave a mis-filed image attached forever.
|
|
1446
1765
|
const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
|
|
@@ -1448,13 +1767,43 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1448
1767
|
// the same way. Capture before the update: `entry` is the pre-update snapshot.
|
|
1449
1768
|
const wasArchived = entry.status === 'archived'
|
|
1450
1769
|
const nextContent = content === undefined ? undefined : redactSecrets(content)
|
|
1451
|
-
|
|
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) {
|
|
1452
1781
|
const others = Object.fromEntries(Object.entries(index.entries).filter(([key]) => key !== id_))
|
|
1453
|
-
const conflict = findConflict(others, { ...entry, title:
|
|
1782
|
+
const conflict = findConflict(others, { ...entry, title: nextTitle, content: nextContent ?? entry.content })
|
|
1454
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
|
+
}
|
|
1455
1803
|
}
|
|
1456
1804
|
const route = engineRoute()
|
|
1457
1805
|
const managed = route === 'managed-local'
|
|
1806
|
+
let committedDb = null
|
|
1458
1807
|
if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
|
|
1459
1808
|
const payload = {
|
|
1460
1809
|
recall_files: [{
|
|
@@ -1467,10 +1816,14 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1467
1816
|
user: {},
|
|
1468
1817
|
}
|
|
1469
1818
|
if (route !== 'lexical') {
|
|
1470
|
-
|
|
1819
|
+
const db = memoryDbPath(scope.libraryId)
|
|
1820
|
+
await commit({ payload, route, db })
|
|
1821
|
+
committedDb = db
|
|
1471
1822
|
}
|
|
1472
1823
|
}
|
|
1473
|
-
|
|
1824
|
+
let updated
|
|
1825
|
+
try {
|
|
1826
|
+
updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1474
1827
|
const target = fresh.entries[id_]
|
|
1475
1828
|
if (!target) throw new Error(`unknown memory: ${id_}`)
|
|
1476
1829
|
if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
|
|
@@ -1480,6 +1833,18 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1480
1833
|
if (currentBinding) assertScopeEnabled(currentBinding)
|
|
1481
1834
|
if (title !== undefined) target.title = clampText(title, 120)
|
|
1482
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
|
+
}
|
|
1483
1848
|
if (tags !== undefined) target.tags = Array.isArray(tags) ? tags.slice(0, 20) : target.tags
|
|
1484
1849
|
if (status !== undefined) target.status = status
|
|
1485
1850
|
if (openLoop !== undefined) target.openLoop = !!openLoop
|
|
@@ -1513,7 +1878,28 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1513
1878
|
if (peak !== undefined) target.peak = peak === true
|
|
1514
1879
|
if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
|
|
1515
1880
|
if (arousal !== undefined) target.arousal = Math.max(0, Math.min(1, Number(arousal)))
|
|
1881
|
+
// Three states, not two: omitted keeps the stored composition, an explicit null
|
|
1882
|
+
// clears it (and the source becomes "we no longer know"), an object replaces it.
|
|
1883
|
+
// Collapsing null into "keep" would make it impossible to repair a wrong reading.
|
|
1884
|
+
if (emotion !== undefined) {
|
|
1885
|
+
if (emotion === null) {
|
|
1886
|
+
target.emotion = null
|
|
1887
|
+
target.emotionSource = 'unknown'
|
|
1888
|
+
target.moodValence = Number(moodValence) || 0
|
|
1889
|
+
} else {
|
|
1890
|
+
target.emotion = normalizeEmotion(emotion)
|
|
1891
|
+
target.emotionSource = 'model'
|
|
1892
|
+
target.moodValence = derivedValence(target.emotion)
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
|
|
1516
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
|
+
}
|
|
1517
1903
|
// Vividness and accuracy are derived, not typed in: correcting the peak flag or
|
|
1518
1904
|
// the arousal reading has to move `accuracy` too, otherwise the repair would only
|
|
1519
1905
|
// change how the entry is stored and not how much it is trusted when recalled.
|
|
@@ -1552,6 +1938,10 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1552
1938
|
}
|
|
1553
1939
|
target.updatedAt = stamp()
|
|
1554
1940
|
}))
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
|
|
1943
|
+
throw error
|
|
1944
|
+
}
|
|
1555
1945
|
if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
|
|
1556
1946
|
return { id: id_, entry: updated.entries[id_] }
|
|
1557
1947
|
}
|
|
@@ -1569,6 +1959,30 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
|
|
|
1569
1959
|
if (action === 'accept') {
|
|
1570
1960
|
if (typeof content === 'string' && content.trim()) next.content = content
|
|
1571
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
|
|
1572
1986
|
const reviewRoute = engineRoute()
|
|
1573
1987
|
if (reviewRoute !== 'lexical') {
|
|
1574
1988
|
await commit({
|
|
@@ -1576,6 +1990,9 @@ async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolutio
|
|
|
1576
1990
|
route: reviewRoute,
|
|
1577
1991
|
db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
|
|
1578
1992
|
})
|
|
1993
|
+
// pending_review 从不提交,所以被接受的那一刻才第一次真正进向量库:补上戳,
|
|
1994
|
+
// 否则这条在向量计数里永远缺失。
|
|
1995
|
+
next.vector = activeEntryStamp(reviewRoute) || next.vector
|
|
1579
1996
|
}
|
|
1580
1997
|
}
|
|
1581
1998
|
const mutate = fresh => {
|
|
@@ -1695,7 +2112,27 @@ async function privateDeleteImpl({ exec, libraryId, id }) {
|
|
|
1695
2112
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1696
2113
|
if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
|
|
1697
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
|
+
}
|
|
1698
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
|
+
}
|
|
1699
2136
|
return { id: id_, libraryId: scope.libraryId, deleted: true, backend, engine }
|
|
1700
2137
|
}
|
|
1701
2138
|
|
|
@@ -1761,22 +2198,27 @@ export function privateUnpin({ exec, id, reason }) {
|
|
|
1761
2198
|
|
|
1762
2199
|
export async function privateRemember(args) {
|
|
1763
2200
|
const libraryId = resolveLibrary(args).libraryId
|
|
1764
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
|
|
2201
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
|
|
2202
|
+
const deduped = result?.deduped === true
|
|
1765
2203
|
record({
|
|
1766
2204
|
kind: 'write',
|
|
1767
2205
|
scope: 'private',
|
|
1768
2206
|
sessionId: sessionIdOf(args.exec) || null,
|
|
1769
2207
|
libraryId,
|
|
1770
|
-
message:
|
|
1771
|
-
|
|
1772
|
-
|
|
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] : []),
|
|
1773
2215
|
})
|
|
1774
2216
|
return result
|
|
1775
2217
|
}
|
|
1776
2218
|
|
|
1777
2219
|
export async function privateUpdate(args) {
|
|
1778
2220
|
const libraryId = resolveLibrary(args).libraryId
|
|
1779
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
|
|
2221
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
|
|
1780
2222
|
record({
|
|
1781
2223
|
kind: 'write',
|
|
1782
2224
|
scope: 'private',
|
|
@@ -1791,7 +2233,7 @@ export async function privateUpdate(args) {
|
|
|
1791
2233
|
|
|
1792
2234
|
export async function privateDelete(args) {
|
|
1793
2235
|
const libraryId = resolveLibrary(args).libraryId
|
|
1794
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
|
|
2236
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
|
|
1795
2237
|
record({
|
|
1796
2238
|
kind: 'write',
|
|
1797
2239
|
scope: 'private',
|
|
@@ -1884,15 +2326,19 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold, retr
|
|
|
1884
2326
|
...f,
|
|
1885
2327
|
// Local lexical rank is already meaningful; confidence still nudges ties, and
|
|
1886
2328
|
// a cross-session fact that has been injected three turns running gets the same
|
|
1887
|
-
// 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),
|
|
1888
2332
|
score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
1889
2333
|
* (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
|
|
1890
2334
|
* habituationFactor(f.entry),
|
|
1891
2335
|
}))
|
|
1892
2336
|
.sort((a, b) => b.score - a.score)
|
|
2337
|
+
const ranked = mmrSelect(cutWeakScores(filtered, threshold, RETRIEVAL_FLOOR), Number(topK || settings.globalTopK || 3))
|
|
1893
2338
|
return {
|
|
1894
2339
|
...enriched,
|
|
1895
|
-
files:
|
|
2340
|
+
files: ranked.chosen,
|
|
2341
|
+
dedup: { collapsed: ranked.skipped, clusterCount: ranked.skipped.length },
|
|
1896
2342
|
domain: domainOf(binding),
|
|
1897
2343
|
unbound: !peeked,
|
|
1898
2344
|
}
|