feat: deepen luker host integration

This commit is contained in:
Youzini-afk
2026-04-15 21:19:36 +08:00
parent 1251938fc6
commit 359a2a07b7
12 changed files with 1637 additions and 59 deletions

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

View File

@@ -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);
}