Merge branch 'Youzini-afk:main' into main

This commit is contained in:
Hao19911125
2026-04-23 23:00:45 +08:00
committed by GitHub
65 changed files with 13066 additions and 3141 deletions

View File

@@ -50,7 +50,6 @@ assert.equal(defaultSettings.injectUserPovMemory, true);
assert.equal(defaultSettings.injectObjectiveGlobalMemory, true);
assert.equal(defaultSettings.enableCognitiveMemory, true);
assert.equal(defaultSettings.enableSpatialAdjacency, true);
assert.equal(defaultSettings.enableAiMonitor, false);
assert.equal(defaultSettings.injectLowConfidenceObjectiveMemory, false);
assert.equal(defaultSettings.enableStoryTimeline, true);
assert.equal(defaultSettings.injectStoryTimeLabel, true);
@@ -67,15 +66,18 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default");
assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
assert.equal(defaultSettings.embeddingTransportMode, "direct");
assert.equal(defaultSettings.graphUseNativeLayout, false);
assert.equal(defaultSettings.graphUseNativeLayout, true);
assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280);
assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600);
assert.equal(defaultSettings.graphNativeLayoutWorkerTimeoutMs, 260);
assert.equal(defaultSettings.persistUseNativeDelta, false);
assert.equal(defaultSettings.persistUseNativeDelta, true);
assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000);
assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600);
assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000);
assert.equal(defaultSettings.persistNativeDeltaBridgeMode, "json");
assert.equal(defaultSettings.loadUseNativeHydrate, true);
assert.equal(defaultSettings.loadNativeHydrateThresholdRecords, 30000);
assert.equal(defaultSettings.nativeRolloutVersion, 2);
assert.equal(defaultSettings.nativeEngineFailOpen, true);
assert.equal(defaultSettings.graphNativeForceDisable, false);
assert.equal(defaultSettings.taskProfilesVersion, 3);
@@ -114,4 +116,44 @@ assert.equal(
defaultSettings.compressionEveryN,
);
const migratedLegacyNativeDisabled = mergePersistedSettings({
graphUseNativeLayout: false,
persistUseNativeDelta: false,
loadUseNativeHydrate: false,
graphNativeForceDisable: true,
});
assert.equal(migratedLegacyNativeDisabled.graphUseNativeLayout, true);
assert.equal(migratedLegacyNativeDisabled.persistUseNativeDelta, true);
assert.equal(migratedLegacyNativeDisabled.loadUseNativeHydrate, true);
assert.equal(migratedLegacyNativeDisabled.loadNativeHydrateThresholdRecords, 30000);
assert.equal(migratedLegacyNativeDisabled.graphNativeForceDisable, true);
assert.equal(migratedLegacyNativeDisabled.nativeRolloutVersion, 2);
const migratedVersionedManualNativeDisabled = mergePersistedSettings({
nativeRolloutVersion: 2,
graphUseNativeLayout: false,
persistUseNativeDelta: false,
loadUseNativeHydrate: false,
graphNativeForceDisable: true,
});
assert.equal(migratedVersionedManualNativeDisabled.graphUseNativeLayout, false);
assert.equal(migratedVersionedManualNativeDisabled.persistUseNativeDelta, false);
assert.equal(migratedVersionedManualNativeDisabled.loadUseNativeHydrate, false);
assert.equal(migratedVersionedManualNativeDisabled.graphNativeForceDisable, true);
assert.equal(migratedVersionedManualNativeDisabled.nativeRolloutVersion, 2);
const migratedLegacyHydrateThresholdDefault = mergePersistedSettings({
nativeRolloutVersion: 1,
loadNativeHydrateThresholdRecords: 12000,
});
assert.equal(migratedLegacyHydrateThresholdDefault.loadNativeHydrateThresholdRecords, 30000);
assert.equal(migratedLegacyHydrateThresholdDefault.nativeRolloutVersion, 2);
const preservedCustomHydrateThreshold = mergePersistedSettings({
nativeRolloutVersion: 1,
loadNativeHydrateThresholdRecords: 45000,
});
assert.equal(preservedCustomHydrateThreshold.loadNativeHydrateThresholdRecords, 45000);
assert.equal(preservedCustomHydrateThreshold.nativeRolloutVersion, 2);
console.log("default-settings tests passed");

View File

@@ -16,6 +16,7 @@ function createRuntime(persistResult) {
};
let processedHistoryUpdates = 0;
let persistedGraphSnapshot = null;
let lastPersistDeltaOptions = null;
return {
graph,
@@ -35,6 +36,22 @@ function createRuntime(persistResult) {
cloneGraphSnapshot(value) {
return JSON.parse(JSON.stringify(value));
},
buildPersistDelta(_beforeSnapshot, _afterSnapshot, options = {}) {
lastPersistDeltaOptions = { ...(options || {}) };
return {
upsertNodes: [],
upsertEdges: [],
deleteNodeIds: [],
deleteEdgeIds: [],
tombstones: [],
countDelta: {
nodes: 0,
edges: 0,
tombstones: 0,
},
runtimeMetaPatch: {},
};
},
buildExtractionMessages() {
return [{ seq: 5, role: "assistant", content: "测试消息" }];
},
@@ -101,6 +118,9 @@ function createRuntime(persistResult) {
get persistedGraphSnapshot() {
return persistedGraphSnapshot;
},
get lastPersistDeltaOptions() {
return lastPersistDeltaOptions;
},
};
}
@@ -124,7 +144,7 @@ function createRuntime(persistResult) {
assert.equal(result.success, true);
assert.equal(result.historyAdvanceAllowed, false);
assert.equal(runtime.processedHistoryUpdates, 0);
assert.equal(runtime.processedHistoryUpdates, 1);
assert.equal(
runtime.graph.historyState.lastBatchStatus.persistence.outcome,
"queued",
@@ -133,6 +153,11 @@ function createRuntime(persistResult) {
runtime.graph.historyState.lastBatchStatus.historyAdvanceAllowed,
false,
);
assert.equal(
runtime.graph.historyState.lastBatchStatus.historyAdvanced,
false,
);
assert.equal(runtime.graph.batchJournal.length, 0);
assert.equal(
runtime.persistedGraphSnapshot?.historyState?.lastProcessedAssistantFloor,
5,
@@ -212,4 +237,85 @@ function createRuntime(persistResult) {
assert.equal(runtime.graph.historyState.lastBatchStatus.persistence, null);
}
{
const originalNativeBuilder = globalThis.__stBmeNativeBuildPersistDelta;
globalThis.__stBmeNativeBuildPersistDelta = () => ({
upsertNodes: [],
upsertEdges: [],
deleteNodeIds: [],
deleteEdgeIds: [],
tombstones: [],
runtimeMetaPatch: {},
});
const runtime = createRuntime({
saved: true,
queued: false,
blocked: false,
accepted: true,
reason: "indexeddb",
revision: 9,
saveMode: "indexeddb",
storageTier: "indexeddb",
});
const result = await executeExtractionBatchController(runtime, {
chat: [{ is_user: false, mes: "测试" }],
startIdx: 5,
endIdx: 5,
settings: {
persistUseNativeDelta: true,
graphNativeForceDisable: false,
nativeEngineFailOpen: true,
persistNativeDeltaThresholdRecords: 123,
persistNativeDeltaThresholdStructuralDelta: 45,
persistNativeDeltaThresholdSerializedChars: 6789,
persistNativeDeltaBridgeMode: "hash",
},
});
assert.equal(result.success, true);
assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, true);
assert.equal(runtime.lastPersistDeltaOptions.nativeFailOpen, true);
assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdRecords, 123);
assert.equal(
runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdStructuralDelta,
45,
);
assert.equal(
runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdSerializedChars,
6789,
);
assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaBridgeMode, "hash");
if (typeof originalNativeBuilder === "function") {
globalThis.__stBmeNativeBuildPersistDelta = originalNativeBuilder;
} else {
delete globalThis.__stBmeNativeBuildPersistDelta;
}
}
{
const runtime = createRuntime({
saved: true,
queued: false,
blocked: false,
accepted: true,
reason: "indexeddb",
revision: 10,
saveMode: "indexeddb",
storageTier: "indexeddb",
});
const result = await executeExtractionBatchController(runtime, {
chat: [{ is_user: false, mes: "测试" }],
startIdx: 5,
endIdx: 5,
settings: {
persistUseNativeDelta: true,
graphNativeForceDisable: true,
},
});
assert.equal(result.success, true);
assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, false);
}
console.log("extraction-persistence-gating tests passed");

View File

@@ -8,7 +8,9 @@ import {
buildBmeDbName,
buildGraphFromSnapshot,
buildPersistDelta,
buildPersistDeltaFromGraphDirtyState,
buildSnapshotFromGraph,
evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
} from "../sync/bme-db.js";
import { onMessageReceivedController } from "../host/event-binding.js";
@@ -82,13 +84,18 @@ import {
getGraphStats,
getNode,
serializeGraph,
updateNode,
} from "../graph/graph.js";
import {
buildPersistedRecallRecord,
readPersistedRecallFromUserMessage,
} from "../retrieval/recall-persistence.js";
import { getNodeDisplayName } from "../graph/node-labels.js";
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
import {
hasGraphPersistDirtyState,
normalizeGraphRuntimeState,
pruneGraphPersistDirtyState,
} from "../runtime/runtime-state.js";
import {
defaultSettings,
getPersistedSettingsSnapshot,
@@ -1031,8 +1038,12 @@ async function createGraphPersistenceHarness({
__contextImmediateSaveCalls: 0,
buildGraphFromSnapshot,
buildPersistDelta,
buildPersistDeltaFromGraphDirtyState,
buildSnapshotFromGraph,
evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
hasGraphPersistDirtyState,
pruneGraphPersistDirtyState,
buildBmeDbName,
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb",
@@ -3199,6 +3210,10 @@ result = {
lastPersistedRevision: 0,
writesBlocked: false,
});
harness.runtimeContext.extension_settings[MODULE_NAME] = {
nativeRolloutVersion: 1,
persistUseNativeDelta: false,
};
harness.runtimeContext.__scheduleUploadShouldThrow = true;
const result = await harness.api.saveGraphToIndexedDb(
@@ -3234,6 +3249,268 @@ result = {
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-idb-single-snapshot-build",
globalChatId: "chat-idb-single-snapshot-build",
chatMetadata: {
integrity: "meta-idb-single-snapshot-build",
},
});
harness.api.setCurrentGraph(
createMeaningfulGraph("chat-idb-single-snapshot-build", "single-snapshot-build"),
);
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId: "chat-idb-single-snapshot-build",
revision: 8,
lastPersistedRevision: 0,
writesBlocked: false,
});
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
let buildSnapshotCallCount = 0;
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
buildSnapshotCallCount += 1;
return originalBuildSnapshotFromGraph(...args);
};
const result = await harness.api.saveGraphToIndexedDb(
"chat-idb-single-snapshot-build",
harness.api.getCurrentGraph(),
{
revision: 8,
reason: "single-snapshot-build-save",
scheduleCloudUpload: false,
},
);
assert.equal(result.saved, true);
assert.equal(
buildSnapshotCallCount,
1,
"saveGraphToIndexedDb 热路径应复用首次构建的 snapshot而不是提交后再重建一次",
);
assert.equal(result.snapshot?.meta?.revision, 8);
assert.equal(
harness.api.getIndexedDbSnapshot()?.meta?.revision,
8,
"复用首次 snapshot 后仍应正确回填缓存 revision",
);
}
{
const chatId = "chat-idb-direct-delta-prebuilt-persist-snapshot";
const baseGraph = createMeaningfulGraph(chatId, "direct-delta-base");
const runtimeGraph = createMeaningfulGraph(chatId, "direct-delta-after");
const baseSnapshot = buildSnapshotFromGraph(baseGraph, {
chatId,
revision: 7,
});
const persistSnapshot = buildSnapshotFromGraph(runtimeGraph, {
chatId,
revision: 8,
baseSnapshot,
});
const directDelta = buildPersistDelta(baseSnapshot, persistSnapshot, {
useNativeDelta: false,
});
const harness = await createGraphPersistenceHarness({
chatId,
globalChatId: chatId,
chatMetadata: {
integrity: "meta-idb-direct-delta-prebuilt-persist-snapshot",
},
indexedDbSnapshot: baseSnapshot,
});
harness.api.setCurrentGraph(runtimeGraph);
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId,
revision: 8,
lastPersistedRevision: 0,
writesBlocked: false,
});
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
let buildSnapshotCallCount = 0;
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
buildSnapshotCallCount += 1;
return originalBuildSnapshotFromGraph(...args);
};
const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, {
revision: 8,
reason: "direct-delta-prebuilt-persist-snapshot-save",
scheduleCloudUpload: false,
persistDelta: directDelta,
persistSnapshot,
});
assert.equal(result.saved, true);
assert.equal(
buildSnapshotCallCount,
0,
"direct-delta 且已提供 persistSnapshot 时不应再次构建 snapshot",
);
assert.equal(result.snapshot?.meta?.revision, 8);
assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8);
}
{
const chatId = "chat-idb-dirty-runtime-fast-path";
const baseGraph = createMeaningfulGraph(chatId, "dirty-runtime-base");
const runtimeGraph = cloneGraphForPersistence(baseGraph, chatId);
updateNode(runtimeGraph, runtimeGraph.nodes[0]?.id, {
importance: Number(runtimeGraph.nodes[0]?.importance || 0) + 2,
});
const baseSnapshot = buildSnapshotFromGraph(baseGraph, {
chatId,
revision: 7,
});
const harness = await createGraphPersistenceHarness({
chatId,
globalChatId: chatId,
chatMetadata: {
integrity: "meta-idb-dirty-runtime-fast-path",
},
indexedDbSnapshot: baseSnapshot,
});
harness.api.setCurrentGraph(runtimeGraph);
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId,
revision: 8,
lastPersistedRevision: 0,
writesBlocked: false,
});
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
let buildSnapshotCallCount = 0;
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
buildSnapshotCallCount += 1;
return originalBuildSnapshotFromGraph(...args);
};
const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, {
revision: 8,
reason: "dirty-runtime-fast-path-save",
scheduleCloudUpload: false,
sourceGraph: runtimeGraph,
});
assert.equal(result.saved, true);
assert.equal(
buildSnapshotCallCount,
0,
"dirty-set 命中时 saveGraphToIndexedDb 不应退回 full snapshot build",
);
assert.equal(result.snapshot?.meta?.revision, 8);
assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8);
}
{
const chatId = "chat-indexeddb-probe-empty-early-return";
const persistedSnapshot = {
meta: { revision: 0, chatId },
nodes: [],
edges: [],
tombstones: [],
state: {
lastProcessedFloor: -1,
extractionCount: 0,
},
};
const harness = await createGraphPersistenceHarness({
chatId,
globalChatId: chatId,
chatMetadata: {
integrity: "meta-indexeddb-probe-empty-early-return",
},
indexedDbSnapshot: persistedSnapshot,
});
harness.runtimeContext.__globalChatId = chatId;
harness.runtimeContext.__chatContext.chatId = chatId;
harness.api.setChatContext({
...harness.api.getChatContext(),
chatId,
chatMetadata: {
integrity: "meta-indexeddb-probe-empty-early-return",
},
});
harness.api.setCurrentGraph(
createMeaningfulGraph(chatId, "probe-empty-runtime-current"),
);
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId,
revision: 1,
lastPersistedRevision: 1,
storagePrimary: "indexeddb",
storageMode: "indexeddb",
writesBlocked: false,
});
const originalCreateDb = harness.runtimeContext.BmeChatManager.prototype._createDb;
let exportSnapshotCalls = 0;
let exportProbeCalls = 0;
harness.runtimeContext.BmeChatManager.prototype._createDb = function(dbChatId = "") {
const baseDb = originalCreateDb.call(this, dbChatId);
return {
...baseDb,
async exportSnapshot() {
exportSnapshotCalls += 1;
return await baseDb.exportSnapshot();
},
async exportSnapshotProbe() {
exportProbeCalls += 1;
const snapshot = harness.api.getIndexedDbSnapshotForChat(dbChatId) || {
meta: { revision: 0, chatId: String(dbChatId || "") },
state: { lastProcessedFloor: -1, extractionCount: 0 },
nodes: [],
edges: [],
tombstones: [],
};
return {
meta: {
...(snapshot.meta || {}),
chatId: String(dbChatId || ""),
revision: Number(snapshot?.meta?.revision || 0),
nodeCount: Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0,
edgeCount: Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0,
tombstoneCount: Array.isArray(snapshot?.tombstones)
? snapshot.tombstones.length
: 0,
},
state: {
lastProcessedFloor: Number(snapshot?.state?.lastProcessedFloor ?? -1),
extractionCount: Number(snapshot?.state?.extractionCount ?? 0),
},
nodes: [],
edges: [],
tombstones: [],
__stBmeProbeOnly: true,
__stBmeTombstonesOmitted: true,
};
},
};
};
const result = await harness.api.loadGraphFromIndexedDb(chatId, {
source: "probe-empty-early-return",
attemptIndex: 0,
});
assert.equal(result.loaded, false);
assert.equal(exportProbeCalls, 1);
assert.equal(
exportSnapshotCalls,
0,
"empty/probe 早退应在 probe 阶段终止,而不是继续全量导出 snapshot",
);
harness.runtimeContext.BmeChatManager.prototype._createDb = originalCreateDb;
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-pending-persist-retry",
@@ -3827,6 +4104,59 @@ result = {
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-queued-save-detached",
globalChatId: "chat-luker-queued-save-detached",
characterId: "char-luker-queued-save",
chatMetadata: {
integrity: "meta-luker-queued-save-detached",
},
});
harness.runtimeContext.Luker = {
getContext() {
return harness.runtimeContext.__chatContext;
},
};
harness.api.setCurrentGraph(
stampPersistedGraph(
createMeaningfulGraph("chat-luker-queued-save-detached", "luker-detached"),
{
revision: 6,
integrity: "meta-luker-queued-save-detached",
chatId: "chat-luker-queued-save-detached",
reason: "luker-detached-seed",
},
),
);
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId: "chat-luker-queued-save-detached",
revision: 6,
lastPersistedRevision: 6,
writesBlocked: false,
});
const result = harness.api.saveGraphToChat({
reason: "luker-detached-save",
markMutation: false,
});
assert.equal(result.queued, true);
assert.equal(result.storageTier, "luker-chat-state");
assert.equal(result.saveMode, "luker-chat-state-queued");
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-mutated-after-queued-save";
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
harness.api.getIndexedDbSnapshot()?.nodes?.[0]?.fields?.title,
"事件-luker-detached",
"Luker queued save 的异步本地 mirror 不应被后续 live graph 修改污染",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-v2-load",

View File

@@ -95,6 +95,8 @@ function resolveCurrentChatIdentity() {
}
function readCachedIndexedDbSnapshot() { return null; }
function resolvePersistRevisionFloor(revision = 0) { return Number(revision) || 1; }
function buildPersistDeltaFromGraphDirtyState() { return null; }
function pruneGraphPersistDirtyState() { return null; }
function buildSnapshotFromGraph(graph, options = {}) {
return {
meta: {
@@ -123,6 +125,7 @@ function evaluatePersistNativeDeltaGate() {
};
}
function readPersistDeltaDiagnosticsNow() { return Date.now(); }
function normalizePersistDeltaDiagnosticsMs(value = 0) { return Math.round((Number(value) || 0) * 10) / 10; }
function updatePersistDeltaDiagnostics() {}
function buildPersistDelta() {
return {

View File

@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
import {
BME_DB_SCHEMA_VERSION,
BME_RUNTIME_BATCH_JOURNAL_META_KEY,
BME_RUNTIME_HISTORY_META_KEY,
BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
BME_RUNTIME_VECTOR_META_KEY,
BME_TOMBSTONE_RETENTION_MS,
BmeDatabase,
buildBmeDbName,
@@ -11,6 +15,7 @@ import {
} from "../sync/bme-db.js";
import { BmeChatManager } from "../sync/bme-chat-manager.js";
import { createEmptyGraph } from "../graph/graph.js";
import { getGraphPersistDirtyStateSnapshot } from "../runtime/runtime-state.js";
const PREFIX = "[ST-BME][indexeddb-persistence]";
@@ -20,6 +25,7 @@ const chatIdsForCleanup = new Set([
"chat-manager-a",
"chat-manager-b",
"chat-manager-selector",
"chat-export-without-tombstones",
"chat-replace-reset",
]);
@@ -196,6 +202,84 @@ async function testSnapshotExportImport() {
await db.close();
}
async function testSnapshotExportWithoutTombstones() {
const db = new BmeDatabase("chat-export-without-tombstones", {
dexieClass: globalThis.Dexie,
});
await db.open();
await db.bulkUpsertNodes([
{
id: "node-light-snapshot",
type: "event",
sourceFloor: 3,
archived: false,
updatedAt: Date.now(),
},
]);
await db.bulkUpsertTombstones([
{
id: "tomb-light-snapshot",
kind: "node",
targetId: "node-deleted-light-snapshot",
deletedAt: Date.now(),
sourceDeviceId: "device-light-snapshot",
},
]);
const exported = await db.exportSnapshot({ includeTombstones: false });
assert.equal(exported.__stBmeTombstonesOmitted, true);
assert.ok(Array.isArray(exported.nodes));
assert.ok(Array.isArray(exported.edges));
assert.deepEqual(exported.tombstones, []);
assert.equal(exported.meta.tombstoneCount, 1);
await db.close();
}
async function testSnapshotProbeExport() {
const db = new BmeDatabase("chat-export-probe", {
dexieClass: globalThis.Dexie,
});
await db.open();
await db.bulkUpsertNodes([
{
id: "node-probe",
type: "event",
sourceFloor: 4,
archived: false,
updatedAt: Date.now(),
},
]);
await db.patchMeta({
lastProcessedFloor: 6,
extractionCount: 3,
runtimeHistoryState: {
chatId: "chat-export-probe",
lastProcessedAssistantFloor: 6,
extractionCount: 3,
},
});
const probe = await db.exportSnapshotProbe();
assert.equal(probe.__stBmeProbeOnly, true);
assert.equal(probe.__stBmeTombstonesOmitted, true);
assert.deepEqual(probe.nodes, []);
assert.deepEqual(probe.edges, []);
assert.deepEqual(probe.tombstones, []);
assert.equal(probe.meta.chatId, "chat-export-probe");
assert.equal(probe.meta.nodeCount, 1);
assert.equal(probe.state.lastProcessedFloor, 6);
assert.equal(probe.state.extractionCount, 3);
assert.equal(
probe.meta.runtimeHistoryState.lastProcessedAssistantFloor,
6,
);
await db.close();
}
async function testReplaceImportResetsStaleMeta() {
const chatId = "chat-replace-reset";
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
@@ -532,32 +616,92 @@ async function testGraphSnapshotConverters() {
id: "node-converter",
type: "event",
sourceFloor: 9,
fields: {
title: "Converter Node",
},
updatedAt: Date.now(),
embedding: [0.25, 0.5, 0.75],
scope: {
layer: "pov",
ownerType: "character",
ownerId: "hero",
ownerName: "Hero",
regionPrimary: "camp",
regionPath: ["camp", "tent"],
regionSecondary: ["forest"],
},
storyTime: {
segmentId: "segment-1",
label: "Dawn",
tense: "ongoing",
relation: "same",
anchorLabel: "Night",
confidence: "high",
source: "derived",
},
storyTimeSpan: {
startSegmentId: "segment-0",
endSegmentId: "segment-1",
startLabel: "Night",
endLabel: "Dawn",
mixed: false,
source: "derived",
},
});
let snapshotDiagnostics = null;
const snapshot = buildSnapshotFromGraph(graph, {
chatId: "chat-a",
revision: 17,
onDiagnostics(snapshotValue) {
snapshotDiagnostics = snapshotValue;
},
});
assert.equal(snapshot.meta.chatId, "chat-a");
assert.equal(snapshot.meta.revision, 17);
assert.equal(snapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY], true);
assert.equal(snapshot.state.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4);
assert.equal(snapshot.nodes.length, 1);
assert.equal(Number.isFinite(snapshotDiagnostics?.nodesMs), true);
assert.equal(Number.isFinite(snapshotDiagnostics?.edgesMs), true);
assert.equal(Number.isFinite(snapshotDiagnostics?.tombstonesMs), true);
assert.equal(Number.isFinite(snapshotDiagnostics?.stateMs), true);
assert.equal(Number.isFinite(snapshotDiagnostics?.metaMs), true);
assert.equal(Number.isFinite(snapshotDiagnostics?.totalMs), true);
assert.equal(snapshotDiagnostics?.nodeCount, 1);
let hydrateDiagnostics = null;
const nextGraph = buildGraphFromSnapshot(snapshot, {
chatId: "chat-a",
onDiagnostics(snapshotValue) {
hydrateDiagnostics = snapshotValue;
},
});
assert.equal(hydrateDiagnostics?.success, true);
assert.equal(Number.isFinite(hydrateDiagnostics?.nodesMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.edgesMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.runtimeMetaMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.stateMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.normalizeMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.integrityMs), true);
assert.equal(Number.isFinite(hydrateDiagnostics?.totalMs), true);
let reusedSnapshotDiagnostics = null;
const reusedSnapshot = buildSnapshotFromGraph(nextGraph, {
chatId: "chat-a",
revision: 18,
baseSnapshot: snapshot,
onDiagnostics(snapshotValue) {
reusedSnapshotDiagnostics = snapshotValue;
},
});
assert.equal(
reusedSnapshot.nodes[0],
snapshot.nodes[0],
"未变化节点应直接复用 baseSnapshot 记录对象",
);
assert.equal(reusedSnapshotDiagnostics?.reusedNodeCount, 1);
nextGraph.nodes[0].updatedAt = Number(nextGraph.nodes[0].updatedAt || 0) + 1;
const changedSnapshot = buildSnapshotFromGraph(nextGraph, {
chatId: "chat-a",
@@ -573,16 +717,126 @@ async function testGraphSnapshotConverters() {
const rebuilt = buildGraphFromSnapshot(snapshot, {
chatId: "chat-a",
});
const legacyCompatibleSnapshot = {
...snapshot,
meta: {
...snapshot.meta,
},
};
delete legacyCompatibleSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY];
legacyCompatibleSnapshot.nodes = [
{
...legacyCompatibleSnapshot.nodes[0],
scope: undefined,
storyTime: undefined,
storyTimeSpan: undefined,
},
];
const rebuiltLegacyCompatible = buildGraphFromSnapshot(legacyCompatibleSnapshot, {
chatId: "chat-a",
});
const malformedButFlaggedSnapshot = {
...legacyCompatibleSnapshot,
meta: {
...legacyCompatibleSnapshot.meta,
[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
},
};
const rebuiltMalformedButFlagged = buildGraphFromSnapshot(malformedButFlaggedSnapshot, {
chatId: "chat-a",
});
const scopeRepairSnapshot = {
...snapshot,
meta: {
...snapshot.meta,
},
nodes: [
{
...snapshot.nodes[0],
scope: {
layer: "objective",
regionPrimary: "王都/钟楼",
regionSecondary: "旧城区 / 集市 / 钟楼",
},
},
],
};
delete scopeRepairSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY];
const rebuiltScopeRepair = buildGraphFromSnapshot(scopeRepairSnapshot, {
chatId: "chat-a",
});
const scopeRepairDirtyState = getGraphPersistDirtyStateSnapshot(
rebuiltScopeRepair,
);
assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 9);
assert.equal(rebuilt.historyState.extractionCount, 4);
assert.equal(rebuilt.nodes.length, 1);
assert.equal(rebuilt.nodes[0].id, "node-converter");
assert.equal(rebuilt.nodes[0].scope?.ownerType, "character");
assert.equal(rebuilt.nodes[0].scope?.regionPrimary, "camp");
assert.equal(rebuilt.nodes[0].storyTime?.label, "Dawn");
assert.equal(rebuilt.nodes[0].storyTimeSpan?.endLabel, "Dawn");
assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter");
assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1");
assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero");
assert.equal(rebuilt.regionState.activeRegion, "camp");
assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1");
assert.equal(rebuilt.summaryState.entries[0].id, "summary-1");
assert.equal(rebuiltLegacyCompatible.nodes[0].scope?.layer, "objective");
assert.equal(rebuiltLegacyCompatible.nodes[0].storyTime?.tense, "unknown");
assert.equal(rebuiltLegacyCompatible.nodes[0].storyTimeSpan?.mixed, false);
assert.equal(rebuiltMalformedButFlagged.nodes[0].scope?.layer, "objective");
assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTime?.tense, "unknown");
assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTimeSpan?.mixed, false);
assert.equal(rebuiltScopeRepair.nodes[0].scope?.regionPrimary, "钟楼");
assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionPath, ["王都", "钟楼"]);
assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionSecondary, [
"旧城区",
"集市",
]);
assert.equal(
scopeRepairDirtyState?.nodeUpsertIds?.includes("node-converter"),
true,
);
assert.equal(rebuiltScopeRepair.vectorIndexState?.dirty, true);
assert.equal(
rebuiltScopeRepair.vectorIndexState?.replayRequiredNodeIds?.includes(
"node-converter",
),
true,
);
rebuilt.nodes[0].fields.title = "Mutated Converter Node";
rebuilt.nodes[0].embedding[0] = 99;
rebuilt.historyState.processedMessageHashes[1] = "mutated-hash";
rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated";
rebuilt.batchJournal[0].processedRange[0] = 99;
assert.equal(
snapshot.nodes[0].fields.title,
"Converter Node",
"buildGraphFromSnapshot 不应复用 snapshot 节点的嵌套字段引用",
);
assert.equal(
snapshot.meta[BME_RUNTIME_HISTORY_META_KEY].processedMessageHashes[1],
"hash-1",
"buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用",
);
assert.equal(
snapshot.nodes[0].embedding[0],
0.25,
"buildGraphFromSnapshot 不应复用 snapshot 节点的数组字段引用",
);
assert.equal(
snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"],
"node-converter",
"buildGraphFromSnapshot 不应复用 snapshot vectorState 的嵌套对象引用",
);
assert.equal(
snapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY][0].processedRange[0],
8,
"buildGraphFromSnapshot 不应复用 snapshot batchJournal 的嵌套数组引用",
);
}
async function main() {
@@ -593,6 +847,8 @@ async function main() {
await testCrudAndMeta();
await testTransactionRollback();
await testSnapshotExportImport();
await testSnapshotExportWithoutTombstones();
await testSnapshotProbeExport();
await testReplaceImportResetsStaleMeta();
await testRevisionMonotonicity();
await testTombstonePrune();

View File

@@ -298,6 +298,10 @@ async function testUploadPayloadMetaFirstAndDebounce() {
assert.equal(uploadResult.uploaded, true);
assert.equal(logs.uploadCalls, 1);
assert.equal(logs.uploadChunkCalls > 0, true);
assert.equal(Number.isFinite(uploadResult.timings?.exportMs), true);
assert.equal(Number.isFinite(uploadResult.timings?.chunkUploadMs), true);
assert.equal(Number.isFinite(uploadResult.timings?.manifestUploadMs), true);
assert.equal(Number.isFinite(uploadResult.timings?.metaPatchMs), true);
const uploadedPayload = logs.uploadedPayloads[0].payload;
assert.equal(uploadedPayload.formatVersion, 2);
@@ -375,6 +379,10 @@ async function testDownloadImport() {
const result = await download("chat-download", runtime);
assert.equal(result.downloaded, true);
assert.equal(Number.isFinite(result.timings?.networkMs), true);
assert.equal(Number.isFinite(result.timings?.importMs), true);
assert.equal(Number.isFinite(result.timings?.metaPatchMs), true);
assert.equal(Number.isFinite(result.timings?.hookMs), true);
assert.equal(db.lastImportPayload.meta.revision, 12);
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node");
assert.equal(db.lastImportPayload.meta.runtimeVectorIndexState.dirty, true);
@@ -731,6 +739,10 @@ async function testManualBackupAndRestoreFlow() {
const backupResult = await backupToServer("chat-backup-flow", runtime);
assert.equal(backupResult.backedUp, true);
assert.equal(Number.isFinite(backupResult.timings?.exportMs), true);
assert.equal(Number.isFinite(backupResult.timings?.uploadMs), true);
assert.equal(Number.isFinite(backupResult.timings?.manifestWriteMs), true);
assert.equal(Number.isFinite(backupResult.timings?.metaPatchMs), true);
assert.equal(db.meta.get("syncDirty"), false);
assert.ok(Number(db.meta.get("lastBackupUploadedAt")) > 0);
assert.ok(String(db.meta.get("lastBackupFilename") || "").startsWith("ST-BME_backup_"));
@@ -801,6 +813,12 @@ async function testManualBackupAndRestoreFlow() {
const restoreResult = await restoreFromServer("chat-backup-flow", runtime);
assert.equal(restoreResult.restored, true);
assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
assert.equal(Number.isFinite(restoreResult.timings?.localExportMs), true);
assert.equal(Number.isFinite(restoreResult.timings?.safetySnapshotMs), true);
assert.equal(Number.isFinite(restoreResult.timings?.importMs), true);
assert.equal(Number.isFinite(restoreResult.timings?.metaPatchMs), true);
assert.equal(Number.isFinite(restoreResult.timings?.hookMs), true);
assert.equal(db.snapshot.nodes[0].id, "local-node");
assert.equal(db.snapshot.meta.runtimeBatchJournal.length, 4);
assert.equal(db.snapshot.meta.maintenanceJournal.length, 0);
@@ -963,6 +981,7 @@ async function testRestoreValidationDoesNotCreateSafetySnapshot() {
const restoreResult = await restoreFromServer("chat-no-backup", runtime);
assert.equal(restoreResult.restored, false);
assert.equal(restoreResult.reason, "not-found");
assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
const safetyStatus = await getRestoreSafetySnapshotStatus(
"chat-no-backup",

View File

@@ -329,6 +329,135 @@ async function testManualExtractIgnoresSupersededPendingPersistence() {
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
}
async function testManualExtractContinuesWithRecoverablePendingPersistence() {
let executeExtractionBatchCalls = 0;
let assistantTurnCallCount = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
isExtracting: false,
graphPersistenceState: {
pendingPersist: true,
lastAcceptedRevision: 0,
queuedPersistRevision: 7,
shadowSnapshotRevision: 7,
lastRecoverableStorageTier: "shadow",
},
currentGraph: {
historyState: {
lastBatchStatus: {
processedRange: [1, 1],
persistence: {
outcome: "queued",
accepted: false,
revision: 7,
reason: "extraction-batch-complete:pending",
storageTier: "shadow",
},
},
},
},
getCurrentChatId() {
return "chat-mobile";
},
getCurrentGraph() {
return context.currentGraph;
},
getIsExtracting() {
return context.isExtracting;
},
getGraphPersistenceState() {
return {
pendingPersist: true,
lastAcceptedRevision: 0,
queuedPersistRevision: 7,
shadowSnapshotRevision: 7,
lastRecoverableStorageTier: "shadow",
};
},
ensureGraphMutationReady() {
return true;
},
async recoverHistoryIfNeeded() {
return true;
},
normalizeGraphRuntimeState(graph) {
return graph;
},
setCurrentGraph(graph) {
context.currentGraph = graph;
},
createEmptyGraph() {
return {};
},
getContext() {
return { chat };
},
getAssistantTurns() {
assistantTurnCallCount += 1;
return assistantTurnCallCount <= 2 ? [1] : [];
},
getLastProcessedAssistantFloor() {
return 0;
},
clampInt(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
},
getSettings() {
return { extractEvery: 1 };
},
beginStageAbortController() {
return { signal: {} };
},
async executeExtractionBatch() {
executeExtractionBatchCalls += 1;
return {
success: true,
result: {
newNodes: 0,
updatedNodes: 0,
newEdges: 0,
},
effects: {},
batchStatus: {
persistence: {
accepted: true,
},
},
historyAdvanceAllowed: true,
};
},
async retryPendingGraphPersist() {
return {
accepted: false,
reason: "shadow-still-pending",
};
},
isAbortError() {
return false;
},
onManualExtractController,
finishStageAbortController() {},
setIsExtracting(value) {
context.isExtracting = value;
},
setLastExtractionStatus(text, meta, level) {
context.lastExtractionStatus = { text, meta, level };
context.runtimeStatus = { text, meta, level };
},
toastr: {
info() {},
success() {},
warning() {},
error() {},
},
result: null,
};
await onManualExtractController(context, { drainAll: false });
assert.equal(executeExtractionBatchCalls, 1);
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
}
async function testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt() {
let executeExtractionBatchCalls = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
@@ -567,6 +696,7 @@ testIndexDefinesLastProcessedAssistantFloorHelper();
await testVectorSyncTerminalStateUpdatesRuntime();
await testManualExtractNoBatchesDoesNotStayRunning();
await testManualExtractIgnoresSupersededPendingPersistence();
await testManualExtractContinuesWithRecoverablePendingPersistence();
await testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt();
await testManualRebuildSetsTerminalRuntimeStatus();

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
function moduleUrl(tag) {
return `../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`;
}
globalThis.__stBmeDisableWasmPackArtifacts = true;
delete globalThis.__stBmeLoadRustWasmLayout;
const firstLoad = await import(moduleUrl("native-hydrate-first"));
let firstError = "";
try {
await firstLoad.installNativeHydrateHook();
} catch (error) {
firstError = error?.message || String(error);
}
assert.match(
firstError,
/native module unavailable|native hydrate builder unavailable|global-loader|Rust\/WASM artifact is not initialized/i,
);
globalThis.__stBmeLoadRustWasmLayout = async () => ({
solve_layout() {
return {
ok: true,
positions: [],
diagnostics: {
solver: "mock-rust-wasm",
},
};
},
build_hydrate_records() {
return {
ok: true,
usedNative: true,
nodes: [],
edges: [],
diagnostics: {
solver: "mock-rust-wasm",
nodeCount: 0,
edgeCount: 0,
recordsNormalized: false,
},
};
},
});
const retryStatus = await firstLoad.installNativeHydrateHook();
assert.equal(retryStatus.loaded, true);
assert.equal(typeof globalThis.__stBmeNativeHydrateSnapshotRecords, "function");
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
delete globalThis.__stBmeLoadRustWasmLayout;
delete globalThis.__stBmeDisableWasmPackArtifacts;
console.log("native-hydrate-failopen tests passed");

View File

@@ -0,0 +1,260 @@
import assert from "node:assert/strict";
import {
BME_RUNTIME_HISTORY_META_KEY,
BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
BME_RUNTIME_VECTOR_META_KEY,
buildGraphFromSnapshot,
evaluateNativeHydrateGate,
resolveNativeHydrateGateOptions,
} from "../sync/bme-db.js";
function cloneValue(value) {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
const snapshot = {
meta: {
chatId: "chat-native-hydrate",
revision: 3,
[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
[BME_RUNTIME_HISTORY_META_KEY]: {
chatId: "chat-native-hydrate",
lastProcessedAssistantFloor: 7,
extractionCount: 2,
processedMessageHashes: {},
processedMessageHashVersion: 1,
processedMessageHashesNeedRefresh: false,
recentRecallOwnerKeys: [],
activeRecallOwnerKey: "",
activeRegion: "",
activeRegionSource: "",
activeStorySegmentId: "",
activeStoryTimeLabel: "",
activeStoryTimeSource: "",
lastBatchStatus: null,
lastMutationSource: "test",
lastExtractedRegion: "",
lastExtractedStorySegmentId: "",
activeCharacterPovOwner: "",
activeUserPovOwner: "",
},
[BME_RUNTIME_VECTOR_META_KEY]: {
chatId: "chat-native-hydrate",
collectionId: "",
hashToNodeId: {},
nodeToHash: {},
replayRequiredNodeIds: [],
dirty: false,
dirtyReason: "",
pendingRepairFromFloor: null,
lastIntegrityIssue: null,
lastStats: {
nodesIndexed: 0,
updatedAt: 0,
},
},
},
state: {
lastProcessedFloor: 7,
extractionCount: 2,
},
nodes: [
{
id: "native-node-1",
type: "event",
updatedAt: 10,
seqRange: [7, 7],
childIds: [],
clusters: [],
fields: {
title: "Native Node",
},
embedding: [1, 2, 3],
scope: {
layer: "pov",
ownerType: "character",
ownerId: "owner-1",
ownerName: "",
regionPrimary: "camp",
regionPath: ["camp"],
regionSecondary: [],
},
storyTime: {
segmentId: "",
label: "Dawn",
tense: "unknown",
relation: "unknown",
anchorLabel: "",
confidence: "medium",
source: "derived",
},
storyTimeSpan: {
startSegmentId: "",
endSegmentId: "",
startLabel: "Dawn",
endLabel: "Dawn",
mixed: false,
source: "derived",
},
},
],
edges: [
{
id: "native-edge-1",
fromId: "native-node-1",
toId: "native-node-2",
relation: "related",
scope: {
layer: "pov",
ownerType: "character",
ownerId: "owner-1",
ownerName: "",
regionPrimary: "camp",
regionPath: ["camp"],
regionSecondary: [],
},
},
],
tombstones: [],
};
const defaultGate = resolveNativeHydrateGateOptions({});
assert.equal(defaultGate.minSnapshotRecords, 30000);
const gatedSmall = evaluateNativeHydrateGate(snapshot, {});
assert.equal(gatedSmall.allowed, false);
assert.deepEqual(gatedSmall.reasons, ["below-min-snapshot-records"]);
const gatedLarge = evaluateNativeHydrateGate(
{
nodes: new Array(15000).fill({ id: "node-x" }),
edges: new Array(15000).fill({ id: "edge-x" }),
},
{},
);
assert.equal(gatedLarge.allowed, true);
assert.deepEqual(gatedLarge.reasons, []);
const originalNativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords;
globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
assert.equal(options.recordsNormalized, true);
return {
ok: true,
usedNative: true,
nodes: cloneValue(snapshotView.nodes).map((node) => ({
...node,
nativeHydrated: true,
})),
edges: cloneValue(snapshotView.edges).map((edge) => ({
...edge,
nativeHydrated: true,
})),
diagnostics: {
solver: "test-native-hydrate",
nodeCount: Array.isArray(snapshotView.nodes) ? snapshotView.nodes.length : 0,
edgeCount: Array.isArray(snapshotView.edges) ? snapshotView.edges.length : 0,
recordsNormalized: options.recordsNormalized === true,
},
};
};
let nativeDiagnostics = null;
const rebuilt = buildGraphFromSnapshot(snapshot, {
chatId: "chat-native-hydrate",
useNativeHydrate: true,
minSnapshotRecords: 0,
onDiagnostics(snapshotValue) {
nativeDiagnostics = snapshotValue;
},
});
assert.equal(rebuilt.nodes[0].nativeHydrated, true);
assert.equal(rebuilt.edges[0].nativeHydrated, true);
assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 7);
assert.equal(nativeDiagnostics.nativeRequested, true);
assert.equal(nativeDiagnostics.nativeUsed, true);
assert.equal(nativeDiagnostics.nativeStatus, "ok");
assert.equal(nativeDiagnostics.nativeGateAllowed, true);
assert.equal(nativeDiagnostics.nativeModuleDiagnostics?.solver, "test-native-hydrate");
assert.equal(Number.isFinite(nativeDiagnostics.nativeRecordsMs), true);
rebuilt.nodes[0].fields.title = "Mutated Native Node";
rebuilt.nodes[0].embedding[0] = 99;
assert.equal(snapshot.nodes[0].fields.title, "Native Node");
assert.equal(snapshot.nodes[0].embedding[0], 1);
globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
assert.equal(options.recordsNormalized, true);
return {
ok: true,
usedNative: true,
nodesJson: JSON.stringify(
cloneValue(snapshotView.nodes).map((node) => ({
...node,
compactHydrated: true,
})),
),
edgesJson: JSON.stringify(
cloneValue(snapshotView.edges).map((edge) => ({
...edge,
compactHydrated: true,
})),
),
diagnostics: {
solver: "test-native-hydrate-compact",
},
};
};
let compactDiagnostics = null;
const compactGraph = buildGraphFromSnapshot(snapshot, {
chatId: "chat-native-hydrate",
useNativeHydrate: true,
minSnapshotRecords: 0,
onDiagnostics(snapshotValue) {
compactDiagnostics = snapshotValue;
},
});
assert.equal(compactGraph.nodes[0].compactHydrated, true);
assert.equal(compactGraph.edges[0].compactHydrated, true);
assert.equal(
compactDiagnostics.nativeModuleDiagnostics?.hydrateBridgeMode,
"compact-json",
);
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
let fallbackDiagnostics = null;
const fallbackGraph = buildGraphFromSnapshot(snapshot, {
chatId: "chat-native-hydrate",
useNativeHydrate: true,
minSnapshotRecords: 0,
onDiagnostics(snapshotValue) {
fallbackDiagnostics = snapshotValue;
},
});
assert.equal(fallbackGraph.nodes.length, 1);
assert.equal(fallbackDiagnostics.nativeRequested, true);
assert.equal(fallbackDiagnostics.nativeUsed, false);
assert.equal(fallbackDiagnostics.nativeStatus, "builder-unavailable");
let threwUnavailable = false;
try {
buildGraphFromSnapshot(snapshot, {
chatId: "chat-native-hydrate",
useNativeHydrate: true,
minSnapshotRecords: 0,
nativeFailOpen: false,
});
} catch (error) {
threwUnavailable =
String(error?.message || "") === "native-hydrate-builder-unavailable";
}
assert.equal(threwUnavailable, true);
if (typeof originalNativeBuilder === "function") {
globalThis.__stBmeNativeHydrateSnapshotRecords = originalNativeBuilder;
}
console.log("native-hydrate-hook tests passed");

View File

@@ -22,6 +22,24 @@ try {
},
};
},
build_hydrate_records(payload = {}) {
return {
ok: true,
usedNative: true,
nodes: Array.isArray(payload?.nodes)
? payload.nodes.map((node) => ({ ...node, nativeHydrated: true }))
: [],
edges: Array.isArray(payload?.edges)
? payload.edges.map((edge) => ({ ...edge, nativeHydrated: true }))
: [],
diagnostics: {
solver: "mock-loader",
nodeCount: Array.isArray(payload?.nodes) ? payload.nodes.length : 0,
edgeCount: Array.isArray(payload?.edges) ? payload.edges.length : 0,
recordsNormalized: payload?.recordsNormalized === true,
},
};
},
build_persist_delta_compact(payload = {}) {
return {
upsertNodeIds: Array.isArray(payload?.afterNodes?.ids)
@@ -105,8 +123,29 @@ try {
assert.deepEqual(deltaResult.upsertNodes, [{ id: "persist-native-node", marker: "after-chat" }]);
assert.equal(deltaResult.runtimeMetaPatch.native, true);
const hydrateInstallStatus = await wrapper.installNativeHydrateHook();
assert.equal(hydrateInstallStatus.loaded, true);
assert.equal(
typeof globalThis.__stBmeNativeHydrateSnapshotRecords,
"function",
);
const hydrateResult = globalThis.__stBmeNativeHydrateSnapshotRecords(
{
nodes: [{ id: "hydrate-node", type: "event" }],
edges: [{ id: "hydrate-edge", fromId: "hydrate-node", toId: "hydrate-node-2" }],
},
{
recordsNormalized: true,
},
);
assert.equal(hydrateResult.ok, true);
assert.equal(hydrateResult.nodes[0].nativeHydrated, true);
assert.equal(hydrateResult.edges[0].nativeHydrated, true);
assert.equal(hydrateResult.diagnostics.recordsNormalized, true);
delete globalThis.__stBmeLoadRustWasmLayout;
delete globalThis.__stBmeNativeBuildPersistDelta;
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
delete globalThis.__stBmeDisableWasmPackArtifacts;
const wrapperNoLoader = await importFreshWrapper("no-loader");
@@ -136,6 +175,7 @@ try {
}
delete globalThis.__stBmeDisableWasmPackArtifacts;
delete globalThis.__stBmeNativeBuildPersistDelta;
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
}
console.log("native-layout-wrapper tests passed");

View File

@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import {
defaultSettings,
mergePersistedSettings,
} from "../runtime/settings-defaults.js";
import {
evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
resolveNativeHydrateGateOptions,
resolvePersistNativeDeltaGateOptions,
} from "../sync/bme-db.js";
import {
GraphNativeLayoutBridge,
normalizeGraphNativeRuntimeOptions,
} from "../ui/graph-native-bridge.js";
const migratedLegacy = mergePersistedSettings({
graphUseNativeLayout: false,
persistUseNativeDelta: false,
loadUseNativeHydrate: false,
});
assert.equal(migratedLegacy.graphUseNativeLayout, true);
assert.equal(migratedLegacy.persistUseNativeDelta, true);
assert.equal(migratedLegacy.loadUseNativeHydrate, true);
assert.equal(migratedLegacy.loadNativeHydrateThresholdRecords, 30000);
assert.equal(migratedLegacy.nativeRolloutVersion, defaultSettings.nativeRolloutVersion);
const preservedManualOptOut = mergePersistedSettings({
nativeRolloutVersion: defaultSettings.nativeRolloutVersion,
graphUseNativeLayout: false,
persistUseNativeDelta: false,
loadUseNativeHydrate: false,
graphNativeForceDisable: true,
});
assert.equal(preservedManualOptOut.graphUseNativeLayout, false);
assert.equal(preservedManualOptOut.persistUseNativeDelta, false);
assert.equal(preservedManualOptOut.loadUseNativeHydrate, false);
assert.equal(preservedManualOptOut.graphNativeForceDisable, true);
const migratedLegacyHydrateThreshold = mergePersistedSettings({
nativeRolloutVersion: 1,
loadNativeHydrateThresholdRecords: 12000,
});
assert.equal(migratedLegacyHydrateThreshold.loadNativeHydrateThresholdRecords, 30000);
const preservedCustomHydrateThreshold = mergePersistedSettings({
nativeRolloutVersion: 1,
loadNativeHydrateThresholdRecords: 42000,
});
assert.equal(preservedCustomHydrateThreshold.loadNativeHydrateThresholdRecords, 42000);
const normalizedRuntimeOptions = normalizeGraphNativeRuntimeOptions({
graphNativeLayoutThresholdNodes: 0,
graphNativeLayoutThresholdEdges: 999999,
graphNativeLayoutWorkerTimeoutMs: 10,
nativeEngineFailOpen: 0,
graphNativeForceDisable: "true",
});
assert.equal(normalizedRuntimeOptions.graphNativeLayoutThresholdNodes, 1);
assert.equal(normalizedRuntimeOptions.graphNativeLayoutThresholdEdges, 50000);
assert.equal(normalizedRuntimeOptions.graphNativeLayoutWorkerTimeoutMs, 40);
assert.equal(normalizedRuntimeOptions.nativeEngineFailOpen, false);
assert.equal(normalizedRuntimeOptions.graphNativeForceDisable, true);
const layoutBridge = new GraphNativeLayoutBridge({
graphUseNativeLayout: true,
graphNativeLayoutThresholdNodes: 280,
graphNativeLayoutThresholdEdges: 1600,
});
assert.equal(layoutBridge.shouldRunForGraph(279, 1599), false);
assert.equal(layoutBridge.shouldRunForGraph(280, 0), true);
assert.equal(layoutBridge.shouldRunForGraph(0, 1600), true);
layoutBridge.updateRuntimeOptions({ graphNativeForceDisable: true });
assert.equal(layoutBridge.shouldRunForGraph(500, 5000), false);
const hydrateGateDefaults = resolveNativeHydrateGateOptions({});
assert.equal(hydrateGateDefaults.minSnapshotRecords, 30000);
const hydrateBlocked = evaluateNativeHydrateGate(
{ nodes: new Array(29999).fill({}), edges: [] },
{ loadNativeHydrateThresholdRecords: 30000 },
);
assert.equal(hydrateBlocked.allowed, false);
assert.deepEqual(hydrateBlocked.reasons, ["below-min-snapshot-records"]);
assert.equal(hydrateBlocked.recordCount, 29999);
const hydrateAllowed = evaluateNativeHydrateGate(
{ nodes: new Array(30000).fill({}), edges: [] },
{ loadNativeHydrateThresholdRecords: 30000 },
);
assert.equal(hydrateAllowed.allowed, true);
assert.deepEqual(hydrateAllowed.reasons, []);
assert.equal(hydrateAllowed.recordCount, 30000);
const persistGateDefaults = resolvePersistNativeDeltaGateOptions({});
assert.equal(persistGateDefaults.minSnapshotRecords, 20000);
assert.equal(persistGateDefaults.minStructuralDelta, 600);
assert.equal(persistGateDefaults.minCombinedSerializedChars, 4000000);
const persistBlocked = evaluatePersistNativeDeltaGate(
{
nodes: new Array(500).fill({}),
edges: new Array(200).fill({}),
tombstones: [],
},
{
nodes: new Array(520).fill({}),
edges: new Array(210).fill({}),
tombstones: [],
},
{
persistNativeDeltaThresholdRecords: 20000,
persistNativeDeltaThresholdStructuralDelta: 600,
persistNativeDeltaThresholdSerializedChars: 4000000,
measuredCombinedSerializedChars: 1024,
},
);
assert.equal(persistBlocked.allowed, false);
assert.deepEqual(persistBlocked.reasons, [
"below-record-threshold",
"below-structural-delta-threshold",
"below-serialized-chars-threshold",
]);
assert.equal(persistBlocked.maxSnapshotRecords, 730);
assert.equal(persistBlocked.structuralDelta, 30);
assert.equal(persistBlocked.combinedSerializedChars, 1024);
const persistAllowed = evaluatePersistNativeDeltaGate(
{
nodes: new Array(10000).fill({}),
edges: new Array(10000).fill({}),
tombstones: [],
},
{
nodes: new Array(10400).fill({}),
edges: new Array(10400).fill({}),
tombstones: new Array(250).fill({}),
},
{
persistNativeDeltaThresholdRecords: 20000,
persistNativeDeltaThresholdStructuralDelta: 600,
persistNativeDeltaThresholdSerializedChars: 4000000,
measuredCombinedSerializedChars: 5000000,
},
);
assert.equal(persistAllowed.allowed, true);
assert.deepEqual(persistAllowed.reasons, []);
assert.equal(persistAllowed.maxSnapshotRecords, 21050);
assert.equal(persistAllowed.structuralDelta, 1050);
assert.equal(persistAllowed.combinedSerializedChars, 5000000);
console.log("native-rollout-matrix tests passed");

View File

@@ -56,12 +56,18 @@ await store.patchMeta({
lastProcessedFloor: 9,
extractionCount: 4,
});
const probe = await store.exportSnapshotProbe();
assert.equal(
loadSnapshotCalls,
0,
"manifest-only meta fast path should not load full snapshot",
);
assert.equal(probe.__stBmeProbeOnly, true);
assert.equal(probe.meta.lastBackupFilename, "after.json");
assert.equal(probe.meta.nodeCount, 1);
assert.equal(probe.state.lastProcessedFloor, 9);
assert.equal(probe.state.extractionCount, 4);
const snapshot = await originalLoadSnapshot();
assert.equal(snapshot.meta.lastBackupFilename, "after.json");

View File

@@ -152,9 +152,27 @@ function getNestedDirectory(directoryHandle, ...names) {
return current;
}
function readJsonFromDirectory(directoryHandle, filename) {
async function readJsonFromDirectory(directoryHandle, filename, { retries = 5 } = {}) {
assert.ok(directoryHandle.files.has(filename), `文件必须存在: ${filename}`);
return JSON.parse(String(directoryHandle.files.get(filename) || ""));
let lastError = null;
let lastText = "";
const normalizedRetries = Math.max(0, Math.floor(Number(retries) || 0));
for (let attempt = 0; attempt <= normalizedRetries; attempt += 1) {
lastText = String(directoryHandle.files.get(filename) || "");
if (lastText) {
try {
return JSON.parse(lastText);
} catch (error) {
lastError = error;
}
}
if (attempt < normalizedRetries) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
throw new Error(
`读取目录 JSON 失败: ${filename} len=${lastText.length} error=${String(lastError?.message || "empty")}`,
);
}
function buildLegacyGraph(chatId) {
@@ -288,7 +306,7 @@ async function testImportExportPersistenceAndFileRotation() {
},
);
const manifestAfterFirstImport = readJsonFromDirectory(chatDirectory, "manifest.json");
const manifestAfterFirstImport = await readJsonFromDirectory(chatDirectory, "manifest.json");
assert.equal(manifestAfterFirstImport.formatVersion, 2);
assert.equal(manifestAfterFirstImport.baseRevision, 4);
assert.equal(manifestAfterFirstImport.headRevision, 4);
@@ -311,6 +329,12 @@ async function testImportExportPersistenceAndFileRotation() {
assert.equal(firstExportedSnapshot.state.extractionCount, 2);
assert.equal(firstExportedSnapshot.meta.storagePrimary, "opfs");
assert.equal(firstExportedSnapshot.meta.storageMode, "opfs-primary");
const lightweightSnapshot = await store.exportSnapshot({
includeTombstones: false,
});
assert.equal(lightweightSnapshot.__stBmeTombstonesOmitted, true);
assert.deepEqual(lightweightSnapshot.tombstones, []);
assert.equal(lightweightSnapshot.meta.tombstoneCount, 1);
assert.deepEqual(firstExportedSnapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY], {
pending: ["job-1"],
});
@@ -370,7 +394,7 @@ async function testImportExportPersistenceAndFileRotation() {
},
);
const manifestAfterSecondImport = readJsonFromDirectory(chatDirectory, "manifest.json");
const manifestAfterSecondImport = await readJsonFromDirectory(chatDirectory, "manifest.json");
assert.equal(manifestAfterSecondImport.formatVersion, 2);
assert.equal(manifestAfterSecondImport.baseRevision, 6);
assert.equal(manifestAfterSecondImport.headRevision, 6);

View File

@@ -261,7 +261,62 @@ async function testGraphLikeDeltaPreservesHistoryFrontier() {
);
}
async function testCommitDeltaDiagnosticsSplitWalAndManifestStages() {
const rootDirectory = createMemoryOpfsRoot();
const store = new OpfsGraphStore("chat-opfs-diagnostics-split", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await store.open();
await store.importSnapshot(
{
meta: { revision: 1 },
state: { lastProcessedFloor: 0, extractionCount: 0 },
nodes: [],
edges: [],
tombstones: [],
},
{ mode: "replace", preserveRevision: true },
);
const result = await store.commitDelta(
{
upsertNodes: [
{
id: "diag-node-1",
type: "event",
fields: { title: "diag" },
archived: false,
updatedAt: 10,
},
],
},
{
reason: "diagnostics-split",
requestedRevision: 2,
markSyncDirty: true,
},
);
assert.equal(Number.isFinite(result.diagnostics?.walSerializeMs), true);
assert.equal(Number.isFinite(result.diagnostics?.walFileWriteMs), true);
assert.equal(Number.isFinite(result.diagnostics?.walWriteMs), true);
assert.equal(Number.isFinite(result.diagnostics?.manifestSerializeMs), true);
assert.equal(Number.isFinite(result.diagnostics?.manifestFileWriteMs), true);
assert.equal(Number.isFinite(result.diagnostics?.manifestWriteMs), true);
assert.equal(
result.diagnostics.walWriteMs >= result.diagnostics.walSerializeMs,
true,
);
assert.equal(
result.diagnostics.manifestWriteMs >= result.diagnostics.manifestSerializeMs,
true,
);
}
await testCommitDeltaAndPatchMetaSerialize();
await testImportSnapshotAndClearAllSerialize();
await testGraphLikeDeltaPreservesHistoryFrontier();
await testCommitDeltaDiagnosticsSplitWalAndManifestStages();
console.log("opfs-write-serialization tests passed");

View File

@@ -4902,6 +4902,86 @@ async function testAutoExtractionDefersWhenHistoryRecoveryBusy() {
assert.deepEqual(deferredReasons, ["history-recovering"]);
}
async function testAutoExtractionContinuesWithRecoverablePendingPersistence() {
const deferredReasons = [];
const executeCalls = [];
const currentGraph = {
historyState: {
lastBatchStatus: {
processedRange: [1, 1],
persistence: {
outcome: "queued",
accepted: false,
revision: 7,
reason: "extraction-batch-complete:pending",
storageTier: "shadow",
},
},
},
};
await runExtractionController({
console,
getIsExtracting: () => false,
getCurrentGraph: () => currentGraph,
getSettings: () => ({ enabled: true, extractEvery: 1 }),
getContext: () => ({
chat: [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }],
}),
getAssistantTurns: () => [1],
getLastProcessedAssistantFloor: () => 0,
getGraphPersistenceState: () => ({
loadState: "loaded",
pendingPersist: true,
lastAcceptedRevision: 0,
queuedPersistRevision: 7,
shadowSnapshotRevision: 7,
lastRecoverableStorageTier: "shadow",
}),
ensureGraphMutationReady: () => true,
async retryPendingGraphPersist() {
return {
accepted: false,
reason: "shadow-still-pending",
};
},
async recoverHistoryIfNeeded() {
return true;
},
deferAutoExtraction(reason) {
deferredReasons.push(reason);
},
setIsExtracting() {},
beginStageAbortController() {
return { signal: {} };
},
setLastExtractionStatus() {},
async executeExtractionBatch(options) {
executeCalls.push(options);
return {
success: true,
result: {
newNodes: 0,
updatedNodes: 0,
newEdges: 0,
},
batchStatus: {
persistence: {
accepted: true,
},
},
historyAdvanceAllowed: true,
};
},
finishStageAbortController() {},
isAbortError: () => false,
notifyExtractionIssue() {},
});
assert.equal(executeCalls.length, 1);
assert.deepEqual(deferredReasons, []);
}
async function testRemoveNodeHandlesCyclicChildGraph() {
const graph = createEmptyGraph();
const nodeA = addNode(
@@ -7415,6 +7495,7 @@ await testLagModePendingResumeKeepsLockedPreviousAssistantAfterLatestDisappears(
await testAutoExtractionDefersWhenGraphNotReady();
await testAutoExtractionDefersWhenAlreadyExtracting();
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
await testAutoExtractionContinuesWithRecoverablePendingPersistence();
await testRemoveNodeHandlesCyclicChildGraph();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
await testHistoryGenerationReusesPersistedRecallForStableUserFloor();

View File

@@ -0,0 +1,397 @@
import { performance } from "node:perf_hooks";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import {
BmeDatabase,
buildBmeDbName,
buildGraphFromSnapshot,
buildSnapshotFromGraph,
ensureDexieLoaded,
} from "../../sync/bme-db.js";
import {
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
OpfsGraphStore,
} from "../../sync/bme-opfs-store.js";
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
const RUNS = 4;
const outputJson = process.argv.includes("--json");
const projectRootHint = String(process.env.ST_BME_NODE_MODULES_ROOT || "").trim();
const requireFromProjectRoot = projectRootHint
? createRequire(path.join(projectRootHint, "package.json"))
: null;
const SIZE_PRESETS = [
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600 },
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800 },
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600 },
];
async function importWithProjectRootFallback(specifier) {
try {
return await import(specifier);
} catch (error) {
if (!requireFromProjectRoot) {
throw error;
}
const resolved = requireFromProjectRoot.resolve(specifier);
return await import(pathToFileURL(resolved).href);
}
}
function summarize(values = []) {
if (!values.length) {
return { avg: 0, p95: 0, min: 0, max: 0 };
}
const sorted = [...values].sort((a, b) => a - b);
const sum = sorted.reduce((acc, value) => acc + value, 0);
const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
return {
avg: sum / sorted.length,
p95: sorted[p95Index],
min: sorted[0],
max: sorted[sorted.length - 1],
};
}
function formatSummary(label, values = []) {
const summary = summarize(values);
return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
}
function createRandom(seed = 1) {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xffffffff;
};
}
function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
const rand = createRandom(seed);
const nodes = [];
const edges = [];
for (let index = 0; index < nodeCount; index += 1) {
nodes.push({
id: `node-${index}`,
type: "event",
updatedAt: 1000 + index,
archived: false,
sourceFloor: index,
fields: {
title: `Node ${index}`,
text: `node-${index}-${Math.floor(rand() * 100000)}`,
},
});
}
for (let index = 0; index < edgeCount; index += 1) {
const fromIndex = Math.floor(rand() * nodeCount);
let toIndex = Math.floor(rand() * nodeCount);
if (toIndex === fromIndex) {
toIndex = (toIndex + 1) % nodeCount;
}
edges.push({
id: `edge-${index}`,
fromId: `node-${fromIndex}`,
toId: `node-${toIndex}`,
relation: "related",
strength: rand(),
updatedAt: 2000 + index,
});
}
return {
version: 1,
nodes,
edges,
historyState: {
chatId,
lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
processedMessageHashes: {},
processedMessageHashVersion: 1,
processedMessageHashesNeedRefresh: false,
recentRecallOwnerKeys: [],
activeRecallOwnerKey: "",
activeRegion: "",
activeRegionSource: "",
activeStorySegmentId: "",
activeStoryTimeLabel: "",
activeStoryTimeSource: "",
lastBatchStatus: null,
lastMutationSource: "bench",
lastExtractedRegion: "",
lastExtractedStorySegmentId: "",
activeCharacterPovOwner: "",
activeUserPovOwner: "",
},
vectorIndexState: {
chatId,
collectionId: "",
hashToNodeId: {},
nodeToHash: {},
replayRequiredNodeIds: [],
dirty: false,
dirtyReason: "",
pendingRepairFromFloor: null,
lastIntegrityIssue: null,
lastStats: {
nodesIndexed: 0,
updatedAt: 0,
},
},
knowledgeState: {
owners: {},
activeOwnerKey: "",
},
regionState: {
activeRegion: "",
knownRegions: {},
manualActiveRegion: "",
},
timelineState: {
activeSegmentId: "",
manualActiveSegmentId: "",
segments: [],
},
summaryState: {
updatedAt: 0,
entries: [],
},
batchJournal: [],
maintenanceJournal: [],
lastRecallResult: null,
lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
};
}
function buildBenchSnapshot({ label, seed, nodeCount, edgeCount }) {
const chatId = `load-bench-${label.toLowerCase()}-${seed}`;
const graph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
return {
chatId,
snapshot: buildSnapshotFromGraph(graph, {
chatId,
revision: 1,
}),
};
}
async function setupIndexedDbTestEnv() {
try {
await importWithProjectRootFallback("fake-indexeddb/auto");
} catch {
// no-op
}
if (!globalThis.Dexie) {
try {
const imported = await importWithProjectRootFallback("dexie");
globalThis.Dexie = imported?.default || imported?.Dexie || imported;
} catch {
await import("../../lib/dexie.min.js");
}
}
await ensureDexieLoaded();
}
async function cleanupDatabase(chatId = "") {
if (!chatId || typeof globalThis.Dexie?.delete !== "function") return;
try {
await globalThis.Dexie.delete(buildBmeDbName(chatId));
} catch {
// no-op
}
}
async function prepareIndexedDb(chatId, snapshot) {
await cleanupDatabase(chatId);
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
await db.open();
await db.importSnapshot(snapshot, {
mode: "replace",
preserveRevision: true,
markSyncDirty: false,
});
return db;
}
async function prepareOpfsStore(chatId, snapshot) {
const rootDirectory = createMemoryOpfsRoot();
const store = new OpfsGraphStore(chatId, {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await store.open();
await store.importSnapshot(snapshot, {
mode: "replace",
preserveRevision: true,
markSyncDirty: false,
});
return store;
}
async function readProbeOrFallback(store) {
let inspectionSnapshot = null;
let exportProbeMs = 0;
let exportSnapshotMs = 0;
let exportSource = "";
if (typeof store.exportSnapshotProbe === "function") {
const probeStartedAt = performance.now();
inspectionSnapshot = await store.exportSnapshotProbe({ includeTombstones: false });
exportProbeMs = performance.now() - probeStartedAt;
exportSource = "probe";
}
if (!inspectionSnapshot) {
const exportStartedAt = performance.now();
inspectionSnapshot = await store.exportSnapshot({ includeTombstones: false });
exportSnapshotMs = performance.now() - exportStartedAt;
exportSource = "full-export";
}
return {
inspectionSnapshot,
exportProbeMs,
exportSnapshotMs,
exportSource,
};
}
async function measureSuccessPreApply(store, chatId) {
const startedAt = performance.now();
const probeResult = await readProbeOrFallback(store);
let snapshot = probeResult.inspectionSnapshot;
let exportSnapshotMs = probeResult.exportSnapshotMs;
let exportSource = probeResult.exportSource;
if (snapshot?.__stBmeProbeOnly === true) {
const exportStartedAt = performance.now();
snapshot = await store.exportSnapshot({ includeTombstones: false });
exportSnapshotMs += performance.now() - exportStartedAt;
exportSource =
probeResult.exportSource === "probe" ? "probe+full-export" : "full-export";
}
const preApplyMs = performance.now() - startedAt;
const hydrateStartedAt = performance.now();
buildGraphFromSnapshot(snapshot, { chatId });
const hydrateMs = performance.now() - hydrateStartedAt;
return {
preApplyMs,
exportProbeMs: probeResult.exportProbeMs,
exportSnapshotMs,
hydrateMs,
exportSource,
};
}
async function measureProbeRejectPreApply(store) {
const startedAt = performance.now();
const probeResult = await readProbeOrFallback(store);
return {
preApplyMs: performance.now() - startedAt,
exportProbeMs: probeResult.exportProbeMs,
exportSnapshotMs: probeResult.exportSnapshotMs,
exportSource: probeResult.exportSource,
};
}
async function runPreset(preset) {
const indexedDbSuccessSamples = [];
const indexedDbProbeRejectSamples = [];
const indexedDbProbeSamples = [];
const indexedDbExportSamples = [];
const indexedDbHydrateSamples = [];
const opfsSuccessSamples = [];
const opfsProbeRejectSamples = [];
const opfsProbeSamples = [];
const opfsExportSamples = [];
const opfsHydrateSamples = [];
for (let run = 0; run < RUNS; run += 1) {
const { chatId, snapshot } = buildBenchSnapshot({
...preset,
seed: preset.seed + run * 17,
});
const indexedDbChatId = `${chatId}-indexeddb`;
const db = await prepareIndexedDb(indexedDbChatId, snapshot);
const indexedDbSuccess = await measureSuccessPreApply(db, indexedDbChatId);
const indexedDbProbeReject = await measureProbeRejectPreApply(db);
indexedDbSuccessSamples.push(indexedDbSuccess.preApplyMs);
indexedDbProbeRejectSamples.push(indexedDbProbeReject.preApplyMs);
indexedDbProbeSamples.push(indexedDbSuccess.exportProbeMs);
indexedDbExportSamples.push(indexedDbSuccess.exportSnapshotMs);
indexedDbHydrateSamples.push(indexedDbSuccess.hydrateMs);
await db.close();
await cleanupDatabase(indexedDbChatId);
const opfsChatId = `${chatId}-opfs`;
const opfsStore = await prepareOpfsStore(opfsChatId, snapshot);
const opfsSuccess = await measureSuccessPreApply(opfsStore, opfsChatId);
const opfsProbeReject = await measureProbeRejectPreApply(opfsStore);
opfsSuccessSamples.push(opfsSuccess.preApplyMs);
opfsProbeRejectSamples.push(opfsProbeReject.preApplyMs);
opfsProbeSamples.push(opfsSuccess.exportProbeMs);
opfsExportSamples.push(opfsSuccess.exportSnapshotMs);
opfsHydrateSamples.push(opfsSuccess.hydrateMs);
await opfsStore.close();
}
const result = {
indexedDbPreApplySuccessMs: summarize(indexedDbSuccessSamples),
indexedDbProbeRejectMs: summarize(indexedDbProbeRejectSamples),
indexedDbExportProbeMs: summarize(indexedDbProbeSamples),
indexedDbExportSnapshotMs: summarize(indexedDbExportSamples),
indexedDbHydrateMs: summarize(indexedDbHydrateSamples),
opfsPreApplySuccessMs: summarize(opfsSuccessSamples),
opfsProbeRejectMs: summarize(opfsProbeRejectSamples),
opfsExportProbeMs: summarize(opfsProbeSamples),
opfsExportSnapshotMs: summarize(opfsExportSamples),
opfsHydrateMs: summarize(opfsHydrateSamples),
};
if (!outputJson) {
console.log(`\n[ST-BME][load-preapply-bench] ${preset.label}`);
console.log(
formatSummary("indexeddb-preapply-success", indexedDbSuccessSamples),
`probeRejectP95=${result.indexedDbProbeRejectMs.p95.toFixed(2)}ms`,
`probeP95=${result.indexedDbExportProbeMs.p95.toFixed(2)}ms`,
`exportP95=${result.indexedDbExportSnapshotMs.p95.toFixed(2)}ms`,
);
console.log(
formatSummary("opfs-preapply-success", opfsSuccessSamples),
`probeRejectP95=${result.opfsProbeRejectMs.p95.toFixed(2)}ms`,
`probeP95=${result.opfsExportProbeMs.p95.toFixed(2)}ms`,
`exportP95=${result.opfsExportSnapshotMs.p95.toFixed(2)}ms`,
);
console.log(
formatSummary("indexeddb-hydrate", indexedDbHydrateSamples),
formatSummary("opfs-hydrate", opfsHydrateSamples),
);
}
return result;
}
async function main() {
await setupIndexedDbTestEnv();
const results = {};
for (const preset of SIZE_PRESETS) {
results[preset.label] = await runPreset(preset);
}
if (outputJson) {
console.log(
JSON.stringify({
runs: RUNS,
presets: results,
}),
);
}
}
await main();

View File

@@ -0,0 +1,419 @@
import { performance } from "node:perf_hooks";
import {
buildGraphFromSnapshot,
buildPersistDelta,
buildSnapshotFromGraph,
} from "../../sync/bme-db.js";
import {
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
OpfsGraphStore,
} from "../../sync/bme-opfs-store.js";
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
const RUNS = 4;
const outputJson = process.argv.includes("--json");
const useNativeHydrate = process.argv.includes("--native-hydrate");
const nativeHydrateThresholdArg = process.argv.find((entry) =>
String(entry || "").startsWith("--native-hydrate-threshold="),
);
const nativeHydrateThresholdRecords = nativeHydrateThresholdArg
? Math.max(
0,
Math.floor(
Number(String(nativeHydrateThresholdArg).split("=").slice(1).join("=") || 0) || 0,
),
)
: undefined;
const SIZE_PRESETS = [
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600, churn: 0.12 },
];
let nativeHydratePreloadStatus = useNativeHydrate ? "pending" : "not-requested";
let nativeHydratePreloadError = "";
function summarize(values = []) {
if (!values.length) {
return { avg: 0, p95: 0, min: 0, max: 0 };
}
const sorted = [...values].sort((a, b) => a - b);
const sum = sorted.reduce((acc, value) => acc + value, 0);
const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
return {
avg: sum / sorted.length,
p95: sorted[p95Index],
min: sorted[0],
max: sorted[sorted.length - 1],
};
}
function formatSummary(label, values = []) {
const summary = summarize(values);
return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
}
function createRandom(seed = 1) {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xffffffff;
};
}
function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
const rand = createRandom(seed);
const nodes = [];
const edges = [];
for (let index = 0; index < nodeCount; index += 1) {
nodes.push({
id: `node-${index}`,
type: "event",
updatedAt: 1000 + index,
archived: false,
sourceFloor: index,
fields: {
title: `Node ${index}`,
text: `node-${index}-${Math.floor(rand() * 100000)}`,
},
});
}
for (let index = 0; index < edgeCount; index += 1) {
const fromIndex = Math.floor(rand() * nodeCount);
let toIndex = Math.floor(rand() * nodeCount);
if (toIndex === fromIndex) {
toIndex = (toIndex + 1) % nodeCount;
}
edges.push({
id: `edge-${index}`,
fromId: `node-${fromIndex}`,
toId: `node-${toIndex}`,
relation: "related",
strength: rand(),
updatedAt: 2000 + index,
});
}
return {
version: 1,
nodes,
edges,
historyState: {
chatId,
lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
processedMessageHashes: {},
processedMessageHashVersion: 1,
processedMessageHashesNeedRefresh: false,
recentRecallOwnerKeys: [],
activeRecallOwnerKey: "",
activeRegion: "",
activeRegionSource: "",
activeStorySegmentId: "",
activeStoryTimeLabel: "",
activeStoryTimeSource: "",
lastBatchStatus: null,
lastMutationSource: "bench",
lastExtractedRegion: "",
lastExtractedStorySegmentId: "",
activeCharacterPovOwner: "",
activeUserPovOwner: "",
},
vectorIndexState: {
chatId,
collectionId: "",
hashToNodeId: {},
nodeToHash: {},
replayRequiredNodeIds: [],
dirty: false,
dirtyReason: "",
pendingRepairFromFloor: null,
lastIntegrityIssue: null,
lastStats: {
nodesIndexed: 0,
updatedAt: 0,
},
},
knowledgeState: {
owners: {},
activeOwnerKey: "",
},
regionState: {
activeRegion: "",
knownRegions: {},
manualActiveRegion: "",
},
timelineState: {
activeSegmentId: "",
manualActiveSegmentId: "",
segments: [],
},
summaryState: {
updatedAt: 0,
entries: [],
},
batchJournal: [],
maintenanceJournal: [],
lastRecallResult: null,
lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
};
}
function mutateRuntimeGraph(baseGraph, seed = 1, churn = 0.1) {
const rand = createRandom(seed);
const nextGraph = structuredClone(baseGraph);
const mutateNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn));
const mutateEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.5));
for (let index = 0; index < mutateNodeCount; index += 1) {
const nodeIndex = Math.floor(rand() * nextGraph.nodes.length);
const node = nextGraph.nodes[nodeIndex];
node.updatedAt += 100 + index;
node.fields.text = `${node.fields.text}-mut-${index}`;
}
for (let index = 0; index < mutateEdgeCount; index += 1) {
const edgeIndex = Math.floor(rand() * nextGraph.edges.length);
const edge = nextGraph.edges[edgeIndex];
edge.updatedAt += 80 + index;
edge.strength = rand();
}
const addNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn * 0.12));
const baseNodeId = nextGraph.nodes.length;
for (let index = 0; index < addNodeCount; index += 1) {
nextGraph.nodes.push({
id: `node-new-${baseNodeId + index}`,
type: "event",
updatedAt: 5000 + index,
archived: false,
sourceFloor: baseNodeId + index,
fields: {
title: `Node new ${index}`,
text: `new-node-${index}`,
},
});
}
const deleteEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.08));
nextGraph.edges.splice(0, deleteEdgeCount);
nextGraph.historyState.lastProcessedAssistantFloor += 1;
nextGraph.historyState.extractionCount += 1;
nextGraph.lastProcessedSeq = nextGraph.historyState.lastProcessedAssistantFloor;
nextGraph.summaryState.updatedAt += 1;
return nextGraph;
}
function buildBenchPair({ label, seed, nodeCount, edgeCount, churn }) {
const chatId = `bench-${label.toLowerCase()}`;
const beforeGraph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
const afterGraph = mutateRuntimeGraph(beforeGraph, seed + 101, churn);
return {
label,
chatId,
beforeGraph,
afterGraph,
};
}
function measureSnapshotBuild(graph, options) {
let diagnostics = null;
const startedAt = performance.now();
const snapshot = buildSnapshotFromGraph(graph, {
...options,
onDiagnostics(snapshotValue) {
diagnostics = snapshotValue;
},
});
return {
elapsedMs: performance.now() - startedAt,
snapshot,
diagnostics,
};
}
function measureHydrate(snapshot, chatId) {
let diagnostics = null;
const startedAt = performance.now();
buildGraphFromSnapshot(snapshot, {
chatId,
useNativeHydrate,
loadNativeHydrateThresholdRecords: nativeHydrateThresholdRecords,
nativeFailOpen: true,
onDiagnostics(snapshotValue) {
diagnostics = snapshotValue;
},
});
return {
elapsedMs: performance.now() - startedAt,
diagnostics,
};
}
async function measureOpfsCommit(baseSnapshot, afterSnapshot, delta, chatId) {
const rootDirectory = createMemoryOpfsRoot();
const store = new OpfsGraphStore(chatId, {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await store.open();
await store.importSnapshot(baseSnapshot, {
mode: "replace",
preserveRevision: true,
markSyncDirty: false,
});
const startedAt = performance.now();
const result = await store.commitDelta(delta, {
reason: "bench-commit",
requestedRevision: Number(afterSnapshot?.meta?.revision || 0),
markSyncDirty: true,
committedSnapshot: afterSnapshot,
});
const elapsedMs = performance.now() - startedAt;
await store.close();
return {
elapsedMs,
diagnostics: result?.diagnostics || {},
};
}
async function runPreset(preset) {
const snapshotBuildSamples = [];
const hydrateSamples = [];
const opfsCommitSamples = [];
const snapshotNodesSamples = [];
const hydrateRuntimeMetaSamples = [];
const hydrateNodesSamples = [];
const hydrateEdgesSamples = [];
const hydrateStateSamples = [];
const hydrateNormalizeSamples = [];
const hydrateIntegritySamples = [];
const hydrateNativeRecordsSamples = [];
const walFileWriteSamples = [];
const manifestFileWriteSamples = [];
let hydrateNativeUsedRuns = 0;
for (let run = 0; run < RUNS; run += 1) {
const pair = buildBenchPair({
...preset,
seed: preset.seed + run * 17,
});
const beforeSnapshotResult = measureSnapshotBuild(pair.beforeGraph, {
chatId: pair.chatId,
revision: 1,
});
const afterSnapshotResult = measureSnapshotBuild(pair.afterGraph, {
chatId: pair.chatId,
revision: 2,
baseSnapshot: beforeSnapshotResult.snapshot,
});
const delta = buildPersistDelta(
beforeSnapshotResult.snapshot,
afterSnapshotResult.snapshot,
{ useNativeDelta: false },
);
const hydrateResult = measureHydrate(afterSnapshotResult.snapshot, pair.chatId);
const opfsCommitResult = await measureOpfsCommit(
beforeSnapshotResult.snapshot,
afterSnapshotResult.snapshot,
delta,
pair.chatId,
);
snapshotBuildSamples.push(afterSnapshotResult.elapsedMs);
hydrateSamples.push(hydrateResult.elapsedMs);
opfsCommitSamples.push(opfsCommitResult.elapsedMs);
snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0));
hydrateNodesSamples.push(Number(hydrateResult.diagnostics?.nodesMs || 0));
hydrateEdgesSamples.push(Number(hydrateResult.diagnostics?.edgesMs || 0));
hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0));
hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0));
hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0));
hydrateNativeRecordsSamples.push(
Number(hydrateResult.diagnostics?.nativeRecordsMs || 0),
);
if (hydrateResult.diagnostics?.nativeUsed === true) {
hydrateNativeUsedRuns += 1;
}
walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
manifestFileWriteSamples.push(
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
);
}
const result = {
snapshotBuildMs: summarize(snapshotBuildSamples),
snapshotNodesMs: summarize(snapshotNodesSamples),
hydrateMs: summarize(hydrateSamples),
hydrateNodesMs: summarize(hydrateNodesSamples),
hydrateEdgesMs: summarize(hydrateEdgesSamples),
hydrateStateMs: summarize(hydrateStateSamples),
hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
hydrateIntegrityMs: summarize(hydrateIntegritySamples),
hydrateNativeRecordsMs: summarize(hydrateNativeRecordsSamples),
hydrateNativeUsedRuns,
nativeHydrateRequested: useNativeHydrate,
nativeHydrateThresholdRecords:
nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
opfsCommitMs: summarize(opfsCommitSamples),
opfsWalFileWriteMs: summarize(walFileWriteSamples),
opfsManifestFileWriteMs: summarize(manifestFileWriteSamples),
};
if (!outputJson) {
console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
console.log(
formatSummary("snapshot-build", snapshotBuildSamples),
`nodesPhaseP95=${result.snapshotNodesMs.p95.toFixed(2)}ms`,
);
console.log(
formatSummary("hydrate", hydrateSamples),
`nodesP95=${result.hydrateNodesMs.p95.toFixed(2)}ms`,
`edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`,
`normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`,
`integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`,
`nativeRecordsP95=${result.hydrateNativeRecordsMs.p95.toFixed(2)}ms`,
`nativeUsed=${result.hydrateNativeUsedRuns}/${RUNS}`,
`runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`,
);
console.log(
formatSummary("opfs-commit", opfsCommitSamples),
`walFileP95=${result.opfsWalFileWriteMs.p95.toFixed(2)}ms`,
`manifestFileP95=${result.opfsManifestFileWriteMs.p95.toFixed(2)}ms`,
);
}
return result;
}
async function main() {
if (useNativeHydrate) {
try {
const nativeModule = await import("../../vendor/wasm/stbme_core.js");
const nativeStatus = await nativeModule?.installNativeHydrateHook?.();
nativeHydratePreloadStatus = nativeStatus?.loaded ? "loaded" : "not-loaded";
nativeHydratePreloadError = String(nativeStatus?.error || "");
} catch (error) {
nativeHydratePreloadStatus = "failed";
nativeHydratePreloadError = error?.message || String(error);
console.warn(
"[ST-BME][persist-load-bench] native hydrate preload failed, fallback to JS hydrate:",
error,
);
}
}
const presets = {};
for (const preset of SIZE_PRESETS) {
presets[preset.label] = await runPreset(preset);
}
const payload = {
runs: RUNS,
nativeHydrateRequested: useNativeHydrate,
nativeHydratePreloadStatus,
nativeHydratePreloadError,
nativeHydrateThresholdRecords:
nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
presets,
};
if (outputJson) {
console.log(JSON.stringify(payload));
}
}
await main();

View File

@@ -69,16 +69,18 @@ const extractPromptBuild = await buildTaskPrompt(settings, "extract", {
const extractPayload = buildTaskLlmPayload(extractPromptBuild, "fallback-user");
assert.equal(extractPayload.systemPrompt, "");
assert.equal(extractPayload.userPrompt, "");
assert.equal(
extractPayload.promptMessages.filter((message) => message.role === "user").length,
2,
);
assert.deepEqual(
extractPayload.promptMessages
.filter((message) => message.role === "user")
.map((message) => message.blockName),
["输出格式", "行为规则"],
);
assert.deepEqual(
extractPayload.promptMessages
.filter((message) => message.role === "assistant")
.map((message) => message.blockName),
["身份确认", "信息确认"],
);
const extractFormatBlock = extractPayload.promptMessages.find(
(message) => message.blockName === "输出格式",
);
@@ -98,10 +100,10 @@ assert.deepEqual(
[
"charDescription",
"userPersona",
"recentMessages",
"graphStats",
"schema",
"currentRange",
"recentMessages",
],
);
@@ -118,9 +120,17 @@ const recallPromptBuild = await buildTaskPrompt(settings, "recall", {
const recallPayload = buildTaskLlmPayload(recallPromptBuild, "fallback-user");
assert.equal(recallPayload.systemPrompt, "");
assert.equal(recallPayload.userPrompt, "");
assert.equal(
recallPayload.promptMessages.filter((message) => message.role === "user").length,
2,
assert.deepEqual(
recallPayload.promptMessages
.filter((message) => message.role === "user")
.map((message) => message.blockName),
["输出格式", "行为规则"],
);
assert.deepEqual(
recallPayload.promptMessages
.filter((message) => message.role === "assistant")
.map((message) => message.blockName),
["身份确认", "信息确认"],
);
assert.deepEqual(
recallPayload.promptMessages
@@ -129,11 +139,11 @@ assert.deepEqual(
[
"charDescription",
"userPersona",
"graphStats",
"sceneOwnerCandidates",
"candidateNodes",
"recentMessages",
"userMessage",
"candidateNodes",
"sceneOwnerCandidates",
"graphStats",
],
);
const recallFormatBlock = recallPayload.promptMessages.find(

View File

@@ -0,0 +1,353 @@
// ST-BME: regression tests — reroll should reuse persisted recall record
//
// Covers:
// 1. ensurePersistedRecallRecordForGeneration re-writes when existing record
// has same injectionText/nodeIds but empty recallInput
// 2. resolveReusablePersistedRecallRecord (inside runRecallController) reuses
// a persisted record when recallInput matches the user floor text
// 3. End-to-end: regenerate does NOT call retrieve when a valid persisted
// record exists
import assert from "node:assert/strict";
import {
buildPersistedRecallRecord,
readPersistedRecallFromUserMessage,
writePersistedRecallToUserMessage,
BME_RECALL_EXTRA_KEY,
} from "../retrieval/recall-persistence.js";
import { runRecallController } from "../retrieval/recall-controller.js";
import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs";
import {
normalizeRecallInputText,
createRecallRunResult,
createRecallInputRecord,
isFreshRecallInputRecord,
} from "../ui/ui-status.js";
import { defaultSettings } from "../runtime/settings-defaults.js";
// ═══════════════════════════════════════════════════════════════
// 1. ensurePersistedRecallRecordForGeneration: empty recallInput override
// ═══════════════════════════════════════════════════════════════
const harness = await createGenerationRecallHarness({ realApplyFinal: true });
// Prime settings
Object.assign(harness.settings, {
...defaultSettings,
enabled: true,
recallEnabled: true,
});
// Set up chat: user + assistant
harness.chat = [
{ is_user: true, mes: "去摩耶山看夜景" },
{ is_user: false, mes: "好的,我们出发吧。", is_system: false },
];
// Pre-write a persisted record with EMPTY recallInput (simulates old bug)
const emptyRecallInputRecord = buildPersistedRecallRecord({
injectionText: "注入:去摩耶山看夜景",
selectedNodeIds: ["node-test-1"],
recallInput: "",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 5,
manuallyEdited: false,
});
writePersistedRecallToUserMessage(harness.chat, 0, emptyRecallInputRecord);
// Verify the record is written with empty recallInput
const beforeRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
assert.ok(beforeRecord, "persisted record should exist before ensure");
assert.equal(beforeRecord.recallInput, "", "recallInput should be empty before fix");
assert.equal(
beforeRecord.injectionText,
"注入:去摩耶山看夜景",
"injectionText should match",
);
// Build a mock recall result with the same injectionText
const mockRecallResult = {
status: "completed",
didRecall: true,
ok: true,
injectionText: "注入:去摩耶山看夜景",
selectedNodeIds: ["node-test-1"],
source: "chat-last-user",
sourceLabel: "历史最后用户楼层",
hookName: "GENERATION_AFTER_COMMANDS",
authoritativeInputUsed: false,
boundUserFloorText: "去摩耶山看夜景",
};
// Build frozen recall options with overrideUserMessage
const frozenRecallOptions = {
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideUserMessage: "去摩耶山看夜景",
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
lockedSource: "chat-last-user",
lockedSourceLabel: "历史最后用户楼层",
authoritativeInputUsed: false,
boundUserFloorText: "去摩耶山看夜景",
};
// Call ensurePersistedRecallRecordForGeneration
const ensureResult = harness.result.ensurePersistedRecallRecordForGeneration({
generationType: "regenerate",
recallResult: mockRecallResult,
transaction: { frozenRecallOptions },
recallOptions: frozenRecallOptions,
hookName: "GENERATION_AFTER_COMMANDS",
});
// After fix: the record should be overwritten because existing recallInput is empty
const afterRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
assert.ok(afterRecord, "persisted record should still exist after ensure");
assert.equal(
afterRecord.recallInput,
"去摩耶山看夜景",
"recallInput should now be populated after ensure overwrites empty-recallInput record",
);
assert.equal(
afterRecord.boundUserFloorText,
"去摩耶山看夜景",
"boundUserFloorText should be populated",
);
console.log(" ✓ ensurePersistedRecallRecordForGeneration overwrites record with empty recallInput");
// ═══════════════════════════════════════════════════════════════
// 2. ensurePersistedRecallRecordForGeneration: populated recallInput skip
// ═══════════════════════════════════════════════════════════════
// Now the record has proper recallInput — calling ensure again should skip
const ensureResult2 = harness.result.ensurePersistedRecallRecordForGeneration({
generationType: "regenerate",
recallResult: mockRecallResult,
transaction: { frozenRecallOptions },
recallOptions: frozenRecallOptions,
hookName: "GENERATION_AFTER_COMMANDS",
});
assert.equal(
ensureResult2.reason,
"already-up-to-date",
"should skip when recallInput is already populated",
);
console.log(" ✓ ensurePersistedRecallRecordForGeneration skips when recallInput is populated");
// ═══════════════════════════════════════════════════════════════
// 3. runRecallController: regenerate reuses persisted record
// ═══════════════════════════════════════════════════════════════
// Set up a fresh chat with a properly persisted recall record
const rerollChat = [
{ is_user: true, mes: "明日去摩耶山看夜景" },
{ is_user: false, mes: "好的,明天约好了。", is_system: false },
];
const validRecord = buildPersistedRecallRecord({
injectionText: "注入:明日去摩耶山看夜景",
selectedNodeIds: ["node-a"],
recallInput: "明日去摩耶山看夜景",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 5,
manuallyEdited: false,
boundUserFloorText: "明日去摩耶山看夜景",
});
writePersistedRecallToUserMessage(rerollChat, 0, validRecord);
let retrieveCalled = false;
const rerollRuntime = {
getIsRecalling: () => false,
getCurrentGraph: () => ({ nodes: [], edges: [] }),
getSettings: () => ({
...defaultSettings,
enabled: true,
recallEnabled: true,
recallLlmContextMessages: 5,
}),
isGraphReadableForRecall: () => true,
isGraphMetadataWriteAllowed: () => true,
recoverHistoryIfNeeded: async () => true,
getContext: () => ({ chat: rerollChat, chatId: "chat-reroll" }),
nextRecallRunSequence: () => 1,
beginStageAbortController: () => ({ signal: { aborted: false } }),
finishStageAbortController: () => {},
setIsRecalling: () => {},
setActiveRecallPromise: () => {},
getActiveRecallPromise: () => null,
setLastRecallStatus: () => {},
clampInt: (v, f, mn, mx) => {
const n = Number(v);
if (!Number.isFinite(n)) return f;
return Math.min(mx, Math.max(mn, Math.trunc(n)));
},
normalizeRecallInputText,
createRecallInputRecord,
createRecallRunResult,
isFreshRecallInputRecord,
getLatestUserChatMessage: (chat = []) =>
[...chat].reverse().find((m) => m?.is_user) || null,
getLastNonSystemChatMessage: (chat = []) =>
[...chat].reverse().find((m) => !m?.is_system) || null,
getRecallUserMessageSourceLabel: (s) => s,
buildRecallRecentMessages: () => [],
readPersistedRecallFromUserMessage,
bumpPersistedRecallGenerationCount: (chat, idx) => {
// no-op in test; just return the record
return readPersistedRecallFromUserMessage(chat, idx);
},
triggerChatMetadataSave: () => {},
schedulePersistedRecallMessageUiRefresh: () => {},
refreshPanelLiveState: () => {},
ensureVectorReadyIfNeeded: async () => {},
resolveRecallInput: (chat, limit, override) => {
// Simulate resolveRecallInputController override path
const overrideText = normalizeRecallInputText(
override?.overrideUserMessage || override?.userMessage || "",
);
return {
userMessage: overrideText,
generationType: String(override?.generationType || "normal"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
? override.targetUserMessageIndex
: null,
source: override?.overrideSource || "chat-last-user",
sourceLabel: override?.overrideSourceLabel || "历史最后用户楼层",
reason: "override-bound",
authoritativeInputUsed: Boolean(override?.authoritativeInputUsed),
boundUserFloorText: normalizeRecallInputText(
override?.boundUserFloorText || "",
),
recentMessages: [],
hookName: override?.hookName || "",
deliveryMode: "immediate",
};
},
applyRecallInjection: (_settings, _input, _recent, result) => ({
injectionText: result?.injectionText || "",
applied: true,
source: "persisted-reuse",
mode: "module-injection",
}),
retrieve: async () => {
retrieveCalled = true;
return {
injectionText: "should-not-appear",
selectedNodeIds: ["node-b"],
};
},
buildRecallRetrieveOptions: () => ({}),
getEmbeddingConfig: () => ({}),
getSchema: () => ({}),
console,
isAbortError: () => false,
toastr: { error: () => {} },
getRecallHookLabel: () => "",
setPendingRecallSendIntent: () => {},
};
// Simulate regenerate: override with the user floor text and generationType regenerate
const rerollResult = await runRecallController(rerollRuntime, {
overrideUserMessage: "明日去摩耶山看夜景",
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
hookName: "GENERATION_AFTER_COMMANDS",
deliveryMode: "immediate",
});
assert.equal(rerollResult.status, "completed", "reroll should complete");
assert.equal(
rerollResult.reason,
"persisted-user-floor-reused",
"reroll should reuse persisted record, not run fresh recall",
);
assert.equal(
retrieveCalled,
false,
"retrieve() should NOT be called when persisted record is reused",
);
assert.equal(
rerollResult.injectionText,
"注入:明日去摩耶山看夜景",
"injection text should come from persisted record",
);
console.log(" ✓ runRecallController reuses persisted record on regenerate");
// ═══════════════════════════════════════════════════════════════
// 4. runRecallController: regenerate with empty recallInput does NOT reuse
// ═══════════════════════════════════════════════════════════════
const noReuseChat = [
{ is_user: true, mes: "去看星星" },
{ is_user: false, mes: "好的。", is_system: false },
];
const emptyInputRecord = buildPersistedRecallRecord({
injectionText: "注入:去看星星",
selectedNodeIds: ["node-c"],
recallInput: "",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 3,
manuallyEdited: false,
});
writePersistedRecallToUserMessage(noReuseChat, 0, emptyInputRecord);
let noReuseRetrieveCalled = false;
const noReuseRuntime = {
...rerollRuntime,
getContext: () => ({ chat: noReuseChat, chatId: "chat-no-reuse" }),
readPersistedRecallFromUserMessage,
retrieve: async () => {
noReuseRetrieveCalled = true;
return {
injectionText: "新召回结果",
selectedNodeIds: ["node-d"],
};
},
resolveRecallInput: (chat, limit, override) => ({
userMessage: normalizeRecallInputText(
override?.overrideUserMessage || "",
),
generationType: String(override?.generationType || "normal"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
? override.targetUserMessageIndex
: null,
source: override?.overrideSource || "chat-last-user",
sourceLabel: override?.overrideSourceLabel || "",
reason: "override-bound",
authoritativeInputUsed: false,
boundUserFloorText: "",
recentMessages: [],
hookName: override?.hookName || "",
deliveryMode: "immediate",
}),
};
const noReuseResult = await runRecallController(noReuseRuntime, {
overrideUserMessage: "去看星星",
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideSource: "chat-last-user",
hookName: "GENERATION_AFTER_COMMANDS",
deliveryMode: "immediate",
});
assert.equal(noReuseResult.status, "completed", "no-reuse should complete");
assert.equal(
noReuseRetrieveCalled,
true,
"retrieve() SHOULD be called when persisted record has empty recallInput",
);
console.log(" ✓ runRecallController does NOT reuse record with empty recallInput");
// ═══════════════════════════════════════════════════════════════
console.log("recall-reroll-reuse tests passed");

View File

@@ -8,6 +8,15 @@ import {
findLatestNode,
serializeGraph,
} from "../graph/graph.js";
import {
buildRegionLine,
getScopeRegionTokens,
normalizeMemoryScope,
} from "../graph/memory-scope.js";
import {
normalizeStoryTime,
normalizeStoryTimeSpan,
} from "../graph/story-timeline.js";
const graph = createEmptyGraph();
const objectiveNode = createNode({
@@ -53,6 +62,105 @@ const latestPov = findLatestNode(
assert.equal(latestObjective?.id, objectiveNode.id);
assert.equal(latestPov?.id, povNode.id);
const normalizedScope = {
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
regionPrimary: "钟楼",
regionPath: ["钟楼", "塔顶"],
regionSecondary: ["旧城区"],
};
assert.equal(
normalizeMemoryScope(normalizedScope),
normalizedScope,
"已规范的 scope 对象应直接复用",
);
const malformedSecondaryScope = normalizeMemoryScope({
layer: "objective",
regionPrimary: "王都/钟楼",
regionSecondary: "旧城区, 集市 / 下水道 / 钟楼",
});
assert.equal(malformedSecondaryScope.regionPrimary, "钟楼");
assert.deepEqual(malformedSecondaryScope.regionPath, ["王都", "钟楼"]);
assert.deepEqual(malformedSecondaryScope.regionSecondary, [
"旧城区",
"集市",
"下水道",
]);
assert.deepEqual(getScopeRegionTokens(malformedSecondaryScope), [
"钟楼",
"王都",
"旧城区",
"集市",
"下水道",
]);
assert.match(buildRegionLine(malformedSecondaryScope), /次级地区/);
const accessorBackedScope = {};
Object.defineProperty(accessorBackedScope, "layer", {
get() {
return "objective";
},
enumerable: true,
});
Object.defineProperty(accessorBackedScope, "regionPrimary", {
get() {
return "钟楼";
},
enumerable: true,
});
Object.defineProperty(accessorBackedScope, "regionPath", {
get() {
return "王都 > 钟楼";
},
enumerable: true,
});
Object.defineProperty(accessorBackedScope, "regionSecondary", {
get() {
return { label: "旧城区 / 集市" };
},
enumerable: true,
});
const normalizedAccessorScope = normalizeMemoryScope(accessorBackedScope);
assert.notEqual(
normalizedAccessorScope,
accessorBackedScope,
"带 accessor 的 scope 不应复用原对象",
);
assert.deepEqual(normalizedAccessorScope.regionPath, ["王都", "钟楼"]);
assert.deepEqual(normalizedAccessorScope.regionSecondary, ["旧城区", "集市"]);
const normalizedStoryTime = {
segmentId: "tl-1",
label: "第二天清晨",
tense: "ongoing",
relation: "same",
anchorLabel: "昨夜",
confidence: "high",
source: "derived",
};
assert.equal(
normalizeStoryTime(normalizedStoryTime),
normalizedStoryTime,
"已规范的 storyTime 对象应直接复用",
);
const normalizedStoryTimeSpan = {
startSegmentId: "tl-0",
endSegmentId: "tl-1",
startLabel: "昨夜",
endLabel: "第二天清晨",
mixed: false,
source: "derived",
};
assert.equal(
normalizeStoryTimeSpan(normalizedStoryTimeSpan),
normalizedStoryTimeSpan,
"已规范的 storyTimeSpan 对象应直接复用",
);
const legacyGraph = deserializeGraph({
version: 6,
lastProcessedSeq: 0,

View File

@@ -155,7 +155,16 @@ try {
},
});
assert.equal(JSON.stringify(graph), graphBefore, "shared ranking should be side-effect-free");
const stripDiagnosticTimings = (json) => {
const obj = JSON.parse(json);
if (obj?.vectorIndexState) delete obj.vectorIndexState.lastSearchTimings;
return JSON.stringify(obj);
};
assert.equal(
stripDiagnosticTimings(JSON.stringify(graph)),
stripDiagnosticTimings(graphBefore),
"shared ranking should be side-effect-free (ignoring diagnostic timings)",
);
assert.equal(first.scoredNodes[0]?.nodeId, confession.id);
assert.equal(second.scoredNodes[0]?.nodeId, confession.id);
assert.deepEqual(

View File

@@ -1,4 +1,8 @@
import assert from "node:assert/strict";
import {
LEGACY_PLANNER_SYSTEM_PROMPT,
PLANNER_ASSISTANT_SEED,
} from "../ena-planner/ena-planner-presets.js";
import {
createDefaultTaskProfiles,
ensureTaskProfiles,
@@ -23,6 +27,7 @@ assert.equal(migrated.taskProfilesVersion, 3);
assert.ok(migrated.taskProfiles);
assert.ok(migrated.taskProfiles.extract);
assert.ok(migrated.taskProfiles.recall);
assert.ok(migrated.taskProfiles.planner);
const extractProfile = getActiveTaskProfile(
{
@@ -34,22 +39,24 @@ const extractProfile = getActiveTaskProfile(
assert.equal(extractProfile.taskType, "extract");
assert.equal(extractProfile.id, "default");
assert.ok(Array.isArray(extractProfile.blocks));
assert.equal(extractProfile.blocks.length, 14);
assert.equal(extractProfile.blocks.length, 16);
assert.deepEqual(
extractProfile.blocks.map((block) => block.name),
[
"抬头",
"角色定义",
"身份确认",
"角色描述",
"用户设定",
"世界书前块",
"世界书后块",
"最近消息",
"图统计",
"Schema",
"当前范围",
"活跃总结",
"故事时间",
"当前范围",
"最近消息",
"信息确认",
"输出格式",
"行为规则",
],
@@ -57,6 +64,7 @@ assert.deepEqual(
assert.deepEqual(
extractProfile.blocks.map((block) => block.type),
[
"custom",
"custom",
"custom",
"builtin",
@@ -71,6 +79,7 @@ assert.deepEqual(
"builtin",
"custom",
"custom",
"custom",
],
);
assert.deepEqual(
@@ -78,6 +87,7 @@ assert.deepEqual(
[
"system",
"system",
"assistant",
"system",
"system",
"system",
@@ -88,6 +98,7 @@ assert.deepEqual(
"system",
"system",
"system",
"assistant",
"user",
"user",
],
@@ -112,15 +123,17 @@ assert.deepEqual(
[
"default-heading",
"default-role",
"default-identity-ack",
"charDescription",
"userPersona",
"worldInfoBefore",
"worldInfoAfter",
"graphStats",
"sceneOwnerCandidates",
"candidateNodes",
"recentMessages",
"userMessage",
"candidateNodes",
"sceneOwnerCandidates",
"graphStats",
"default-info-ack",
"default-format",
"default-rules",
],
@@ -130,19 +143,259 @@ assert.deepEqual(
[
"default-heading",
"default-role",
"default-identity-ack",
"charDescription",
"userPersona",
"worldInfoBefore",
"worldInfoAfter",
"recentMessages",
"graphStats",
"candidateText",
"currentRange",
"graphStats",
"recentMessages",
"default-info-ack",
"default-format",
"default-rules",
],
);
assert.ok(defaults.summary_rollup.profiles.length > 0);
assert.ok(defaults.planner.profiles.length > 0);
assert.deepEqual(
defaults.planner.profiles[0].blocks.map((block) => block.sourceKey || block.id),
[
"planner-default-heading",
"planner-default-role",
"planner-default-identity-ack",
"plannerCharacterCard",
"plannerWorldbook",
"plannerMemory",
"plannerPreviousPlots",
"plannerRecentChat",
"plannerUserInput",
"planner-default-info-ack",
"planner-default-format",
"planner-default-rules",
"planner-default-assistant-seed",
],
);
assert.equal(defaults.planner.profiles[0].generation.stream, true);
assert.equal(defaults.planner.profiles[0].generation.temperature, 1);
const currentDefaultPlanner = defaults.planner.profiles[0];
const cloneValue = (value) => JSON.parse(JSON.stringify(value));
function buildLegacyPlannerDefaultLikeBlocks() {
return [
{
id: "planner-legacy-default-system",
name: "Ena Planner System",
type: "custom",
enabled: true,
role: "system",
sourceKey: "",
sourceField: "",
content: LEGACY_PLANNER_SYSTEM_PROMPT,
injectionMode: "relative",
order: 0,
},
{
id: "planner-legacy-default-char",
name: "角色卡",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerCharacterCard",
sourceField: "",
content: "",
injectionMode: "relative",
order: 1,
},
{
id: "planner-legacy-default-worldbook",
name: "世界书",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerWorldbook",
sourceField: "",
content: "",
injectionMode: "relative",
order: 2,
},
{
id: "planner-legacy-default-recent-chat",
name: "最近聊天",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerRecentChat",
sourceField: "",
content: "",
injectionMode: "relative",
order: 3,
},
{
id: "planner-legacy-default-memory",
name: "BME 记忆",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerMemory",
sourceField: "",
content: "",
injectionMode: "relative",
order: 4,
},
{
id: "planner-legacy-default-previous-plots",
name: "历史 plot",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerPreviousPlots",
sourceField: "",
content: "",
injectionMode: "relative",
order: 5,
},
{
id: "planner-legacy-default-user-input",
name: "玩家输入",
type: "builtin",
enabled: true,
role: "user",
sourceKey: "plannerUserInput",
sourceField: "",
content: "",
injectionMode: "relative",
order: 6,
},
{
id: "planner-legacy-default-seed",
name: "Assistant Seed",
type: "custom",
enabled: true,
role: "assistant",
sourceKey: "",
sourceField: "",
content: PLANNER_ASSISTANT_SEED,
injectionMode: "relative",
order: 7,
},
];
}
function createLegacyPlannerDefaultLikeProfile(overrides = {}) {
return {
id: "planner-legacy-default-like",
taskType: "planner",
builtin: false,
name: "ENA 当前配置",
promptMode: "block-based",
enabled: true,
updatedAt: "2026-04-23T00:00:00.000Z",
blocks: buildLegacyPlannerDefaultLikeBlocks(),
generation: cloneValue(currentDefaultPlanner.generation),
metadata: {
migratedFromLegacy: true,
enaLegacySource: "legacy-working-copy",
},
...overrides,
blocks: Array.isArray(overrides.blocks)
? overrides.blocks
: buildLegacyPlannerDefaultLikeBlocks(),
generation: {
...cloneValue(currentDefaultPlanner.generation),
...(overrides.generation || {}),
},
metadata: {
migratedFromLegacy: true,
enaLegacySource: "legacy-working-copy",
...(overrides.metadata || {}),
},
};
}
const legacyPlannerDefaultLikeProfile = createLegacyPlannerDefaultLikeProfile();
const alignedLegacyPlannerDefaults = ensureTaskProfiles({
taskProfilesVersion: 3,
taskProfiles: {
planner: {
activeProfileId: legacyPlannerDefaultLikeProfile.id,
profiles: [cloneValue(currentDefaultPlanner), legacyPlannerDefaultLikeProfile],
},
},
});
const alignedLegacyPlannerProfile = alignedLegacyPlannerDefaults.planner.profiles.find(
(profile) => profile.id === legacyPlannerDefaultLikeProfile.id,
);
assert.equal(alignedLegacyPlannerDefaults.planner.activeProfileId, "default");
assert.deepEqual(
alignedLegacyPlannerProfile.blocks.map((block) => block.sourceKey || block.id),
currentDefaultPlanner.blocks.map((block) => block.sourceKey || block.id),
);
assert.equal(alignedLegacyPlannerProfile.metadata.plannerLegacyDefaultAligned, true);
const legacyPlannerCustomGenerationProfile = createLegacyPlannerDefaultLikeProfile({
id: "planner-legacy-custom-generation",
generation: {
temperature: 0.7,
},
});
const alignedLegacyPlannerCustomGeneration = ensureTaskProfiles({
taskProfilesVersion: 3,
taskProfiles: {
planner: {
activeProfileId: legacyPlannerCustomGenerationProfile.id,
profiles: [
cloneValue(currentDefaultPlanner),
legacyPlannerCustomGenerationProfile,
],
},
},
});
const alignedLegacyPlannerCustomGenerationProfile =
alignedLegacyPlannerCustomGeneration.planner.profiles.find(
(profile) => profile.id === legacyPlannerCustomGenerationProfile.id,
);
assert.equal(
alignedLegacyPlannerCustomGeneration.planner.activeProfileId,
legacyPlannerCustomGenerationProfile.id,
);
assert.deepEqual(
alignedLegacyPlannerCustomGenerationProfile.blocks.map(
(block) => block.sourceKey || block.id,
),
currentDefaultPlanner.blocks.map((block) => block.sourceKey || block.id),
);
assert.equal(alignedLegacyPlannerCustomGenerationProfile.generation.temperature, 0.7);
const customizedLegacyPlannerBlocks = buildLegacyPlannerDefaultLikeBlocks();
customizedLegacyPlannerBlocks[0].content = `${customizedLegacyPlannerBlocks[0].content}\n\n自定义补充`;
const customizedLegacyPlannerProfile = createLegacyPlannerDefaultLikeProfile({
id: "planner-legacy-customized",
blocks: customizedLegacyPlannerBlocks,
});
const preservedCustomizedLegacyPlanner = ensureTaskProfiles({
taskProfilesVersion: 3,
taskProfiles: {
planner: {
activeProfileId: customizedLegacyPlannerProfile.id,
profiles: [cloneValue(currentDefaultPlanner), customizedLegacyPlannerProfile],
},
},
});
const preservedCustomizedLegacyPlannerProfile =
preservedCustomizedLegacyPlanner.planner.profiles.find(
(profile) => profile.id === customizedLegacyPlannerProfile.id,
);
assert.equal(
preservedCustomizedLegacyPlanner.planner.activeProfileId,
customizedLegacyPlannerProfile.id,
);
assert.match(
preservedCustomizedLegacyPlannerProfile.blocks[0].content,
/自定义补充/,
);
const upgradedLegacyDefault = getActiveTaskProfile(
{
@@ -220,16 +473,34 @@ const upgradedLegacyDefault = getActiveTaskProfile(
},
"extract",
);
assert.equal(upgradedLegacyDefault.blocks.length, 14);
assert.equal(upgradedLegacyDefault.blocks.length, 16);
assert.equal(upgradedLegacyDefault.blocks[0].name, "抬头");
assert.match(upgradedLegacyDefault.blocks[0].content, /虚拟的世界/);
assert.equal(upgradedLegacyDefault.blocks[0].role, "system");
assert.equal(upgradedLegacyDefault.blocks[0].injectionMode, "relative");
assert.equal(upgradedLegacyDefault.blocks[1].content, "保留我自己的角色定义");
assert.equal(upgradedLegacyDefault.blocks[12].content, "保留我自己的输出格式");
assert.equal(upgradedLegacyDefault.blocks[13].content, "保留我自己的行为规则");
assert.equal(upgradedLegacyDefault.blocks[12].role, "user");
assert.equal(upgradedLegacyDefault.blocks[13].role, "user");
const upgradedIdentityAck = upgradedLegacyDefault.blocks.find(
(block) => block.id === "default-identity-ack",
);
assert.ok(
upgradedIdentityAck,
"legacy upgrade should backfill default-identity-ack block",
);
assert.equal(upgradedIdentityAck.role, "assistant");
const upgradedInfoAck = upgradedLegacyDefault.blocks.find(
(block) => block.id === "default-info-ack",
);
assert.ok(
upgradedInfoAck,
"legacy upgrade should backfill default-info-ack block",
);
assert.equal(upgradedInfoAck.role, "assistant");
assert.equal(upgradedLegacyDefault.blocks[14].id, "default-format");
assert.equal(upgradedLegacyDefault.blocks[15].id, "default-rules");
assert.equal(upgradedLegacyDefault.blocks[14].content, "保留我自己的输出格式");
assert.equal(upgradedLegacyDefault.blocks[15].content, "保留我自己的行为规则");
assert.equal(upgradedLegacyDefault.blocks[14].role, "user");
assert.equal(upgradedLegacyDefault.blocks[15].role, "user");
const currentDefaults = createDefaultTaskProfiles();
const currentDefaultExtract = currentDefaults.extract.profiles[0];
@@ -389,15 +660,33 @@ assert.equal(
assert.deepEqual(
upgradedLegacyDefault.blocks
.slice(6, 10)
.slice(7, 13)
.map((block) => block.sourceKey),
["recentMessages", "graphStats", "schema", "currentRange"],
[
"graphStats",
"schema",
"activeSummaries",
"storyTimeContext",
"currentRange",
"recentMessages",
],
);
assert.ok(
upgradedLegacyDefault.blocks
.slice(0, 12)
.slice(0, 2)
.every((block) => block.role === "system"),
"heading / role 头部块应保持 system 角色",
);
assert.equal(upgradedLegacyDefault.blocks[2].id, "default-identity-ack");
assert.equal(upgradedLegacyDefault.blocks[2].role, "assistant");
assert.ok(
upgradedLegacyDefault.blocks
.slice(3, 13)
.every((block) => block.role === "system"),
"参考材料与本轮输入块应为 system 角色",
);
assert.equal(upgradedLegacyDefault.blocks[13].id, "default-info-ack");
assert.equal(upgradedLegacyDefault.blocks[13].role, "assistant");
const legacyRegexSettings = {
taskProfilesVersion: 3,

View File

@@ -53,7 +53,7 @@ const activeProfile = getActiveTaskProfile(
"extract",
);
assert.equal(activeProfile.name, "激进提取");
assert.equal(activeProfile.blocks.length, 16);
assert.equal(activeProfile.blocks.length, 18);
const builtinBlock = activeProfile.blocks.find(
(block) => block.type === "builtin" && block.sourceKey === "userMessage",
);