dsh-memu-memory 1.1.2 → 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 +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- 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/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- 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 +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -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 +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
|
@@ -6,16 +6,19 @@ import {
|
|
|
6
6
|
ensureSession, getSessionBinding, memoryDbPath, globalDbPath,
|
|
7
7
|
loadPrivateIndex, savePrivateIndex, loadGlobalIndex, saveGlobalIndex,
|
|
8
8
|
updatePrivateIndex, updateGlobalIndex,
|
|
9
|
-
newMemoryId, getSettings,
|
|
10
|
-
getLibrary, setSessionBinding, effectiveScope, listLibrariesAll,
|
|
9
|
+
newMemoryId, getSettings, effectiveVectorRoute, getActiveCue, consumeCue,
|
|
10
|
+
getLibrary, setSessionBinding, setRecallError, effectiveScope, listLibrariesAll,
|
|
11
11
|
} from './state.js'
|
|
12
|
-
import { stamp, redactSecrets, retryStoreWrite } from './paths.js'
|
|
13
|
-
import {
|
|
12
|
+
import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
|
|
13
|
+
import { scopeGenerationOf } from './consolidation-ledger.js'
|
|
14
|
+
import { record } from './activity-log.js'
|
|
15
|
+
import { BASE_AROUSAL, derivedValence, dominanceBias, emotionActive, maxShare, moodCongruence, moodDecay, normalizeEmotion, overlap, readMood } from './mood.js'
|
|
14
16
|
import { commit, retrieve, listFiles, forget } from './memu.js'
|
|
15
17
|
import { bumpStats } from './stats.js'
|
|
16
18
|
import { recallRelations } from './graph.js'
|
|
17
19
|
import { attachmentRefs } from './media.js'
|
|
18
20
|
import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
|
|
21
|
+
import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
|
|
19
22
|
|
|
20
23
|
const HALF_LIFE_DAYS = 14
|
|
21
24
|
// A short-term entry is meant to live for the length of a conversation, not a
|
|
@@ -35,6 +38,14 @@ const HABITUATION_FLOOR = 0.5
|
|
|
35
38
|
// library with three variants of the same decision would spend the whole block on it.
|
|
36
39
|
// The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
|
|
37
40
|
const MMR_LAMBDA = 0.72
|
|
41
|
+
// Semantic fusion uses RRF: each route contributes a
|
|
42
|
+
// rank term and the merged score is normalised to the best fused hit before the
|
|
43
|
+
// existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
|
|
44
|
+
// library from turning every turn into two full scans.
|
|
45
|
+
const HYBRID_RRF_K = 60
|
|
46
|
+
const HYBRID_CANDIDATE_MIN = 12
|
|
47
|
+
const HYBRID_CANDIDATE_MAX = 40
|
|
48
|
+
const HYBRID_SEMANTIC_TIMEOUT_MS = 15_000
|
|
38
49
|
const LAYERS = ['long_term', 'short_term', 'must_remember']
|
|
39
50
|
// These three are read back by the injector to decide how a memory may be spoken and
|
|
40
51
|
// where it may appear. An unchecked typo would silently mean "no suffix at all", so
|
|
@@ -51,6 +62,141 @@ const VISIBILITIES = STORED_VISIBILITIES
|
|
|
51
62
|
// quote, which systematically inflated confidence in things nobody had said.
|
|
52
63
|
const SOURCES = ['user_said', 'inferred', 'imagined', 'told']
|
|
53
64
|
|
|
65
|
+
function managedVectorIntent() {
|
|
66
|
+
if (effectiveVectorRoute() !== 'managed-local') return { managed: false, route: effectiveVectorRoute(), state: null, error: null }
|
|
67
|
+
try {
|
|
68
|
+
const state = readVectorState({ tolerateMissing: true }).state
|
|
69
|
+
return { managed: true, route: 'managed-local', state, error: null }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { managed: true, route: 'managed-local', state: null, error }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 引擎调用用的路由。受管路由被选中但还没有任何代际时,MemU 的 envFor 会直接拒绝
|
|
76
|
+
// managed-local("managed local vector profile is not active"),写入/删除/列举都会连带
|
|
77
|
+
// 失败。这时引擎这一步按词法处理:索引照常写,等下一次迁移把历史补进向量库。
|
|
78
|
+
// 召回仍按原路由走,好让侧边栏继续显示降级原因,而不是悄悄退回词法。
|
|
79
|
+
function engineRoute() {
|
|
80
|
+
const route = effectiveVectorRoute()
|
|
81
|
+
return route === 'managed-local' && !hasActiveVectorGeneration() ? 'lexical' : route
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rememberRecallError(exec, error) {
|
|
85
|
+
const message = String(error?.message || error)
|
|
86
|
+
warn('managed vector recall degraded to lexical: ' + message)
|
|
87
|
+
const sessionId = sessionIdOf(exec)
|
|
88
|
+
if (sessionId) setRecallError(sessionId, message)
|
|
89
|
+
return message
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearRecallError(exec) {
|
|
93
|
+
const sessionId = sessionIdOf(exec)
|
|
94
|
+
if (sessionId) setRecallError(sessionId, null)
|
|
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()
|
|
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) {
|
|
150
|
+
const intent = managedVectorIntent()
|
|
151
|
+
if (!intent.managed) return action()
|
|
152
|
+
// 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
|
|
153
|
+
// 没有可对齐的代际,写入必须降级成索引 + 引擎,而不是把整次整理写死。召回侧一直是
|
|
154
|
+
// 这个口径(local-fallback),写入侧漏了同样的降级 —— 结果就是整理跑完一条没写进来。
|
|
155
|
+
if (!hasActiveVectorGeneration()) {
|
|
156
|
+
warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
|
|
157
|
+
record({ kind: 'vector', level: 'warn', libraryId, message: '向量未激活,写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
|
|
158
|
+
return action()
|
|
159
|
+
}
|
|
160
|
+
return withVectorWrite(async () => {
|
|
161
|
+
assertVectorWriteAllowed()
|
|
162
|
+
enrollActiveLibrary(libraryId)
|
|
163
|
+
return action()
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function withManagedGlobalWrite(action) {
|
|
168
|
+
const intent = managedVectorIntent()
|
|
169
|
+
if (!intent.managed) return action()
|
|
170
|
+
if (!hasActiveVectorGeneration()) {
|
|
171
|
+
warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
|
|
172
|
+
record({ kind: 'vector', level: 'warn', scope: 'global', message: '向量未激活,全局写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
|
|
173
|
+
return action()
|
|
174
|
+
}
|
|
175
|
+
return withVectorWrite(() => { assertVectorWriteAllowed(); return action() })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function managedRecall({ exec, index, query, topK, libraryId, automatic = false, intent, retrieveImpl, byId = false }) {
|
|
179
|
+
const local = localRecall(index, query)
|
|
180
|
+
try {
|
|
181
|
+
if (intent.error) throw intent.error
|
|
182
|
+
const captured = intent.route === 'managed-local' ? captureGeneration() : null
|
|
183
|
+
if (byId) return { ...local, recallRoute: 'local' }
|
|
184
|
+
const db = captured ? (libraryId ? captured.libraries[libraryId] : captured.global) : (libraryId ? memoryDbPath(libraryId) : globalDbPath())
|
|
185
|
+
if (!db) throw new Error('managed vector generation has no database for library ' + libraryId)
|
|
186
|
+
const semantic = await retrieveImpl({ query, db, route: intent.route, timeoutMs: HYBRID_SEMANTIC_TIMEOUT_MS })
|
|
187
|
+
if (semantic?.parseWarning) throw new Error(semantic.parseWarning)
|
|
188
|
+
if (!Array.isArray(semantic?.files)) throw new Error('managed vector recall returned no file list')
|
|
189
|
+
if (captured) assertGeneration(captured)
|
|
190
|
+
const fused = fuseRecallCandidates({ local: local.files, semantic: semantic.files, index,
|
|
191
|
+
limit: hybridCandidateLimit(topK), automatic, sessionId: memorySessionId(exec), route: 'managed-vector-rrf' })
|
|
192
|
+
clearRecallError(exec)
|
|
193
|
+
return { ...semantic, files: fused.files, recallRoute: 'managed-vector-rrf', fusion: fused.debug }
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const reason = rememberRecallError(exec, error)
|
|
196
|
+
return { ...local, recallRoute: 'local-fallback', errors: ['semantic recall failed: ' + reason] }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
54
200
|
function checkEnums({ disclosure, owner, visibility, source }) {
|
|
55
201
|
if (disclosure !== undefined && !DISCLOSURES.includes(disclosure)) {
|
|
56
202
|
throw new Error(`disclosure must be ${DISCLOSURES.join(', ')}`)
|
|
@@ -126,6 +272,7 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
126
272
|
}
|
|
127
273
|
const binding = readOnly ? peekBinding(exec) : bindingFor(exec)
|
|
128
274
|
if (!binding) throw new Error('no bound session')
|
|
275
|
+
assertConsolidationParent(binding)
|
|
129
276
|
// A binding without a library means the conversation explicitly opted out of
|
|
130
277
|
// private memory. Writing through it used to land in `index/null.json` — a
|
|
131
278
|
// "successful" write that can never be recalled. Refuse with an actionable
|
|
@@ -139,8 +286,18 @@ export function resolveLibrary({ exec, libraryId, readOnly = false }) {
|
|
|
139
286
|
// The sidebar switches (and the folder ceiling behind them) must constrain tool
|
|
140
287
|
// calls too, not just injection. One gate, sourced from `effectiveScope` so the
|
|
141
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
|
+
|
|
142
297
|
function assertScopeEnabled(binding, { layer = 'library' } = {}) {
|
|
143
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')
|
|
144
301
|
const scope = effectiveScope(binding)
|
|
145
302
|
if (layer === 'library' && !scope.useLibrary) {
|
|
146
303
|
throw new Error(scope.libraryBlocked
|
|
@@ -237,6 +394,22 @@ const RELATIVE_DAY_OFFSETS = [
|
|
|
237
394
|
[/tomorrow/i, 1], [/明天|明日/, 1], [/tonight/i, 0], [/today/i, 0], [/今天|今晚/, 0],
|
|
238
395
|
]
|
|
239
396
|
|
|
397
|
+
/**
|
|
398
|
+
* 从标题/正文里取唯一的显式日期,作为"模型忘了填 salient_date"的兜底。
|
|
399
|
+
* 只接受全篇唯一的 YYYY-MM-DD;出现多个候选就不猜(宁可为空,也不把日志里的日期
|
|
400
|
+
* 误装成纪念日)。相对日期不在这里解析,交给 reconcileSalientDate。
|
|
401
|
+
*/
|
|
402
|
+
export function deriveSalientDateFromText(title, content) {
|
|
403
|
+
const dates = new Set()
|
|
404
|
+
for (const text of [title, content]) {
|
|
405
|
+
for (const match of String(text || '').matchAll(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/g)) {
|
|
406
|
+
const normalized = normalizeSalientDate(`${match[1]}-${match[2]}-${match[3]}`)
|
|
407
|
+
if (normalized && isDatedSalientDate(normalized)) dates.add(normalized)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return dates.size === 1 ? [...dates][0] : null
|
|
411
|
+
}
|
|
412
|
+
|
|
240
413
|
export function reconcileSalientDate(value, text, now = new Date()) {
|
|
241
414
|
if (!SALIENT_SHORT.test(String(value || ''))) return value
|
|
242
415
|
const body = String(text || '')
|
|
@@ -559,6 +732,108 @@ function localRecall(index, query, filter = () => true) {
|
|
|
559
732
|
}
|
|
560
733
|
}
|
|
561
734
|
|
|
735
|
+
/** The per-route candidate cap for a requested topK; bounded on both ends. */
|
|
736
|
+
export function hybridCandidateLimit(topK) {
|
|
737
|
+
const wanted = Math.max(1, Number(topK) || 1)
|
|
738
|
+
return Math.max(HYBRID_CANDIDATE_MIN, Math.min(HYBRID_CANDIDATE_MAX, wanted * 4))
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Reciprocal-rank fusion of lexical index hits and MemU vector hits.
|
|
743
|
+
*
|
|
744
|
+
* Filtering happens before the per-route cap and before fusion: a pending review,
|
|
745
|
+
* an out-of-context entry or an automatic secret must not occupy a candidate slot
|
|
746
|
+
* and then be dropped later. Ids are canonicalised against the live index so the
|
|
747
|
+
* two routes cannot return the same memory twice under a prefix or a .md suffix.
|
|
748
|
+
*
|
|
749
|
+
* The returned retrievalScore is the normalised RRF share (best fused hit = 1).
|
|
750
|
+
* recallScore consumes that value instead of recomputing 1/(rank+1), so the
|
|
751
|
+
* fusion order is not silently overwritten by the old rank decay.
|
|
752
|
+
*/
|
|
753
|
+
export function fuseRecallCandidates({ local = [], semantic = [], index, limit = HYBRID_CANDIDATE_MIN, automatic = false, sessionId = null, now = Date.now(), route = 'vector-rrf' } = {}) {
|
|
754
|
+
const entries = index?.entries || {}
|
|
755
|
+
const canonical = (ref) => {
|
|
756
|
+
const raw = String(ref || '').trim()
|
|
757
|
+
if (!raw) return null
|
|
758
|
+
try { return resolveEntryId(index, raw) } catch {
|
|
759
|
+
const clean = raw.replace(/\.md$/i, '')
|
|
760
|
+
return entries[clean] ? clean : null
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const eligible = (file, route, rank) => {
|
|
764
|
+
const id = canonical(file?.name)
|
|
765
|
+
if (!id) return null
|
|
766
|
+
const entry = entries[id] || file?.entry || null
|
|
767
|
+
if (!entry) return null
|
|
768
|
+
if (!isMemoryEligible(entry, { automatic, now })) return null
|
|
769
|
+
if (!visibleToSession(entry, sessionId)) return null
|
|
770
|
+
if (automatic && entry.visibility === 'secret') return null
|
|
771
|
+
return { id, entry, file, route, rank }
|
|
772
|
+
}
|
|
773
|
+
const take = (files, route) => {
|
|
774
|
+
const picked = []
|
|
775
|
+
// One route can name the same canonical entry more than once: a caller may
|
|
776
|
+
// pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
|
|
777
|
+
// canonical id before scoring and truncating, keeping the first (best) rank.
|
|
778
|
+
// Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
|
|
779
|
+
const seen = new Set()
|
|
780
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
781
|
+
const item = eligible(file, route, picked.length + 1)
|
|
782
|
+
if (!item || seen.has(item.id)) continue
|
|
783
|
+
seen.add(item.id)
|
|
784
|
+
picked.push(item)
|
|
785
|
+
if (picked.length >= limit) break
|
|
786
|
+
}
|
|
787
|
+
return picked
|
|
788
|
+
}
|
|
789
|
+
const localPicked = take(local, 'local')
|
|
790
|
+
const semanticPicked = take(semantic, 'semantic')
|
|
791
|
+
const merged = new Map()
|
|
792
|
+
const add = (item) => {
|
|
793
|
+
const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
|
|
794
|
+
current.rrf += 1 / (HYBRID_RRF_K + item.rank)
|
|
795
|
+
current.bestRank = Math.min(current.bestRank, item.rank)
|
|
796
|
+
if (item.route === 'local') current.localRank = item.rank
|
|
797
|
+
else current.semanticRank = item.rank
|
|
798
|
+
if (!current.routes.includes(item.route)) current.routes.push(item.route)
|
|
799
|
+
// Prefer the local object when both routes carry the entry: it already has the
|
|
800
|
+
// lexical score and the index-backed content used below by enrichFiles.
|
|
801
|
+
if (item.route === 'local') current.file = item.file
|
|
802
|
+
merged.set(item.id, current)
|
|
803
|
+
}
|
|
804
|
+
for (const item of localPicked) add(item)
|
|
805
|
+
for (const item of semanticPicked) add(item)
|
|
806
|
+
const rows = [...merged.values()]
|
|
807
|
+
const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
|
|
808
|
+
const files = rows
|
|
809
|
+
.map((row) => ({
|
|
810
|
+
...row.file,
|
|
811
|
+
name: row.id,
|
|
812
|
+
entry: row.entry,
|
|
813
|
+
retrievalScore: row.rrf / maxRrf,
|
|
814
|
+
fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
|
|
815
|
+
}))
|
|
816
|
+
.sort((a, b) => b.retrievalScore - a.retrievalScore
|
|
817
|
+
|| a.fusion.bestRank - b.fusion.bestRank
|
|
818
|
+
|| a.name.localeCompare(b.name))
|
|
819
|
+
const localIds = new Set(localPicked.map((item) => item.id))
|
|
820
|
+
const semanticIds = new Set(semanticPicked.map((item) => item.id))
|
|
821
|
+
return {
|
|
822
|
+
files,
|
|
823
|
+
debug: {
|
|
824
|
+
route,
|
|
825
|
+
k: HYBRID_RRF_K,
|
|
826
|
+
candidateLimit: limit,
|
|
827
|
+
localCandidates: localPicked.length,
|
|
828
|
+
semanticCandidates: semanticPicked.length,
|
|
829
|
+
fused: files.length,
|
|
830
|
+
localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
|
|
831
|
+
semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
|
|
832
|
+
shared: [...localIds].filter((id) => semanticIds.has(id)).length,
|
|
833
|
+
},
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
562
837
|
// Human-like recall ordering. Retrieval answers first (semantic match is still
|
|
563
838
|
// the primary signal, which is why it stays a rank decay), then these biases
|
|
564
839
|
// re-order within that set:
|
|
@@ -568,15 +843,25 @@ function localRecall(index, query, filter = () => true) {
|
|
|
568
843
|
// Every factor is bounded so no bias can outrank a good semantic hit by luck.
|
|
569
844
|
function recallScore(file, rank, context) {
|
|
570
845
|
const entry = file.entry || {}
|
|
571
|
-
|
|
846
|
+
// A fused file carries its own normalised RRF share. Falling back to the old
|
|
847
|
+
// rank decay keeps every lexical caller byte-for-byte compatible.
|
|
848
|
+
const fused = Number(file.retrievalScore)
|
|
849
|
+
const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
|
|
572
850
|
const strengthTerm = 0.6 + 0.4 * file.strength
|
|
573
851
|
const marginal = entry.marginalized ? 0.35 : 1
|
|
574
852
|
|
|
575
853
|
// Fading affect bias: what pulls is the tint the memory still carries today, not
|
|
576
854
|
// the one it was written with. Old grievances quieten down; old fondness does not.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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))
|
|
580
865
|
const peak = entry.peak === true ? 1 : Math.max(0, Number(entry.arousal || 0))
|
|
581
866
|
const end = context.lastCreatedAt && file.createdAtIdentity === context.lastCreatedAt ? 1 : 0
|
|
582
867
|
const peakEnd = 1 + 0.25 * Math.max(peak, end)
|
|
@@ -588,13 +873,33 @@ function recallScore(file, rank, context) {
|
|
|
588
873
|
// this asks "does its loudness match the room right now?" — different questions that
|
|
589
874
|
// only look like duplicates.
|
|
590
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
|
|
591
880
|
// An open loop is an unfinished thread — a promise, a question, a plan nobody
|
|
592
881
|
// closed. Those are what a person brings up unprompted, so they get a nudge and not
|
|
593
882
|
// a trump card: 1.25 still loses to a clearly better semantic hit.
|
|
594
883
|
const openLoopBias = entry.openLoop ? 1.25 : 1
|
|
595
884
|
const habituation = habituationFactor(entry, context.now)
|
|
596
885
|
|
|
597
|
-
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)
|
|
598
903
|
}
|
|
599
904
|
|
|
600
905
|
/**
|
|
@@ -608,9 +913,12 @@ function recallScore(file, rank, context) {
|
|
|
608
913
|
function arousalBias(entry, context) {
|
|
609
914
|
const present = Number(context.arousal == null ? BASE_AROUSAL : context.arousal)
|
|
610
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.
|
|
611
918
|
const intensity = Math.max(
|
|
612
919
|
entry.peak === true ? 1 : 0,
|
|
613
920
|
Math.min(1, Math.max(0, Number(entry.arousal || 0))),
|
|
921
|
+
Math.min(1, maxShare(entry.emotion)),
|
|
614
922
|
Math.min(1, Math.abs(Number(entry.moodValence || 0)) * 0.8),
|
|
615
923
|
)
|
|
616
924
|
return 1 + 0.5 * drive * (intensity - 0.5)
|
|
@@ -644,7 +952,9 @@ function privateWeighted(result, max, context = {}) {
|
|
|
644
952
|
const ctx = {
|
|
645
953
|
moodEnabled: false,
|
|
646
954
|
moodValence: 0,
|
|
955
|
+
emotion: null,
|
|
647
956
|
arousal: BASE_AROUSAL,
|
|
957
|
+
dominance: 0,
|
|
648
958
|
firstCreatedAt: null,
|
|
649
959
|
lastCreatedAt: null,
|
|
650
960
|
sessionId: null,
|
|
@@ -738,11 +1048,66 @@ function createdAtSpan(files) {
|
|
|
738
1048
|
return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
|
|
739
1049
|
}
|
|
740
1050
|
|
|
741
|
-
|
|
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
|
+
|
|
1105
|
+
async function privateRememberImpl({
|
|
742
1106
|
exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
|
|
743
1107
|
sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
|
|
744
1108
|
owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
|
|
745
1109
|
moodValence = null, arousal = null, peak = false, salientDate = null,
|
|
1110
|
+
emotion = undefined, dominance = undefined, emotionSource = null,
|
|
746
1111
|
attachments, track = 'memory',
|
|
747
1112
|
context,
|
|
748
1113
|
}) {
|
|
@@ -771,8 +1136,14 @@ export async function privateRemember({
|
|
|
771
1136
|
// An unreadable date is refused instead of dropped. Dropping it looked like
|
|
772
1137
|
// success to the caller while losing the only part of the memory it asked for:
|
|
773
1138
|
// a date that never fires is indistinguishable from no date at all.
|
|
774
|
-
|
|
775
|
-
|
|
1139
|
+
let normalizedSalientDate = reconcileSalientDate(normalizeSalientDate(salientDate), `${title || ''} ${content || ''}`)
|
|
1140
|
+
// 模型常把日期写进正文却漏填 salient_date(详情页日期栏因此为空)。只有显式传了
|
|
1141
|
+
// 非法值才报错;什么都没传时,从文本取唯一的 YYYY-MM-DD 作为兜底。
|
|
1142
|
+
const explicitDate = salientDate !== null && salientDate !== undefined && String(salientDate).trim()
|
|
1143
|
+
if (normalizedSalientDate === null && !explicitDate) {
|
|
1144
|
+
normalizedSalientDate = deriveSalientDateFromText(title, content)
|
|
1145
|
+
}
|
|
1146
|
+
if (normalizedSalientDate === null && explicitDate) {
|
|
776
1147
|
throw new Error('salient_date must be MM-DD (recurs every year) or YYYY-MM-DD (one specific day)')
|
|
777
1148
|
}
|
|
778
1149
|
content = redactSecrets(content)
|
|
@@ -816,6 +1187,12 @@ export async function privateRemember({
|
|
|
816
1187
|
&& (item.attachments || []).some(attachment => media.some(candidate => candidate.sha256 === attachment.sha256)))
|
|
817
1188
|
: null
|
|
818
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 })
|
|
819
1196
|
const entry = {
|
|
820
1197
|
...(previousHabit?.[1] || {}),
|
|
821
1198
|
track,
|
|
@@ -848,9 +1225,18 @@ export async function privateRemember({
|
|
|
848
1225
|
// Retrieval lens + resurface metadata. When the agent does not state a mood,
|
|
849
1226
|
// the current session read is stored instead, so a memory written during a
|
|
850
1227
|
// hard conversation is tinted by it without the model having to name it.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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,
|
|
854
1240
|
arousal: arousal === null || arousal === undefined
|
|
855
1241
|
? (binding?.mood ? Number(binding.mood.arousal) || 0.35 : 0.35)
|
|
856
1242
|
: Math.max(0, Math.min(1, Number(arousal) || 0)),
|
|
@@ -885,9 +1271,26 @@ export async function privateRemember({
|
|
|
885
1271
|
// A held (pending_review) claim stays index-only: committing it to MemU now
|
|
886
1272
|
// would make a contested memory retrievable before anyone decided it. Accepting
|
|
887
1273
|
// the review later is what commits it (see resolveReview → privateUpdate).
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
1274
|
+
const route = engineRoute()
|
|
1275
|
+
const db = route !== 'lexical' && !conflict ? memoryDbPath(scope.libraryId) : null
|
|
1276
|
+
const result = db
|
|
1277
|
+
? await commit({ payload, route, db })
|
|
1278
|
+
: { ok: true, backend: route, held: conflict ? true : undefined }
|
|
1279
|
+
try {
|
|
1280
|
+
// 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
|
|
1281
|
+
// 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
|
|
1282
|
+
if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
|
|
1283
|
+
const active = readVectorState({ tolerateMissing: true }).state?.active
|
|
1284
|
+
if (active) {
|
|
1285
|
+
entry.vector = {
|
|
1286
|
+
route,
|
|
1287
|
+
model: active.profile?.catalogId || null,
|
|
1288
|
+
generation: active.generation,
|
|
1289
|
+
fingerprint: String(active.fingerprint || '').slice(0, 8),
|
|
1290
|
+
at: stamp(),
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
891
1294
|
// Re-read after the await: a curator job or another turn may have written in
|
|
892
1295
|
// the meantime, and saving the pre-await snapshot would erase it.
|
|
893
1296
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
@@ -897,6 +1300,7 @@ export async function privateRemember({
|
|
|
897
1300
|
if (previousHabit && JSON.stringify(fresh.entries[id]) !== JSON.stringify(previousHabit[1])) throw new Error('habit changed concurrently; recall and retry')
|
|
898
1301
|
const latestConflict = findConflict(Object.fromEntries(Object.entries(fresh.entries).filter(([key]) => key !== id)), entry)
|
|
899
1302
|
if (latestConflict && !conflict) {
|
|
1303
|
+
if (db) throw new Error('memory conflict changed during write; retry for review')
|
|
900
1304
|
entry.status = 'pending_review'
|
|
901
1305
|
entry.conflictingWith = latestConflict.id
|
|
902
1306
|
entry.reviewReason = 'concurrent conflicting memory; review required'
|
|
@@ -907,6 +1311,10 @@ export async function privateRemember({
|
|
|
907
1311
|
}
|
|
908
1312
|
fresh.entries[id] = entry
|
|
909
1313
|
}))
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (db) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id, route, db, expectedEntry: previousHabit?.[1] || null })
|
|
1316
|
+
throw error
|
|
1317
|
+
}
|
|
910
1318
|
if (!previousHabit) bumpStats(scope.libraryId, 'written')
|
|
911
1319
|
if (layer === 'must_remember' && pinSource === 'side_event') {
|
|
912
1320
|
consumeCue(sessionIdOf(exec), sideEventId)
|
|
@@ -918,7 +1326,7 @@ export async function privateRemember({
|
|
|
918
1326
|
// writes the index directly instead of going through privateRemember: the pin cap,
|
|
919
1327
|
// the short-term deadline and the "no duplicate text" check would otherwise turn a
|
|
920
1328
|
// faithful restore into a pile of downgrades.
|
|
921
|
-
|
|
1329
|
+
async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
|
|
922
1330
|
const library = getLibrary(libraryId)
|
|
923
1331
|
if (!library) throw new Error('library not found')
|
|
924
1332
|
const list = Array.isArray(memories) ? memories : []
|
|
@@ -1048,7 +1456,9 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1048
1456
|
// committed there too or they would only show up in the browser. Best effort: a
|
|
1049
1457
|
// failed commit must not roll back a restore that already passed validation.
|
|
1050
1458
|
let engineWarning = null
|
|
1051
|
-
|
|
1459
|
+
const route = engineRoute()
|
|
1460
|
+
const managed = route === 'managed-local'
|
|
1461
|
+
if (route !== 'lexical') {
|
|
1052
1462
|
try {
|
|
1053
1463
|
await commit({
|
|
1054
1464
|
payload: {
|
|
@@ -1061,9 +1471,10 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1061
1471
|
resource: [],
|
|
1062
1472
|
user: {},
|
|
1063
1473
|
},
|
|
1064
|
-
db: memoryDbPath(libraryId),
|
|
1474
|
+
route, db: memoryDbPath(libraryId),
|
|
1065
1475
|
})
|
|
1066
1476
|
} catch (error) {
|
|
1477
|
+
if (managed || isVectorStateError(error)) throw error
|
|
1067
1478
|
engineWarning = String(error?.message || error)
|
|
1068
1479
|
}
|
|
1069
1480
|
}
|
|
@@ -1078,38 +1489,41 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1078
1489
|
bumpStats(libraryId, 'written', entries.length)
|
|
1079
1490
|
return { imported: entries.length, ids: entries.map((item) => item.id), idMap, counts, examples, engineWarning }
|
|
1080
1491
|
}
|
|
1081
|
-
export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit' }) {
|
|
1082
|
-
// Reads must not materialize a library; only the explicit new-session choice
|
|
1083
|
-
// (or a user-initiated write) is allowed to create one.
|
|
1492
|
+
export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit', retrieveImpl = retrieve }) {
|
|
1084
1493
|
const binding = peekBinding(exec)
|
|
1085
1494
|
if (!binding) throw new Error('no bound session')
|
|
1086
|
-
// An opt-out is not a missing binding. Reporting both as "no bound session" sent
|
|
1087
|
-
// the reader looking for a broken session when the conversation simply has no
|
|
1088
|
-
// library to read.
|
|
1089
1495
|
if (!binding.libraryId) throw new Error('this conversation has no memory library')
|
|
1090
1496
|
assertScopeEnabled(binding)
|
|
1091
1497
|
const settings = getSettings()
|
|
1092
|
-
// The caller may hand in its own settings snapshot (injection carries one for the
|
|
1093
|
-
// turn); falling back to the stored defaults keeps tool-initiated reads identical.
|
|
1094
1498
|
const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
|
|
1095
1499
|
let index = loadPrivateIndex(binding.libraryId)
|
|
1096
1500
|
const byId = /^(?:g?mem)[-_][a-z0-9_-]+(?:\.md)?$/i.test(String(query || '').trim())
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1501
|
+
const route = effectiveVectorRoute(settings)
|
|
1502
|
+
const wanted = Number(topK || settings.privateTopK || 4)
|
|
1503
|
+
const result = route === 'lexical' || byId
|
|
1504
|
+
? { ...localRecall(index, query), recallRoute: 'local' }
|
|
1505
|
+
: await managedRecall({ exec, index, query, topK: wanted, libraryId: binding.libraryId, automatic: purpose === 'automatic', intent: managedVectorIntent(), retrieveImpl, byId })
|
|
1100
1506
|
const current = peekBinding(exec)
|
|
1101
1507
|
if (!current || current.libraryId !== binding.libraryId) throw new Error('memory scope changed during recall; retry')
|
|
1102
1508
|
assertScopeEnabled(current)
|
|
1103
1509
|
index = loadPrivateIndex(binding.libraryId)
|
|
1104
1510
|
const enriched = enrichFiles(result, index)
|
|
1511
|
+
// enrichFiles rebuilds the result object; carry the route/error metadata across
|
|
1512
|
+
// so buildRecall can report a degraded semantic leg and the tool can warn.
|
|
1513
|
+
if (result?.recallRoute) enriched.recallRoute = result.recallRoute
|
|
1514
|
+
if (result?.errors) enriched.errors = result.errors.slice()
|
|
1515
|
+
if (result?.fusion) enriched.fusion = result.fusion
|
|
1105
1516
|
const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
|
|
1106
1517
|
const mood = moodDecay(binding.mood)
|
|
1107
1518
|
const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
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 }),
|
|
1111
1523
|
moodValence: mood.valence,
|
|
1524
|
+
emotion: mood.emotion,
|
|
1112
1525
|
arousal: mood.arousal,
|
|
1526
|
+
dominance: mood.dominance,
|
|
1113
1527
|
firstCreatedAt: span.first,
|
|
1114
1528
|
lastCreatedAt: span.last,
|
|
1115
1529
|
relevanceThreshold: threshold,
|
|
@@ -1133,15 +1547,24 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1133
1547
|
return scored
|
|
1134
1548
|
}
|
|
1135
1549
|
|
|
1136
|
-
export async function privateList({ exec, libraryId }) {
|
|
1550
|
+
export async function privateList({ exec, libraryId, preferIndex = false }) {
|
|
1137
1551
|
const scope = resolveLibrary({ exec, libraryId, readOnly: true })
|
|
1138
1552
|
if (scope.binding) assertScopeEnabled(scope.binding)
|
|
1139
1553
|
const index = loadPrivateIndex(scope.libraryId)
|
|
1140
1554
|
const allowed = (entry) => !!entry && (!sessionIdOf(exec) || (isMemoryEligible(entry) && visibleToSession(entry, memorySessionId(exec))))
|
|
1141
|
-
|
|
1555
|
+
// UI 列表直接读索引:索引是权威,引擎只是一面镜子。走引擎意味着起 Python 和
|
|
1556
|
+
// embedding worker(秒级),归档/还原后刷新一下就卡住,这就是 2026-10-30 的现场。
|
|
1557
|
+
const route = preferIndex ? 'lexical' : engineRoute()
|
|
1558
|
+
// 引擎没有可用代际时列表照样能从索引给出答案,但必须说清为什么这里没有向量:
|
|
1559
|
+
// 静默返回"空库"曾被当成"记忆丢了"。
|
|
1560
|
+
const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
|
|
1561
|
+
? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
|
|
1562
|
+
: null
|
|
1563
|
+
if (route === 'lexical') {
|
|
1142
1564
|
return {
|
|
1143
1565
|
libraryId: scope.libraryId,
|
|
1144
1566
|
source: 'index',
|
|
1567
|
+
warning,
|
|
1145
1568
|
files: localIndexFiles(index).filter(file => allowed(file.entry)).map((file) => ({
|
|
1146
1569
|
name: file.name,
|
|
1147
1570
|
description: file.description,
|
|
@@ -1152,11 +1575,12 @@ export async function privateList({ exec, libraryId }) {
|
|
|
1152
1575
|
parseWarning: null,
|
|
1153
1576
|
}
|
|
1154
1577
|
}
|
|
1155
|
-
const result = await listFiles({ db: memoryDbPath(scope.libraryId) })
|
|
1578
|
+
const result = await listFiles({ route, db: memoryDbPath(scope.libraryId) })
|
|
1156
1579
|
const files = Array.isArray(result?.recall_files) ? result.recall_files : []
|
|
1157
1580
|
return {
|
|
1158
1581
|
libraryId: scope.libraryId,
|
|
1159
1582
|
source: 'engine',
|
|
1583
|
+
warning: null,
|
|
1160
1584
|
files: files.filter(file => allowed(index.entries[file.name])).map((file) => ({
|
|
1161
1585
|
...file,
|
|
1162
1586
|
entry: index.entries[file.name] || null,
|
|
@@ -1171,7 +1595,7 @@ export async function privateList({ exec, libraryId }) {
|
|
|
1171
1595
|
// mood lens and the attachment list all change how an entry is scored, aggregated or
|
|
1172
1596
|
// rendered, so a wrong value in any of them was a permanent mis-filing that could
|
|
1173
1597
|
// only be undone by archiving the memory and writing it again.
|
|
1174
|
-
|
|
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 }) {
|
|
1175
1599
|
// `source` is editable because it is a claim about provenance, and claims get
|
|
1176
1600
|
// corrected: a memory the model first filed as `inferred` may turn out to be
|
|
1177
1601
|
// something the user actually said. Enum-checked like the rest — a typo here
|
|
@@ -1193,6 +1617,8 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1193
1617
|
if (confidence !== undefined && !Number.isFinite(Number(confidence))) throw new Error('confidence must be a number between 0 and 1')
|
|
1194
1618
|
if (moodValence !== undefined && !Number.isFinite(Number(moodValence))) throw new Error('moodValence must be a number between -1 and 1')
|
|
1195
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')
|
|
1196
1622
|
// Throws for anything the library never received: an attachment list you cannot
|
|
1197
1623
|
// correct would leave a mis-filed image attached forever.
|
|
1198
1624
|
const nextAttachments = attachments === undefined ? undefined : attachmentRefs(scope.libraryId, attachments)
|
|
@@ -1205,22 +1631,29 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1205
1631
|
const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
|
|
1206
1632
|
if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
|
|
1207
1633
|
}
|
|
1208
|
-
|
|
1634
|
+
const route = engineRoute()
|
|
1635
|
+
const managed = route === 'managed-local'
|
|
1636
|
+
let committedDb = null
|
|
1637
|
+
if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
|
|
1209
1638
|
const payload = {
|
|
1210
1639
|
recall_files: [{
|
|
1211
1640
|
name: id_,
|
|
1212
1641
|
track: entry.track || 'memory',
|
|
1213
1642
|
description: clampText(title || entry.title || nextContent, 180),
|
|
1214
|
-
content: nextContent,
|
|
1643
|
+
content: nextContent === undefined ? entry.content : nextContent,
|
|
1215
1644
|
}],
|
|
1216
1645
|
resource: [],
|
|
1217
1646
|
user: {},
|
|
1218
1647
|
}
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1648
|
+
if (route !== 'lexical') {
|
|
1649
|
+
const db = memoryDbPath(scope.libraryId)
|
|
1650
|
+
await commit({ payload, route, db })
|
|
1651
|
+
committedDb = db
|
|
1221
1652
|
}
|
|
1222
1653
|
}
|
|
1223
|
-
|
|
1654
|
+
let updated
|
|
1655
|
+
try {
|
|
1656
|
+
updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1224
1657
|
const target = fresh.entries[id_]
|
|
1225
1658
|
if (!target) throw new Error(`unknown memory: ${id_}`)
|
|
1226
1659
|
if (JSON.stringify(target) !== JSON.stringify(entry)) throw new Error('memory changed concurrently; reload and retry')
|
|
@@ -1263,6 +1696,21 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1263
1696
|
if (peak !== undefined) target.peak = peak === true
|
|
1264
1697
|
if (moodValence !== undefined) target.moodValence = Math.max(-1, Math.min(1, Number(moodValence)))
|
|
1265
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)))
|
|
1266
1714
|
if (nextAttachments !== undefined) target.attachments = nextAttachments
|
|
1267
1715
|
// Vividness and accuracy are derived, not typed in: correcting the peak flag or
|
|
1268
1716
|
// the arousal reading has to move `accuracy` too, otherwise the repair would only
|
|
@@ -1302,12 +1750,16 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1302
1750
|
}
|
|
1303
1751
|
target.updatedAt = stamp()
|
|
1304
1752
|
}))
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
if (committedDb) await compensatePrivateCommit(error, { libraryId: scope.libraryId, id: id_, route, db: committedDb, expectedEntry: entry })
|
|
1755
|
+
throw error
|
|
1756
|
+
}
|
|
1305
1757
|
if (status === 'archived' && !wasArchived) bumpStats(scope.libraryId, 'archived')
|
|
1306
1758
|
return { id: id_, entry: updated.entries[id_] }
|
|
1307
1759
|
}
|
|
1308
1760
|
|
|
1309
1761
|
/** Review activation and invalidation of the previous claim are one index commit. */
|
|
1310
|
-
|
|
1762
|
+
async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolution = 'replace', reason = '', content, title }) {
|
|
1311
1763
|
if (!['accept', 'reject'].includes(action)) throw new Error('action must be accept or reject')
|
|
1312
1764
|
if (!['replace', 'coexist'].includes(resolution)) throw new Error('resolution must be replace or coexist')
|
|
1313
1765
|
if (resolution === 'coexist' && String(reason).trim().length < 8) throw new Error('coexist needs a reason describing the distinct conditions (at least 8 characters)')
|
|
@@ -1319,9 +1771,11 @@ export async function resolveMemoryReview({ libraryId = null, id, action, resolu
|
|
|
1319
1771
|
if (action === 'accept') {
|
|
1320
1772
|
if (typeof content === 'string' && content.trim()) next.content = content
|
|
1321
1773
|
if (typeof title === 'string' && title.trim()) next.title = title
|
|
1322
|
-
|
|
1774
|
+
const reviewRoute = engineRoute()
|
|
1775
|
+
if (reviewRoute !== 'lexical') {
|
|
1323
1776
|
await commit({
|
|
1324
1777
|
payload: { recall_files: [{ name: id, track: next.track || 'memory', description: clampText(next.title, 180), content: next.content }], resource: [], user: {} },
|
|
1778
|
+
route: reviewRoute,
|
|
1325
1779
|
db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
|
|
1326
1780
|
})
|
|
1327
1781
|
}
|
|
@@ -1424,7 +1878,7 @@ export function privateArchive({ exec, libraryId, id }) {
|
|
|
1424
1878
|
* is still in the index and no longer in the engine — visible in the browser, still
|
|
1425
1879
|
* deletable, never recalled from a ghost row.
|
|
1426
1880
|
*/
|
|
1427
|
-
|
|
1881
|
+
async function privateDeleteImpl({ exec, libraryId, id }) {
|
|
1428
1882
|
const scope = resolveLibrary({ exec, libraryId })
|
|
1429
1883
|
if (scope.binding) assertScopeEnabled(scope.binding)
|
|
1430
1884
|
const index = loadPrivateIndex(scope.libraryId)
|
|
@@ -1435,10 +1889,10 @@ export async function privateDelete({ exec, libraryId, id }) {
|
|
|
1435
1889
|
if (!['archived', 'suppressed', 'expired'].includes(entry.status)) {
|
|
1436
1890
|
throw new Error('only archived memories can be deleted permanently; archive it first')
|
|
1437
1891
|
}
|
|
1438
|
-
const backend =
|
|
1892
|
+
const backend = engineRoute()
|
|
1439
1893
|
let engine = null
|
|
1440
|
-
if (backend
|
|
1441
|
-
engine = await forget({ db: memoryDbPath(scope.libraryId), name: id_ })
|
|
1894
|
+
if (backend !== 'lexical') {
|
|
1895
|
+
engine = await forget({ route: backend, db: memoryDbPath(scope.libraryId), name: id_ })
|
|
1442
1896
|
}
|
|
1443
1897
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1444
1898
|
if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
|
|
@@ -1507,6 +1961,78 @@ export function privateUnpin({ exec, id, reason }) {
|
|
|
1507
1961
|
return { id: resolvedId, entry: updated.entries[resolvedId], remaining: Math.max(0, budget - used - 1) }
|
|
1508
1962
|
}
|
|
1509
1963
|
|
|
1964
|
+
export async function privateRemember(args) {
|
|
1965
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1966
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl({ ...args, libraryId }))
|
|
1967
|
+
record({
|
|
1968
|
+
kind: 'write',
|
|
1969
|
+
scope: 'private',
|
|
1970
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
1971
|
+
libraryId,
|
|
1972
|
+
message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
|
|
1973
|
+
detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
|
|
1974
|
+
refs: result?.id ? [result.id] : [],
|
|
1975
|
+
})
|
|
1976
|
+
return result
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
export async function privateUpdate(args) {
|
|
1980
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1981
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl({ ...args, libraryId }))
|
|
1982
|
+
record({
|
|
1983
|
+
kind: 'write',
|
|
1984
|
+
scope: 'private',
|
|
1985
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
1986
|
+
libraryId,
|
|
1987
|
+
message: '更新 ' + (result?.id || args.id || '?') + (args.content ? ':' + clampText(args.content, 60) : ''),
|
|
1988
|
+
detail: { id: result?.id || args.id || null, fields: Object.keys(args).filter(key => key !== 'exec' && key !== 'id') },
|
|
1989
|
+
refs: result?.id || args.id ? [result?.id || args.id] : [],
|
|
1990
|
+
})
|
|
1991
|
+
return result
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export async function privateDelete(args) {
|
|
1995
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1996
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl({ ...args, libraryId }))
|
|
1997
|
+
record({
|
|
1998
|
+
kind: 'write',
|
|
1999
|
+
scope: 'private',
|
|
2000
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
2001
|
+
libraryId,
|
|
2002
|
+
message: '删除 ' + (args.id || '?'),
|
|
2003
|
+
detail: { id: args.id || null, deleted: result?.deleted !== false },
|
|
2004
|
+
refs: args.id ? [args.id] : [],
|
|
2005
|
+
})
|
|
2006
|
+
return result
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
export async function importMemories(args) {
|
|
2010
|
+
const result = await withManagedPrivateWrite(args.libraryId, () => importMemoriesImpl(args))
|
|
2011
|
+
record({
|
|
2012
|
+
kind: 'write',
|
|
2013
|
+
scope: 'private',
|
|
2014
|
+
libraryId: args.libraryId || null,
|
|
2015
|
+
message: '导入 ' + (result?.imported ?? 0) + ' 条' + (result?.engineWarning ? '(引擎警告:' + clampText(result.engineWarning, 80) + ')' : ''),
|
|
2016
|
+
detail: { imported: result?.imported ?? 0, counts: result?.counts || null, engineWarning: result?.engineWarning || null },
|
|
2017
|
+
refs: Array.isArray(result?.ids) ? result.ids.slice(0, 20) : [],
|
|
2018
|
+
})
|
|
2019
|
+
return result
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
export async function resolveMemoryReview(args) {
|
|
2023
|
+
const action = () => resolveMemoryReviewImpl(args)
|
|
2024
|
+
const result = args.libraryId ? await withManagedPrivateWrite(args.libraryId, action) : await withManagedGlobalWrite(action)
|
|
2025
|
+
record({
|
|
2026
|
+
kind: 'write',
|
|
2027
|
+
scope: args.libraryId ? 'private' : 'global',
|
|
2028
|
+
libraryId: args.libraryId || null,
|
|
2029
|
+
message: '复核 ' + (args.id || '?') + ' → ' + (args.action || '?'),
|
|
2030
|
+
detail: { id: args.id || null, action: args.action || null, status: result?.status || null },
|
|
2031
|
+
refs: args.id ? [args.id] : [],
|
|
2032
|
+
})
|
|
2033
|
+
return result
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1510
2036
|
export function appliesToDomain(entry, binding) {
|
|
1511
2037
|
const domain = domainOf(binding)
|
|
1512
2038
|
if (!entry) return domain === 'general'
|
|
@@ -1523,7 +2049,7 @@ export function appliesToDomain(entry, binding) {
|
|
|
1523
2049
|
return false
|
|
1524
2050
|
}
|
|
1525
2051
|
|
|
1526
|
-
export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
2052
|
+
export async function globalRecall({ exec, query, topK, relevanceThreshold, retrieveImpl = retrieve }) {
|
|
1527
2053
|
// peek, not ensure: injection has already bound any live session before a tool
|
|
1528
2054
|
// can run, so a missing binding here means a read from an unbound conversation.
|
|
1529
2055
|
// That fallback quietly narrows results to the `general` domain, so it is
|
|
@@ -1536,8 +2062,12 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1536
2062
|
const settings = getSettings()
|
|
1537
2063
|
const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
|
|
1538
2064
|
let index = loadGlobalIndex()
|
|
1539
|
-
const
|
|
1540
|
-
|
|
2065
|
+
const intent = managedVectorIntent()
|
|
2066
|
+
const domainIndex = { ...index, entries: Object.fromEntries(Object.entries(index.entries || {})
|
|
2067
|
+
.filter(([, entry]) => appliesToDomain(entry, binding))) }
|
|
2068
|
+
const result = effectiveVectorRoute() !== 'lexical'
|
|
2069
|
+
? await managedRecall({ exec, index: domainIndex, query, topK: topK || settings.globalTopK || 3,
|
|
2070
|
+
libraryId: null, automatic: true, intent, retrieveImpl })
|
|
1541
2071
|
: localRecall(index, query, (file) => appliesToDomain(file.entry, binding))
|
|
1542
2072
|
if (peeked) {
|
|
1543
2073
|
const current = peekBinding(exec)
|
|
@@ -1546,6 +2076,9 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1546
2076
|
}
|
|
1547
2077
|
index = loadGlobalIndex()
|
|
1548
2078
|
const enriched = enrichFiles(result, index)
|
|
2079
|
+
if (result?.recallRoute) enriched.recallRoute = result.recallRoute
|
|
2080
|
+
if (result?.errors) enriched.errors = result.errors.slice()
|
|
2081
|
+
if (result?.fusion) enriched.fusion = result.fusion
|
|
1549
2082
|
const filtered = enriched.files
|
|
1550
2083
|
.filter((f) => isMemoryEligible(f.entry, { automatic: true }))
|
|
1551
2084
|
.filter((f) => appliesToDomain(f.entry, binding))
|
|
@@ -1554,7 +2087,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1554
2087
|
// Local lexical rank is already meaningful; confidence still nudges ties, and
|
|
1555
2088
|
// a cross-session fact that has been injected three turns running gets the same
|
|
1556
2089
|
// wear penalty as a private one.
|
|
1557
|
-
score: (f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
2090
|
+
score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
1558
2091
|
* (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
|
|
1559
2092
|
* habituationFactor(f.entry),
|
|
1560
2093
|
}))
|
|
@@ -1567,7 +2100,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1567
2100
|
}
|
|
1568
2101
|
}
|
|
1569
2102
|
|
|
1570
|
-
export async function globalList({ exec, domain }) {
|
|
2103
|
+
export async function globalList({ exec, domain, preferIndex = false }) {
|
|
1571
2104
|
const binding = peekBinding(exec)
|
|
1572
2105
|
if (binding) assertScopeEnabled(binding, { layer: 'global' })
|
|
1573
2106
|
const index = loadGlobalIndex()
|
|
@@ -1575,11 +2108,19 @@ export async function globalList({ exec, domain }) {
|
|
|
1575
2108
|
// "Optional domain filter", so a caller asking for one domain must not get all.
|
|
1576
2109
|
const domainFilter = (file) => (!domain || (file.entry?.domain || 'unknown') === domain)
|
|
1577
2110
|
&& (!sessionIdOf(exec) || (isMemoryEligible(file.entry) && appliesToDomain(file.entry, binding || { domain: 'general' })))
|
|
1578
|
-
|
|
2111
|
+
// 与 privateList 同一条口径:受管路由还没有 active 代际时,引擎必然抛
|
|
2112
|
+
// "managed local vector profile is not active";列表本身其实能答,就按索引答,
|
|
2113
|
+
// 并把原因带上。否则全局记忆页会闪一下红字再消失。
|
|
2114
|
+
const route = preferIndex ? 'lexical' : engineRoute()
|
|
2115
|
+
const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
|
|
2116
|
+
? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
|
|
2117
|
+
: null
|
|
2118
|
+
if (route === 'lexical') {
|
|
1579
2119
|
const files = localIndexFiles(index)
|
|
1580
2120
|
return {
|
|
1581
2121
|
domain: domain || (binding ? domainOf(binding) : 'general'),
|
|
1582
2122
|
source: 'index',
|
|
2123
|
+
warning,
|
|
1583
2124
|
files: (domainFilter ? files.filter(domainFilter) : files).map((file) => ({
|
|
1584
2125
|
name: file.name,
|
|
1585
2126
|
description: file.description,
|
|
@@ -1590,7 +2131,7 @@ export async function globalList({ exec, domain }) {
|
|
|
1590
2131
|
parseWarning: null,
|
|
1591
2132
|
}
|
|
1592
2133
|
}
|
|
1593
|
-
const result = await listFiles({ db: globalDbPath() })
|
|
2134
|
+
const result = await listFiles({ route, db: globalDbPath() })
|
|
1594
2135
|
const files = Array.isArray(result?.recall_files) ? result.recall_files : []
|
|
1595
2136
|
const enriched = files.map((file) => ({
|
|
1596
2137
|
...file,
|
|
@@ -1600,6 +2141,7 @@ export async function globalList({ exec, domain }) {
|
|
|
1600
2141
|
return {
|
|
1601
2142
|
domain: domain || (binding ? domainOf(binding) : 'general'),
|
|
1602
2143
|
source: 'engine',
|
|
2144
|
+
warning: null,
|
|
1603
2145
|
files: domainFilter ? enriched.filter(domainFilter) : enriched,
|
|
1604
2146
|
raw: result?.raw,
|
|
1605
2147
|
parseWarning: result?.parseWarning,
|