dsh-memu-memory 1.1.2 → 1.1.3
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 +21 -5
- package/README.zh.md +21 -5
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +32 -5
- package/docs/troubleshooting.md +36 -7
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +697 -49
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- 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 +131 -30
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -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 +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- 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/lib/memory.js
CHANGED
|
@@ -6,16 +6,18 @@ 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'
|
|
12
|
+
import { stamp, redactSecrets, retryStoreWrite, warn } from './paths.js'
|
|
13
|
+
import { record } from './activity-log.js'
|
|
13
14
|
import { BASE_AROUSAL, moodCongruence, moodDecay } from './mood.js'
|
|
14
15
|
import { commit, retrieve, listFiles, forget } from './memu.js'
|
|
15
16
|
import { bumpStats } from './stats.js'
|
|
16
17
|
import { recallRelations } from './graph.js'
|
|
17
18
|
import { attachmentRefs } from './media.js'
|
|
18
19
|
import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
|
|
20
|
+
import { assertGeneration, assertVectorWriteAllowed, captureGeneration, enrollActiveLibrary, hasActiveVectorGeneration, isVectorStateError, readVectorState, withVectorWrite } from './vector-profile.js'
|
|
19
21
|
|
|
20
22
|
const HALF_LIFE_DAYS = 14
|
|
21
23
|
// A short-term entry is meant to live for the length of a conversation, not a
|
|
@@ -35,6 +37,14 @@ const HABITUATION_FLOOR = 0.5
|
|
|
35
37
|
// library with three variants of the same decision would spend the whole block on it.
|
|
36
38
|
// The top hit is always kept; later picks pay lambda x max-similarity-to-what-is-in.
|
|
37
39
|
const MMR_LAMBDA = 0.72
|
|
40
|
+
// Semantic fusion uses RRF: each route contributes a
|
|
41
|
+
// rank term and the merged score is normalised to the best fused hit before the
|
|
42
|
+
// existing mood/strength/MMR pipeline sees it. The candidate cap keeps a long
|
|
43
|
+
// library from turning every turn into two full scans.
|
|
44
|
+
const HYBRID_RRF_K = 60
|
|
45
|
+
const HYBRID_CANDIDATE_MIN = 12
|
|
46
|
+
const HYBRID_CANDIDATE_MAX = 40
|
|
47
|
+
const HYBRID_SEMANTIC_TIMEOUT_MS = 15_000
|
|
38
48
|
const LAYERS = ['long_term', 'short_term', 'must_remember']
|
|
39
49
|
// These three are read back by the injector to decide how a memory may be spoken and
|
|
40
50
|
// where it may appear. An unchecked typo would silently mean "no suffix at all", so
|
|
@@ -51,6 +61,89 @@ const VISIBILITIES = STORED_VISIBILITIES
|
|
|
51
61
|
// quote, which systematically inflated confidence in things nobody had said.
|
|
52
62
|
const SOURCES = ['user_said', 'inferred', 'imagined', 'told']
|
|
53
63
|
|
|
64
|
+
function managedVectorIntent() {
|
|
65
|
+
if (effectiveVectorRoute() !== 'managed-local') return { managed: false, route: effectiveVectorRoute(), state: null, error: null }
|
|
66
|
+
try {
|
|
67
|
+
const state = readVectorState({ tolerateMissing: true }).state
|
|
68
|
+
return { managed: true, route: 'managed-local', state, error: null }
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { managed: true, route: 'managed-local', state: null, error }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 引擎调用用的路由。受管路由被选中但还没有任何代际时,MemU 的 envFor 会直接拒绝
|
|
75
|
+
// managed-local("managed local vector profile is not active"),写入/删除/列举都会连带
|
|
76
|
+
// 失败。这时引擎这一步按词法处理:索引照常写,等下一次迁移把历史补进向量库。
|
|
77
|
+
// 召回仍按原路由走,好让侧边栏继续显示降级原因,而不是悄悄退回词法。
|
|
78
|
+
function engineRoute() {
|
|
79
|
+
const route = effectiveVectorRoute()
|
|
80
|
+
return route === 'managed-local' && !hasActiveVectorGeneration() ? 'lexical' : route
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rememberRecallError(exec, error) {
|
|
84
|
+
const message = String(error?.message || error)
|
|
85
|
+
warn('managed vector recall degraded to lexical: ' + message)
|
|
86
|
+
const sessionId = sessionIdOf(exec)
|
|
87
|
+
if (sessionId) setRecallError(sessionId, message)
|
|
88
|
+
return message
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clearRecallError(exec) {
|
|
92
|
+
const sessionId = sessionIdOf(exec)
|
|
93
|
+
if (sessionId) setRecallError(sessionId, null)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function withManagedPrivateWrite(libraryId, action) {
|
|
97
|
+
const intent = managedVectorIntent()
|
|
98
|
+
if (!intent.managed) return action()
|
|
99
|
+
// 选了本地向量、但下载完模型后从没跑过迁移(vector-active 只有 requested):这里
|
|
100
|
+
// 没有可对齐的代际,写入必须降级成索引 + 引擎,而不是把整次整理写死。召回侧一直是
|
|
101
|
+
// 这个口径(local-fallback),写入侧漏了同样的降级 —— 结果就是整理跑完一条没写进来。
|
|
102
|
+
if (!hasActiveVectorGeneration()) {
|
|
103
|
+
warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
|
|
104
|
+
record({ kind: 'vector', level: 'warn', libraryId, message: '向量未激活,写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
|
|
105
|
+
return action()
|
|
106
|
+
}
|
|
107
|
+
return withVectorWrite(async () => {
|
|
108
|
+
assertVectorWriteAllowed()
|
|
109
|
+
enrollActiveLibrary(libraryId)
|
|
110
|
+
return action()
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function withManagedGlobalWrite(action) {
|
|
115
|
+
const intent = managedVectorIntent()
|
|
116
|
+
if (!intent.managed) return action()
|
|
117
|
+
if (!hasActiveVectorGeneration()) {
|
|
118
|
+
warn('managed vector write degraded to index+engine: no active vector generation; run the profile switch to enable vectors')
|
|
119
|
+
record({ kind: 'vector', level: 'warn', scope: 'global', message: '向量未激活,全局写入已降级为索引+引擎', detail: { reason: 'no active vector generation' } })
|
|
120
|
+
return action()
|
|
121
|
+
}
|
|
122
|
+
return withVectorWrite(() => { assertVectorWriteAllowed(); return action() })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function managedRecall({ exec, index, query, topK, libraryId, automatic = false, intent, retrieveImpl, byId = false }) {
|
|
126
|
+
const local = localRecall(index, query)
|
|
127
|
+
try {
|
|
128
|
+
if (intent.error) throw intent.error
|
|
129
|
+
const captured = intent.route === 'managed-local' ? captureGeneration() : null
|
|
130
|
+
if (byId) return { ...local, recallRoute: 'local' }
|
|
131
|
+
const db = captured ? (libraryId ? captured.libraries[libraryId] : captured.global) : (libraryId ? memoryDbPath(libraryId) : globalDbPath())
|
|
132
|
+
if (!db) throw new Error('managed vector generation has no database for library ' + libraryId)
|
|
133
|
+
const semantic = await retrieveImpl({ query, db, route: intent.route, timeoutMs: HYBRID_SEMANTIC_TIMEOUT_MS })
|
|
134
|
+
if (semantic?.parseWarning) throw new Error(semantic.parseWarning)
|
|
135
|
+
if (!Array.isArray(semantic?.files)) throw new Error('managed vector recall returned no file list')
|
|
136
|
+
if (captured) assertGeneration(captured)
|
|
137
|
+
const fused = fuseRecallCandidates({ local: local.files, semantic: semantic.files, index,
|
|
138
|
+
limit: hybridCandidateLimit(topK), automatic, sessionId: memorySessionId(exec), route: 'managed-vector-rrf' })
|
|
139
|
+
clearRecallError(exec)
|
|
140
|
+
return { ...semantic, files: fused.files, recallRoute: 'managed-vector-rrf', fusion: fused.debug }
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const reason = rememberRecallError(exec, error)
|
|
143
|
+
return { ...local, recallRoute: 'local-fallback', errors: ['semantic recall failed: ' + reason] }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
54
147
|
function checkEnums({ disclosure, owner, visibility, source }) {
|
|
55
148
|
if (disclosure !== undefined && !DISCLOSURES.includes(disclosure)) {
|
|
56
149
|
throw new Error(`disclosure must be ${DISCLOSURES.join(', ')}`)
|
|
@@ -237,6 +330,22 @@ const RELATIVE_DAY_OFFSETS = [
|
|
|
237
330
|
[/tomorrow/i, 1], [/明天|明日/, 1], [/tonight/i, 0], [/today/i, 0], [/今天|今晚/, 0],
|
|
238
331
|
]
|
|
239
332
|
|
|
333
|
+
/**
|
|
334
|
+
* 从标题/正文里取唯一的显式日期,作为"模型忘了填 salient_date"的兜底。
|
|
335
|
+
* 只接受全篇唯一的 YYYY-MM-DD;出现多个候选就不猜(宁可为空,也不把日志里的日期
|
|
336
|
+
* 误装成纪念日)。相对日期不在这里解析,交给 reconcileSalientDate。
|
|
337
|
+
*/
|
|
338
|
+
export function deriveSalientDateFromText(title, content) {
|
|
339
|
+
const dates = new Set()
|
|
340
|
+
for (const text of [title, content]) {
|
|
341
|
+
for (const match of String(text || '').matchAll(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/g)) {
|
|
342
|
+
const normalized = normalizeSalientDate(`${match[1]}-${match[2]}-${match[3]}`)
|
|
343
|
+
if (normalized && isDatedSalientDate(normalized)) dates.add(normalized)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return dates.size === 1 ? [...dates][0] : null
|
|
347
|
+
}
|
|
348
|
+
|
|
240
349
|
export function reconcileSalientDate(value, text, now = new Date()) {
|
|
241
350
|
if (!SALIENT_SHORT.test(String(value || ''))) return value
|
|
242
351
|
const body = String(text || '')
|
|
@@ -559,6 +668,108 @@ function localRecall(index, query, filter = () => true) {
|
|
|
559
668
|
}
|
|
560
669
|
}
|
|
561
670
|
|
|
671
|
+
/** The per-route candidate cap for a requested topK; bounded on both ends. */
|
|
672
|
+
export function hybridCandidateLimit(topK) {
|
|
673
|
+
const wanted = Math.max(1, Number(topK) || 1)
|
|
674
|
+
return Math.max(HYBRID_CANDIDATE_MIN, Math.min(HYBRID_CANDIDATE_MAX, wanted * 4))
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Reciprocal-rank fusion of lexical index hits and MemU vector hits.
|
|
679
|
+
*
|
|
680
|
+
* Filtering happens before the per-route cap and before fusion: a pending review,
|
|
681
|
+
* an out-of-context entry or an automatic secret must not occupy a candidate slot
|
|
682
|
+
* and then be dropped later. Ids are canonicalised against the live index so the
|
|
683
|
+
* two routes cannot return the same memory twice under a prefix or a .md suffix.
|
|
684
|
+
*
|
|
685
|
+
* The returned retrievalScore is the normalised RRF share (best fused hit = 1).
|
|
686
|
+
* recallScore consumes that value instead of recomputing 1/(rank+1), so the
|
|
687
|
+
* fusion order is not silently overwritten by the old rank decay.
|
|
688
|
+
*/
|
|
689
|
+
export function fuseRecallCandidates({ local = [], semantic = [], index, limit = HYBRID_CANDIDATE_MIN, automatic = false, sessionId = null, now = Date.now(), route = 'vector-rrf' } = {}) {
|
|
690
|
+
const entries = index?.entries || {}
|
|
691
|
+
const canonical = (ref) => {
|
|
692
|
+
const raw = String(ref || '').trim()
|
|
693
|
+
if (!raw) return null
|
|
694
|
+
try { return resolveEntryId(index, raw) } catch {
|
|
695
|
+
const clean = raw.replace(/\.md$/i, '')
|
|
696
|
+
return entries[clean] ? clean : null
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const eligible = (file, route, rank) => {
|
|
700
|
+
const id = canonical(file?.name)
|
|
701
|
+
if (!id) return null
|
|
702
|
+
const entry = entries[id] || file?.entry || null
|
|
703
|
+
if (!entry) return null
|
|
704
|
+
if (!isMemoryEligible(entry, { automatic, now })) return null
|
|
705
|
+
if (!visibleToSession(entry, sessionId)) return null
|
|
706
|
+
if (automatic && entry.visibility === 'secret') return null
|
|
707
|
+
return { id, entry, file, route, rank }
|
|
708
|
+
}
|
|
709
|
+
const take = (files, route) => {
|
|
710
|
+
const picked = []
|
|
711
|
+
// One route can name the same canonical entry more than once: a caller may
|
|
712
|
+
// pass both `mem-x` and `mem-x.md`, and an engine can repeat a hit. Dedupe by
|
|
713
|
+
// canonical id before scoring and truncating, keeping the first (best) rank.
|
|
714
|
+
// Otherwise every duplicate adds another 1/(k+rank) and consumes a slot.
|
|
715
|
+
const seen = new Set()
|
|
716
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
717
|
+
const item = eligible(file, route, picked.length + 1)
|
|
718
|
+
if (!item || seen.has(item.id)) continue
|
|
719
|
+
seen.add(item.id)
|
|
720
|
+
picked.push(item)
|
|
721
|
+
if (picked.length >= limit) break
|
|
722
|
+
}
|
|
723
|
+
return picked
|
|
724
|
+
}
|
|
725
|
+
const localPicked = take(local, 'local')
|
|
726
|
+
const semanticPicked = take(semantic, 'semantic')
|
|
727
|
+
const merged = new Map()
|
|
728
|
+
const add = (item) => {
|
|
729
|
+
const current = merged.get(item.id) || { id: item.id, entry: item.entry, file: item.file, rrf: 0, bestRank: Infinity, localRank: null, semanticRank: null, routes: [] }
|
|
730
|
+
current.rrf += 1 / (HYBRID_RRF_K + item.rank)
|
|
731
|
+
current.bestRank = Math.min(current.bestRank, item.rank)
|
|
732
|
+
if (item.route === 'local') current.localRank = item.rank
|
|
733
|
+
else current.semanticRank = item.rank
|
|
734
|
+
if (!current.routes.includes(item.route)) current.routes.push(item.route)
|
|
735
|
+
// Prefer the local object when both routes carry the entry: it already has the
|
|
736
|
+
// lexical score and the index-backed content used below by enrichFiles.
|
|
737
|
+
if (item.route === 'local') current.file = item.file
|
|
738
|
+
merged.set(item.id, current)
|
|
739
|
+
}
|
|
740
|
+
for (const item of localPicked) add(item)
|
|
741
|
+
for (const item of semanticPicked) add(item)
|
|
742
|
+
const rows = [...merged.values()]
|
|
743
|
+
const maxRrf = rows.reduce((max, row) => Math.max(max, row.rrf), 0) || 1
|
|
744
|
+
const files = rows
|
|
745
|
+
.map((row) => ({
|
|
746
|
+
...row.file,
|
|
747
|
+
name: row.id,
|
|
748
|
+
entry: row.entry,
|
|
749
|
+
retrievalScore: row.rrf / maxRrf,
|
|
750
|
+
fusion: { rrf: row.rrf, bestRank: row.bestRank, localRank: row.localRank, semanticRank: row.semanticRank, routes: row.routes.slice() },
|
|
751
|
+
}))
|
|
752
|
+
.sort((a, b) => b.retrievalScore - a.retrievalScore
|
|
753
|
+
|| a.fusion.bestRank - b.fusion.bestRank
|
|
754
|
+
|| a.name.localeCompare(b.name))
|
|
755
|
+
const localIds = new Set(localPicked.map((item) => item.id))
|
|
756
|
+
const semanticIds = new Set(semanticPicked.map((item) => item.id))
|
|
757
|
+
return {
|
|
758
|
+
files,
|
|
759
|
+
debug: {
|
|
760
|
+
route,
|
|
761
|
+
k: HYBRID_RRF_K,
|
|
762
|
+
candidateLimit: limit,
|
|
763
|
+
localCandidates: localPicked.length,
|
|
764
|
+
semanticCandidates: semanticPicked.length,
|
|
765
|
+
fused: files.length,
|
|
766
|
+
localOnly: [...localIds].filter((id) => !semanticIds.has(id)).length,
|
|
767
|
+
semanticOnly: [...semanticIds].filter((id) => !localIds.has(id)).length,
|
|
768
|
+
shared: [...localIds].filter((id) => semanticIds.has(id)).length,
|
|
769
|
+
},
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
562
773
|
// Human-like recall ordering. Retrieval answers first (semantic match is still
|
|
563
774
|
// the primary signal, which is why it stays a rank decay), then these biases
|
|
564
775
|
// re-order within that set:
|
|
@@ -568,7 +779,10 @@ function localRecall(index, query, filter = () => true) {
|
|
|
568
779
|
// Every factor is bounded so no bias can outrank a good semantic hit by luck.
|
|
569
780
|
function recallScore(file, rank, context) {
|
|
570
781
|
const entry = file.entry || {}
|
|
571
|
-
|
|
782
|
+
// A fused file carries its own normalised RRF share. Falling back to the old
|
|
783
|
+
// rank decay keeps every lexical caller byte-for-byte compatible.
|
|
784
|
+
const fused = Number(file.retrievalScore)
|
|
785
|
+
const retrieval = Number.isFinite(fused) ? Math.max(0, Math.min(1, fused)) : 1 / (rank + 1)
|
|
572
786
|
const strengthTerm = 0.6 + 0.4 * file.strength
|
|
573
787
|
const marginal = entry.marginalized ? 0.35 : 1
|
|
574
788
|
|
|
@@ -738,7 +952,7 @@ function createdAtSpan(files) {
|
|
|
738
952
|
return { first: stamps[0] || null, last: stamps[stamps.length - 1] || null }
|
|
739
953
|
}
|
|
740
954
|
|
|
741
|
-
|
|
955
|
+
async function privateRememberImpl({
|
|
742
956
|
exec, libraryId, content, title, layer, pinReason = null, pinSource = null,
|
|
743
957
|
sideEventId = null, tags = [], openLoop = false, disclosure = 'can_tell',
|
|
744
958
|
owner = 'me', visibility = 'shared', source = 'inferred', confidence = 0.8,
|
|
@@ -771,8 +985,14 @@ export async function privateRemember({
|
|
|
771
985
|
// An unreadable date is refused instead of dropped. Dropping it looked like
|
|
772
986
|
// success to the caller while losing the only part of the memory it asked for:
|
|
773
987
|
// a date that never fires is indistinguishable from no date at all.
|
|
774
|
-
|
|
775
|
-
|
|
988
|
+
let normalizedSalientDate = reconcileSalientDate(normalizeSalientDate(salientDate), `${title || ''} ${content || ''}`)
|
|
989
|
+
// 模型常把日期写进正文却漏填 salient_date(详情页日期栏因此为空)。只有显式传了
|
|
990
|
+
// 非法值才报错;什么都没传时,从文本取唯一的 YYYY-MM-DD 作为兜底。
|
|
991
|
+
const explicitDate = salientDate !== null && salientDate !== undefined && String(salientDate).trim()
|
|
992
|
+
if (normalizedSalientDate === null && !explicitDate) {
|
|
993
|
+
normalizedSalientDate = deriveSalientDateFromText(title, content)
|
|
994
|
+
}
|
|
995
|
+
if (normalizedSalientDate === null && explicitDate) {
|
|
776
996
|
throw new Error('salient_date must be MM-DD (recurs every year) or YYYY-MM-DD (one specific day)')
|
|
777
997
|
}
|
|
778
998
|
content = redactSecrets(content)
|
|
@@ -885,9 +1105,24 @@ export async function privateRemember({
|
|
|
885
1105
|
// A held (pending_review) claim stays index-only: committing it to MemU now
|
|
886
1106
|
// would make a contested memory retrievable before anyone decided it. Accepting
|
|
887
1107
|
// the review later is what commits it (see resolveReview → privateUpdate).
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
1108
|
+
const route = engineRoute()
|
|
1109
|
+
const result = (route !== 'lexical') && !conflict
|
|
1110
|
+
? await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
|
|
1111
|
+
: { ok: true, backend: route, held: conflict ? true : undefined }
|
|
1112
|
+
// 记下"这条进没进向量库":只有真的带代际提交成功才写,冲突暂扣/降级都不写。
|
|
1113
|
+
// 之前没有任何字段能回答"哪条记忆进向量库了",只能靠猜。
|
|
1114
|
+
if (route !== 'lexical' && !conflict && result?.ok !== false && !result?.parseWarning) {
|
|
1115
|
+
const active = readVectorState({ tolerateMissing: true }).state?.active
|
|
1116
|
+
if (active) {
|
|
1117
|
+
entry.vector = {
|
|
1118
|
+
route,
|
|
1119
|
+
model: active.profile?.catalogId || null,
|
|
1120
|
+
generation: active.generation,
|
|
1121
|
+
fingerprint: String(active.fingerprint || '').slice(0, 8),
|
|
1122
|
+
at: stamp(),
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
891
1126
|
// Re-read after the await: a curator job or another turn may have written in
|
|
892
1127
|
// the meantime, and saving the pre-await snapshot would erase it.
|
|
893
1128
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
@@ -918,7 +1153,7 @@ export async function privateRemember({
|
|
|
918
1153
|
// writes the index directly instead of going through privateRemember: the pin cap,
|
|
919
1154
|
// the short-term deadline and the "no duplicate text" check would otherwise turn a
|
|
920
1155
|
// faithful restore into a pile of downgrades.
|
|
921
|
-
|
|
1156
|
+
async function importMemoriesImpl({ libraryId, memories, maxFiles = 2000, now = Date.now() }) {
|
|
922
1157
|
const library = getLibrary(libraryId)
|
|
923
1158
|
if (!library) throw new Error('library not found')
|
|
924
1159
|
const list = Array.isArray(memories) ? memories : []
|
|
@@ -1048,7 +1283,9 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1048
1283
|
// committed there too or they would only show up in the browser. Best effort: a
|
|
1049
1284
|
// failed commit must not roll back a restore that already passed validation.
|
|
1050
1285
|
let engineWarning = null
|
|
1051
|
-
|
|
1286
|
+
const route = engineRoute()
|
|
1287
|
+
const managed = route === 'managed-local'
|
|
1288
|
+
if (route !== 'lexical') {
|
|
1052
1289
|
try {
|
|
1053
1290
|
await commit({
|
|
1054
1291
|
payload: {
|
|
@@ -1061,9 +1298,10 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1061
1298
|
resource: [],
|
|
1062
1299
|
user: {},
|
|
1063
1300
|
},
|
|
1064
|
-
db: memoryDbPath(libraryId),
|
|
1301
|
+
route, db: memoryDbPath(libraryId),
|
|
1065
1302
|
})
|
|
1066
1303
|
} catch (error) {
|
|
1304
|
+
if (managed || isVectorStateError(error)) throw error
|
|
1067
1305
|
engineWarning = String(error?.message || error)
|
|
1068
1306
|
}
|
|
1069
1307
|
}
|
|
@@ -1078,30 +1316,30 @@ export async function importMemories({ libraryId, memories, maxFiles = 2000, now
|
|
|
1078
1316
|
bumpStats(libraryId, 'written', entries.length)
|
|
1079
1317
|
return { imported: entries.length, ids: entries.map((item) => item.id), idMap, counts, examples, engineWarning }
|
|
1080
1318
|
}
|
|
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.
|
|
1319
|
+
export async function privateRecall({ exec, query, topK, relevanceThreshold, purpose = 'explicit', retrieveImpl = retrieve }) {
|
|
1084
1320
|
const binding = peekBinding(exec)
|
|
1085
1321
|
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
1322
|
if (!binding.libraryId) throw new Error('this conversation has no memory library')
|
|
1090
1323
|
assertScopeEnabled(binding)
|
|
1091
1324
|
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
1325
|
const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
|
|
1095
1326
|
let index = loadPrivateIndex(binding.libraryId)
|
|
1096
1327
|
const byId = /^(?:g?mem)[-_][a-z0-9_-]+(?:\.md)?$/i.test(String(query || '').trim())
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1328
|
+
const route = effectiveVectorRoute(settings)
|
|
1329
|
+
const wanted = Number(topK || settings.privateTopK || 4)
|
|
1330
|
+
const result = route === 'lexical' || byId
|
|
1331
|
+
? { ...localRecall(index, query), recallRoute: 'local' }
|
|
1332
|
+
: await managedRecall({ exec, index, query, topK: wanted, libraryId: binding.libraryId, automatic: purpose === 'automatic', intent: managedVectorIntent(), retrieveImpl, byId })
|
|
1100
1333
|
const current = peekBinding(exec)
|
|
1101
1334
|
if (!current || current.libraryId !== binding.libraryId) throw new Error('memory scope changed during recall; retry')
|
|
1102
1335
|
assertScopeEnabled(current)
|
|
1103
1336
|
index = loadPrivateIndex(binding.libraryId)
|
|
1104
1337
|
const enriched = enrichFiles(result, index)
|
|
1338
|
+
// enrichFiles rebuilds the result object; carry the route/error metadata across
|
|
1339
|
+
// so buildRecall can report a degraded semantic leg and the tool can warn.
|
|
1340
|
+
if (result?.recallRoute) enriched.recallRoute = result.recallRoute
|
|
1341
|
+
if (result?.errors) enriched.errors = result.errors.slice()
|
|
1342
|
+
if (result?.fusion) enriched.fusion = result.fusion
|
|
1105
1343
|
const span = createdAtSpan(Object.values(index.entries || {}).map((entry) => ({ entry })))
|
|
1106
1344
|
const mood = moodDecay(binding.mood)
|
|
1107
1345
|
const scored = privateWeighted(enriched, Number(topK || settings.privateTopK || 4), {
|
|
@@ -1133,15 +1371,24 @@ export async function privateRecall({ exec, query, topK, relevanceThreshold, pur
|
|
|
1133
1371
|
return scored
|
|
1134
1372
|
}
|
|
1135
1373
|
|
|
1136
|
-
export async function privateList({ exec, libraryId }) {
|
|
1374
|
+
export async function privateList({ exec, libraryId, preferIndex = false }) {
|
|
1137
1375
|
const scope = resolveLibrary({ exec, libraryId, readOnly: true })
|
|
1138
1376
|
if (scope.binding) assertScopeEnabled(scope.binding)
|
|
1139
1377
|
const index = loadPrivateIndex(scope.libraryId)
|
|
1140
1378
|
const allowed = (entry) => !!entry && (!sessionIdOf(exec) || (isMemoryEligible(entry) && visibleToSession(entry, memorySessionId(exec))))
|
|
1141
|
-
|
|
1379
|
+
// UI 列表直接读索引:索引是权威,引擎只是一面镜子。走引擎意味着起 Python 和
|
|
1380
|
+
// embedding worker(秒级),归档/还原后刷新一下就卡住,这就是 2026-10-30 的现场。
|
|
1381
|
+
const route = preferIndex ? 'lexical' : engineRoute()
|
|
1382
|
+
// 引擎没有可用代际时列表照样能从索引给出答案,但必须说清为什么这里没有向量:
|
|
1383
|
+
// 静默返回"空库"曾被当成"记忆丢了"。
|
|
1384
|
+
const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
|
|
1385
|
+
? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
|
|
1386
|
+
: null
|
|
1387
|
+
if (route === 'lexical') {
|
|
1142
1388
|
return {
|
|
1143
1389
|
libraryId: scope.libraryId,
|
|
1144
1390
|
source: 'index',
|
|
1391
|
+
warning,
|
|
1145
1392
|
files: localIndexFiles(index).filter(file => allowed(file.entry)).map((file) => ({
|
|
1146
1393
|
name: file.name,
|
|
1147
1394
|
description: file.description,
|
|
@@ -1152,11 +1399,12 @@ export async function privateList({ exec, libraryId }) {
|
|
|
1152
1399
|
parseWarning: null,
|
|
1153
1400
|
}
|
|
1154
1401
|
}
|
|
1155
|
-
const result = await listFiles({ db: memoryDbPath(scope.libraryId) })
|
|
1402
|
+
const result = await listFiles({ route, db: memoryDbPath(scope.libraryId) })
|
|
1156
1403
|
const files = Array.isArray(result?.recall_files) ? result.recall_files : []
|
|
1157
1404
|
return {
|
|
1158
1405
|
libraryId: scope.libraryId,
|
|
1159
1406
|
source: 'engine',
|
|
1407
|
+
warning: null,
|
|
1160
1408
|
files: files.filter(file => allowed(index.entries[file.name])).map((file) => ({
|
|
1161
1409
|
...file,
|
|
1162
1410
|
entry: index.entries[file.name] || null,
|
|
@@ -1171,7 +1419,7 @@ export async function privateList({ exec, libraryId }) {
|
|
|
1171
1419
|
// mood lens and the attachment list all change how an entry is scored, aggregated or
|
|
1172
1420
|
// rendered, so a wrong value in any of them was a permanent mis-filing that could
|
|
1173
1421
|
// only be undone by archiving the memory and writing it again.
|
|
1174
|
-
|
|
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 }) {
|
|
1175
1423
|
// `source` is editable because it is a claim about provenance, and claims get
|
|
1176
1424
|
// corrected: a memory the model first filed as `inferred` may turn out to be
|
|
1177
1425
|
// something the user actually said. Enum-checked like the rest — a typo here
|
|
@@ -1205,19 +1453,21 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1205
1453
|
const conflict = findConflict(others, { ...entry, title: title ?? entry.title, content: nextContent })
|
|
1206
1454
|
if (conflict) throw new Error(`content conflicts with ${conflict.id}; write a new memory for review`)
|
|
1207
1455
|
}
|
|
1208
|
-
|
|
1456
|
+
const route = engineRoute()
|
|
1457
|
+
const managed = route === 'managed-local'
|
|
1458
|
+
if ((nextContent !== undefined || managed) && entry.status !== 'pending_review' && status !== 'pending_review') {
|
|
1209
1459
|
const payload = {
|
|
1210
1460
|
recall_files: [{
|
|
1211
1461
|
name: id_,
|
|
1212
1462
|
track: entry.track || 'memory',
|
|
1213
1463
|
description: clampText(title || entry.title || nextContent, 180),
|
|
1214
|
-
content: nextContent,
|
|
1464
|
+
content: nextContent === undefined ? entry.content : nextContent,
|
|
1215
1465
|
}],
|
|
1216
1466
|
resource: [],
|
|
1217
1467
|
user: {},
|
|
1218
1468
|
}
|
|
1219
|
-
if (
|
|
1220
|
-
await commit({ payload, db: memoryDbPath(scope.libraryId) })
|
|
1469
|
+
if (route !== 'lexical') {
|
|
1470
|
+
await commit({ payload, route, db: memoryDbPath(scope.libraryId) })
|
|
1221
1471
|
}
|
|
1222
1472
|
}
|
|
1223
1473
|
const updated = await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
@@ -1307,7 +1557,7 @@ export async function privateUpdate({ exec, libraryId, id, content, title, tags,
|
|
|
1307
1557
|
}
|
|
1308
1558
|
|
|
1309
1559
|
/** Review activation and invalidation of the previous claim are one index commit. */
|
|
1310
|
-
|
|
1560
|
+
async function resolveMemoryReviewImpl({ libraryId = null, id, action, resolution = 'replace', reason = '', content, title }) {
|
|
1311
1561
|
if (!['accept', 'reject'].includes(action)) throw new Error('action must be accept or reject')
|
|
1312
1562
|
if (!['replace', 'coexist'].includes(resolution)) throw new Error('resolution must be replace or coexist')
|
|
1313
1563
|
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 +1569,11 @@ export async function resolveMemoryReview({ libraryId = null, id, action, resolu
|
|
|
1319
1569
|
if (action === 'accept') {
|
|
1320
1570
|
if (typeof content === 'string' && content.trim()) next.content = content
|
|
1321
1571
|
if (typeof title === 'string' && title.trim()) next.title = title
|
|
1322
|
-
|
|
1572
|
+
const reviewRoute = engineRoute()
|
|
1573
|
+
if (reviewRoute !== 'lexical') {
|
|
1323
1574
|
await commit({
|
|
1324
1575
|
payload: { recall_files: [{ name: id, track: next.track || 'memory', description: clampText(next.title, 180), content: next.content }], resource: [], user: {} },
|
|
1576
|
+
route: reviewRoute,
|
|
1325
1577
|
db: libraryId ? memoryDbPath(libraryId) : globalDbPath(),
|
|
1326
1578
|
})
|
|
1327
1579
|
}
|
|
@@ -1424,7 +1676,7 @@ export function privateArchive({ exec, libraryId, id }) {
|
|
|
1424
1676
|
* is still in the index and no longer in the engine — visible in the browser, still
|
|
1425
1677
|
* deletable, never recalled from a ghost row.
|
|
1426
1678
|
*/
|
|
1427
|
-
|
|
1679
|
+
async function privateDeleteImpl({ exec, libraryId, id }) {
|
|
1428
1680
|
const scope = resolveLibrary({ exec, libraryId })
|
|
1429
1681
|
if (scope.binding) assertScopeEnabled(scope.binding)
|
|
1430
1682
|
const index = loadPrivateIndex(scope.libraryId)
|
|
@@ -1435,10 +1687,10 @@ export async function privateDelete({ exec, libraryId, id }) {
|
|
|
1435
1687
|
if (!['archived', 'suppressed', 'expired'].includes(entry.status)) {
|
|
1436
1688
|
throw new Error('only archived memories can be deleted permanently; archive it first')
|
|
1437
1689
|
}
|
|
1438
|
-
const backend =
|
|
1690
|
+
const backend = engineRoute()
|
|
1439
1691
|
let engine = null
|
|
1440
|
-
if (backend
|
|
1441
|
-
engine = await forget({ db: memoryDbPath(scope.libraryId), name: id_ })
|
|
1692
|
+
if (backend !== 'lexical') {
|
|
1693
|
+
engine = await forget({ route: backend, db: memoryDbPath(scope.libraryId), name: id_ })
|
|
1442
1694
|
}
|
|
1443
1695
|
await retryStoreWrite(() => updatePrivateIndex(scope.libraryId, (fresh) => {
|
|
1444
1696
|
if (JSON.stringify(fresh.entries[id_]) !== JSON.stringify(entry)) throw new Error('memory changed during deletion; reload and retry')
|
|
@@ -1507,6 +1759,78 @@ export function privateUnpin({ exec, id, reason }) {
|
|
|
1507
1759
|
return { id: resolvedId, entry: updated.entries[resolvedId], remaining: Math.max(0, budget - used - 1) }
|
|
1508
1760
|
}
|
|
1509
1761
|
|
|
1762
|
+
export async function privateRemember(args) {
|
|
1763
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1764
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateRememberImpl(args))
|
|
1765
|
+
record({
|
|
1766
|
+
kind: 'write',
|
|
1767
|
+
scope: 'private',
|
|
1768
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
1769
|
+
libraryId,
|
|
1770
|
+
message: '写入 ' + (result?.id || '?') + ':' + clampText(args.title || args.content, 60) + (result?.conflict ? '(待复核)' : ''),
|
|
1771
|
+
detail: { id: result?.id || null, title: args.title || null, layer: args.layer || null, conflict: result?.conflict || null },
|
|
1772
|
+
refs: result?.id ? [result.id] : [],
|
|
1773
|
+
})
|
|
1774
|
+
return result
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
export async function privateUpdate(args) {
|
|
1778
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1779
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateUpdateImpl(args))
|
|
1780
|
+
record({
|
|
1781
|
+
kind: 'write',
|
|
1782
|
+
scope: 'private',
|
|
1783
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
1784
|
+
libraryId,
|
|
1785
|
+
message: '更新 ' + (result?.id || args.id || '?') + (args.content ? ':' + clampText(args.content, 60) : ''),
|
|
1786
|
+
detail: { id: result?.id || args.id || null, fields: Object.keys(args).filter(key => key !== 'exec' && key !== 'id') },
|
|
1787
|
+
refs: result?.id || args.id ? [result?.id || args.id] : [],
|
|
1788
|
+
})
|
|
1789
|
+
return result
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
export async function privateDelete(args) {
|
|
1793
|
+
const libraryId = resolveLibrary(args).libraryId
|
|
1794
|
+
const result = await withManagedPrivateWrite(libraryId, () => privateDeleteImpl(args))
|
|
1795
|
+
record({
|
|
1796
|
+
kind: 'write',
|
|
1797
|
+
scope: 'private',
|
|
1798
|
+
sessionId: sessionIdOf(args.exec) || null,
|
|
1799
|
+
libraryId,
|
|
1800
|
+
message: '删除 ' + (args.id || '?'),
|
|
1801
|
+
detail: { id: args.id || null, deleted: result?.deleted !== false },
|
|
1802
|
+
refs: args.id ? [args.id] : [],
|
|
1803
|
+
})
|
|
1804
|
+
return result
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
export async function importMemories(args) {
|
|
1808
|
+
const result = await withManagedPrivateWrite(args.libraryId, () => importMemoriesImpl(args))
|
|
1809
|
+
record({
|
|
1810
|
+
kind: 'write',
|
|
1811
|
+
scope: 'private',
|
|
1812
|
+
libraryId: args.libraryId || null,
|
|
1813
|
+
message: '导入 ' + (result?.imported ?? 0) + ' 条' + (result?.engineWarning ? '(引擎警告:' + clampText(result.engineWarning, 80) + ')' : ''),
|
|
1814
|
+
detail: { imported: result?.imported ?? 0, counts: result?.counts || null, engineWarning: result?.engineWarning || null },
|
|
1815
|
+
refs: Array.isArray(result?.ids) ? result.ids.slice(0, 20) : [],
|
|
1816
|
+
})
|
|
1817
|
+
return result
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export async function resolveMemoryReview(args) {
|
|
1821
|
+
const action = () => resolveMemoryReviewImpl(args)
|
|
1822
|
+
const result = args.libraryId ? await withManagedPrivateWrite(args.libraryId, action) : await withManagedGlobalWrite(action)
|
|
1823
|
+
record({
|
|
1824
|
+
kind: 'write',
|
|
1825
|
+
scope: args.libraryId ? 'private' : 'global',
|
|
1826
|
+
libraryId: args.libraryId || null,
|
|
1827
|
+
message: '复核 ' + (args.id || '?') + ' → ' + (args.action || '?'),
|
|
1828
|
+
detail: { id: args.id || null, action: args.action || null, status: result?.status || null },
|
|
1829
|
+
refs: args.id ? [args.id] : [],
|
|
1830
|
+
})
|
|
1831
|
+
return result
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1510
1834
|
export function appliesToDomain(entry, binding) {
|
|
1511
1835
|
const domain = domainOf(binding)
|
|
1512
1836
|
if (!entry) return domain === 'general'
|
|
@@ -1523,7 +1847,7 @@ export function appliesToDomain(entry, binding) {
|
|
|
1523
1847
|
return false
|
|
1524
1848
|
}
|
|
1525
1849
|
|
|
1526
|
-
export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
1850
|
+
export async function globalRecall({ exec, query, topK, relevanceThreshold, retrieveImpl = retrieve }) {
|
|
1527
1851
|
// peek, not ensure: injection has already bound any live session before a tool
|
|
1528
1852
|
// can run, so a missing binding here means a read from an unbound conversation.
|
|
1529
1853
|
// That fallback quietly narrows results to the `general` domain, so it is
|
|
@@ -1536,8 +1860,12 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1536
1860
|
const settings = getSettings()
|
|
1537
1861
|
const threshold = relevanceThreshold === undefined ? settings.relevanceThreshold : relevanceThreshold
|
|
1538
1862
|
let index = loadGlobalIndex()
|
|
1539
|
-
const
|
|
1540
|
-
|
|
1863
|
+
const intent = managedVectorIntent()
|
|
1864
|
+
const domainIndex = { ...index, entries: Object.fromEntries(Object.entries(index.entries || {})
|
|
1865
|
+
.filter(([, entry]) => appliesToDomain(entry, binding))) }
|
|
1866
|
+
const result = effectiveVectorRoute() !== 'lexical'
|
|
1867
|
+
? await managedRecall({ exec, index: domainIndex, query, topK: topK || settings.globalTopK || 3,
|
|
1868
|
+
libraryId: null, automatic: true, intent, retrieveImpl })
|
|
1541
1869
|
: localRecall(index, query, (file) => appliesToDomain(file.entry, binding))
|
|
1542
1870
|
if (peeked) {
|
|
1543
1871
|
const current = peekBinding(exec)
|
|
@@ -1546,6 +1874,9 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1546
1874
|
}
|
|
1547
1875
|
index = loadGlobalIndex()
|
|
1548
1876
|
const enriched = enrichFiles(result, index)
|
|
1877
|
+
if (result?.recallRoute) enriched.recallRoute = result.recallRoute
|
|
1878
|
+
if (result?.errors) enriched.errors = result.errors.slice()
|
|
1879
|
+
if (result?.fusion) enriched.fusion = result.fusion
|
|
1549
1880
|
const filtered = enriched.files
|
|
1550
1881
|
.filter((f) => isMemoryEligible(f.entry, { automatic: true }))
|
|
1551
1882
|
.filter((f) => appliesToDomain(f.entry, binding))
|
|
@@ -1554,7 +1885,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1554
1885
|
// Local lexical rank is already meaningful; confidence still nudges ties, and
|
|
1555
1886
|
// a cross-session fact that has been injected three turns running gets the same
|
|
1556
1887
|
// wear penalty as a private one.
|
|
1557
|
-
score: (f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
1888
|
+
score: (Number.isFinite(f.retrievalScore) ? f.retrievalScore : f.lexical > 0 ? f.lexical : 1 / (i + 1))
|
|
1558
1889
|
* (0.85 + 0.15 * Number(f.entry.confidence ?? 0.5))
|
|
1559
1890
|
* habituationFactor(f.entry),
|
|
1560
1891
|
}))
|
|
@@ -1567,7 +1898,7 @@ export async function globalRecall({ exec, query, topK, relevanceThreshold }) {
|
|
|
1567
1898
|
}
|
|
1568
1899
|
}
|
|
1569
1900
|
|
|
1570
|
-
export async function globalList({ exec, domain }) {
|
|
1901
|
+
export async function globalList({ exec, domain, preferIndex = false }) {
|
|
1571
1902
|
const binding = peekBinding(exec)
|
|
1572
1903
|
if (binding) assertScopeEnabled(binding, { layer: 'global' })
|
|
1573
1904
|
const index = loadGlobalIndex()
|
|
@@ -1575,11 +1906,19 @@ export async function globalList({ exec, domain }) {
|
|
|
1575
1906
|
// "Optional domain filter", so a caller asking for one domain must not get all.
|
|
1576
1907
|
const domainFilter = (file) => (!domain || (file.entry?.domain || 'unknown') === domain)
|
|
1577
1908
|
&& (!sessionIdOf(exec) || (isMemoryEligible(file.entry) && appliesToDomain(file.entry, binding || { domain: 'general' })))
|
|
1578
|
-
|
|
1909
|
+
// 与 privateList 同一条口径:受管路由还没有 active 代际时,引擎必然抛
|
|
1910
|
+
// "managed local vector profile is not active";列表本身其实能答,就按索引答,
|
|
1911
|
+
// 并把原因带上。否则全局记忆页会闪一下红字再消失。
|
|
1912
|
+
const route = preferIndex ? 'lexical' : engineRoute()
|
|
1913
|
+
const warning = !preferIndex && effectiveVectorRoute() !== 'lexical' && route === 'lexical'
|
|
1914
|
+
? '本地向量未激活(只有 requested,尚未建成 active 代际);以下列表来自索引,向量库为空'
|
|
1915
|
+
: null
|
|
1916
|
+
if (route === 'lexical') {
|
|
1579
1917
|
const files = localIndexFiles(index)
|
|
1580
1918
|
return {
|
|
1581
1919
|
domain: domain || (binding ? domainOf(binding) : 'general'),
|
|
1582
1920
|
source: 'index',
|
|
1921
|
+
warning,
|
|
1583
1922
|
files: (domainFilter ? files.filter(domainFilter) : files).map((file) => ({
|
|
1584
1923
|
name: file.name,
|
|
1585
1924
|
description: file.description,
|
|
@@ -1590,7 +1929,7 @@ export async function globalList({ exec, domain }) {
|
|
|
1590
1929
|
parseWarning: null,
|
|
1591
1930
|
}
|
|
1592
1931
|
}
|
|
1593
|
-
const result = await listFiles({ db: globalDbPath() })
|
|
1932
|
+
const result = await listFiles({ route, db: globalDbPath() })
|
|
1594
1933
|
const files = Array.isArray(result?.recall_files) ? result.recall_files : []
|
|
1595
1934
|
const enriched = files.map((file) => ({
|
|
1596
1935
|
...file,
|
|
@@ -1600,6 +1939,7 @@ export async function globalList({ exec, domain }) {
|
|
|
1600
1939
|
return {
|
|
1601
1940
|
domain: domain || (binding ? domainOf(binding) : 'general'),
|
|
1602
1941
|
source: 'engine',
|
|
1942
|
+
warning: null,
|
|
1603
1943
|
files: domainFilter ? enriched.filter(domainFilter) : enriched,
|
|
1604
1944
|
raw: result?.raw,
|
|
1605
1945
|
parseWarning: result?.parseWarning,
|