From 359a2a07b7c52290f22dc93c628adabb22c6f75b Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 15 Apr 2026 21:19:36 +0800 Subject: [PATCH] feat: deepen luker host integration --- graph/graph-persistence.js | 82 ++- host/event-binding.js | 49 +- host/runtime-host-adapter.js | 371 +++++++++++++ index.js | 760 ++++++++++++++++++++++++-- llm/llm.js | 17 +- prompting/prompt-builder.js | 11 +- tests/graph-persistence.mjs | 192 ++++++- tests/luker-host-adapter.mjs | 100 ++++ tests/message-updated-lightweight.mjs | 78 +++ tests/p0-regressions.mjs | 2 +- ui/panel.js | 14 + ui/ui-status.js | 20 + 12 files changed, 1637 insertions(+), 59 deletions(-) create mode 100644 host/runtime-host-adapter.js create mode 100644 tests/luker-host-adapter.mjs create mode 100644 tests/message-updated-lightweight.mjs diff --git a/graph/graph-persistence.js b/graph/graph-persistence.js index e641ea0..028fa1c 100644 --- a/graph/graph-persistence.js +++ b/graph/graph-persistence.js @@ -17,6 +17,12 @@ export const GRAPH_CHAT_STATE_MAX_OPERATIONS = 4000; export const LUKER_GRAPH_MANIFEST_NAMESPACE = `${MODULE_NAME}_graph_manifest`; export const LUKER_GRAPH_JOURNAL_NAMESPACE = `${MODULE_NAME}_graph_journal`; export const LUKER_GRAPH_CHECKPOINT_NAMESPACE = `${MODULE_NAME}_graph_checkpoint`; +export const LUKER_HISTORY_STATE_NAMESPACE = `${MODULE_NAME}_history_state`; +export const LUKER_SUMMARY_STATE_NAMESPACE = `${MODULE_NAME}_summary_state`; +export const LUKER_RECALL_STATE_NAMESPACE = `${MODULE_NAME}_recall_state`; +export const LUKER_PROJECTION_STATE_NAMESPACE = `${MODULE_NAME}_projection_state`; +export const LUKER_UI_STATE_NAMESPACE = `${MODULE_NAME}_ui_state`; +export const LUKER_DEBUG_STATE_NAMESPACE = `${MODULE_NAME}_debug_state`; export const LUKER_GRAPH_SIDECAR_V2_FORMAT = 2; export const LUKER_GRAPH_JOURNAL_COMPACTION_DEPTH = 32; export const LUKER_GRAPH_JOURNAL_COMPACTION_BYTES = 2 * 1024 * 1024; @@ -795,9 +801,10 @@ export function buildLukerGraphManifestV2( }); } -async function readGraphChatStateNamespaces( +export async function readGraphChatStateNamespaces( context = null, namespaces = [], + { target = null } = {}, ) { if (!canUseGraphChatState(context) || !Array.isArray(namespaces) || namespaces.length === 0) { return new Map(); @@ -805,7 +812,10 @@ async function readGraphChatStateNamespaces( try { if (canBatchReadGraphChatState(context)) { - const batch = await context.getChatStateBatch(namespaces); + const batch = await context.getChatStateBatch( + namespaces, + target ? { target } : undefined, + ); if (batch instanceof Map) { return batch; } @@ -820,7 +830,10 @@ async function readGraphChatStateNamespaces( const result = new Map(); for (const namespace of namespaces) { try { - result.set(namespace, await context.getChatState(namespace)); + result.set( + namespace, + await context.getChatState(namespace, target ? { target } : undefined), + ); } catch { result.set(namespace, null); } @@ -828,11 +841,15 @@ async function readGraphChatStateNamespaces( return result; } -async function writeGraphChatStatePayload( +export async function writeGraphChatStatePayload( context = null, namespace = "", payload = null, - { maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, asyncDiff = false } = {}, + { + maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + asyncDiff = false, + target = null, + } = {}, ) { if (!canUseGraphChatState(context) || !namespace || !payload) { return { @@ -848,6 +865,7 @@ async function writeGraphChatStatePayload( namespace, () => cloneRuntimeDebugValue(payload, payload), { + ...(target ? { target } : {}), maxOperations, asyncDiff, maxRetries: 1, @@ -882,6 +900,7 @@ export async function readLukerGraphSidecarV2( manifestNamespace = LUKER_GRAPH_MANIFEST_NAMESPACE, journalNamespace = LUKER_GRAPH_JOURNAL_NAMESPACE, checkpointNamespace = LUKER_GRAPH_CHECKPOINT_NAMESPACE, + chatStateTarget = null, } = {}, ) { if (!canUseGraphChatState(context)) { @@ -896,7 +915,9 @@ export async function readLukerGraphSidecarV2( manifestNamespace, journalNamespace, checkpointNamespace, - ]); + ], { + target: chatStateTarget, + }); return { manifest: normalizeLukerGraphManifestV2(payloads.get(manifestNamespace) || null), @@ -911,6 +932,7 @@ export async function writeLukerGraphManifestV2( { namespace = LUKER_GRAPH_MANIFEST_NAMESPACE, maxOperations = 512, + chatStateTarget = null, } = {}, ) { const normalizedManifest = normalizeLukerGraphManifestV2(manifest); @@ -926,6 +948,7 @@ export async function writeLukerGraphManifestV2( const result = await writeGraphChatStatePayload(context, namespace, normalizedManifest, { maxOperations, asyncDiff: false, + target: chatStateTarget, }); return { ...result, @@ -941,6 +964,7 @@ export async function appendLukerGraphJournalEntryV2( chatId = "", integrity = "", maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + chatStateTarget = null, } = {}, ) { const normalizedEntry = normalizeLukerGraphJournalEntry(entry); @@ -988,12 +1012,17 @@ export async function appendLukerGraphJournalEntryV2( return nextJournal; }, { + ...(chatStateTarget ? { target: chatStateTarget } : {}), maxOperations, asyncDiff: false, maxRetries: 1, }, ); - const journal = await readGraphChatStateNamespaces(context, [namespace]); + const journal = await readGraphChatStateNamespaces( + context, + [namespace], + { target: chatStateTarget }, + ); return { ok: result?.ok === true, updated: result?.updated !== false, @@ -1025,6 +1054,7 @@ export async function replaceLukerGraphJournalV2( { namespace = LUKER_GRAPH_JOURNAL_NAMESPACE, maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + chatStateTarget = null, } = {}, ) { const normalizedJournal = normalizeLukerGraphJournalV2(journal); @@ -1040,6 +1070,7 @@ export async function replaceLukerGraphJournalV2( const result = await writeGraphChatStatePayload(context, namespace, normalizedJournal, { maxOperations, asyncDiff: false, + target: chatStateTarget, }); return { ...result, @@ -1053,6 +1084,7 @@ export async function writeLukerGraphCheckpointV2( { namespace = LUKER_GRAPH_CHECKPOINT_NAMESPACE, maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + chatStateTarget = null, } = {}, ) { const normalizedCheckpoint = normalizeLukerGraphCheckpointV2(checkpoint); @@ -1068,6 +1100,7 @@ export async function writeLukerGraphCheckpointV2( const result = await writeGraphChatStatePayload(context, namespace, normalizedCheckpoint, { maxOperations, asyncDiff: false, + target: chatStateTarget, }); return { ...result, @@ -1153,14 +1186,17 @@ export function buildGraphChatStateSnapshot( export async function readGraphChatStateSnapshot( context = null, - { namespace = GRAPH_CHAT_STATE_NAMESPACE } = {}, + { namespace = GRAPH_CHAT_STATE_NAMESPACE, target = null } = {}, ) { if (!canUseGraphChatState(context)) { return null; } try { - const payload = await context.getChatState(namespace); + const payload = await context.getChatState( + namespace, + target ? { target } : undefined, + ); return normalizeGraphChatStateSnapshot(payload); } catch (error) { console.warn("[ST-BME] 读取聊天侧车图谱失败:", error); @@ -1182,6 +1218,7 @@ export async function writeGraphChatStateSnapshot( lastProcessedAssistantFloor = null, extractionCount = null, maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + target = null, } = {}, ) { if (!canUseGraphChatState(context) || !graph) { @@ -1217,6 +1254,7 @@ export async function writeGraphChatStateSnapshot( namespace, () => snapshot, { + ...(target ? { target } : {}), maxOperations, asyncDiff: false, maxRetries: 1, @@ -1245,6 +1283,32 @@ export async function writeGraphChatStateSnapshot( } } +export async function deleteGraphChatStateNamespace( + context = null, + namespace = "", + { target = null } = {}, +) { + if ( + !canUseGraphChatState(context) || + typeof context?.deleteChatState !== "function" || + !String(namespace || "").trim() + ) { + return false; + } + + try { + return Boolean( + await context.deleteChatState( + namespace, + target ? { target } : undefined, + ), + ); + } catch (error) { + console.warn(`[ST-BME] 删除聊天侧车 ${namespace} 失败:`, error); + return false; + } +} + export function normalizeGraphCommitMarker(marker = null) { if (!marker || typeof marker !== "object" || Array.isArray(marker)) { return null; diff --git a/host/event-binding.js b/host/event-binding.js index e9a59bf..16b3dc5 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -139,9 +139,9 @@ export function registerCoreEventHooksController(runtime) { } const cleanups = []; - const bind = (eventName, listener) => { + const bind = (eventName, listener, options = undefined) => { if (!eventName || typeof listener !== "function") return; - eventSource.on(eventName, listener); + eventSource.on(eventName, listener, options); if (typeof eventSource.off === "function") { cleanups.push(() => eventSource.off(eventName, listener)); } else if (typeof eventSource.removeListener === "function") { @@ -182,7 +182,10 @@ export function registerCoreEventHooksController(runtime) { bind(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited); bind(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped); if (eventTypes.MESSAGE_UPDATED) { - bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited); + bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageUpdated); + } + if (eventTypes.MESSAGE_SWIPE_DELETED && typeof handlers.onMessageDeleted === "function") { + bind(eventTypes.MESSAGE_SWIPE_DELETED, handlers.onMessageDeleted); } if (eventTypes.USER_MESSAGE_RENDERED) { bind(eventTypes.USER_MESSAGE_RENDERED, handlers.onUserMessageRendered); @@ -193,6 +196,32 @@ export function registerCoreEventHooksController(runtime) { handlers.onCharacterMessageRendered, ); } + bind(eventTypes.GENERATION_CONTEXT_READY, handlers.onGenerationContextReady, { + priority: 20, + }); + bind( + eventTypes.GENERATION_BEFORE_WORLD_INFO_SCAN, + handlers.onGenerationBeforeWorldInfoScan, + { priority: 20 }, + ); + bind( + eventTypes.GENERATION_AFTER_WORLD_INFO_SCAN, + handlers.onGenerationAfterWorldInfoScan, + { priority: 20 }, + ); + bind( + eventTypes.GENERATION_WORLD_INFO_FINALIZED, + handlers.onGenerationWorldInfoFinalized, + { priority: 20 }, + ); + bind( + eventTypes.GENERATION_BEFORE_API_REQUEST, + handlers.onGenerationBeforeApiRequest, + { priority: 20 }, + ); + bind(eventTypes.CHAT_BRANCH_CREATED, handlers.onChatBranchCreated, { + priority: 20, + }); const nextState = { registered: true, @@ -418,6 +447,20 @@ export function onMessageEditedController(runtime, messageId, meta = null) { runtime.refreshPersistedRecallMessageUi?.(); } +export function onMessageUpdatedController(runtime, messageId, meta = null) { + runtime.recordIgnoredMutationEvent?.("message-updated", { + messageId: Number.isFinite(Number(messageId)) ? Number(messageId) : null, + meta, + reason: "lightweight-refresh-only", + }); + runtime.refreshPersistedRecallMessageUi?.(); + return { + messageId: Number.isFinite(Number(messageId)) ? Number(messageId) : null, + lightweight: true, + refreshed: true, + }; +} + export async function onMessageSwipedController(runtime, messageId, meta = null) { runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe"); const parsedFloor = Number(messageId); diff --git a/host/runtime-host-adapter.js b/host/runtime-host-adapter.js new file mode 100644 index 0000000..c6a6fb5 --- /dev/null +++ b/host/runtime-host-adapter.js @@ -0,0 +1,371 @@ +export const BME_HOST_PROFILE_GENERIC_ST = "generic-st"; +export const BME_HOST_PROFILE_LUKER = "luker"; + +function normalizeString(value = "") { + return String(value ?? "").trim(); +} + +function getHostRuntimeContext() { + try { + if (typeof globalThis.Luker?.getContext === "function") { + return globalThis.Luker.getContext(); + } + } catch { + // ignore + } + + try { + if (typeof globalThis.SillyTavern?.getContext === "function") { + return globalThis.SillyTavern.getContext(); + } + } catch { + // ignore + } + + try { + if (typeof globalThis.getContext === "function") { + return globalThis.getContext(); + } + } catch { + // ignore + } + + return {}; +} + +export function normalizeBmeChatStateTarget(target = null) { + if (!target || typeof target !== "object" || Array.isArray(target)) { + return null; + } + + const isGroup = target.is_group === true; + if (isGroup) { + const id = normalizeString(target.id); + return id + ? { + is_group: true, + id, + } + : null; + } + + const avatarUrl = normalizeString(target.avatar_url); + const fileName = normalizeString(target.file_name); + if (!avatarUrl || !fileName) { + return null; + } + + return { + is_group: false, + avatar_url: avatarUrl, + file_name: fileName, + }; +} + +function resolveCharacterAvatar(context = null) { + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const directCandidates = [ + activeContext.characterAvatar, + activeContext.character_avatar, + activeContext.avatar_url, + activeContext.characterAvatarUrl, + activeContext.name2_avatar, + ]; + + for (const candidate of directCandidates) { + const normalized = normalizeString(candidate); + if (normalized) { + return normalized; + } + } + + const characterId = activeContext.characterId; + const character = + activeContext.character || + activeContext.characters?.[Number(characterId)] || + activeContext.characters?.[characterId] || + null; + + return normalizeString( + character?.avatar || + character?.avatar_url || + character?.data?.avatar || + character?.data?.avatar_url, + ); +} + +function resolveChatFileName(context = null) { + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const candidates = [ + activeContext.chatId, + typeof activeContext.getCurrentChatId === "function" + ? activeContext.getCurrentChatId() + : "", + activeContext.chatMetadata?.chat_id, + activeContext.chatMetadata?.chatId, + activeContext.chatMetadata?.session_id, + activeContext.chatMetadata?.sessionId, + ]; + + for (const candidate of candidates) { + const normalized = normalizeString(candidate); + if (normalized) { + return normalized; + } + } + + return ""; +} + +export function resolveCurrentBmeChatStateTarget( + context = getHostRuntimeContext(), + explicitTarget = null, +) { + const normalizedExplicit = normalizeBmeChatStateTarget(explicitTarget); + if (normalizedExplicit) { + return normalizedExplicit; + } + + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const groupId = normalizeString(activeContext.groupId); + const chatId = resolveChatFileName(activeContext); + if (groupId) { + return normalizeBmeChatStateTarget({ + is_group: true, + id: chatId || groupId, + }); + } + + const avatarUrl = resolveCharacterAvatar(activeContext); + const fileName = chatId; + if (avatarUrl && fileName) { + return normalizeBmeChatStateTarget({ + is_group: false, + avatar_url: avatarUrl, + file_name: fileName, + }); + } + + return null; +} + +export function serializeBmeChatStateTarget(target = null) { + const normalized = normalizeBmeChatStateTarget(target); + if (!normalized) return ""; + return normalized.is_group + ? `group:${normalized.id}` + : `char:${normalized.avatar_url}:${normalized.file_name}`; +} + +export function resolveChatStateTargetChatId(target = null) { + const normalized = normalizeBmeChatStateTarget(target); + if (!normalized) return ""; + return normalized.is_group + ? normalizeString(normalized.id) + : normalizeString(normalized.file_name); +} + +function isAndroidWebViewLike() { + const userAgent = normalizeString(globalThis.navigator?.userAgent).toLowerCase(); + if (!userAgent) return false; + return ( + userAgent.includes("wv") || + (userAgent.includes("android") && !userAgent.includes("chrome/")) || + userAgent.includes(" version/") || + userAgent.includes("lukerandroid") + ); +} + +export function isLukerHostContext(context = getHostRuntimeContext()) { + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const hasImplicitCurrentChat = + Boolean(resolveChatFileName(activeContext)) || + normalizeString(activeContext.groupId) !== "" || + normalizeString(activeContext.characterId) !== ""; + return ( + !!globalThis.Luker && + typeof globalThis.Luker?.getContext === "function" && + typeof activeContext.getChatState === "function" && + typeof activeContext.updateChatState === "function" && + typeof activeContext.getChatStateBatch === "function" && + hasImplicitCurrentChat + ); +} + +export function resolveBmeHostProfile(context = getHostRuntimeContext()) { + return isLukerHostContext(context) + ? BME_HOST_PROFILE_LUKER + : BME_HOST_PROFILE_GENERIC_ST; +} + +export function isBmeLightweightHostMode(context = getHostRuntimeContext()) { + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const hostProfile = resolveBmeHostProfile(activeContext); + if (hostProfile !== BME_HOST_PROFILE_LUKER) { + return false; + } + + if (activeContext.lightweightHostMode === true) { + return true; + } + + if (typeof activeContext.isMobile === "function" && activeContext.isMobile()) { + return true; + } + + if (globalThis.matchMedia?.("(pointer: coarse)")?.matches) { + return true; + } + + return isAndroidWebViewLike(); +} + +function callContextMethod(context, methodName, args = []) { + const fn = context?.[methodName]; + if (typeof fn !== "function") { + return null; + } + return Reflect.apply(fn, context, args); +} + +function createBaseAdapter(context = getHostRuntimeContext()) { + const activeContext = + context && typeof context === "object" ? context : getHostRuntimeContext(); + const hostProfile = resolveBmeHostProfile(activeContext); + + return { + context: activeContext, + hostProfile, + resolveCurrentTarget(options = {}) { + return resolveCurrentBmeChatStateTarget(activeContext, options?.target); + }, + getChatIdFromTarget(target = null) { + return resolveChatStateTargetChatId(target); + }, + isLightweightHostMode() { + return isBmeLightweightHostMode(activeContext); + }, + async readChatStateBatch(namespaces = [], options = {}) { + const normalizedTarget = this.resolveCurrentTarget(options); + const result = callContextMethod(activeContext, "getChatStateBatch", [ + namespaces, + normalizedTarget ? { ...(options || {}), target: normalizedTarget } : options, + ]); + if (result instanceof Promise) { + return await result; + } + return result ?? new Map(); + }, + async readChatState(namespace = "", options = {}) { + const normalizedTarget = this.resolveCurrentTarget(options); + const result = callContextMethod(activeContext, "getChatState", [ + namespace, + normalizedTarget ? { ...(options || {}), target: normalizedTarget } : options, + ]); + if (result instanceof Promise) { + return await result; + } + return result ?? null; + }, + async updateChatState(namespace = "", updater, options = {}) { + const normalizedTarget = this.resolveCurrentTarget(options); + const result = callContextMethod(activeContext, "updateChatState", [ + namespace, + updater, + normalizedTarget ? { ...(options || {}), target: normalizedTarget } : options, + ]); + if (result instanceof Promise) { + return await result; + } + return result ?? { ok: false, updated: false, state: null }; + }, + async deleteChatState(namespace = "", options = {}) { + const normalizedTarget = this.resolveCurrentTarget(options); + const result = callContextMethod(activeContext, "deleteChatState", [ + namespace, + normalizedTarget ? { ...(options || {}), target: normalizedTarget } : options, + ]); + if (result instanceof Promise) { + return await result; + } + return Boolean(result); + }, + buildPresetAwarePromptMessages(options = {}) { + return callContextMethod(activeContext, "buildPresetAwarePromptMessages", [ + options, + ]); + }, + async simulateWorldInfoActivation(options = {}) { + const result = callContextMethod(activeContext, "simulateWorldInfoActivation", [ + options, + ]); + if (result instanceof Promise) { + return await result; + } + return result ?? null; + }, + resolveChatCompletionRequestProfile(options = {}) { + return callContextMethod( + activeContext, + "resolveChatCompletionRequestProfile", + [options], + ); + }, + registerGenerationHooks(handlers = {}, options = {}) { + const eventSource = activeContext?.eventSource; + const eventTypes = activeContext?.eventTypes || {}; + if (!eventSource || typeof eventSource.on !== "function") { + return []; + } + + const cleanups = []; + const priority = + Number.isFinite(Number(options.priority)) ? Number(options.priority) : 20; + const bind = (eventName, handler) => { + if (!eventName || typeof handler !== "function") return; + eventSource.on(eventName, handler, { priority }); + if (typeof eventSource.off === "function") { + cleanups.push(() => eventSource.off(eventName, handler)); + } else if (typeof eventSource.removeListener === "function") { + cleanups.push(() => eventSource.removeListener(eventName, handler)); + } + }; + + bind(eventTypes.GENERATION_CONTEXT_READY, handlers.onGenerationContextReady); + bind( + eventTypes.GENERATION_BEFORE_WORLD_INFO_SCAN, + handlers.onGenerationBeforeWorldInfoScan, + ); + bind( + eventTypes.GENERATION_AFTER_WORLD_INFO_SCAN, + handlers.onGenerationAfterWorldInfoScan, + ); + bind( + eventTypes.GENERATION_WORLD_INFO_FINALIZED, + handlers.onGenerationWorldInfoFinalized, + ); + bind( + eventTypes.GENERATION_BEFORE_API_REQUEST, + handlers.onGenerationBeforeApiRequest, + ); + bind(eventTypes.CHAT_BRANCH_CREATED, handlers.onChatBranchCreated); + bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageUpdated); + return cleanups; + }, + registerManagedRegexProvider(owner = "", options = {}) { + return callContextMethod(activeContext, "registerManagedRegexProvider", [ + owner, + options, + ]); + }, + }; +} + +export function getBmeHostAdapter(context = getHostRuntimeContext()) { + return createBaseAdapter(context); +} diff --git a/index.js b/index.js index 66dd4b3..867a1f4 100644 --- a/index.js +++ b/index.js @@ -81,6 +81,7 @@ import { onGenerationStartedController, onMessageDeletedController, onMessageEditedController, + onMessageUpdatedController, onMessageReceivedController, onMessageSentController, onMessageSwipedController, @@ -90,6 +91,16 @@ import { registerGenerationAfterCommandsController, scheduleSendIntentHookRetryController, } from "./host/event-binding.js"; +import { + BME_HOST_PROFILE_LUKER, + getBmeHostAdapter, + isBmeLightweightHostMode, + normalizeBmeChatStateTarget, + resolveBmeHostProfile, + resolveChatStateTargetChatId, + resolveCurrentBmeChatStateTarget, + serializeBmeChatStateTarget, +} from "./host/runtime-host-adapter.js"; import { executeExtractionBatchController, onExtractionTaskController, @@ -125,6 +136,7 @@ import { buildLukerGraphJournalV2, buildLukerGraphManifestV2, canUseGraphChatState, + deleteGraphChatStateNamespace, detectIndexedDbSnapshotCommitMarkerMismatch, findGraphShadowSnapshotByIntegrity, getAcceptedCommitMarkerRevision, @@ -140,6 +152,8 @@ import { LUKER_GRAPH_JOURNAL_COMPACTION_REVISION_GAP, LUKER_GRAPH_JOURNAL_NAMESPACE, LUKER_GRAPH_MANIFEST_NAMESPACE, + LUKER_PROJECTION_STATE_NAMESPACE, + LUKER_DEBUG_STATE_NAMESPACE, LUKER_GRAPH_SIDECAR_V2_FORMAT, MODULE_NAME, cloneGraphForPersistence, @@ -147,6 +161,7 @@ import { getGraphPersistedRevision, getGraphPersistenceMeta, getGraphIdentityAliasCandidates, + readGraphChatStateNamespaces, readGraphShadowSnapshot, removeGraphShadowSnapshot, rememberGraphIdentityAlias, @@ -158,6 +173,7 @@ import { shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, + writeGraphChatStatePayload, writeGraphChatStateSnapshot, writeLukerGraphCheckpointV2, writeLukerGraphManifestV2, @@ -337,6 +353,65 @@ function normalizeChatIdCandidate(value = "") { return String(value ?? "").trim(); } +function getActiveBmeHostAdapter(context = getContext()) { + if (typeof getBmeHostAdapter === "function") { + return getBmeHostAdapter(context); + } + return { + hostProfile: resolvePersistenceHostProfile(context), + resolveCurrentTarget() { + return resolveCurrentChatStateTarget(context); + }, + isLightweightHostMode() { + return false; + }, + }; +} + +function resolveCurrentChatStateTarget( + context = getContext(), + explicitTarget = null, +) { + if ( + typeof normalizeBmeChatStateTarget === "function" && + typeof resolveCurrentBmeChatStateTarget === "function" + ) { + return normalizeBmeChatStateTarget( + resolveCurrentBmeChatStateTarget(context, explicitTarget), + ); + } + if (explicitTarget && typeof explicitTarget === "object") { + return explicitTarget; + } + const activeContext = + context && typeof context === "object" ? context : getContext(); + const chatId = normalizeChatIdCandidate( + activeContext?.chatId || + (typeof activeContext?.getCurrentChatId === "function" + ? activeContext.getCurrentChatId() + : ""), + ); + if (activeContext?.groupId != null && String(activeContext.groupId || "").trim()) { + return chatId ? { is_group: true, id: chatId } : null; + } + return null; +} + +function syncBmeHostRuntimeFlags(context = getContext()) { + const adapter = getActiveBmeHostAdapter(context); + const target = adapter.resolveCurrentTarget(); + const lightweightHostMode = + typeof adapter.isLightweightHostMode === "function" + ? adapter.isLightweightHostMode() + : false; + globalThis.__stBmeLightweightHostMode = lightweightHostMode === true; + return { + adapter, + target, + lightweightHostMode, + }; +} + function readGlobalCurrentChatId() { try { return normalizeChatIdCandidate( @@ -1308,28 +1383,13 @@ function resolveLocalStoreTierFromPresentation( } function hasValidLukerChatStateTarget(context = getContext()) { - if (!context || typeof context !== "object") { - return false; - } - const chatId = normalizeChatIdCandidate( - context.chatId || - (typeof context.getCurrentChatId === "function" - ? context.getCurrentChatId() - : getCurrentChatId(context)), - ); - if (!chatId) { - return false; - } - if (context.groupId != null && String(context.groupId || "").trim()) { - return true; - } - if (context.characterId != null && String(context.characterId || "").trim()) { - return true; - } - return true; + return resolveCurrentChatStateTarget(context) !== null; } function resolvePersistenceHostProfile(context = getContext()) { + if (typeof resolveBmeHostProfile === "function") { + return resolveBmeHostProfile(context); + } const activeContext = context && typeof context === "object" ? context : getContext(); const hasLukerApi = @@ -1337,7 +1397,12 @@ function resolvePersistenceHostProfile(context = getContext()) { if ( hasLukerApi && canUseGraphChatState(activeContext) && - hasValidLukerChatStateTarget(activeContext) + normalizeChatIdCandidate( + activeContext?.chatId || + (typeof activeContext?.getCurrentChatId === "function" + ? activeContext.getCurrentChatId() + : ""), + ) ) { return "luker"; } @@ -1372,8 +1437,11 @@ function getGraphPersistenceLiveState() { getContext(), getPreferredGraphLocalStorePresentationSync(), ); + const adapterRuntime = syncBmeHostRuntimeFlags(getContext()); const hostProfile = normalizePersistenceHostProfile( - graphPersistenceState.hostProfile || persistenceEnvironment.hostProfile, + graphPersistenceState.hostProfile || + adapterRuntime.adapter.hostProfile || + persistenceEnvironment.hostProfile, ); const primaryStorageTier = normalizePersistenceStorageTier( graphPersistenceState.primaryStorageTier || @@ -1411,6 +1479,13 @@ function getGraphPersistenceLiveState() { cacheStorageTier, cacheMirrorState: String(graphPersistenceState.cacheMirrorState || "idle"), cacheLag: Number(graphPersistenceState.cacheLag || 0), + chatStateTarget: cloneRuntimeDebugValue( + graphPersistenceState.chatStateTarget || adapterRuntime.target, + null, + ), + lightweightHostMode: + graphPersistenceState.lightweightHostMode ?? + adapterRuntime.lightweightHostMode, persistDiagnosticTier: String( graphPersistenceState.persistDiagnosticTier || "none", ), @@ -1443,6 +1518,28 @@ function getGraphPersistenceLiveState() { lukerCheckpointRevision: Number( graphPersistenceState.lukerCheckpointRevision || 0, ), + projectionState: cloneRuntimeDebugValue( + graphPersistenceState.projectionState, + null, + ), + lastHookPhase: String(graphPersistenceState.lastHookPhase || ""), + lastRequestRescanReason: String( + graphPersistenceState.lastRequestRescanReason || "", + ), + lastIgnoredMutationEvent: String( + graphPersistenceState.lastIgnoredMutationEvent || "", + ), + lastIgnoredMutationReason: String( + graphPersistenceState.lastIgnoredMutationReason || "", + ), + lastChatStateConflict: cloneRuntimeDebugValue( + graphPersistenceState.lastChatStateConflict, + null, + ), + lastBranchInheritResult: cloneRuntimeDebugValue( + graphPersistenceState.lastBranchInheritResult, + null, + ), localStoreFormatVersion: Number(graphPersistenceState.localStoreFormatVersion || 0) || 1, localStoreMigrationState: String( graphPersistenceState.localStoreMigrationState || "idle", @@ -1503,6 +1600,49 @@ function updateGraphPersistenceState(patch = {}) { return graphPersistenceState; } +function recordIgnoredMutationEvent(eventName = "", detail = {}) { + updateGraphPersistenceState({ + lastIgnoredMutationEvent: String(eventName || ""), + lastIgnoredMutationReason: String( + detail?.reason || detail?.message || "lightweight-only", + ), + }); +} + +function recordLukerHookPhase(phase = "", detail = {}) { + updateGraphPersistenceState({ + lastHookPhase: String(phase || ""), + chatStateTarget: + cloneRuntimeDebugValue(detail?.chatStateTarget, null) || + graphPersistenceState.chatStateTarget || + resolveCurrentChatStateTarget(getContext()), + lightweightHostMode: + detail?.lightweightHostMode ?? + graphPersistenceState.lightweightHostMode ?? + isBmeLightweightHostMode(getContext()), + }); +} + +function updateLukerProjectionState(patch = {}) { + const previous = + graphPersistenceState.projectionState && + typeof graphPersistenceState.projectionState === "object" && + !Array.isArray(graphPersistenceState.projectionState) + ? graphPersistenceState.projectionState + : { + runtime: { status: "idle", updatedAt: 0, reason: "" }, + persistent: { status: "idle", updatedAt: 0, reason: "" }, + }; + const nextState = { + ...cloneRuntimeDebugValue(previous, previous), + ...cloneRuntimeDebugValue(patch, {}), + }; + updateGraphPersistenceState({ + projectionState: nextState, + }); + return nextState; +} + function readPersistDeltaDiagnosticsNow() { if (typeof performance === "object" && typeof performance.now === "function") { return performance.now(); @@ -5928,6 +6068,12 @@ function buildLukerManifestStatePatch( return { hostProfile: "luker", primaryStorageTier: "luker-chat-state", + chatStateTarget: + cloneRuntimeDebugValue(graphPersistenceState.chatStateTarget, null) || + resolveCurrentChatStateTarget(getContext()), + lightweightHostMode: + graphPersistenceState.lightweightHostMode ?? + isBmeLightweightHostMode(getContext()), cacheStorageTier: buildPersistenceEnvironment( getContext(), getPreferredGraphLocalStorePresentationSync(), @@ -5995,23 +6141,27 @@ function resolveLukerHeadRevision(manifest = null, checkpoint = null) { ); } -function queueLukerSidecarWrite(chatId, operation) { +function queueLukerSidecarWrite(chatId, operation, { chatStateTarget = null } = {}) { const normalizedChatId = normalizeChatIdCandidate(chatId); - if (!normalizedChatId || typeof operation !== "function") { + const normalizedTarget = normalizeBmeChatStateTarget(chatStateTarget); + const queueKey = + serializeBmeChatStateTarget(normalizedTarget) || + normalizedChatId; + if (!queueKey || typeof operation !== "function") { return Promise.resolve().then(() => operation()); } - const previous = bmeLukerSidecarWriteByChatId.get(normalizedChatId) || Promise.resolve(); + const previous = bmeLukerSidecarWriteByChatId.get(queueKey) || Promise.resolve(); let settled = null; const queued = previous .catch(() => null) .then(() => operation()); settled = queued.finally(() => { - if (bmeLukerSidecarWriteByChatId.get(normalizedChatId) === settled) { - bmeLukerSidecarWriteByChatId.delete(normalizedChatId); + if (bmeLukerSidecarWriteByChatId.get(queueKey) === settled) { + bmeLukerSidecarWriteByChatId.delete(queueKey); } }); - bmeLukerSidecarWriteByChatId.set(normalizedChatId, settled); + bmeLukerSidecarWriteByChatId.set(queueKey, settled); return settled; } @@ -6179,9 +6329,11 @@ async function compactLukerGraphSidecarV2( revision = graphPersistenceState.lukerManifestRevision || graphPersistenceState.revision, reason = "luker-chat-state-compaction", integrity = "", + chatStateTarget = null, } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); if ( !normalizedChatId || !graph || @@ -6228,6 +6380,7 @@ async function compactLukerGraphSidecarV2( }); const checkpointResult = await writeLukerGraphCheckpointV2(context, checkpoint, { namespace: LUKER_GRAPH_CHECKPOINT_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!checkpointResult?.ok || !checkpointResult?.checkpoint) { updateGraphPersistenceState({ @@ -6255,6 +6408,7 @@ async function compactLukerGraphSidecarV2( }); const journalResult = await replaceLukerGraphJournalV2(context, emptyJournal, { namespace: LUKER_GRAPH_JOURNAL_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!journalResult?.ok || !journalResult?.journal) { updateGraphPersistenceState({ @@ -6297,6 +6451,7 @@ async function compactLukerGraphSidecarV2( }); const manifestResult = await writeLukerGraphManifestV2(context, manifest, { namespace: LUKER_GRAPH_MANIFEST_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!manifestResult?.ok || !manifestResult?.manifest) { updateGraphPersistenceState({ @@ -6348,6 +6503,8 @@ async function compactLukerGraphSidecarV2( manifest: manifestResult.manifest, checkpoint: checkpointResult.checkpoint, }; + }, { + chatStateTarget: normalizedTarget, }); } @@ -6356,7 +6513,10 @@ function scheduleLukerGraphSidecarCompaction( options = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); - if (!normalizedChatId || bmeLukerSidecarCompactionByChatId.has(normalizedChatId)) { + const queueKey = + serializeBmeChatStateTarget(options?.chatStateTarget) || + normalizedChatId; + if (!normalizedChatId || bmeLukerSidecarCompactionByChatId.has(queueKey)) { return; } updateGraphPersistenceState({ @@ -6383,17 +6543,18 @@ function scheduleLukerGraphSidecarCompaction( return null; }) .finally(() => { - if (bmeLukerSidecarCompactionByChatId.get(normalizedChatId) === promise) { - bmeLukerSidecarCompactionByChatId.delete(normalizedChatId); + if (bmeLukerSidecarCompactionByChatId.get(queueKey) === promise) { + bmeLukerSidecarCompactionByChatId.delete(queueKey); } }); - bmeLukerSidecarCompactionByChatId.set(normalizedChatId, promise); + bmeLukerSidecarCompactionByChatId.set(queueKey, promise); } async function persistGraphToLukerSidecarV2( context = getContext(), { graph = currentGraph, + chatId: explicitChatId = "", revision = graphPersistenceState.revision, reason = "luker-chat-state-save", accepted = true, @@ -6401,6 +6562,7 @@ async function persistGraphToLukerSidecarV2( extractionCount: nextExtractionCount = null, mode = "primary", persistDelta = null, + chatStateTarget = null, } = {}, ) { if (!context || !graph || !canUseHostGraphChatStatePersistence(context)) { @@ -6413,7 +6575,14 @@ async function persistGraphToLukerSidecarV2( }; } - const chatId = resolvePersistenceChatId(context, graph); + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); + const chatId = resolvePersistenceChatId( + context, + graph, + explicitChatId || + resolveChatStateTargetChatId(normalizedTarget) || + "", + ); if (!chatId) { return { saved: false, @@ -6425,7 +6594,14 @@ async function persistGraphToLukerSidecarV2( } const resolvedIdentity = resolveCurrentChatIdentity(context); + const currentTargetKey = serializeBmeChatStateTarget( + resolveCurrentChatStateTarget(context), + ); + const requestedTargetKey = serializeBmeChatStateTarget(normalizedTarget); + const shouldRememberAlias = + !requestedTargetKey || requestedTargetKey === currentTargetKey; const nextIntegrity = + getGraphPersistenceMeta(graph)?.integrity || getChatMetadataIntegrity(context) || normalizeChatIdCandidate(resolvedIdentity?.integrity) || graphPersistenceState.metadataIntegrity; @@ -6442,6 +6618,7 @@ async function persistGraphToLukerSidecarV2( manifestNamespace: LUKER_GRAPH_MANIFEST_NAMESPACE, journalNamespace: LUKER_GRAPH_JOURNAL_NAMESPACE, checkpointNamespace: LUKER_GRAPH_CHECKPOINT_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (existingSidecar?.manifest) { cacheChatStateManifest(chatId, existingSidecar.manifest); @@ -6515,6 +6692,7 @@ async function persistGraphToLukerSidecarV2( }); const checkpointResult = await writeLukerGraphCheckpointV2(context, checkpoint, { namespace: LUKER_GRAPH_CHECKPOINT_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!checkpointResult?.ok || !checkpointResult?.checkpoint) { return { @@ -6538,6 +6716,7 @@ async function persistGraphToLukerSidecarV2( emptyJournal, { namespace: LUKER_GRAPH_JOURNAL_NAMESPACE, + chatStateTarget: normalizedTarget, }, ); if (!bootstrapJournalResult?.ok || !bootstrapJournalResult?.journal) { @@ -6573,6 +6752,7 @@ async function persistGraphToLukerSidecarV2( }); const manifestResult = await writeLukerGraphManifestV2(context, bootstrapManifest, { namespace: LUKER_GRAPH_MANIFEST_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!manifestResult?.ok || !manifestResult?.manifest) { return { @@ -6586,7 +6766,9 @@ async function persistGraphToLukerSidecarV2( }; } cacheChatStateManifest(chatId, manifestResult.manifest); - rememberResolvedGraphIdentityAlias(context, chatId); + if (shouldRememberAlias) { + rememberResolvedGraphIdentityAlias(context, chatId); + } updateGraphPersistenceState({ ...buildLukerManifestStatePatch(manifestResult.manifest, { cacheMirrorState: @@ -6652,6 +6834,7 @@ async function persistGraphToLukerSidecarV2( namespace: LUKER_GRAPH_JOURNAL_NAMESPACE, chatId, integrity: nextIntegrity, + chatStateTarget: normalizedTarget, }); if (!journalResult?.ok || !journalResult?.journal || !journalResult?.entry) { updateGraphPersistenceState({ @@ -6710,6 +6893,7 @@ async function persistGraphToLukerSidecarV2( }); const manifestResult = await writeLukerGraphManifestV2(context, manifest, { namespace: LUKER_GRAPH_MANIFEST_NAMESPACE, + chatStateTarget: normalizedTarget, }); if (!manifestResult?.ok || !manifestResult?.manifest) { updateGraphPersistenceState({ @@ -6744,7 +6928,9 @@ async function persistGraphToLukerSidecarV2( } cacheChatStateManifest(chatId, manifestResult.manifest); - rememberResolvedGraphIdentityAlias(context, chatId); + if (shouldRememberAlias) { + rememberResolvedGraphIdentityAlias(context, chatId); + } updateGraphPersistenceState({ ...buildLukerManifestStatePatch(manifestResult.manifest, { cacheMirrorState: @@ -6797,6 +6983,7 @@ async function persistGraphToLukerSidecarV2( revision: manifestResult.manifest.headRevision, reason: `${reason}:auto-compact`, integrity: nextIntegrity, + chatStateTarget: normalizedTarget, }); } @@ -6818,6 +7005,8 @@ async function persistGraphToLukerSidecarV2( storageTier: "luker-chat-state", manifest: manifestResult.manifest, }; + }, { + chatStateTarget: normalizedTarget, }); } @@ -6829,10 +7018,12 @@ async function loadGraphFromLukerSidecarV2( allowOverride = false, consistencyRetryIndex = 0, consistencyRetryDelays = LUKER_SIDECAR_CONSISTENCY_RETRY_DELAYS_MS, + chatStateTarget = null, } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); const context = getContext(); + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); if (!normalizedChatId) { return { success: false, @@ -6847,6 +7038,7 @@ async function loadGraphFromLukerSidecarV2( manifestNamespace: LUKER_GRAPH_MANIFEST_NAMESPACE, journalNamespace: LUKER_GRAPH_JOURNAL_NAMESPACE, checkpointNamespace: LUKER_GRAPH_CHECKPOINT_NAMESPACE, + chatStateTarget: normalizedTarget, }); const manifest = sidecar?.manifest || null; if (!manifest) { @@ -6926,6 +7118,7 @@ async function loadGraphFromLukerSidecarV2( allowOverride, consistencyRetryIndex: consistencyRetryIndex + 1, consistencyRetryDelays, + chatStateTarget: normalizedTarget, }); } const blockedReason = String( @@ -7009,6 +7202,7 @@ async function loadGraphFromLukerSidecarV2( acceptedStorageTier: "luker-chat-state", acceptedBy: "luker-chat-state", }), + chatStateTarget: cloneRuntimeDebugValue(normalizedTarget, null), metadataIntegrity: String( manifest.integrity || graphPersistenceState.metadataIntegrity || "", ), @@ -7026,6 +7220,7 @@ async function persistGraphToHostChatState( context = getContext(), { graph = currentGraph, + chatId: explicitChatId = "", revision = graphPersistenceState.revision, reason = "graph-chat-state", storageTier = "chat-state", @@ -7034,6 +7229,7 @@ async function persistGraphToHostChatState( extractionCount: nextExtractionCount = null, mode = "primary", persistDelta = null, + chatStateTarget = null, } = {}, ) { if (!context || !graph || !canUseHostGraphChatStatePersistence(context)) { @@ -7046,7 +7242,14 @@ async function persistGraphToHostChatState( }; } - const chatId = resolvePersistenceChatId(context, graph); + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); + const chatId = resolvePersistenceChatId( + context, + graph, + explicitChatId || + resolveChatStateTargetChatId(normalizedTarget) || + "", + ); if (!chatId) { return { saved: false, @@ -7065,6 +7268,7 @@ async function persistGraphToHostChatState( if (persistenceEnvironment.hostProfile === "luker") { return await persistGraphToLukerSidecarV2(context, { graph, + chatId, revision, reason, accepted, @@ -7072,6 +7276,7 @@ async function persistGraphToHostChatState( extractionCount: nextExtractionCount, mode, persistDelta, + chatStateTarget: normalizedTarget, }); } const effectiveStorageTier = @@ -7103,6 +7308,7 @@ async function persistGraphToHostChatState( integrity: nextIntegrity, lastProcessedAssistantFloor, extractionCount: nextExtractionCount, + target: normalizedTarget, }, ); @@ -7193,10 +7399,12 @@ async function loadGraphFromChatState( source = "chat-state-probe", attemptIndex = 0, allowOverride = false, + chatStateTarget = null, } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); const context = getContext(); + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); const shouldFallbackToLocalStore = isLukerPrimaryPersistenceHost(context); if (!normalizedChatId) { return { @@ -7222,6 +7430,7 @@ async function loadGraphFromChatState( source, attemptIndex, allowOverride, + chatStateTarget: normalizedTarget, }); if (lukerResult?.loaded || lukerResult?.reason !== "luker-chat-state-v2-empty") { return lukerResult; @@ -7231,6 +7440,7 @@ async function loadGraphFromChatState( const payload = (await readGraphChatStateSnapshot(context, { namespace: GRAPH_CHAT_STATE_NAMESPACE, + target: normalizedTarget, })) || null; if (!payload?.serializedGraph) { if (shouldFallbackToLocalStore) { @@ -7418,6 +7628,316 @@ async function loadGraphFromChatState( return loadResult; } +function getNodeBranchCutoffSeq(node = null) { + if (!node || typeof node !== "object") return -1; + if (Array.isArray(node.seqRange) && Number.isFinite(Number(node.seqRange[1]))) { + return Number(node.seqRange[1]); + } + return Number.isFinite(Number(node.seq)) ? Number(node.seq) : -1; +} + +function deriveBranchGraphFromSourceGraph( + sourceGraph = null, + { + targetChatId = "", + cutoffFloor = null, + assistantMessageCount = null, + } = {}, +) { + if (!sourceGraph) return null; + const nextChatId = + normalizeChatIdCandidate(targetChatId) || + normalizeChatIdCandidate(sourceGraph?.historyState?.chatId); + const branchGraph = cloneGraphForPersistence(sourceGraph, nextChatId); + normalizeGraphRuntimeState(branchGraph, nextChatId); + + const safeCutoff = + Number.isFinite(Number(cutoffFloor)) && Number(cutoffFloor) >= 0 + ? Math.floor(Number(cutoffFloor)) + : null; + if (safeCutoff != null) { + const allowedNodeIds = new Set( + (Array.isArray(branchGraph.nodes) ? branchGraph.nodes : []) + .filter((node) => { + const nodeCutoffSeq = getNodeBranchCutoffSeq(node); + return nodeCutoffSeq < 0 || nodeCutoffSeq <= safeCutoff; + }) + .map((node) => String(node.id || "")), + ); + + branchGraph.nodes = (Array.isArray(branchGraph.nodes) ? branchGraph.nodes : []).filter( + (node) => allowedNodeIds.has(String(node.id || "")), + ); + branchGraph.edges = (Array.isArray(branchGraph.edges) ? branchGraph.edges : []).filter( + (edge) => + allowedNodeIds.has(String(edge?.fromId || "")) && + allowedNodeIds.has(String(edge?.toId || "")), + ); + branchGraph.batchJournal = (Array.isArray(branchGraph.batchJournal) + ? branchGraph.batchJournal + : [] + ).filter((journal) => { + const rangeEnd = Number(journal?.processedRange?.[1]); + return !Number.isFinite(rangeEnd) || rangeEnd <= safeCutoff; + }); + + const summaryEntries = Array.isArray(branchGraph.summaryState?.entries) + ? branchGraph.summaryState.entries + : []; + branchGraph.summaryState.entries = summaryEntries.filter((entry) => { + const messageRangeEnd = Number(entry?.messageRange?.[1]); + return !Number.isFinite(messageRangeEnd) || messageRangeEnd <= safeCutoff; + }); + branchGraph.summaryState.activeEntryIds = (branchGraph.summaryState.activeEntryIds || []) + .filter((entryId) => + branchGraph.summaryState.entries.some((entry) => entry.id === entryId), + ); + branchGraph.summaryState.lastSummarizedAssistantFloor = Math.max( + -1, + ...branchGraph.summaryState.entries.map((entry) => + Number.isFinite(Number(entry?.messageRange?.[1])) + ? Number(entry.messageRange[1]) + : -1, + ), + ); + + pruneProcessedMessageHashesFromFloor(branchGraph, safeCutoff + 1); + branchGraph.historyState.lastProcessedAssistantFloor = Math.min( + Number(branchGraph.historyState.lastProcessedAssistantFloor ?? safeCutoff), + safeCutoff, + ); + if ( + Array.isArray(branchGraph.historyState?.lastBatchStatus?.processedRange) && + Number(branchGraph.historyState.lastBatchStatus.processedRange[1]) > safeCutoff + ) { + branchGraph.historyState.lastBatchStatus = null; + } + } + + const extractionCountCeiling = + Number.isFinite(Number(assistantMessageCount)) && Number(assistantMessageCount) >= 0 + ? Math.floor(Number(assistantMessageCount)) + : Number.isFinite(Number(branchGraph.historyState.extractionCount)) + ? Number(branchGraph.historyState.extractionCount) + : 0; + branchGraph.historyState.chatId = nextChatId; + branchGraph.historyState.extractionCount = Math.max( + 0, + Math.min( + Number(branchGraph.historyState.extractionCount || 0), + extractionCountCeiling, + ), + ); + branchGraph.historyState.lastRecoveryResult = null; + branchGraph.historyState.lastBatchStatus = null; + branchGraph.historyState.historyDirtyFrom = null; + branchGraph.historyState.lastMutationSource = "chat-branch-created"; + branchGraph.historyState.lastMutationReason = "chat-branch-created"; + branchGraph.lastRecallResult = null; + return normalizeGraphRuntimeState(branchGraph, nextChatId); +} + +async function readPersistedGraphForChatStateTarget( + context = getContext(), + chatStateTarget = null, +) { + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); + const targetChatId = resolveChatStateTargetChatId(normalizedTarget); + if (!normalizedTarget || !targetChatId) { + return null; + } + + const sidecar = await readLukerGraphSidecarV2(context, { + chatStateTarget: normalizedTarget, + }); + const sidecarResult = buildSnapshotFromLukerSidecarState(sidecar, { + chatId: targetChatId, + source: "branch-source-sidecar", + }); + if (sidecarResult?.ok && sidecarResult?.snapshot) { + try { + return cloneGraphForPersistence( + normalizeGraphRuntimeState( + buildGraphFromSnapshot(sidecarResult.snapshot), + targetChatId, + ), + targetChatId, + ); + } catch (error) { + console.warn("[ST-BME] 读取 Luker branch source snapshot 失败:", error); + } + } + + const legacySnapshot = await readGraphChatStateSnapshot(context, { + namespace: GRAPH_CHAT_STATE_NAMESPACE, + target: normalizedTarget, + }); + if (legacySnapshot?.serializedGraph) { + try { + return cloneGraphForPersistence( + normalizeGraphRuntimeState( + deserializeGraph(legacySnapshot.serializedGraph), + targetChatId, + ), + targetChatId, + ); + } catch (error) { + console.warn("[ST-BME] 读取 Luker branch source legacy snapshot 失败:", error); + } + } + + return null; +} + +async function persistLukerAuxStateNamespace( + namespace, + payload, + { + chatStateTarget = null, + maxOperations = 1024, + } = {}, +) { + const context = getContext(); + if (!isLukerPrimaryPersistenceHost(context)) { + return false; + } + const normalizedTarget = resolveCurrentChatStateTarget(context, chatStateTarget); + if (!normalizedTarget) { + return false; + } + const result = await writeGraphChatStatePayload( + context, + namespace, + payload, + { + maxOperations, + asyncDiff: false, + target: normalizedTarget, + }, + ); + return result?.ok === true; +} + +async function onChatBranchCreated(payload = {}) { + const context = getContext(); + if (!isLukerPrimaryPersistenceHost(context)) { + return { skipped: true, reason: "not-luker" }; + } + + const sourceTarget = resolveCurrentChatStateTarget(context, payload?.sourceTarget); + const targetTarget = resolveCurrentChatStateTarget(context, payload?.targetTarget); + const targetChatId = + resolveChatStateTargetChatId(targetTarget) || + normalizeChatIdCandidate(payload?.branchName); + const cutoffFloor = Number.isFinite(Number(payload?.mesId)) + ? Math.floor(Number(payload.mesId)) + : null; + const assistantMessageCount = Number.isFinite(Number(payload?.assistantMessageCount)) + ? Math.max(0, Math.floor(Number(payload.assistantMessageCount))) + : null; + + if (!sourceTarget || !targetTarget || !targetChatId) { + const skipped = { + ok: false, + reason: "invalid-branch-target", + sourceTarget: cloneRuntimeDebugValue(sourceTarget, null), + targetTarget: cloneRuntimeDebugValue(targetTarget, null), + }; + updateGraphPersistenceState({ + lastBranchInheritResult: skipped, + }); + return skipped; + } + + const sourceGraph = await readPersistedGraphForChatStateTarget( + context, + sourceTarget, + ); + if (!sourceGraph) { + const missing = { + ok: false, + reason: "source-graph-unavailable", + targetChatId, + cutoffFloor, + assistantMessageCount, + }; + updateGraphPersistenceState({ + lastBranchInheritResult: missing, + }); + return missing; + } + + const branchGraph = deriveBranchGraphFromSourceGraph(sourceGraph, { + targetChatId, + cutoffFloor, + assistantMessageCount, + }); + const branchRevision = Math.max( + 1, + Number(getGraphPersistedRevision(sourceGraph) || 0) + 1, + ); + const persistResult = await persistGraphToLukerSidecarV2(context, { + graph: branchGraph, + chatId: targetChatId, + revision: branchRevision, + reason: "chat-branch-created", + accepted: true, + lastProcessedAssistantFloor: + branchGraph?.historyState?.lastProcessedAssistantFloor ?? null, + extractionCount: branchGraph?.historyState?.extractionCount ?? null, + mode: "primary", + chatStateTarget: targetTarget, + }); + + await persistLukerAuxStateNamespace( + LUKER_PROJECTION_STATE_NAMESPACE, + { + version: 1, + runtime: { + status: "idle", + updatedAt: Date.now(), + reason: "chat-branch-created", + }, + persistent: { + status: "idle", + updatedAt: Date.now(), + reason: "chat-branch-created", + }, + targetChatId, + derivedFrom: resolveChatStateTargetChatId(sourceTarget), + }, + { chatStateTarget: targetTarget }, + ); + await persistLukerAuxStateNamespace( + LUKER_DEBUG_STATE_NAMESPACE, + { + version: 1, + updatedAt: Date.now(), + lastBranchInheritResult: { + targetChatId, + cutoffFloor, + assistantMessageCount, + }, + }, + { chatStateTarget: targetTarget }, + ); + + const result = { + ok: persistResult?.saved === true, + reason: persistResult?.reason || "", + targetChatId, + cutoffFloor, + assistantMessageCount, + sourceTarget: cloneRuntimeDebugValue(sourceTarget, null), + targetTarget: cloneRuntimeDebugValue(targetTarget, null), + revision: Number(persistResult?.revision || branchRevision || 0), + }; + updateGraphPersistenceState({ + lastBranchInheritResult: result, + }); + return result; +} + function scheduleGraphChatStateProbe(chatId, options = {}) { const normalizedChatId = normalizeChatIdCandidate(chatId); if ( @@ -9828,6 +10348,7 @@ async function persistGraphToConfiguredDurableTier( reason, lastProcessedAssistantFloor = null, persistDelta = null, + chatStateTarget = null, } = {}, ) { const preferredLocalStore = getPreferredGraphLocalStorePresentationSync(); @@ -9843,6 +10364,7 @@ async function persistGraphToConfiguredDurableTier( ) { const chatStateResult = await persistGraphToHostChatState(context, { graph, + chatId, revision, reason, storageTier: "luker-chat-state", @@ -9851,6 +10373,7 @@ async function persistGraphToConfiguredDurableTier( extractionCount, mode: "primary", persistDelta, + chatStateTarget, }); if (chatStateResult?.saved) { const acceptedRevision = Number(chatStateResult.revision || revision); @@ -9973,6 +10496,7 @@ async function persistGraphToConfiguredDurableTier( if (canUseHostGraphChatStatePersistence(context)) { const chatStateResult = await persistGraphToHostChatState(context, { graph, + chatId, revision, reason: `${reason}:chat-state-fallback`, storageTier: "chat-state", @@ -9981,6 +10505,7 @@ async function persistGraphToConfiguredDurableTier( extractionCount, mode: "primary", persistDelta, + chatStateTarget, }); if (chatStateResult?.saved) { const acceptedRevision = Number(chatStateResult.revision || revision); @@ -13256,6 +13781,7 @@ function saveGraphToChat(options = {}) { if (persistenceEnvironment.hostProfile === "luker") { const persistGraph = cloneGraphForPersistence(currentGraph, chatId); + const chatStateTarget = resolveCurrentChatStateTarget(context); const lastProcessedAssistantFloor = Number.isFinite( Number(persistGraph?.historyState?.lastProcessedAssistantFloor), ) @@ -13270,6 +13796,7 @@ function saveGraphToChat(options = {}) { revision, reason, lastProcessedAssistantFloor, + chatStateTarget, }, ); if (!persistResult?.accepted) { @@ -15963,7 +16490,7 @@ async function runExtraction() { } function applyRecallInjection(settings, recallInput, recentMessages, result) { - return applyRecallInjectionController( + const injectionResult = applyRecallInjectionController( settings, recallInput, recentMessages, @@ -15993,6 +16520,20 @@ function applyRecallInjection(settings, recallInput, recentMessages, result) { updateLastRecalledItems, }, ); + if ( + isLukerPrimaryPersistenceHost(getContext()) && + String(injectionResult?.injectionText || "").trim() + ) { + updateLukerProjectionState({ + runtime: { + status: "pending", + updatedAt: Date.now(), + reason: + String(recallInput?.hookName || "").trim() || "recall-injection", + }, + }); + } + return injectionResult; } function buildRecallRetrieveOptions(settings, context) { @@ -16271,6 +16812,12 @@ async function runRecall(options = {}) { function onChatChanged() { isHostGenerationRunning = false; lastHostGenerationEndedAt = 0; + const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext()); + updateGraphPersistenceState({ + hostProfile: adapter.hostProfile, + chatStateTarget: cloneRuntimeDebugValue(target, null), + lightweightHostMode, + }); if (typeof clearMessageHideState === "function") { clearMessageHideState("chat-changed"); } @@ -16327,6 +16874,12 @@ function onChatChanged() { } function onChatLoaded() { + const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext()); + updateGraphPersistenceState({ + hostProfile: adapter.hostProfile, + chatStateTarget: cloneRuntimeDebugValue(target, null), + lightweightHostMode, + }); const result = onChatLoadedController({ refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, syncGraphLoadFromLiveContext, @@ -16426,6 +16979,18 @@ function onMessageEdited(messageId, meta = null) { return result; } +function onMessageUpdated(messageId, meta = null) { + const result = onMessageUpdatedController( + { + recordIgnoredMutationEvent, + refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, + }, + messageId, + meta, + ); + return result; +} + async function onMessageSwiped(messageId, meta = null) { const result = await onMessageSwipedController( { @@ -16443,6 +17008,99 @@ async function onMessageSwiped(messageId, meta = null) { return result; } +function onGenerationContextReady(payload = {}) { + const { target, lightweightHostMode } = syncBmeHostRuntimeFlags(getContext()); + recordLukerHookPhase("GENERATION_CONTEXT_READY", { + chatStateTarget: target, + lightweightHostMode, + }); + updateLukerProjectionState({ + runtime: { + ...(graphPersistenceState.projectionState?.runtime || {}), + status: "context-ready", + updatedAt: Date.now(), + reason: "generation-context-ready", + }, + }); + return { + phase: "GENERATION_CONTEXT_READY", + lightweightHostMode, + }; +} + +function onGenerationBeforeWorldInfoScan(payload = {}) { + const { target, lightweightHostMode } = syncBmeHostRuntimeFlags(getContext()); + recordLukerHookPhase("GENERATION_BEFORE_WORLD_INFO_SCAN", { + chatStateTarget: target, + lightweightHostMode, + }); + return { + phase: "GENERATION_BEFORE_WORLD_INFO_SCAN", + lightweightHostMode, + }; +} + +function onGenerationAfterWorldInfoScan(payload = {}) { + const { target, lightweightHostMode } = syncBmeHostRuntimeFlags(getContext()); + recordLukerHookPhase("GENERATION_AFTER_WORLD_INFO_SCAN", { + chatStateTarget: target, + lightweightHostMode, + }); + if (String(graphPersistenceState.projectionState?.runtime?.status || "") === "pending") { + payload.__stBmeProjectionRequestedRescan = true; + } + return { + phase: "GENERATION_AFTER_WORLD_INFO_SCAN", + requestRescan: payload?.__stBmeProjectionRequestedRescan === true, + }; +} + +function onGenerationWorldInfoFinalized(payload = {}) { + const { target, lightweightHostMode } = syncBmeHostRuntimeFlags(getContext()); + recordLukerHookPhase("GENERATION_WORLD_INFO_FINALIZED", { + chatStateTarget: target, + lightweightHostMode, + }); + + if ( + isLukerPrimaryPersistenceHost(getContext()) && + graphPersistenceState.projectionState?.runtime?.status === "pending" + ) { + payload.requestRescan = true; + const reason = + graphPersistenceState.projectionState?.runtime?.reason || + "runtime-projection-pending"; + updateGraphPersistenceState({ + lastRequestRescanReason: String(reason || ""), + }); + updateLukerProjectionState({ + runtime: { + ...(graphPersistenceState.projectionState?.runtime || {}), + status: "rescan-requested", + updatedAt: Date.now(), + reason, + }, + }); + } + + return { + phase: "GENERATION_WORLD_INFO_FINALIZED", + requestRescan: payload?.requestRescan === true, + }; +} + +function onGenerationBeforeApiRequest(payload = {}) { + const { target, lightweightHostMode } = syncBmeHostRuntimeFlags(getContext()); + recordLukerHookPhase("GENERATION_BEFORE_API_REQUEST", { + chatStateTarget: target, + lightweightHostMode, + }); + return { + phase: "GENERATION_BEFORE_API_REQUEST", + lightweightHostMode, + }; +} + function onGenerationStarted(type, params = {}, dryRun = false) { const generationType = String(type || "normal").trim() || "normal"; if ( @@ -16480,6 +17138,16 @@ function onGenerationStarted(type, params = {}, dryRun = false) { function onGenerationEnded(_chatLength = null) { isHostGenerationRunning = false; lastHostGenerationEndedAt = Date.now(); + if (isLukerPrimaryPersistenceHost(getContext())) { + updateLukerProjectionState({ + runtime: { + ...(graphPersistenceState.projectionState?.runtime || {}), + status: "idle", + updatedAt: Date.now(), + reason: "generation-ended", + }, + }); + } const recentTransaction = findRecentGenerationRecallTransactionForChat(); const recentRecallResult = getGenerationRecallTransactionResult(recentTransaction); @@ -17614,6 +18282,7 @@ async function onProbeGraphLoad() { async function onRebuildLocalCacheFromLukerSidecar() { const context = getContext(); + const chatStateTarget = resolveCurrentChatStateTarget(context); if (!isLukerPrimaryPersistenceHost(context)) { toastr.info("当前宿主不是 Luker,无需从主 sidecar 重建本地缓存"); return { handledToast: true, reason: "not-luker" }; @@ -17627,6 +18296,7 @@ async function onRebuildLocalCacheFromLukerSidecar() { const loadResult = await loadGraphFromLukerSidecarV2(chatId, { source: "panel-manual-luker-cache-rebuild", allowOverride: true, + chatStateTarget, }); if (!loadResult?.loaded || !currentGraph) { toastr.warning( @@ -17652,6 +18322,7 @@ async function onRebuildLocalCacheFromLukerSidecar() { async function onRepairLukerSidecar() { const context = getContext(); + const chatStateTarget = resolveCurrentChatStateTarget(context); if (!isLukerPrimaryPersistenceHost(context)) { toastr.info("当前宿主不是 Luker,无需修复主 sidecar"); return { handledToast: true, reason: "not-luker" }; @@ -17667,6 +18338,7 @@ async function onRepairLukerSidecar() { !(await loadGraphFromLukerSidecarV2(chatId, { source: "panel-manual-luker-sidecar-repair", allowOverride: true, + chatStateTarget, }))?.loaded ) { toastr.warning("当前无法从 Luker 主 sidecar 恢复运行时图谱,暂时不能修复"); @@ -17684,6 +18356,7 @@ async function onRepairLukerSidecar() { reason: "panel-manual-luker-sidecar-repair", integrity: getChatMetadataIntegrity(context) || graphPersistenceState.metadataIntegrity, + chatStateTarget, }); refreshPanelLiveState(); if (result?.ok) { @@ -17697,6 +18370,7 @@ async function onRepairLukerSidecar() { async function onCompactLukerSidecar() { const context = getContext(); + const chatStateTarget = resolveCurrentChatStateTarget(context); if (!isLukerPrimaryPersistenceHost(context)) { toastr.info("当前宿主不是 Luker,无需压实主 sidecar"); return { handledToast: true, reason: "not-luker" }; @@ -17718,6 +18392,7 @@ async function onCompactLukerSidecar() { reason: "panel-manual-luker-sidecar-compact", integrity: getChatMetadataIntegrity(context) || graphPersistenceState.metadataIntegrity, + chatStateTarget, }); refreshPanelLiveState(); if (result?.ok) { @@ -17730,6 +18405,12 @@ async function onCompactLukerSidecar() { (async function init() { await loadServerSettings(); + const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext()); + updateGraphPersistenceState({ + hostProfile: adapter.hostProfile, + chatStateTarget: cloneRuntimeDebugValue(target, null), + lightweightHostMode, + }); syncGraphPersistenceDebugState(); await initializePanelBridgeController({ @@ -17846,13 +18527,20 @@ async function onCompactLukerSidecar() { handlers: { onBeforeCombinePrompts, onCharacterMessageRendered, + onChatBranchCreated, onChatChanged, onChatLoaded, + onGenerationBeforeApiRequest, + onGenerationBeforeWorldInfoScan, onGenerationAfterCommands, + onGenerationAfterWorldInfoScan, + onGenerationContextReady, onGenerationEnded, onGenerationStarted, + onGenerationWorldInfoFinalized, onMessageDeleted, onMessageEdited, + onMessageUpdated, onMessageReceived, onMessageSent, onMessageSwiped, diff --git a/llm/llm.js b/llm/llm.js index dfee25f..741ed9e 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -96,10 +96,23 @@ function isVerboseRuntimeDebugEnabled() { return globalThis.__stBmeVerboseDebug === true; } +function isLightweightHostModeEnabled() { + return globalThis.__stBmeLightweightHostMode === true; +} + +function getTaskDebugTimelineLimit() { + return isLightweightHostModeEnabled() ? 12 : TASK_DEBUG_TIMELINE_LIMIT; +} + function buildPreviewText(value, maxChars = TASK_DEBUG_PREVIEW_MAX_CHARS) { + const effectiveMaxChars = isLightweightHostModeEnabled() + ? Math.min(maxChars, 180) + : maxChars; const text = String(value ?? "").replace(/\s+/g, " ").trim(); if (!text) return ""; - return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; + return text.length > effectiveMaxChars + ? `${text.slice(0, effectiveMaxChars)}...` + : text; } function summarizeMessageArray(messages = []) { @@ -446,7 +459,7 @@ function recordTaskLlmRequest(taskType, snapshot = {}, options = {}) { ); if (timelineEntry) { state.taskTimeline = Array.isArray(state.taskTimeline) - ? [...state.taskTimeline, timelineEntry].slice(-TASK_DEBUG_TIMELINE_LIMIT) + ? [...state.taskTimeline, timelineEntry].slice(-getTaskDebugTimelineLimit()) : [timelineEntry]; } state.updatedAt = new Date().toISOString(); diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 633a7d5..7291779 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -119,10 +119,19 @@ function isVerboseRuntimeDebugEnabled() { return globalThis.__stBmeVerboseDebug === true; } +function isLightweightHostModeEnabled() { + return globalThis.__stBmeLightweightHostMode === true; +} + function buildPreviewText(value, maxChars = 240) { + const effectiveMaxChars = isLightweightHostModeEnabled() + ? Math.min(maxChars, 160) + : maxChars; const text = String(value ?? "").replace(/\s+/g, " ").trim(); if (!text) return ""; - return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; + return text.length > effectiveMaxChars + ? `${text.slice(0, effectiveMaxChars)}...` + : text; } function summarizeExecutionMessages(messages = []) { diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index a17db21..bf1ea7d 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -12,6 +12,15 @@ import { evaluatePersistNativeDeltaGate, } from "../sync/bme-db.js"; import { onMessageReceivedController } from "../host/event-binding.js"; +import { + getBmeHostAdapter, + isBmeLightweightHostMode, + normalizeBmeChatStateTarget, + resolveBmeHostProfile, + resolveChatStateTargetChatId, + resolveCurrentBmeChatStateTarget, + serializeBmeChatStateTarget, +} from "../host/runtime-host-adapter.js"; import { buildGraphCommitMarker, buildGraphChatStateSnapshot, @@ -22,6 +31,7 @@ import { appendLukerGraphJournalEntryV2, canUseGraphChatState, detectIndexedDbSnapshotCommitMarkerMismatch, + deleteGraphChatStateNamespace, cloneGraphForPersistence, cloneRuntimeDebugValue, findGraphShadowSnapshotByIntegrity, @@ -48,6 +58,7 @@ import { GRAPH_STARTUP_RECONCILE_DELAYS_MS, MODULE_NAME, normalizeGraphCommitMarker, + readGraphChatStateNamespaces, readGraphCommitMarker, readGraphChatStateSnapshot, readLukerGraphSidecarV2, @@ -59,6 +70,7 @@ import { shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, + writeGraphChatStatePayload, writeGraphChatStateSnapshot, writeLukerGraphCheckpointV2, writeLukerGraphManifestV2, @@ -513,6 +525,77 @@ async function createGraphPersistenceHarness({ clampInt, clampFloat, formatRecallContextLine, + getBmeHostAdapter(context = null) { + const activeContext = context || runtimeContext.__chatContext || {}; + return { + context: activeContext, + hostProfile: runtimeContext.resolveBmeHostProfile(activeContext), + resolveCurrentTarget(options = {}) { + return runtimeContext.resolveCurrentBmeChatStateTarget( + activeContext, + options?.target, + ); + }, + getChatIdFromTarget(target = null) { + return runtimeContext.resolveChatStateTargetChatId(target); + }, + isLightweightHostMode() { + return runtimeContext.isBmeLightweightHostMode(activeContext); + }, + }; + }, + isBmeLightweightHostMode(context = null) { + return runtimeContext.resolveBmeHostProfile(context) === "luker"; + }, + normalizeBmeChatStateTarget, + resolveBmeHostProfile(context = null) { + const activeContext = context || runtimeContext.__chatContext || {}; + const hasImplicitCurrentChat = + String(activeContext?.chatId || "").trim() || + String(activeContext?.groupId || "").trim() || + String(activeContext?.characterId || "").trim(); + return runtimeContext.Luker && + typeof runtimeContext.Luker?.getContext === "function" && + typeof activeContext.getChatState === "function" && + typeof activeContext.updateChatState === "function" && + typeof activeContext.getChatStateBatch === "function" && + hasImplicitCurrentChat + ? "luker" + : "generic-st"; + }, + resolveChatStateTargetChatId(target = null) { + return resolveChatStateTargetChatId(target); + }, + resolveCurrentBmeChatStateTarget(context = null, explicitTarget = null) { + if (explicitTarget) { + return normalizeBmeChatStateTarget(explicitTarget); + } + const activeContext = context || runtimeContext.__chatContext || {}; + if (String(activeContext?.groupId || "").trim()) { + return { + is_group: true, + id: String(activeContext.chatId || activeContext.groupId).trim(), + }; + } + const avatar = + activeContext?.characterAvatar || + activeContext?.avatar_url || + activeContext?.characters?.[activeContext?.characterId]?.avatar || + activeContext?.characters?.[Number(activeContext?.characterId)]?.avatar || + ""; + const fileName = String(activeContext?.chatId || "").trim(); + if (avatar && fileName) { + return { + is_group: false, + avatar_url: String(avatar), + file_name: fileName, + }; + } + return null; + }, + serializeBmeChatStateTarget(target = null) { + return serializeBmeChatStateTarget(target); + }, readPersistedRecallFromUserMessage, cloneGraphForPersistence, buildGraphCommitMarker, @@ -523,6 +606,7 @@ async function createGraphPersistenceHarness({ buildLukerGraphManifestV2, canUseGraphChatState, cloneRuntimeDebugValue, + deleteGraphChatStateNamespace, detectIndexedDbSnapshotCommitMarkerMismatch, onMessageReceivedController, GRAPH_CHAT_STATE_NAMESPACE, @@ -549,6 +633,7 @@ async function createGraphPersistenceHarness({ MODULE_NAME, findGraphShadowSnapshotByIntegrity, normalizeGraphCommitMarker, + readGraphChatStateNamespaces, readGraphCommitMarker, readGraphChatStateSnapshot, readLukerGraphSidecarV2, @@ -561,6 +646,7 @@ async function createGraphPersistenceHarness({ replaceLukerGraphJournalV2, appendLukerGraphJournalEntryV2, writeChatMetadataPatch, + writeGraphChatStatePayload, writeGraphChatStateSnapshot, writeLukerGraphManifestV2, writeLukerGraphCheckpointV2, @@ -875,22 +961,45 @@ async function createGraphPersistenceHarness({ async saveMetadata() { runtimeContext.__contextImmediateSaveCalls += 1; }, - async getChatState(namespace) { + __chatStateTargetStore: new Map(), + __chatStateCalls: [], + async getChatState(namespace, options = {}) { const key = String(namespace || "").trim().toLowerCase(); - const value = this.__chatStateStore.get(key); + const targetKey = serializeBmeChatStateTarget(options?.target); + const scopedKey = targetKey ? `${targetKey}::${key}` : key; + this.__chatStateCalls.push({ + type: "get", + namespace: key, + target: options?.target ? structuredClone(options.target) : null, + }); + const value = this.__chatStateStore.get(scopedKey); return value == null ? null : structuredClone(value); }, - async updateChatState(namespace, updater) { + async getChatStateBatch(namespaces = [], options = {}) { + const batch = new Map(); + for (const namespace of namespaces) { + batch.set(namespace, await this.getChatState(namespace, options)); + } + return batch; + }, + async updateChatState(namespace, updater, options = {}) { const key = String(namespace || "").trim().toLowerCase(); + const targetKey = serializeBmeChatStateTarget(options?.target); + const scopedKey = targetKey ? `${targetKey}::${key}` : key; if (!key || typeof updater !== "function") { return { ok: false, state: null, updated: false }; } - const current = this.__chatStateStore.has(key) - ? structuredClone(this.__chatStateStore.get(key)) + this.__chatStateCalls.push({ + type: "update", + namespace: key, + target: options?.target ? structuredClone(options.target) : null, + }); + const current = this.__chatStateStore.has(scopedKey) + ? structuredClone(this.__chatStateStore.get(scopedKey)) : {}; const next = await updater(structuredClone(current), { attempt: 0, - target: null, + target: options?.target ?? null, namespace: key, }); if (next == null) { @@ -898,13 +1007,25 @@ async function createGraphPersistenceHarness({ } const currentJson = JSON.stringify(current); const nextJson = JSON.stringify(next); - this.__chatStateStore.set(key, structuredClone(next)); + this.__chatStateStore.set(scopedKey, structuredClone(next)); return { ok: true, state: structuredClone(next), updated: currentJson !== nextJson, }; }, + async deleteChatState(namespace, options = {}) { + const key = String(namespace || "").trim().toLowerCase(); + const targetKey = serializeBmeChatStateTarget(options?.target); + const scopedKey = targetKey ? `${targetKey}::${key}` : key; + this.__chatStateStore.delete(scopedKey); + this.__chatStateCalls.push({ + type: "delete", + namespace: key, + target: options?.target ? structuredClone(options.target) : null, + }); + return true; + }, }, __contextSaveCalls: 0, __contextImmediateSaveCalls: 0, @@ -3896,4 +4017,61 @@ result = { assert.equal(Number(checkpoint?.revision || 0), 5); } +{ + const chatId = "chat-luker-targeted-write"; + const integrity = "meta-luker-targeted-write"; + const harness = await createGraphPersistenceHarness({ + chatId, + globalChatId: chatId, + groupId: "group-luker-targeted-write", + chatMetadata: { + integrity, + }, + }); + harness.runtimeContext.Luker = { + getContext() { + return harness.runtimeContext.__chatContext; + }, + }; + const branchTarget = { + is_group: true, + id: "group-luker-targeted-branch", + }; + const graph = stampPersistedGraph( + createMeaningfulGraph("group-luker-targeted-branch", "luker-targeted-write"), + { + revision: 2, + integrity, + chatId: "group-luker-targeted-branch", + reason: "luker-targeted-write", + }, + ); + + const result = await harness.runtimeContext.persistGraphToHostChatState( + harness.runtimeContext.__chatContext, + { + graph, + chatId: "group-luker-targeted-branch", + revision: 2, + reason: "luker-targeted-write", + storageTier: "luker-chat-state", + accepted: true, + lastProcessedAssistantFloor: 6, + extractionCount: 3, + mode: "primary", + chatStateTarget: branchTarget, + }, + ); + + assert.equal(result.saved, true); + assert.equal(result.accepted, true); + const targetedCalls = harness.runtimeContext.__chatContext.__chatStateCalls.filter( + (call) => call.type === "update" && call.target?.id === branchTarget.id, + ); + assert.ok( + targetedCalls.length >= 3, + "显式 chatStateTarget 写入 Luker sidecar 时应把 target 传给 manifest/journal/checkpoint 链路", + ); +} + console.log("graph-persistence tests passed"); diff --git a/tests/luker-host-adapter.mjs b/tests/luker-host-adapter.mjs new file mode 100644 index 0000000..ae6f3ca --- /dev/null +++ b/tests/luker-host-adapter.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; + +import { + getBmeHostAdapter, + isBmeLightweightHostMode, + normalizeBmeChatStateTarget, + resolveBmeHostProfile, + resolveCurrentBmeChatStateTarget, + resolveChatStateTargetChatId, + serializeBmeChatStateTarget, +} from "../host/runtime-host-adapter.js"; + +const originalNavigator = globalThis.navigator; +const originalLuker = globalThis.Luker; + +try { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + userAgent: + "Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 Mobile Safari/537.36", + }, + }); + + const context = { + groupId: "group-1", + chatId: "group-1", + getChatState() {}, + updateChatState() {}, + getChatStateBatch() {}, + }; + globalThis.Luker = { + getContext() { + return context; + }, + }; + + assert.equal(resolveBmeHostProfile(context), "luker"); + assert.equal(isBmeLightweightHostMode(context), true); + + const target = resolveCurrentBmeChatStateTarget(context); + assert.deepEqual(target, { + is_group: true, + id: "group-1", + }); + assert.equal(resolveChatStateTargetChatId(target), "group-1"); + assert.equal(serializeBmeChatStateTarget(target), "group:group-1"); + + const characterContext = { + chatId: "chat-char-1", + characterId: "char-1", + characters: { + "char-1": { + avatar: "alice.png", + }, + }, + getChatState() {}, + updateChatState() {}, + getChatStateBatch() {}, + }; + globalThis.Luker = { + getContext() { + return characterContext; + }, + }; + const adapter = getBmeHostAdapter(characterContext); + const explicitTarget = normalizeBmeChatStateTarget({ + is_group: false, + avatar_url: "alice.png", + file_name: "chat-char-branch", + }); + + let recordedTarget = null; + characterContext.updateChatState = async function(namespace, updater, options = {}) { + recordedTarget = options?.target ?? null; + return { ok: true, updated: true, state: await updater({}) }; + }; + + await adapter.updateChatState("st_bme_graph_manifest", () => ({ ok: true }), { + target: explicitTarget, + }); + assert.deepEqual(recordedTarget, explicitTarget); +} finally { + if (originalNavigator === undefined) { + delete globalThis.navigator; + } else { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: originalNavigator, + }); + } + + if (originalLuker === undefined) { + delete globalThis.Luker; + } else { + globalThis.Luker = originalLuker; + } +} + +console.log("luker-host-adapter tests passed"); diff --git a/tests/message-updated-lightweight.mjs b/tests/message-updated-lightweight.mjs new file mode 100644 index 0000000..7670566 --- /dev/null +++ b/tests/message-updated-lightweight.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; + +import { + onMessageUpdatedController, + registerCoreEventHooksController, +} from "../host/event-binding.js"; + +{ + let invalidated = 0; + let rechecked = 0; + let refreshed = 0; + let ignored = null; + + const result = onMessageUpdatedController( + { + invalidateRecallAfterHistoryMutation() { + invalidated += 1; + }, + scheduleHistoryMutationRecheck() { + rechecked += 1; + }, + refreshPersistedRecallMessageUi() { + refreshed += 1; + }, + recordIgnoredMutationEvent(eventName, detail) { + ignored = { eventName, detail }; + }, + }, + 17, + { source: "unit-test" }, + ); + + assert.equal(invalidated, 0); + assert.equal(rechecked, 0); + assert.equal(refreshed, 1); + assert.equal(result.lightweight, true); + assert.equal(ignored?.eventName, "message-updated"); + assert.equal(ignored?.detail?.reason, "lightweight-refresh-only"); +} + +{ + const bindings = []; + const runtime = { + eventSource: { + on(eventName, handler) { + bindings.push({ eventName, handler }); + }, + }, + eventTypes: { + MESSAGE_UPDATED: "message-updated", + MESSAGE_EDITED: "message-edited", + CHAT_CHANGED: "chat-changed", + }, + handlers: { + onChatChanged() {}, + onMessageEdited() {}, + onMessageUpdated() {}, + }, + registerBeforeCombinePrompts() { + return null; + }, + registerGenerationAfterCommands() { + return null; + }, + getCoreEventBindingState() { + return { registered: false, cleanups: [] }; + }, + setCoreEventBindingState() {}, + }; + + registerCoreEventHooksController(runtime); + const updatedBinding = bindings.find((entry) => entry.eventName === "message-updated"); + const editedBinding = bindings.find((entry) => entry.eventName === "message-edited"); + assert.equal(updatedBinding?.handler, runtime.handlers.onMessageUpdated); + assert.equal(editedBinding?.handler, runtime.handlers.onMessageEdited); +} + +console.log("message-updated-lightweight tests passed"); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index c5a8168..3315c4f 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -3945,7 +3945,7 @@ async function testRegisterCoreEventHooksIsIdempotent() { registerCoreEventHooksController(runtime); registerCoreEventHooksController(runtime); - assert.equal(eventRegistrations.length, 12); + assert.equal(eventRegistrations.length, 11); assert.equal(makeFirstRegistrations.length, 2); assert.equal(bindingState.registered, true); } diff --git a/ui/panel.js b/ui/panel.js index 901a633..cef2fd0 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -2232,10 +2232,13 @@ function _refreshTaskPersistence() { const cacheLagLabel = ps.hostProfile === "luker" ? String(Number(ps.cacheLag || 0)) : "—"; const verboseDebugLabel = globalThis.__stBmeVerboseDebug === true ? "开启" : "关闭"; + const projectionLabel = + ps?.projectionState?.runtime?.status || ps?.projectionState?.persistent?.status || "—"; const kvs = [ ["加载状态", loadStateLabel], ["宿主档案", hostProfileLabel], + ["Chat Target", ps.chatStateTarget ? JSON.stringify(ps.chatStateTarget) : "—"], ["主 durable", primaryTierLabel], ["当前 accepted", acceptedTierLabel], ["accepted by", ps.acceptedBy || "—"], @@ -2252,6 +2255,11 @@ function _refreshTaskPersistence() { ["版本号", ps.revision ?? "—"], ["提交标记", ps.commitMarker ? "存在(诊断锚点)" : "无"], ["Verbose Debug", verboseDebugLabel], + ["轻量模式", ps.lightweightHostMode ? "开启" : "关闭"], + ["Luker Hook", ps.lastHookPhase || "—"], + ["Projection", projectionLabel], + ["Rescan 原因", ps.lastRequestRescanReason || "—"], + ["忽略变更", ps.lastIgnoredMutationEvent || "—"], ["诊断层", STORAGE_TIER_LABELS[ps.persistDiagnosticTier] || ps.persistDiagnosticTier || "无"], ["阻塞原因", ps.blockedReason || ps.reason || "—"], ["影子快照", ps.shadowSnapshotUsed ? "已使用" : "未使用"], @@ -2275,6 +2283,7 @@ function _refreshTaskPersistence() { const guidePairs = [ ["加载状态", "记忆图谱在当前聊天中的加载进度。\"已加载\" 表示正常运行。"], ["宿主档案", "当前运行环境。Luker 会把聊天侧车当主 durable 存储,其它宿主仍以本地存储为主。"], + ["Chat Target", "Luker 当前绑定的 chat-state target。branch 派生和后台任务应显式指向它,而不是依赖当前聊天。"], ["主 durable", "当前宿主下真正负责 accepted 的主存储层。"], ["当前 accepted", "最近一次已确认持久化最终落在哪一层。"], ["accepted by", "本批最近一次 accepted 是由哪一层确认的。"], @@ -2291,6 +2300,11 @@ function _refreshTaskPersistence() { ["版本号", "图谱修订号,每次写入操作自增。用于检测并发冲突。"], ["提交标记", "聊天元数据中的诊断锚点,只用于对账与修复建议,不再单独代表 accepted。"], ["Verbose Debug", "是否抓取完整调试载荷。默认关闭,仅保留轻量摘要。"], + ["轻量模式", "Luker Android/WebView 或移动端下默认启用,主动收紧调试和运行态缓存。"], + ["Luker Hook", "最近一次命中的 Luker 正式 generation hook 阶段。"], + ["Projection", "当前 runtime / persistent projection 的轻量状态。runtime projection 会在生成结束后回落为空闲。"], + ["Rescan 原因", "如果当前轮次通过 runtime projection 请求了 world-info rescan,这里会显示最后一次原因。"], + ["忽略变更", "最近一次被按 MESSAGE_UPDATED 轻刷新降级处理的消息变更。"], ["诊断层", "最近一次仅作诊断/恢复用途的层级,例如影子快照或完整 metadata。"], ["阻塞原因", "如果加载被阻塞,这里显示具体原因。\"—\" 表示未阻塞。"], ["影子快照", "是否在启动时使用了上次会话留下的影子快照来加速加载。"], diff --git a/ui/ui-status.js b/ui/ui-status.js index e036656..a492a01 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -52,10 +52,12 @@ export function createGraphPersistenceState() { lastAcceptedRevision: 0, acceptedStorageTier: "none", hostProfile: "generic-st", + chatStateTarget: null, primaryStorageTier: "indexeddb", cacheStorageTier: "none", cacheMirrorState: "idle", cacheLag: 0, + lightweightHostMode: false, persistDiagnosticTier: "none", acceptedBy: "none", lastRecoverableStorageTier: "none", @@ -66,6 +68,24 @@ export function createGraphPersistenceState() { lukerJournalDepth: 0, lukerJournalBytes: 0, lukerCheckpointRevision: 0, + projectionState: { + runtime: { + status: "idle", + updatedAt: 0, + reason: "", + }, + persistent: { + status: "idle", + updatedAt: 0, + reason: "", + }, + }, + lastHookPhase: "", + lastRequestRescanReason: "", + lastIgnoredMutationEvent: "", + lastIgnoredMutationReason: "", + lastChatStateConflict: null, + lastBranchInheritResult: null, restoreLock: { active: false, depth: 0,