mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
feat: deepen luker host integration
This commit is contained in:
@@ -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");
|
||||
|
||||
100
tests/luker-host-adapter.mjs
Normal file
100
tests/luker-host-adapter.mjs
Normal 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");
|
||||
78
tests/message-updated-lightweight.mjs
Normal file
78
tests/message-updated-lightweight.mjs
Normal 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");
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user