dsh-memu-memory 1.1.3 → 1.2.0
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 +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -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 +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- 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 +115 -0
- package/lib/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- 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 +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- 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/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 +212 -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 +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -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-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-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -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 +536 -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/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 +208 -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 +733 -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 +164 -0
- package/tests/windows-env.test.mjs +89 -0
package/lib/memory.js
CHANGED
|
@@ -10,8 +10,9 @@ 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
18
|
import { recallRelations } from './graph.js'
|
|
@@ -93,7 +94,59 @@ function clearRecallError(exec) {
|
|
|
93
94
|
if (sessionId) setRecallError(sessionId, null)
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
// Serialize backend-bearing private writes across every route in this module.
|
|
98
|
+
// Acquire this queue BEFORE the vector lease; neither waits while holding a
|
|
99
|
+
// synchronous file lock. This is not a cross-process/backend transaction.
|
|
100
|
+
const privateWriteTails = new Map()
|
|
96
101
|
async function withManagedPrivateWrite(libraryId, action) {
|
|
102
|
+
const previous = privateWriteTails.get(libraryId) || Promise.resolve()
|
|
103
|
+
const running = previous.then(() => withManagedPrivateWriteImpl(libraryId, action))
|
|
104
|
+
const settled = running.then(() => {}, () => {})
|
|
105
|
+
privateWriteTails.set(libraryId, settled)
|
|
106
|
+
try { return await running } finally {
|
|
107
|
+
if (privateWriteTails.get(libraryId) === settled) privateWriteTails.delete(libraryId)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertCompensationResult(result, operation) {
|
|
112
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || result.ok === false || result.error || result.parseWarning
|
|
113
|
+
|| (operation === 'backend compensation delete' && (!Number.isSafeInteger(result.deleted) || result.deleted < 0))) {
|
|
114
|
+
throw new Error(operation + ' was not acknowledged')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The index remains authoritative. Refuse destructive repair if another index
|
|
119
|
+
// writer replaced the expected pre-await entry; otherwise rebuild that old row.
|
|
120
|
+
// No transaction spans these external calls: crashes/other processes can still
|
|
121
|
+
// interrupt compensation. Never describe an uncertain repair as success.
|
|
122
|
+
async function compensatePrivateCommit(error, { libraryId, id, route, db, expectedEntry }) {
|
|
123
|
+
try {
|
|
124
|
+
const current = loadPrivateIndex(libraryId).entries[id]
|
|
125
|
+
if (JSON.stringify(current ?? null) !== JSON.stringify(expectedEntry ?? null)) {
|
|
126
|
+
throw new Error('memory changed before backend compensation')
|
|
127
|
+
}
|
|
128
|
+
const snapshot = JSON.stringify(current)
|
|
129
|
+
assertCompensationResult(await forget({ route, db, name: id }), 'backend compensation delete')
|
|
130
|
+
if (current && !['pending_review', 'superseded'].includes(current.status)) {
|
|
131
|
+
assertCompensationResult(await commit({ route, db, payload: {
|
|
132
|
+
recall_files: [{ name: id, track: current.track || 'memory', description: clampText(current.title, 180), content: current.content }],
|
|
133
|
+
resource: [], user: {},
|
|
134
|
+
} }), 'backend compensation restore')
|
|
135
|
+
}
|
|
136
|
+
if (JSON.stringify(loadPrivateIndex(libraryId).entries[id]) !== snapshot) {
|
|
137
|
+
throw new Error('memory changed during backend compensation')
|
|
138
|
+
}
|
|
139
|
+
} catch (compensationError) {
|
|
140
|
+
const failure = new Error('private memory index write failed; backend compensation uncertain: ' + String(compensationError?.message || compensationError), { cause: error })
|
|
141
|
+
failure.code = 'PRIVATE_BACKEND_COMPENSATION_FAILED'
|
|
142
|
+
failure.compensationError = compensationError
|
|
143
|
+
warn(failure.message)
|
|
144
|
+
throw failure
|
|
145
|
+
}
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function withManagedPrivateWriteImpl(libraryId, action) {
|
|
97
150
|
const intent = managedVectorIntent()
|
|
98
151
|
if (!intent.managed) return action()
|
|
99
152
|
// 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
|
|
@@ -219,6 +272,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
219
272
|
}
|
|
220
273
|
const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
|
|
221
274
|
if (!binding) throw new Error('no bound session')
|
|
275
|
+
assertConsolidationParent(binding)
|
|
222
276
|
// A binding without a library means the conversation explicitly opted out of
|
|
223
277
|
// private memory. Writing through it used to land in `index/null.json` — a
|
|
224
278
|
// "successful" write that can never be recalled. Refuse with an actionable
|
|
@@ -232,8 +286,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
232
286
|
// The sidebar switches (and the folder ceiling behind them) must constrain tool
|
|
233
287
|
// calls too, not just injection. One gate, sourced from `effectiveScope` so the
|
|
234
288
|
// folder policy is always part of the answer.
|
|
289
|
+
function assertConsolidationParent(binding) {
|
|
290
|
+
if (!binding?.consolidationFork) return
|
|
291
|
+
const parent = getSessionBinding(binding.parentSessionId)
|
|
292
|
+
if (!parent || !binding.parentScopeGeneration || scopeGenerationOf(parent) !== binding.parentScopeGeneration) {
|
|
293
|
+
throw new Error('DSH_MEMU_SCOPE_STALE: consolidation worker parent authorization changed')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
235
297
|
function assertScopeEnabled(binding, { layer = 'library' } = {}) {
|
|
236
298
|
if (!binding) return
|
|
299
|
+
assertConsolidationParent(binding)
|
|
300
|
+
if (binding.consolidationFork && layer === 'global') throw new Error('DSH_MEMU_WORKER_RESTRICTED: private worker cannot access global memory')
|
|
237
301
|
const scope = effectiveScope(binding)
|
|
238
302
|
if (layer === 'library' && !scope.useLibrary) {
|
|
239
303
|
throw new Error(scope.libraryBlocked
|
|
@@ -788,9 +852,16 @@ function recallScore(file, rank, context) {
|
|
|
788
852
|
|
|
789
853
|
// Fading affect bias: what pulls is the tint the memory still carries today, not
|
|
790
854
|
// the one it was written with. Old grievances quieten down; old fondness does not.
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
855
|
+
//
|
|
856
|
+
// Two paths, one decision: when both sides carry a composition, compare the
|
|
857
|
+
// compositions (how much of the present shape the memory shares). Otherwise fall
|
|
858
|
+
// back to the legacy signed-valence congruence. Entries written before this model
|
|
859
|
+
// existed have no composition and must keep working unchanged.
|
|
860
|
+
const moodBias = !context.moodEnabled
|
|
861
|
+
? 1
|
|
862
|
+
: (context.emotion && entry.emotion
|
|
863
|
+
? 1 + 0.3 * affectFadeFactor(entry, context.now) * (2 * overlap(entry.emotion, context.emotion) - 1)
|
|
864
|
+
: 1 + 0.3 * moodCongruence(fadedValence(entry, context.now), context.moodValence))
|
|
794
865
|
const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
|
|
795
866
|
const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
|
|
796
867
|
const peakEnd = 1 + 0.25 * Math.max(peak, end)
|
|
@@ -802,13 +873,33 @@ function recallScore(file, rank, context) {
|
|
|
802
873
|
// this asks "does its loudness match the room right now?" — different questions that
|
|
803
874
|
// only look like duplicates.
|
|
804
875
|
const arousalTerm = context.moodEnabled ? arousalBias(entry, context) : 1
|
|
876
|
+
// Dominance asks a third question: who was steering. Two equally sad memories pull
|
|
877
|
+
// differently depending on whether the present state is "I can fix this" or "nothing
|
|
878
|
+
// I do matters". Bounded to +-10% so it can only reorder near-ties.
|
|
879
|
+
const dominanceTerm = context.moodEnabled ? dominanceBias(entry.dominance, context.dominance) : 1
|
|
805
880
|
// An open loop is an unfinished thread — a promise, a question, a plan nobody
|
|
806
881
|
// closed. Those are what a person brings up unprompted, so they get a nudge and not
|
|
807
882
|
// a trump card: 1.25 still loses to a clearly better semantic hit.
|
|
808
883
|
const openLoopBias = entry.openLoop ? 1.25 : 1
|
|
809
884
|
const habituation = habituationFactor(entry, context.now)
|
|
810
885
|
|
|
811
|
-
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm
|
|
886
|
+
return retrieval * strengthTerm * marginal * moodBias * peakEnd * primacy * openLoopBias * habituation * arousalTerm * dominanceTerm
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* How much of a memory's original tint is still there, 0..1.
|
|
891
|
+
*
|
|
892
|
+
* Same curve as `fadedValence`, but returned as a bare factor so the composition path
|
|
893
|
+
* applies the *fading* to the whole comparison instead of decaying the shares (shares
|
|
894
|
+
* sum to 1, so decaying them proportionally would change nothing).
|
|
895
|
+
*/
|
|
896
|
+
function affectFadeFactor(entry, now = Date.now()) {
|
|
897
|
+
const valence = entry.emotion ? derivedValence(entry.emotion) : Number(entry.moodValence || 0)
|
|
898
|
+
if (!valence) return 0
|
|
899
|
+
const updated = Date.parse(entry.updatedAt || entry.createdAt || '') || now
|
|
900
|
+
const days = Math.max(0, (now - updated) / 86_400_000)
|
|
901
|
+
const halfLife = valence < 0 ? FAB_HALF_LIFE_DAYS.negative : FAB_HALF_LIFE_DAYS.positive
|
|
902
|
+
return Math.pow(0.5, days / halfLife)
|
|
812
903
|
}
|
|
813
904
|
|
|
814
905
|
/**
|
|
@@ -822,9 +913,12 @@ function recallScore(file, rank, context) {
|
|
|
822
913
|
function arousalBias(entry, context) {
|
|
823
914
|
const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
|
|
824
915
|
const drive = Math.max(-0.35, Math.min(0.65, present - BASE_AROUSAL))
|
|
916
|
+
// A composed entry can be *very* angry while its legacy valence projection looks
|
|
917
|
+
// mild, so the largest share counts as intensity alongside the stored arousal.
|
|
825
918
|
const intensity = Math.max(
|
|
826
919
|
entry.peak === true ? 1 : 0,
|
|
827
920
|
Math.min(1, Math.max(0, Number(entry.arousal || 0))),
|
|
921
|
+
Math.min(1, maxShare(entry.emotion)),
|
|
828
922
|
Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
|
|
829
923
|
)
|
|
830
924
|
return 1 + 0.5 * drive * (intensity - 0.5)
|
|
@@ -858,7 +952,9 @@ function privateWeighted(result, max, context = {}) {
|
|
|
858
952
|
const ctx = {
|
|
859
953
|
moodEnabled: false,
|
|
860
954
|
moodValence: 0,
|
|
955
|
+
emotion: null,
|
|
861
956
|
arousal: BASE_AROUSAL,
|
|
957
|
+
dominance: 0,
|
|
862
958
|
firstCreatedAt: null,
|
|
863
959
|
lastCreatedAt: null,
|
|
864
960
|
sessionId: null,
|
|
@@ -952,11 +1048,66 @@ function createdAtSpan(files) {
|
|
|
952
1048
|
return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
|
|
953
1049
|
}
|
|
954
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* Decide what an entry's affect actually is, and which tier answered.
|
|
1053
|
+
*
|
|
1054
|
+
* Order: the model said it; the conversation is in a mood and inheritance is allowed;
|
|
1055
|
+
* the memory's own text reads as emotional; nothing. `emotionSource` keeps "neutral"
|
|
1056
|
+
* and "no idea" distinguishable — they drive the same recall multiplier, but only one
|
|
1057
|
+
* of them means the write path worked.
|
|
1058
|
+
*
|
|
1059
|
+
* Engineering facts stay neutral on purpose. A bug being hard to fix is not the
|
|
1060
|
+
* project feeling sad, and letting the turn's mood leak into a technical entry is how
|
|
1061
|
+
* "the deployment failed and we were furious" becomes a fact about the deployment.
|
|
1062
|
+
*/
|
|
1063
|
+
function resolveEntryEmotion({ emotion, dominance, emotionSource, owner, content, binding }) {
|
|
1064
|
+
const providedDominance = Number(dominance)
|
|
1065
|
+
const explicitDominance = dominance !== null && dominance !== undefined && Number.isFinite(providedDominance)
|
|
1066
|
+
? Math.max(-1, Math.min(1, providedDominance))
|
|
1067
|
+
: null
|
|
1068
|
+
// Three states, and they are not the same thing: omitted = inherit (a caller that
|
|
1069
|
+
// says nothing wants the turn's context), null = explicitly neutral (the model looked
|
|
1070
|
+
// and found nothing), an object = the model's reading. Collapsing null into "omitted"
|
|
1071
|
+
// would silently reinstate inherited mood on a memory the model called neutral.
|
|
1072
|
+
const explicitNeutral = emotion === null
|
|
1073
|
+
const fromModel = emotion !== undefined && emotion !== null
|
|
1074
|
+
let resolved = null
|
|
1075
|
+
let sourceName = 'unknown'
|
|
1076
|
+
let inheritedDominance = null
|
|
1077
|
+
if (fromModel) {
|
|
1078
|
+
resolved = normalizeEmotion(emotion)
|
|
1079
|
+
sourceName = 'model'
|
|
1080
|
+
} else if (!explicitNeutral && owner !== 'about_project') {
|
|
1081
|
+
const sessionMood = binding && binding.mood ? binding.mood : null
|
|
1082
|
+
if (sessionMood && sessionMood.emotion) {
|
|
1083
|
+
resolved = normalizeEmotion(sessionMood.emotion)
|
|
1084
|
+
sourceName = resolved ? 'session' : 'unknown'
|
|
1085
|
+
const sessionDominance = Number(sessionMood.dominance)
|
|
1086
|
+
if (Number.isFinite(sessionDominance)) inheritedDominance = Math.max(-1, Math.min(1, sessionDominance))
|
|
1087
|
+
}
|
|
1088
|
+
if (resolved === null && sourceName === 'unknown') {
|
|
1089
|
+
const read = readMood(content)
|
|
1090
|
+
if (read.emotion) {
|
|
1091
|
+
resolved = read.emotion
|
|
1092
|
+
sourceName = 'content'
|
|
1093
|
+
inheritedDominance = read.dominance
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return {
|
|
1098
|
+
emotion: resolved,
|
|
1099
|
+
dominance: explicitDominance === null ? (inheritedDominance === null ? 0 : inheritedDominance) : explicitDominance,
|
|
1100
|
+
source: explicitNeutral ? 'model' : (emotionSource || sourceName),
|
|
1101
|
+
neutral: explicitNeutral,
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
955
1105
|
async function privateRememberImpl({
|
|
956
1106
|
exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
|
|
957
1107
|
sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
|
|
958
1108
|
owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
|
|
959
1109
|
moodValence = null, arousal = null, peak = false, salientDate = null,
|
|
1110
|
+
emotion = undefined, dominance = undefined, emotionSource = null,
|
|
960
1111
|
attachments, track = 'memory',
|
|
961
1112
|
context,
|
|
962
1113
|
}) {
|
|
@@ -1036,6 +1187,12 @@ async function privateRememberImpl({
|
|
|
1036
1187
|
&& (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
|
|
1037
1188
|
: null
|
|
1038
1189
|
const id = previousHabit?.[0] || newMemoryId('mem')
|
|
1190
|
+
// A malformed share is refused here rather than clamped later: a negative or
|
|
1191
|
+
// non-finite composition is a caller bug, and repairing it silently would keep the
|
|
1192
|
+
// bug alive while the entry looks fine.
|
|
1193
|
+
if (emotion !== null && emotion !== undefined) normalizeEmotion(emotion)
|
|
1194
|
+
if (dominance !== null && dominance !== undefined && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
|
|
1195
|
+
const resolvedAffect = resolveEntryEmotion({ emotion, dominance, owner, content: String(content), binding })
|
|
1039
1196
|
const entry = {
|
|
1040
1197
|
...(previousHabit?.[1] || {}),
|
|
1041
1198
|
track,
|
|
@@ -1068,9 +1225,18 @@ async function privateRememberImpl({
|
|
|
1068
1225
|
// Retrieval lens + resurface metadata. When the agent does not state a mood,
|
|
1069
1226
|
// the current session read is stored instead, so a memory written during a
|
|
1070
1227
|
// hard conversation is tinted by it without the model having to name it.
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1228
|
+
// `moodValence` stays as the legacy projection of the four shares; the shares
|
|
1229
|
+
// themselves are what the new recall path compares.
|
|
1230
|
+
moodValence: resolvedAffect.emotion
|
|
1231
|
+
? derivedValence(resolvedAffect.emotion)
|
|
1232
|
+
: (resolvedAffect.neutral
|
|
1233
|
+
? 0
|
|
1234
|
+
: (moodValence === null || moodValence === undefined
|
|
1235
|
+
? (binding?.mood ? Number(binding.mood.valence) || 0 : 0)
|
|
1236
|
+
: Math.max(-1, Math.min(1, Number(moodValence) || 0)))),
|
|
1237
|
+
emotion: resolvedAffect.emotion,
|
|
1238
|
+
dominance: resolvedAffect.dominance,
|
|
1239
|
+
emotionSource: resolvedAffect.source,
|
|
1074
1240
|
arousal: arousal === null || arousal === undefined
|
|
1075
1241
|
? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
|
|
1076
1242
|
: Math.max(0, Math.min(1, Number(arousal) || 0)),
|
|
@@ -1106,9 +1272,11 @@ async function privateRememberImpl({
|
|
|
1106
1272
|
// would make a contested memory retrievable before anyone decided it. Accepting
|
|
1107
1273
|
// the review later is what commits it (see resolveReview → privateUpdate).
|
|
1108
1274
|
const route = engineRoute()
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1275
|
+
const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
|
|
1276
|
+
const result = db
|
|
1277
|
+
? await commit({ payload, route, db })
|
|
1111
1278
|
: { ok: true, backend: route, held: conflict ? true : undefined }
|
|
1279
|
+
try {
|
|
1112
1280
|
// 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
|
|
1113
1281
|
// 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
|
|
1114
1282
|
if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
|
|
@@ -1132,6 +1300,7 @@ async function privateRememberImpl({
|
|
|
1132
1300
|
if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
|
|
1133
1301
|
const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
|
|
1134
1302
|
if (latestConflict && !conflict) {
|
|
1303
|
+
if (db) throw new Error('memory conflict changed during write; retry for review')
|
|
1135
1304
|
entry.status = 'pending_review'
|
|
1136
1305
|
entry.conflictingWith = latestConflict.id
|
|
1137
1306
|
entry.reviewReason = 'concurrent conflicting memory; review required'
|
|
@@ -1142,6 +1311,10 @@ async function privateRememberImpl({
|
|
|
1142
1311
|
}
|
|
1143
1312
|
fresh.entries[id] = entry
|
|
1144
1313
|
}))
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
|
|
1316
|
+
throw error
|
|
1317
|
+
}
|
|
1145
1318
|
if (!previousHabit) bumpStats(scope.libraryId, 'written')
|
|
1146
1319
|
if (layer === 'must_remember' && pinSource === 'side_event') {
|
|
1147
1320
|
consumeCue(sessionIdOf(exec), sideEventId)
|
|
@@ -1343,11 +1516,14 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1343
1516
|
const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
|
|
1344
1517
|
const mood = moodDecay(binding.mood)
|
|
1345
1518
|
const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1519
|
+
// Three independent legs: a clear composition, an arousal away from rest, or a
|
|
1520
|
+
// clear sense of control. A null composition only closes the first one — a tense
|
|
1521
|
+
// room with no clear good/bad reading still changes which memories come up.
|
|
1522
|
+
moodEnabled: emotionActive({ emotion: mood.emotion, arousal: mood.arousal, dominance: mood.dominance }),
|
|
1349
1523
|
moodValence: mood.valence,
|
|
1524
|
+
emotion: mood.emotion,
|
|
1350
1525
|
arousal: mood.arousal,
|
|
1526
|
+
dominance: mood.dominance,
|
|
1351
1527
|
firstCreatedAt: span.first,
|
|
1352
1528
|
lastCreatedAt: span.last,
|
|
1353
1529
|
relevanceThreshold: threshold,
|
|
@@ -1419,7 +1595,7 @@ export async function privateList({ exec, libraryId, preferIndex = false }) {
|
|
|
1419
1595
|
// mood lens and the attachment list all change how an entry is scored, aggregated or
|
|
1420
1596
|
// rendered, so a wrong value in any of them was a permanent mis-filing that could
|
|
1421
1597
|
// 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 }) {
|
|
1598
|
+
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
1599
|
// `source` is editable because it is a claim about provenance, and claims get
|
|
1424
1600
|
// corrected: a memory the model first filed as `inferred` may turn out to be
|
|
1425
1601
|
// something the user actually said. Enum-checked like the rest — a typo here
|
|
@@ -1441,6 +1617,8 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1441
1617
|
if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
|
|
1442
1618
|
if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
|
|
1443
1619
|
if (arousal !== undefined && !Number.isFinite(Number(arousal))) throw new Error('arousal must be a number between 0 and 1')
|
|
1620
|
+
if (emotion !== undefined && emotion !== null) normalizeEmotion(emotion)
|
|
1621
|
+
if (dominance !== undefined && dominance !== null && !Number.isFinite(Number(dominance))) throw new Error('dominance must be a number between -1 and 1')
|
|
1444
1622
|
// Throws for anything the library never received: an attachment list you cannot
|
|
1445
1623
|
// correct would leave a mis-filed image attached forever.
|
|
1446
1624
|
const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
|
|
@@ -1455,6 +1633,7 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1455
1633
|
}
|
|
1456
1634
|
const route = engineRoute()
|
|
1457
1635
|
const managed = route === 'managed-local'
|
|
1636
|
+
let committedDb = null
|
|
1458
1637
|
if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
|
|
1459
1638
|
const payload = {
|
|
1460
1639
|
recall_files: [{
|
|
@@ -1467,10 +1646,14 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1467
1646
|
user: {},
|
|
1468
1647
|
}
|
|
1469
1648
|
if (route !== 'lexical') {
|
|
1470
|
-
|
|
1649
|
+
const db = memoryDbPath(scope.libraryId)
|
|
1650
|
+
await commit({ payload, route, db })
|
|
1651
|
+
committedDb = db
|
|
1471
1652
|
}
|
|
1472
1653
|
}
|
|
1473
|
-
|
|
1654
|
+
let updated
|
|
1655
|
+
try {
|
|
1656
|
+
updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1474
1657
|
const target = fresh.entries[id_]
|
|
1475
1658
|
if (!target) throw new Error(`unknown memory: ${id_}`)
|
|
1476
1659
|
if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
|
|
@@ -1513,6 +1696,21 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1513
1696
|
if (peak !== undefined) target.peak = peak === true
|
|
1514
1697
|
if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
|
|
1515
1698
|
if (arousal !== undefined) target.arousal = Math.max(0, Math.min(1, Number(arousal)))
|
|
1699
|
+
// Three states, not two: omitted keeps the stored composition, an explicit null
|
|
1700
|
+
// clears it (and the source becomes "we no longer know"), an object replaces it.
|
|
1701
|
+
// Collapsing null into "keep" would make it impossible to repair a wrong reading.
|
|
1702
|
+
if (emotion !== undefined) {
|
|
1703
|
+
if (emotion === null) {
|
|
1704
|
+
target.emotion = null
|
|
1705
|
+
target.emotionSource = 'unknown'
|
|
1706
|
+
target.moodValence = Number(moodValence) || 0
|
|
1707
|
+
} else {
|
|
1708
|
+
target.emotion = normalizeEmotion(emotion)
|
|
1709
|
+
target.emotionSource = 'model'
|
|
1710
|
+
target.moodValence = derivedValence(target.emotion)
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (dominance !== undefined) target.dominance = dominance === null ? 0 : Math.max(-1, Math.min(1, Number(dominance)))
|
|
1516
1714
|
if (nextAttachments !== undefined) target.attachments = nextAttachments
|
|
1517
1715
|
// Vividness and accuracy are derived, not typed in: correcting the peak flag or
|
|
1518
1716
|
// the arousal reading has to move `accuracy` too, otherwise the repair would only
|
|
@@ -1552,6 +1750,10 @@ async function privateUpdateImpl({ exec, libraryId, id, content, title, tags, st
|
|
|
1552
1750
|
}
|
|
1553
1751
|
target.updatedAt = stamp()
|
|
1554
1752
|
}))
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
|
|
1755
|
+
throw error
|
|
1756
|
+
}
|
|
1555
1757
|
if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
|
|
1556
1758
|
return { id: id_, entry: updated.entries[id_] }
|
|
1557
1759
|
}
|
|
@@ -1761,7 +1963,7 @@ export function privateUnpin({ exec, id, reason }) {
|
|
|
1761
1963
|
|
|
1762
1964
|
export async function privateRemember(args) {
|
|
1763
1965
|
const libraryId = resolveLibrary(args).libraryId
|
|
1764
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
|
|
1966
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
|
|
1765
1967
|
record({
|
|
1766
1968
|
kind: 'write',
|
|
1767
1969
|
scope: 'private',
|
|
@@ -1776,7 +1978,7 @@ export async function privateRemember(args) {
|
|
|
1776
1978
|
|
|
1777
1979
|
export async function privateUpdate(args) {
|
|
1778
1980
|
const libraryId = resolveLibrary(args).libraryId
|
|
1779
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
|
|
1981
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
|
|
1780
1982
|
record({
|
|
1781
1983
|
kind: 'write',
|
|
1782
1984
|
scope: 'private',
|
|
@@ -1791,7 +1993,7 @@ export async function privateUpdate(args) {
|
|
|
1791
1993
|
|
|
1792
1994
|
export async function privateDelete(args) {
|
|
1793
1995
|
const libraryId = resolveLibrary(args).libraryId
|
|
1794
|
-
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
|
|
1996
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
|
|
1795
1997
|
record({
|
|
1796
1998
|
kind: 'write',
|
|
1797
1999
|
scope: 'private',
|