mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-10 00:20:44 +08:00
7385 lines
284 KiB
JavaScript
7385 lines
284 KiB
JavaScript
import assert from "node:assert/strict";
|
||
|
||
import {
|
||
buildBmeDbName,
|
||
buildGraphFromSnapshot,
|
||
buildPersistDelta,
|
||
buildPersistDeltaFromGraphDirtyState,
|
||
buildSnapshotFromGraph,
|
||
evaluateNativeHydrateGate,
|
||
evaluatePersistNativeDeltaGate,
|
||
} from "../sync/bme-db.js";
|
||
import { onMessageReceivedController } from "../host/event-binding.js";
|
||
import {
|
||
getBmeHostAdapter,
|
||
isBmeLightweightHostMode,
|
||
normalizeBmeChatStateTarget,
|
||
resolveBmeHostProfile,
|
||
resolveChatStateTargetChatId,
|
||
resolveCurrentBmeChatStateTarget,
|
||
serializeBmeChatStateTarget,
|
||
} from "../host/runtime-host-adapter.js";
|
||
import {
|
||
buildGraphCommitMarker,
|
||
buildGraphChatStateSnapshot,
|
||
buildLukerGraphCheckpointV2,
|
||
buildLukerGraphJournalEntry,
|
||
buildLukerGraphJournalV2,
|
||
buildLukerGraphManifestV2,
|
||
appendLukerGraphJournalEntryV2,
|
||
canUseGraphChatState,
|
||
detectIndexedDbSnapshotCommitMarkerMismatch,
|
||
deleteGraphChatStateNamespace,
|
||
cloneGraphForPersistence,
|
||
cloneRuntimeDebugValue,
|
||
findGraphShadowSnapshotByIntegrity,
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
getAcceptedCommitMarkerRevision,
|
||
getGraphPersistedRevision,
|
||
getGraphIdentityAliasCandidates,
|
||
getGraphPersistenceMeta,
|
||
GRAPH_COMMIT_MARKER_KEY,
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_BYTES,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_DEPTH,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_REVISION_GAP,
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
getGraphShadowSnapshotStorageKey,
|
||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
GRAPH_PERSISTENCE_SESSION_ID,
|
||
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
|
||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||
MODULE_NAME,
|
||
normalizeGraphCommitMarker,
|
||
readGraphChatStateNamespaces,
|
||
readGraphCommitMarker,
|
||
readGraphChatStateSnapshot,
|
||
readLukerGraphSidecarV2,
|
||
readGraphShadowSnapshot,
|
||
replaceLukerGraphJournalV2,
|
||
rememberGraphIdentityAlias,
|
||
removeGraphShadowSnapshot,
|
||
resolveGraphIdentityAliasByHostChatId,
|
||
shouldPreferShadowSnapshotOverOfficial,
|
||
stampGraphPersistenceMeta,
|
||
writeChatMetadataPatch,
|
||
writeGraphChatStatePayload,
|
||
writeGraphChatStateSnapshot,
|
||
writeLukerGraphCheckpointV2,
|
||
writeLukerGraphManifestV2,
|
||
writeGraphShadowSnapshot,
|
||
} from "../graph/graph-persistence.js";
|
||
import {
|
||
createEmptyGraph,
|
||
deserializeGraph,
|
||
getGraphStats,
|
||
getNode,
|
||
serializeGraph,
|
||
updateNode,
|
||
} from "../graph/graph.js";
|
||
import {
|
||
buildPersistedRecallRecord,
|
||
bumpPersistedRecallGenerationCount,
|
||
readPersistedRecallFromUserMessage,
|
||
resolveFinalRecallInjectionSource,
|
||
writePersistedRecallToUserMessage,
|
||
} from "../retrieval/recall-persistence.js";
|
||
import { getNodeDisplayName } from "../graph/node-labels.js";
|
||
import {
|
||
buildVectorCollectionId,
|
||
hasGraphPersistDirtyState,
|
||
normalizeGraphRuntimeState,
|
||
pruneGraphPersistDirtyState,
|
||
} from "../runtime/runtime-state.js";
|
||
import {
|
||
defaultSettings,
|
||
getPersistedSettingsSnapshot,
|
||
mergePersistedSettings,
|
||
} from "../runtime/settings-defaults.js";
|
||
import {
|
||
areChatIdsEquivalentForIdentityCore,
|
||
canMutateRuntimeGraphForIdentityCore,
|
||
doesChatIdMatchIdentityCore,
|
||
planRuntimeGraphIdentityRepairCore,
|
||
resolveActiveHostChatIdCore,
|
||
resolveCurrentChatIdentityCore,
|
||
resolveGraphOwnerIdentityCore,
|
||
resolvePersistenceChatIdCore,
|
||
resolveRuntimeGraphFallbackIdentityCore,
|
||
} from "../runtime/identity-resolver.js";
|
||
import {
|
||
createDefaultAuthorityCapabilityState,
|
||
normalizeAuthoritySettings,
|
||
normalizeAuthorityCapabilityState,
|
||
probeAuthorityCapabilities,
|
||
} from "../runtime/authority-capabilities.js";
|
||
import { normalizeAuthorityJobConfig } from "../maintenance/authority-job-adapter.js";
|
||
import { normalizeAuthorityBlobConfig } from "../maintenance/authority-blob-adapter.js";
|
||
import {
|
||
createAuthorityBrowserState,
|
||
getAuthorityBrowserStateSnapshot,
|
||
normalizeAuthorityBrowserState,
|
||
recordAuthorityAcceptedRevision,
|
||
} from "../sync/authority-browser-state.js";
|
||
import {
|
||
AUTHORITY_GRAPH_STORE_KIND,
|
||
AUTHORITY_GRAPH_STORE_MODE,
|
||
AuthorityGraphStore,
|
||
} from "../sync/authority-graph-store.js";
|
||
import {
|
||
isAcceptedLegacyPersistenceTier,
|
||
isRecoveryOnlyLegacyPersistenceTier,
|
||
planAcceptedPendingPersistenceRepair,
|
||
repairLegacyLastBatchPersistenceStatus,
|
||
} from "../sync/legacy-persistence-repair.js";
|
||
import {
|
||
PERSISTENCE_EVENT_TYPES,
|
||
applyPersistenceRecordToBatchStatus as reducePersistenceRecordToBatchStatus,
|
||
buildAcceptedPersistenceStatePatch,
|
||
buildBatchPersistenceRecordFromPersistResult as reduceBatchPersistenceRecordFromPersistResult,
|
||
buildQueuedPersistenceStatePatch,
|
||
planAcceptedPendingClear,
|
||
reducePersistenceStatePatch,
|
||
} from "../sync/persistence-reducer.js";
|
||
import {
|
||
clampFloat,
|
||
clampInt,
|
||
createGraphPersistenceState,
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
createUiStatus,
|
||
formatRecallContextLine,
|
||
getStageNoticeDuration,
|
||
getStageNoticeTitle,
|
||
hashRecallInput,
|
||
isFreshRecallInputRecord,
|
||
normalizeRecallInputText,
|
||
normalizeStageNoticeLevel,
|
||
shouldRunRecallForTransaction,
|
||
} from "../ui/ui-status.js";
|
||
|
||
function normalizeChatIdCandidate(value = "") {
|
||
return String(value ?? "").trim();
|
||
}
|
||
import { createRecallInputState } from "../runtime/recall-input-state.js";
|
||
import { createRerollRecallInput } from "../runtime/reroll-recall-input.js";
|
||
import { createGenerationRecallTransactions } from "../runtime/generation-recall-transactions.js";
|
||
import { createFinalRecallInjection } from "../runtime/final-recall-injection.js";
|
||
import { createAutoExtractionDefer } from "../runtime/auto-extraction-defer.js";
|
||
import { runPlannerRecallForEnaController } from "../runtime/planner-recall-controller.js";
|
||
import {
|
||
loadGraphFromIndexedDbImpl,
|
||
maybeFlushQueuedGraphPersistImpl,
|
||
queueGraphPersistToIndexedDbImpl,
|
||
retryPendingGraphPersistImpl,
|
||
saveGraphToIndexedDbImpl,
|
||
} from "../sync/graph-persistence-io.js";
|
||
import {
|
||
assertRecoveryChatStillActiveImpl,
|
||
applyGraphLoadStateImpl,
|
||
buildPanelOpenLocalStoreRefreshPlanImpl,
|
||
ensureGraphMutationReadyImpl,
|
||
getGraphMutationBlockReasonImpl,
|
||
getGraphPersistenceLiveStateImpl,
|
||
getPanelRuntimeStatusImpl,
|
||
readRuntimeDebugSnapshotImpl,
|
||
} from "../sync/graph-mutation-gate.js";
|
||
import {
|
||
buildBmeSyncRuntimeOptionsImpl,
|
||
loadGraphFromChatImpl,
|
||
maybeCaptureGraphShadowSnapshotImpl,
|
||
onRebuildLocalCacheFromLukerSidecarImpl,
|
||
persistExtractionBatchResultImpl,
|
||
saveGraphToChatImpl,
|
||
shouldUseAuthorityGraphStoreImpl,
|
||
shouldUseAuthorityJobsImpl,
|
||
syncGraphLoadFromLiveContextImpl,
|
||
writeAuthorityCheckpointFromCurrentGraphImpl,
|
||
} from "../sync/graph-load-persist.js";
|
||
|
||
function isAuthorityVectorConfig(config = null) {
|
||
return config?.mode === "authority" || config?.source === "authority-trivium";
|
||
}
|
||
|
||
function normalizeAuthorityVectorConfig(settings = {}, overrides = {}) {
|
||
return {
|
||
...overrides,
|
||
mode: "authority",
|
||
source: "authority-trivium",
|
||
baseUrl: String(settings?.authorityBaseUrl || overrides?.baseUrl || "/api/plugins/authority"),
|
||
};
|
||
}
|
||
|
||
function createAuthorityBlobAdapter() {
|
||
return {
|
||
async writeJson(path = "", payload = null) {
|
||
globalThis.__authorityBlobWrites?.set(String(path || ""), structuredClone(payload));
|
||
return { path, payload, written: true };
|
||
},
|
||
};
|
||
}
|
||
|
||
function createSessionStorage(seed = null) {
|
||
const store = seed instanceof Map ? seed : new Map();
|
||
return {
|
||
__store: store,
|
||
get length() {
|
||
return store.size;
|
||
},
|
||
key(index) {
|
||
return Array.from(store.keys())[Number(index)] ?? null;
|
||
},
|
||
getItem(key) {
|
||
return store.has(key) ? store.get(key) : null;
|
||
},
|
||
setItem(key, value) {
|
||
store.set(String(key), String(value));
|
||
},
|
||
removeItem(key) {
|
||
store.delete(String(key));
|
||
},
|
||
};
|
||
}
|
||
|
||
function createLocalStorage(seed = null) {
|
||
const store = seed instanceof Map ? seed : new Map();
|
||
return {
|
||
__store: store,
|
||
getItem(key) {
|
||
return store.has(key) ? store.get(key) : null;
|
||
},
|
||
setItem(key, value) {
|
||
store.set(String(key), String(value));
|
||
},
|
||
removeItem(key) {
|
||
store.delete(String(key));
|
||
},
|
||
};
|
||
}
|
||
|
||
function createMeaningfulGraph(chatId = "chat-test", suffix = "base") {
|
||
const graph = createEmptyGraph();
|
||
graph.historyState.chatId = chatId;
|
||
graph.historyState.extractionCount = 3;
|
||
graph.historyState.lastProcessedAssistantFloor = 6;
|
||
graph.lastProcessedSeq = 6;
|
||
graph.lastRecallResult = [{ id: `recall-${suffix}` }];
|
||
graph.nodes.push({
|
||
id: `node-${suffix}`,
|
||
type: "event",
|
||
fields: {
|
||
title: `事件-${suffix}`,
|
||
summary: `摘要-${suffix}`,
|
||
},
|
||
seq: 6,
|
||
seqRange: [6, 6],
|
||
archived: false,
|
||
embedding: null,
|
||
importance: 5,
|
||
accessCount: 0,
|
||
lastAccessTime: Date.now(),
|
||
createdTime: Date.now(),
|
||
level: 0,
|
||
parentId: null,
|
||
childIds: [],
|
||
prevId: null,
|
||
nextId: null,
|
||
clusters: [],
|
||
});
|
||
return normalizeGraphRuntimeState(graph, chatId);
|
||
}
|
||
|
||
function stampPersistedGraph(
|
||
graph,
|
||
{
|
||
revision = 1,
|
||
integrity = "",
|
||
chatId = graph?.historyState?.chatId || "",
|
||
reason = "test",
|
||
} = {},
|
||
) {
|
||
graph.__stBmePersistence = {
|
||
revision,
|
||
integrity,
|
||
chatId,
|
||
reason,
|
||
updatedAt: new Date().toISOString(),
|
||
sessionId: "test-session",
|
||
};
|
||
return graph;
|
||
}
|
||
|
||
async function createGraphPersistenceHarness({
|
||
chatId = "chat-test",
|
||
chatMetadata = undefined,
|
||
sessionStore = null,
|
||
localStore = null,
|
||
globalChatId = "",
|
||
characterId = "",
|
||
groupId = null,
|
||
indexedDbSnapshot = null,
|
||
indexedDbSnapshots = null,
|
||
chat = [],
|
||
} = {}) {
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
const storage = createSessionStorage(sessionStore);
|
||
const sessionShadowSnapshots = sessionStore instanceof Map ? sessionStore : storage.__store;
|
||
const localStorage = createLocalStorage(localStore);
|
||
const indexedDbSnapshotMap =
|
||
indexedDbSnapshots instanceof Map
|
||
? new Map(indexedDbSnapshots)
|
||
: new Map(
|
||
Object.entries(
|
||
indexedDbSnapshots &&
|
||
typeof indexedDbSnapshots === "object" &&
|
||
!Array.isArray(indexedDbSnapshots)
|
||
? indexedDbSnapshots
|
||
: {},
|
||
),
|
||
);
|
||
|
||
if (indexedDbSnapshot) {
|
||
const primaryChatId = String(chatId || globalChatId || "");
|
||
if (primaryChatId) {
|
||
indexedDbSnapshotMap.set(primaryChatId, structuredClone(indexedDbSnapshot));
|
||
}
|
||
}
|
||
|
||
function buildEmptyIndexedDbSnapshot(targetChatId = "") {
|
||
return {
|
||
meta: { revision: 0, chatId: String(targetChatId || "") },
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||
};
|
||
}
|
||
|
||
function getIndexedDbSnapshotForChat(targetChatId = "") {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (normalizedChatId && indexedDbSnapshotMap.has(normalizedChatId)) {
|
||
return structuredClone(indexedDbSnapshotMap.get(normalizedChatId));
|
||
}
|
||
|
||
if (
|
||
normalizedChatId &&
|
||
indexedDbSnapshot &&
|
||
!indexedDbSnapshotMap.size &&
|
||
normalizedChatId === String(chatId || globalChatId || "")
|
||
) {
|
||
return structuredClone(indexedDbSnapshot);
|
||
}
|
||
|
||
return buildEmptyIndexedDbSnapshot(normalizedChatId);
|
||
}
|
||
|
||
function setIndexedDbSnapshotForChat(targetChatId = "", snapshot = null) {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (!normalizedChatId) return;
|
||
if (!snapshot) {
|
||
indexedDbSnapshotMap.delete(normalizedChatId);
|
||
return;
|
||
}
|
||
indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot));
|
||
}
|
||
|
||
const authoritySnapshotMap = new Map();
|
||
const authorityBlobWrites = new Map();
|
||
globalThis.__authorityBlobWrites = authorityBlobWrites;
|
||
|
||
function getAuthoritySnapshotForChat(targetChatId = "") {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (normalizedChatId && authoritySnapshotMap.has(normalizedChatId)) {
|
||
return structuredClone(authoritySnapshotMap.get(normalizedChatId));
|
||
}
|
||
return {
|
||
meta: {
|
||
revision: 0,
|
||
chatId: normalizedChatId,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||
};
|
||
}
|
||
|
||
function setAuthoritySnapshotForChat(targetChatId = "", snapshot = null) {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (!normalizedChatId) return;
|
||
if (!snapshot) {
|
||
authoritySnapshotMap.delete(normalizedChatId);
|
||
return;
|
||
}
|
||
authoritySnapshotMap.set(normalizedChatId, structuredClone(snapshot));
|
||
}
|
||
|
||
class HarnessAuthorityGraphStore {
|
||
constructor(dbChatId = "") {
|
||
this.chatId = String(dbChatId || "");
|
||
this.storeKind = AUTHORITY_GRAPH_STORE_KIND;
|
||
this.storeMode = AUTHORITY_GRAPH_STORE_MODE;
|
||
}
|
||
async open() {}
|
||
async close() {}
|
||
getStorageDiagnosticsSync() {
|
||
return {
|
||
formatVersion: 1,
|
||
migrationState: "idle",
|
||
resolvedStoreMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
storageKind: AUTHORITY_GRAPH_STORE_KIND,
|
||
browserCacheMode: "minimal",
|
||
};
|
||
}
|
||
async exportSnapshot() {
|
||
return getAuthoritySnapshotForChat(this.chatId);
|
||
}
|
||
async exportSnapshotProbe() {
|
||
return getAuthoritySnapshotForChat(this.chatId);
|
||
}
|
||
async importSnapshot(snapshot = {}, options = {}) {
|
||
const current = getAuthoritySnapshotForChat(this.chatId);
|
||
const currentRevision = Number(current?.meta?.revision || 0);
|
||
const incomingRevision = Number(snapshot?.meta?.revision || 0);
|
||
const requestedRevision = Number.isFinite(Number(options?.revision))
|
||
? Math.max(0, Math.floor(Number(options.revision)))
|
||
: options?.preserveRevision === true
|
||
? Math.max(0, Math.floor(incomingRevision))
|
||
: currentRevision + 1;
|
||
const nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||
const nowMs = Date.now();
|
||
const nodes = Array.isArray(snapshot?.nodes)
|
||
? snapshot.nodes.map((node) => structuredClone(node))
|
||
: [];
|
||
const edges = Array.isArray(snapshot?.edges)
|
||
? snapshot.edges.map((edge) => structuredClone(edge))
|
||
: [];
|
||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||
? snapshot.tombstones.map((record) => structuredClone(record))
|
||
: [];
|
||
const nextSnapshot = {
|
||
meta: {
|
||
...(snapshot?.meta && typeof snapshot.meta === "object"
|
||
? structuredClone(snapshot.meta)
|
||
: {}),
|
||
chatId: this.chatId,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
revision: nextRevision,
|
||
lastModified: nowMs,
|
||
lastMutationReason: "importSnapshot",
|
||
syncDirty: options?.markSyncDirty !== false,
|
||
syncDirtyReason: options?.markSyncDirty === false ? "" : "importSnapshot",
|
||
nodeCount: nodes.length,
|
||
edgeCount: edges.length,
|
||
tombstoneCount: tombstones.length,
|
||
},
|
||
nodes,
|
||
edges,
|
||
tombstones,
|
||
state:
|
||
snapshot?.state && typeof snapshot.state === "object"
|
||
? structuredClone(snapshot.state)
|
||
: { lastProcessedFloor: -1, extractionCount: 0 },
|
||
};
|
||
setAuthoritySnapshotForChat(this.chatId, nextSnapshot);
|
||
return {
|
||
mode: String(options?.mode || "replace"),
|
||
revision: nextRevision,
|
||
imported: {
|
||
nodes: nodes.length,
|
||
edges: edges.length,
|
||
tombstones: tombstones.length,
|
||
},
|
||
};
|
||
}
|
||
async importLegacyGraph(graph, options = {}) {
|
||
const revision = Math.max(1, Math.floor(Number(options?.revision) || 1));
|
||
const snapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: this.chatId,
|
||
revision,
|
||
meta: {
|
||
migrationCompletedAt: Number(options?.nowMs || Date.now()),
|
||
migrationSource: String(options?.source || "chat_metadata"),
|
||
},
|
||
});
|
||
const importResult = await this.importSnapshot(snapshot, {
|
||
mode: "replace",
|
||
preserveRevision: true,
|
||
revision,
|
||
markSyncDirty: options?.markSyncDirty,
|
||
});
|
||
return {
|
||
migrated: true,
|
||
revision: importResult.revision,
|
||
imported: importResult.imported,
|
||
};
|
||
}
|
||
async commitDelta(delta = {}, options = {}) {
|
||
return commitSnapshotDelta({
|
||
targetChatId: this.chatId,
|
||
delta,
|
||
options,
|
||
getSnapshot: getAuthoritySnapshotForChat,
|
||
setSnapshot: setAuthoritySnapshotForChat,
|
||
metaPatch: {
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
},
|
||
});
|
||
}
|
||
async isEmpty() {
|
||
const snapshot = getAuthoritySnapshotForChat(this.chatId);
|
||
const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0;
|
||
const edges = Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0;
|
||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||
? snapshot.tombstones.length
|
||
: 0;
|
||
return {
|
||
empty: nodes === 0 && edges === 0,
|
||
nodes,
|
||
edges,
|
||
tombstones,
|
||
};
|
||
}
|
||
async getRevision() {
|
||
return Number(getAuthoritySnapshotForChat(this.chatId)?.meta?.revision || 0);
|
||
}
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getAuthoritySnapshotForChat(this.chatId);
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
}
|
||
async patchMeta(record = {}) {
|
||
const snapshot = getAuthoritySnapshotForChat(this.chatId);
|
||
snapshot.meta = {
|
||
...(snapshot.meta || {}),
|
||
...(record && typeof record === "object" ? structuredClone(record) : {}),
|
||
};
|
||
setAuthoritySnapshotForChat(this.chatId, snapshot);
|
||
return record;
|
||
}
|
||
}
|
||
|
||
function commitSnapshotDelta({
|
||
targetChatId = "",
|
||
delta = {},
|
||
options = {},
|
||
getSnapshot,
|
||
setSnapshot,
|
||
metaPatch = {},
|
||
} = {}) {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
const currentSnapshot =
|
||
typeof getSnapshot === "function" ? getSnapshot(normalizedChatId) : null;
|
||
const now = Date.now();
|
||
|
||
const nodeMap = new Map(
|
||
(Array.isArray(currentSnapshot?.nodes) ? currentSnapshot.nodes : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
const edgeMap = new Map(
|
||
(Array.isArray(currentSnapshot?.edges) ? currentSnapshot.edges : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
const tombstoneMap = new Map(
|
||
(Array.isArray(currentSnapshot?.tombstones) ? currentSnapshot.tombstones : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
|
||
for (const edgeId of Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds : []) {
|
||
edgeMap.delete(String(edgeId));
|
||
}
|
||
for (const nodeId of Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds : []) {
|
||
nodeMap.delete(String(nodeId));
|
||
}
|
||
for (const record of Array.isArray(delta?.upsertNodes) ? delta.upsertNodes : []) {
|
||
if (!record?.id) continue;
|
||
nodeMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
for (const record of Array.isArray(delta?.upsertEdges) ? delta.upsertEdges : []) {
|
||
if (!record?.id) continue;
|
||
edgeMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
for (const record of Array.isArray(delta?.tombstones) ? delta.tombstones : []) {
|
||
if (!record?.id) continue;
|
||
tombstoneMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
|
||
const runtimeMetaPatch =
|
||
delta?.runtimeMetaPatch &&
|
||
typeof delta.runtimeMetaPatch === "object" &&
|
||
!Array.isArray(delta.runtimeMetaPatch)
|
||
? structuredClone(delta.runtimeMetaPatch)
|
||
: {};
|
||
const shouldMarkSyncDirty = options?.markSyncDirty !== false;
|
||
const nextRevision = Math.max(
|
||
Number(currentSnapshot?.meta?.revision || 0) + 1,
|
||
Number(options?.requestedRevision || 0),
|
||
);
|
||
const nextState = {
|
||
lastProcessedFloor: Number.isFinite(Number(runtimeMetaPatch.lastProcessedFloor))
|
||
? Number(runtimeMetaPatch.lastProcessedFloor)
|
||
: Number(currentSnapshot?.state?.lastProcessedFloor ?? -1),
|
||
extractionCount: Number.isFinite(Number(runtimeMetaPatch.extractionCount))
|
||
? Number(runtimeMetaPatch.extractionCount)
|
||
: Number(currentSnapshot?.state?.extractionCount ?? 0),
|
||
};
|
||
const nextSnapshot = {
|
||
meta: {
|
||
...(currentSnapshot?.meta || {}),
|
||
...runtimeMetaPatch,
|
||
...(metaPatch && typeof metaPatch === "object" ? structuredClone(metaPatch) : {}),
|
||
chatId: normalizedChatId,
|
||
revision: nextRevision,
|
||
lastModified: now,
|
||
lastMutationReason: String(options?.reason || "commitDelta"),
|
||
syncDirty: shouldMarkSyncDirty,
|
||
syncDirtyReason: shouldMarkSyncDirty
|
||
? String(options?.reason || "commitDelta")
|
||
: "",
|
||
nodeCount: nodeMap.size,
|
||
edgeCount: edgeMap.size,
|
||
tombstoneCount: tombstoneMap.size,
|
||
},
|
||
nodes: Array.from(nodeMap.values()),
|
||
edges: Array.from(edgeMap.values()),
|
||
tombstones: Array.from(tombstoneMap.values()),
|
||
state: nextState,
|
||
};
|
||
|
||
if (typeof setSnapshot === "function") {
|
||
setSnapshot(normalizedChatId, nextSnapshot);
|
||
}
|
||
|
||
return {
|
||
revision: nextRevision,
|
||
lastModified: now,
|
||
imported: {
|
||
nodes: nodeMap.size,
|
||
edges: edgeMap.size,
|
||
tombstones: tombstoneMap.size,
|
||
},
|
||
delta: {
|
||
upsertNodes: Array.isArray(delta?.upsertNodes) ? delta.upsertNodes.length : 0,
|
||
upsertEdges: Array.isArray(delta?.upsertEdges) ? delta.upsertEdges.length : 0,
|
||
deleteNodeIds: Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds.length : 0,
|
||
deleteEdgeIds: Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds.length : 0,
|
||
tombstones: Array.isArray(delta?.tombstones) ? delta.tombstones.length : 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
function commitIndexedDbDelta(targetChatId = "", delta = {}, options = {}) {
|
||
const result = commitSnapshotDelta({
|
||
targetChatId,
|
||
delta,
|
||
options,
|
||
getSnapshot: getIndexedDbSnapshotForChat,
|
||
setSnapshot: setIndexedDbSnapshotForChat,
|
||
});
|
||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||
String(targetChatId || ""),
|
||
);
|
||
return result;
|
||
}
|
||
|
||
function buildPersistenceEnvironment(
|
||
context = null,
|
||
presentation = { storagePrimary: "indexeddb", storageMode: "indexeddb" },
|
||
) {
|
||
const hostProfile = resolveBmeHostProfile(context);
|
||
const primaryStorageTier = resolveBmeHostProfile(context) === "luker" ? "luker-chat-state" : String(presentation?.storagePrimary || "indexeddb");
|
||
const cacheStorageTier = primaryStorageTier === "authority" || primaryStorageTier === "luker-chat-state" ? "indexeddb" : "none";
|
||
return {
|
||
hostProfile,
|
||
primaryStorageTier,
|
||
cacheStorageTier,
|
||
};
|
||
}
|
||
|
||
let currentGraph = null;
|
||
let extractionCount = 0;
|
||
let lastExtractedItems = [];
|
||
let lastRecalledItems = [];
|
||
let lastInjectionContent = "";
|
||
let runtimeStatus = createUiStatus("待命", "准备就绪", "idle");
|
||
let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle");
|
||
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
|
||
let lastRecallStatus = createUiStatus("待命", "尚未执行召回", "idle");
|
||
let graphPersistenceState = createGraphPersistenceState();
|
||
let authorityCapabilityState = createDefaultAuthorityCapabilityState();
|
||
let authorityBrowserState = createAuthorityBrowserState();
|
||
let bmeLocalStoreCapabilitySnapshot = {
|
||
checked: false,
|
||
checkedAt: 0,
|
||
opfsAvailable: false,
|
||
reason: "unprobed",
|
||
};
|
||
let bmeChatManager = null;
|
||
let bmeChatManagerUnavailableWarned = false;
|
||
let nativeHydrateInstallPromise = null;
|
||
let nativePersistDeltaInstallPromise = null;
|
||
let pendingGraphLoadRetryTimer = null;
|
||
let pendingGraphLoadRetryChatId = "";
|
||
let pendingGraphPersistRetryTimer = null;
|
||
let pendingGraphPersistRetryChatId = "";
|
||
let pendingGraphPersistRetryAttempt = 0;
|
||
let pendingRecallSendIntent = createRecallInputRecord();
|
||
const bmeIndexedDbSnapshotCacheByChatId = new Map();
|
||
const bmeIndexedDbLatestQueuedRevisionByChatId = new Map();
|
||
const bmeIndexedDbWriteInFlightByChatId = new Map();
|
||
const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500];
|
||
const PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS = [500, 1500, 5000];
|
||
const PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS = 5;
|
||
const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([
|
||
GRAPH_LOAD_STATES.LOADING,
|
||
GRAPH_LOAD_STATES.BLOCKED,
|
||
GRAPH_LOAD_STATES.NO_CHAT,
|
||
GRAPH_LOAD_STATES.SHADOW_RESTORED,
|
||
]);
|
||
|
||
const runtimeContext = {
|
||
console,
|
||
Date,
|
||
Math,
|
||
JSON,
|
||
Object,
|
||
Array,
|
||
String,
|
||
Number,
|
||
Boolean,
|
||
structuredClone,
|
||
result: null,
|
||
__indexedDbSnapshot: getIndexedDbSnapshotForChat(
|
||
String(chatId || globalChatId || ""),
|
||
),
|
||
__indexedDbSnapshots: indexedDbSnapshotMap,
|
||
__authoritySnapshots: authoritySnapshotMap,
|
||
__authorityBlobWrites: authorityBlobWrites,
|
||
__getAuthoritySnapshotForChat: getAuthoritySnapshotForChat,
|
||
__setAuthoritySnapshotForChat: setAuthoritySnapshotForChat,
|
||
sessionStorage: storage,
|
||
localStorage,
|
||
get currentGraph() { return currentGraph; },
|
||
set currentGraph(value) { currentGraph = value; },
|
||
get graphPersistenceState() { return graphPersistenceState; },
|
||
set graphPersistenceState(value) { graphPersistenceState = value; },
|
||
get bmeLocalStoreCapabilitySnapshot() { return bmeLocalStoreCapabilitySnapshot; },
|
||
set bmeLocalStoreCapabilitySnapshot(value) { bmeLocalStoreCapabilitySnapshot = value; },
|
||
get authorityCapabilityState() { return authorityCapabilityState; },
|
||
set authorityCapabilityState(value) { authorityCapabilityState = value; },
|
||
get authorityBrowserState() { return authorityBrowserState; },
|
||
set authorityBrowserState(value) { authorityBrowserState = value; },
|
||
extension_settings: {
|
||
[MODULE_NAME]: {},
|
||
},
|
||
defaultSettings,
|
||
getPersistedSettingsSnapshot,
|
||
mergePersistedSettings,
|
||
createDefaultAuthorityCapabilityState,
|
||
normalizeAuthoritySettings,
|
||
normalizeAuthorityCapabilityState,
|
||
normalizeAuthorityJobConfig,
|
||
probeAuthorityCapabilities,
|
||
isAuthorityVectorConfig,
|
||
normalizeAuthorityVectorConfig,
|
||
normalizeAuthorityBlobConfig,
|
||
createAuthorityBlobAdapter,
|
||
createAuthorityBrowserState,
|
||
getAuthorityBrowserStateSnapshot,
|
||
normalizeAuthorityBrowserState,
|
||
recordAuthorityAcceptedRevision,
|
||
AUTHORITY_GRAPH_STORE_KIND,
|
||
AUTHORITY_GRAPH_STORE_MODE,
|
||
AUTHORITY_DIAGNOSTICS_MANIFEST_LIMIT: 20,
|
||
AuthorityGraphStore: HarnessAuthorityGraphStore,
|
||
isAcceptedLegacyPersistenceTier,
|
||
isRecoveryOnlyLegacyPersistenceTier,
|
||
planAcceptedPendingPersistenceRepair,
|
||
repairLegacyLastBatchPersistenceStatus,
|
||
PERSISTENCE_EVENT_TYPES,
|
||
reducePersistenceRecordToBatchStatus,
|
||
buildAcceptedPersistenceStatePatch,
|
||
reduceBatchPersistenceRecordFromPersistResult,
|
||
buildQueuedPersistenceStatePatch,
|
||
planAcceptedPendingClear,
|
||
reducePersistenceStatePatch,
|
||
migrateLegacyTaskProfiles(settings = {}) {
|
||
return {
|
||
taskProfilesVersion: Number(settings?.taskProfilesVersion || 0),
|
||
taskProfiles:
|
||
settings?.taskProfiles && typeof settings.taskProfiles === "object"
|
||
? settings.taskProfiles
|
||
: {},
|
||
};
|
||
},
|
||
migratePerTaskRegexToGlobal(settings = {}) {
|
||
return {
|
||
changed: false,
|
||
settings,
|
||
};
|
||
},
|
||
setTimeout(fn, delay) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { fn, delay });
|
||
return id;
|
||
},
|
||
clearTimeout(id) {
|
||
timers.delete(id);
|
||
},
|
||
queueMicrotask(fn) {
|
||
fn();
|
||
},
|
||
toastr: {
|
||
info() {},
|
||
warning() {},
|
||
error() {},
|
||
success() {},
|
||
},
|
||
window: {
|
||
addEventListener() {},
|
||
removeEventListener() {},
|
||
},
|
||
document: {
|
||
visibilityState: "visible",
|
||
getElementById() {
|
||
return null;
|
||
},
|
||
},
|
||
createRecallInputState,
|
||
createRerollRecallInput,
|
||
createGenerationRecallTransactions,
|
||
createFinalRecallInjection,
|
||
createAutoExtractionDefer,
|
||
runPlannerRecallForEnaController,
|
||
loadGraphFromIndexedDbImpl,
|
||
maybeFlushQueuedGraphPersistImpl,
|
||
queueGraphPersistToIndexedDbImpl,
|
||
retryPendingGraphPersistImpl,
|
||
saveGraphToIndexedDbImpl,
|
||
assertRecoveryChatStillActiveImpl,
|
||
buildPanelOpenLocalStoreRefreshPlanImpl,
|
||
ensureGraphMutationReadyImpl,
|
||
getGraphMutationBlockReasonImpl,
|
||
getGraphPersistenceLiveStateImpl,
|
||
getPanelRuntimeStatusImpl,
|
||
readRuntimeDebugSnapshotImpl,
|
||
buildBmeSyncRuntimeOptionsImpl,
|
||
loadGraphFromChatImpl,
|
||
maybeCaptureGraphShadowSnapshotImpl,
|
||
onRebuildLocalCacheFromLukerSidecarImpl,
|
||
persistExtractionBatchResultImpl,
|
||
saveGraphToChatImpl,
|
||
shouldUseAuthorityGraphStoreImpl,
|
||
shouldUseAuthorityJobsImpl,
|
||
syncGraphLoadFromLiveContextImpl,
|
||
writeAuthorityCheckpointFromCurrentGraphImpl,
|
||
createRecallMessageUiController() {
|
||
return {
|
||
refreshPersistedRecallMessageUi: () => ({
|
||
status: "missing_recall_record",
|
||
renderedCount: 0,
|
||
persistedRecordCount: 0,
|
||
waitingMessageIndices: [],
|
||
anchorFailureIndices: [],
|
||
skippedNonUserIndices: [],
|
||
}),
|
||
schedulePersistedRecallMessageUiRefresh() {},
|
||
cleanupPersistedRecallMessageUi() {},
|
||
resolveMessageIndexFromElement: () => null,
|
||
resolveRecallCardAnchor: () => null,
|
||
};
|
||
},
|
||
openRecallSidebar() {},
|
||
removePersistedRecallFromUserMessage: () => false,
|
||
writePersistedRecallToUserMessage: () => false,
|
||
buildPersistedRecallRecord: (record = {}) => ({ ...record }),
|
||
markPersistedRecallManualEdit: () => null,
|
||
createRecallCardElement: () => null,
|
||
updateRecallCardData() {},
|
||
estimateTokens: (text = "") =>
|
||
String(text || "").trim().split(/\s+/).filter(Boolean).length || 1,
|
||
SillyTavern: {
|
||
getCurrentChatId() {
|
||
return runtimeContext.__globalChatId;
|
||
},
|
||
},
|
||
__globalChatId: String(globalChatId || ""),
|
||
Dexie: {
|
||
async exists(dbName = "") {
|
||
return Array.from(indexedDbSnapshotMap.keys()).some(
|
||
(candidateChatId) => buildBmeDbName(candidateChatId) === String(dbName),
|
||
);
|
||
},
|
||
async getDatabaseNames() {
|
||
return Array.from(indexedDbSnapshotMap.keys()).map((candidateChatId) =>
|
||
buildBmeDbName(candidateChatId),
|
||
);
|
||
},
|
||
},
|
||
async ensureDexieLoaded() {
|
||
return runtimeContext.Dexie;
|
||
},
|
||
refreshPanelLiveState() {
|
||
runtimeContext.__panelRefreshCount += 1;
|
||
},
|
||
schedulePersistedRecallMessageUiRefresh() {},
|
||
restoreRecallUiStateFromPersistence(chat = runtimeContext.getContext()?.chat) {
|
||
let latestPersisted = null;
|
||
if (Array.isArray(chat)) {
|
||
for (let index = chat.length - 1; index >= 0; index--) {
|
||
if (!chat[index]?.is_user) continue;
|
||
const record = readPersistedRecallFromUserMessage(chat, index);
|
||
if (record?.injectionText) {
|
||
latestPersisted = { messageIndex: index, record };
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const normalizeRecallNodeIdList = (nodeIds = []) => Array.isArray(nodeIds)
|
||
? nodeIds.map((entry) => {
|
||
if (typeof entry === "string" || typeof entry === "number") return String(entry).trim();
|
||
if (entry && typeof entry === "object") return String(entry.id || entry.nodeId || "").trim();
|
||
return "";
|
||
}).filter(Boolean)
|
||
: [];
|
||
const graphRecallNodeIds = normalizeRecallNodeIdList(currentGraph?.lastRecallResult);
|
||
const persistedNodeIds = normalizeRecallNodeIdList(latestPersisted?.record?.selectedNodeIds);
|
||
const effectiveNodeIds = graphRecallNodeIds.length ? graphRecallNodeIds : persistedNodeIds;
|
||
lastRecalledItems = effectiveNodeIds.map((id) => ({ id }));
|
||
lastInjectionContent = String(latestPersisted?.record?.injectionText || "").trim();
|
||
return {
|
||
restored: Boolean(lastInjectionContent || effectiveNodeIds.length),
|
||
latestPersistedMessageIndex: Number.isFinite(latestPersisted?.messageIndex) ? latestPersisted.messageIndex : null,
|
||
selectedNodeIds: effectiveNodeIds,
|
||
injectionTextLength: lastInjectionContent.length,
|
||
};
|
||
},
|
||
scheduleGraphChatStateProbe() {
|
||
return false;
|
||
},
|
||
scheduleIndexedDbGraphProbe() {
|
||
return false;
|
||
},
|
||
canUseHostGraphChatStatePersistence(context = runtimeContext.getContext()) {
|
||
return runtimeContext.resolveBmeHostProfile(context) === "luker" || canUseGraphChatState(context);
|
||
},
|
||
async refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
|
||
const action = String(syncPayload?.action || "").trim().toLowerCase();
|
||
if (action !== "download" && action !== "merge" && action !== "restore-backup") return { refreshed: false, reason: "action-not-supported", action };
|
||
const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId);
|
||
const activeIdentity = runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext());
|
||
const activeChatId = normalizeChatIdCandidate(activeIdentity.chatId);
|
||
const targetChatId = activeChatId && syncedChatId && runtimeContext.doesChatIdMatchResolvedGraphIdentity(syncedChatId, activeIdentity) ? activeChatId : syncedChatId || activeChatId;
|
||
if (!targetChatId) return { refreshed: false, reason: "missing-chat-id", action };
|
||
if (activeChatId && targetChatId !== activeChatId) return { refreshed: false, reason: "chat-switched", action, chatId: targetChatId, activeChatId };
|
||
const loadResult = await runtimeContext.loadGraphFromIndexedDb(targetChatId, { source: `sync-post-refresh:${action}`, allowOverride: true, applyEmptyState: true });
|
||
return { refreshed: Boolean(loadResult?.loaded || loadResult?.emptyConfirmed), action, chatId: targetChatId, ...loadResult };
|
||
},
|
||
getGraphPersistenceState() { return graphPersistenceState; },
|
||
getBmeLocalStoreCapabilitySnapshot() { return bmeLocalStoreCapabilitySnapshot; },
|
||
getCurrentGraph() { return currentGraph; },
|
||
setCurrentGraph(graph) { currentGraph = graph; return currentGraph; },
|
||
getExtractionCount() { return extractionCount; },
|
||
setExtractionCount(value) { extractionCount = Number(value) || 0; return extractionCount; },
|
||
getLastExtractedItems() { return lastExtractedItems; },
|
||
setLastExtractedItems(items) { lastExtractedItems = Array.isArray(items) ? items : []; return lastExtractedItems; },
|
||
getLastRecalledItems() { return lastRecalledItems; },
|
||
setLastRecalledItems(items) { lastRecalledItems = Array.isArray(items) ? items : []; return lastRecalledItems; },
|
||
getLastInjectionContent() { return lastInjectionContent; },
|
||
setLastInjectionContent(content) { lastInjectionContent = String(content || ""); return lastInjectionContent; },
|
||
getRuntimeStatus() { return runtimeStatus; },
|
||
setRuntimeStatus(status) { runtimeStatus = status; return runtimeStatus; },
|
||
getLastExtractionStatus() { return lastExtractionStatus; },
|
||
setLastExtractionStatus(status) { lastExtractionStatus = status; return lastExtractionStatus; },
|
||
getLastVectorStatus() { return lastVectorStatus; },
|
||
setLastVectorStatus(status) { lastVectorStatus = status; return lastVectorStatus; },
|
||
getLastRecallStatus() { return lastRecallStatus; },
|
||
setLastRecallStatus(status) { lastRecallStatus = status; return lastRecallStatus; },
|
||
__panelRefreshCount: 0,
|
||
getLastProcessedAssistantFloor() {
|
||
const historyFloor = Number(
|
||
runtimeContext.currentGraph?.historyState?.lastProcessedAssistantFloor,
|
||
);
|
||
if (Number.isFinite(historyFloor)) {
|
||
return historyFloor;
|
||
}
|
||
const legacySeq = Number(runtimeContext.currentGraph?.lastProcessedSeq);
|
||
if (Number.isFinite(legacySeq)) return legacySeq;
|
||
return -1;
|
||
},
|
||
createEmptyGraph,
|
||
normalizeGraphRuntimeState,
|
||
serializeGraph,
|
||
deserializeGraph,
|
||
getGraphStats,
|
||
getNode,
|
||
getNodeDisplayName,
|
||
createUiStatus,
|
||
createGraphPersistenceState,
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
getPendingRecallSendIntent() { return null; },
|
||
setPendingRecallSendIntent() {},
|
||
consumeCurrentGenerationTrivialSkip() { return false; },
|
||
resolveAutoExtractionPlan() { return { canRun: false, reason: "test-disabled", strategy: "normal" }; },
|
||
deferAutoExtraction() { return null; },
|
||
maybeResumePendingAutoExtraction() { return null; },
|
||
async persistGraphToHostChatState(context = runtimeContext.__chatContext, options = {}) {
|
||
const graph = options?.graph || currentGraph;
|
||
const revision = Math.max(1, Math.floor(Number(options?.revision || 0)));
|
||
const storageTier = String(options?.storageTier || "chat-state");
|
||
const target = options?.chatStateTarget || null;
|
||
if (storageTier === "chat-state") {
|
||
const result = await writeGraphChatStateSnapshot(context, graph, {
|
||
revision,
|
||
storageTier,
|
||
accepted: options?.accepted !== false,
|
||
reason: options?.reason || "",
|
||
chatId: options?.chatId || graph?.historyState?.chatId || runtimeContext.getCurrentChatId(),
|
||
integrity: runtimeContext.getChatMetadataIntegrity(context),
|
||
lastProcessedAssistantFloor: options?.lastProcessedAssistantFloor,
|
||
extractionCount: options?.extractionCount,
|
||
target,
|
||
});
|
||
runtimeContext.updateGraphPersistenceState({ dualWriteLastResult: { target: "chat-state" } });
|
||
return { saved: result?.ok === true, accepted: result?.ok === true, revision, reason: result?.reason || "chat-state-saved" };
|
||
}
|
||
const chatIdValue = options?.chatId || graph?.historyState?.chatId || runtimeContext.getCurrentChatId();
|
||
const integrity = runtimeContext.getChatMetadataIntegrity(context);
|
||
const checkpoint = buildLukerGraphCheckpointV2(graph, { revision, chatId: chatIdValue, integrity, reason: options?.reason || "", storageTier });
|
||
await context.updateChatState(LUKER_GRAPH_CHECKPOINT_NAMESPACE, () => checkpoint, target ? { target } : undefined);
|
||
const existingManifest = await context.getChatState(LUKER_GRAPH_MANIFEST_NAMESPACE, target ? { target } : undefined);
|
||
let nextRevision = revision;
|
||
if (options?.persistDelta && existingManifest?.headRevision) nextRevision = Number(existingManifest.headRevision || 0) + 1;
|
||
else if (options?.persistDelta) nextRevision = 2;
|
||
if (options?.reason === "luker-bootstrap-journal-fail") {
|
||
const journalResult = await context.updateChatState(LUKER_GRAPH_JOURNAL_NAMESPACE, () => ({ entries: [] }), target ? { target } : undefined);
|
||
if (journalResult?.ok !== true) return { saved: false, accepted: false, revision };
|
||
} else if (options?.persistDelta) {
|
||
const entry = buildLukerGraphJournalEntry(options?.persistDelta || null, { revision: nextRevision, reason: options?.reason || "", storageTier, chatId: chatIdValue, integrity });
|
||
await appendLukerGraphJournalEntryV2(context, entry, { chatId: chatIdValue, integrity, chatStateTarget: target });
|
||
}
|
||
if (!options?.persistDelta) await context.updateChatState(LUKER_GRAPH_JOURNAL_NAMESPACE, () => buildLukerGraphJournalV2([], { chatId: chatIdValue, integrity, headRevision: nextRevision }), target ? { target } : undefined);
|
||
const manifest = buildLukerGraphManifestV2(graph, { headRevision: nextRevision, checkpointRevision: revision, journalDepth: options?.persistDelta ? 1 : 0, chatId: chatIdValue, integrity, reason: options?.reason || "", storageTier, accepted: options?.accepted !== false, lastProcessedAssistantFloor: options?.lastProcessedAssistantFloor, extractionCount: options?.extractionCount });
|
||
await writeLukerGraphManifestV2(context, manifest, { chatStateTarget: target });
|
||
return { saved: true, accepted: options?.accepted !== false, revision: nextRevision };
|
||
},
|
||
getIsHostGenerationRunning() { return false; },
|
||
refreshPersistedRecallMessageUi() {},
|
||
normalizeStageNoticeLevel,
|
||
getStageNoticeTitle,
|
||
getStageNoticeDuration,
|
||
normalizeRecallInputText,
|
||
hashRecallInput,
|
||
isFreshRecallInputRecord,
|
||
clampInt,
|
||
clampFloat,
|
||
formatRecallContextLine,
|
||
getBmeHostAdapter(context = null) {
|
||
const activeContext = context || runtimeContext.__chatContext || {};
|
||
return {
|
||
context: activeContext,
|
||
hostProfile: runtimeContext.resolveBmeHostProfile(activeContext),
|
||
resolveCurrentTarget(options = {}) {
|
||
return runtimeContext.resolveCurrentBmeChatStateTarget(
|
||
activeContext,
|
||
options?.target,
|
||
);
|
||
},
|
||
getChatIdFromTarget(target = null) {
|
||
return runtimeContext.resolveChatStateTargetChatId(target);
|
||
},
|
||
isLightweightHostMode() {
|
||
return runtimeContext.isBmeLightweightHostMode(activeContext);
|
||
},
|
||
};
|
||
},
|
||
isBmeLightweightHostMode(context = null) {
|
||
return runtimeContext.resolveBmeHostProfile(context) === "luker";
|
||
},
|
||
normalizeBmeChatStateTarget,
|
||
resolveBmeHostProfile(context = null) {
|
||
const activeContext = context || runtimeContext.__chatContext || {};
|
||
const hasImplicitCurrentChat =
|
||
String(activeContext?.chatId || "").trim() ||
|
||
String(activeContext?.groupId || "").trim() ||
|
||
String(activeContext?.characterId || "").trim();
|
||
return runtimeContext.Luker &&
|
||
typeof runtimeContext.Luker?.getContext === "function" &&
|
||
hasImplicitCurrentChat
|
||
? "luker"
|
||
: "generic-st";
|
||
},
|
||
resolveChatStateTargetChatId(target = null) {
|
||
return resolveChatStateTargetChatId(target);
|
||
},
|
||
resolveCurrentBmeChatStateTarget(context = null, explicitTarget = null) {
|
||
if (explicitTarget) {
|
||
return normalizeBmeChatStateTarget(explicitTarget);
|
||
}
|
||
const activeContext = context || runtimeContext.__chatContext || {};
|
||
if (String(activeContext?.groupId || "").trim()) {
|
||
return {
|
||
is_group: true,
|
||
id: String(activeContext.chatId || activeContext.groupId).trim(),
|
||
};
|
||
}
|
||
const avatar =
|
||
activeContext?.characterAvatar ||
|
||
activeContext?.avatar_url ||
|
||
activeContext?.characters?.[activeContext?.characterId]?.avatar ||
|
||
activeContext?.characters?.[Number(activeContext?.characterId)]?.avatar ||
|
||
"";
|
||
const fileName = String(activeContext?.chatId || "").trim();
|
||
if (avatar && fileName) {
|
||
return {
|
||
is_group: false,
|
||
avatar_url: String(avatar),
|
||
file_name: fileName,
|
||
};
|
||
}
|
||
return null;
|
||
},
|
||
serializeBmeChatStateTarget(target = null) {
|
||
return serializeBmeChatStateTarget(target);
|
||
},
|
||
readPersistedRecallFromUserMessage,
|
||
writePersistedRecallToUserMessage,
|
||
bumpPersistedRecallGenerationCount,
|
||
resolveFinalRecallInjectionSource,
|
||
formatInjection: (result = null) =>
|
||
String(result?.injectionText || result?.memoryBlock || ""),
|
||
getSchema: () => [],
|
||
shouldRunRecallForTransaction,
|
||
areChatIdsEquivalentForIdentityCore,
|
||
cloneGraphForPersistence,
|
||
canMutateRuntimeGraphForIdentityCore,
|
||
buildGraphCommitMarker,
|
||
buildGraphChatStateSnapshot,
|
||
buildLukerGraphCheckpointV2,
|
||
buildLukerGraphJournalEntry,
|
||
buildLukerGraphJournalV2,
|
||
buildLukerGraphManifestV2,
|
||
canUseGraphChatState,
|
||
cloneRuntimeDebugValue,
|
||
deleteGraphChatStateNamespace,
|
||
doesChatIdMatchIdentityCore,
|
||
detectIndexedDbSnapshotCommitMarkerMismatch,
|
||
onMessageReceivedController,
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
getAcceptedCommitMarkerRevision,
|
||
getGraphPersistenceMeta,
|
||
getGraphPersistedRevision,
|
||
getGraphIdentityAliasCandidates,
|
||
GRAPH_COMMIT_MARKER_KEY,
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_BYTES,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_DEPTH,
|
||
LUKER_GRAPH_JOURNAL_COMPACTION_REVISION_GAP,
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
getGraphShadowSnapshotStorageKey,
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
GRAPH_PERSISTENCE_SESSION_ID,
|
||
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
|
||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||
MODULE_NAME,
|
||
planRuntimeGraphIdentityRepairCore,
|
||
findGraphShadowSnapshotByIntegrity,
|
||
normalizeGraphCommitMarker,
|
||
readGraphChatStateNamespaces,
|
||
readGraphCommitMarker,
|
||
readGraphChatStateSnapshot,
|
||
readLukerGraphSidecarV2,
|
||
readGraphShadowSnapshot,
|
||
rememberGraphIdentityAlias,
|
||
removeGraphShadowSnapshot,
|
||
resolveActiveHostChatIdCore,
|
||
resolveCurrentChatIdentityCore,
|
||
resolveGraphOwnerIdentityCore,
|
||
resolvePersistenceChatIdCore,
|
||
resolveRuntimeGraphFallbackIdentityCore,
|
||
resolveGraphIdentityAliasByHostChatId,
|
||
shouldPreferShadowSnapshotOverOfficial,
|
||
stampGraphPersistenceMeta,
|
||
replaceLukerGraphJournalV2,
|
||
appendLukerGraphJournalEntryV2,
|
||
writeChatMetadataPatch,
|
||
writeGraphChatStatePayload,
|
||
writeGraphChatStateSnapshot,
|
||
writeLukerGraphManifestV2,
|
||
writeLukerGraphCheckpointV2,
|
||
writeGraphShadowSnapshot,
|
||
// Shadow snapshot functions need VM-local sessionStorage overrides
|
||
// because imported versions use the outer globalThis (no sessionStorage)
|
||
rememberGraphIdentityAlias({
|
||
integrity = "",
|
||
hostChatId = "",
|
||
persistenceChatId = "",
|
||
} = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
if (!normalizedIntegrity) return null;
|
||
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
const normalizedPersistenceChatId = String(
|
||
persistenceChatId || normalizedIntegrity,
|
||
).trim();
|
||
let registry = { byIntegrity: {} };
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (
|
||
parsed?.byIntegrity &&
|
||
typeof parsed.byIntegrity === "object" &&
|
||
!Array.isArray(parsed.byIntegrity)
|
||
) {
|
||
registry = { byIntegrity: parsed.byIntegrity };
|
||
}
|
||
}
|
||
} catch {
|
||
registry = { byIntegrity: {} };
|
||
}
|
||
|
||
const current = registry.byIntegrity[normalizedIntegrity] || {};
|
||
const hostChatIds = Array.from(
|
||
new Set(
|
||
[
|
||
normalizedHostChatId,
|
||
...(Array.isArray(current.hostChatIds) ? current.hostChatIds : []),
|
||
].filter(Boolean),
|
||
),
|
||
);
|
||
const next = {
|
||
integrity: normalizedIntegrity,
|
||
persistenceChatId: normalizedPersistenceChatId,
|
||
hostChatIds,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
registry.byIntegrity[normalizedIntegrity] = next;
|
||
localStorage.setItem(
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
JSON.stringify(registry),
|
||
);
|
||
return next;
|
||
},
|
||
resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
if (!normalizedHostChatId) return "";
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
|
||
let best = "";
|
||
let bestUpdatedAt = "";
|
||
for (const value of Object.values(parsed.byIntegrity || {})) {
|
||
const hostChatIds = Array.isArray(value?.hostChatIds)
|
||
? value.hostChatIds.map((item) => String(item || "").trim())
|
||
: [];
|
||
if (!hostChatIds.includes(normalizedHostChatId)) continue;
|
||
const persistenceChatId = String(
|
||
value?.persistenceChatId || value?.integrity || "",
|
||
).trim();
|
||
if (!persistenceChatId) continue;
|
||
const updatedAt = String(value?.updatedAt || "");
|
||
if (!best || updatedAt > bestUpdatedAt) {
|
||
best = persistenceChatId;
|
||
bestUpdatedAt = updatedAt;
|
||
}
|
||
}
|
||
return best;
|
||
} catch {
|
||
return "";
|
||
}
|
||
},
|
||
getGraphIdentityAliasCandidates({
|
||
integrity = "",
|
||
hostChatId = "",
|
||
persistenceChatId = "",
|
||
} = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
const normalizedPersistenceChatId = String(
|
||
persistenceChatId || "",
|
||
).trim();
|
||
const candidates = [];
|
||
const seen = new Set();
|
||
const addCandidate = (value) => {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
candidates.push(normalized);
|
||
};
|
||
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
|
||
if (normalizedIntegrity) {
|
||
const value = parsed.byIntegrity?.[normalizedIntegrity] || {};
|
||
addCandidate(value?.persistenceChatId || value?.integrity || "");
|
||
for (const candidate of Array.isArray(value?.hostChatIds)
|
||
? value.hostChatIds
|
||
: []) {
|
||
addCandidate(candidate);
|
||
}
|
||
} else if (normalizedHostChatId) {
|
||
addCandidate(
|
||
runtimeContext.resolveGraphIdentityAliasByHostChatId(
|
||
normalizedHostChatId,
|
||
),
|
||
);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
addCandidate(normalizedHostChatId);
|
||
addCandidate(normalizedPersistenceChatId);
|
||
return candidates;
|
||
},
|
||
readGraphShadowSnapshot(chatId = "") {
|
||
if (String(chatId || "") === "chat-official" && globalThis.__returnOfficialShadow === true) {
|
||
return { chatId: "chat-official", revision: 3, serializedGraph: serializeGraph(createMeaningfulGraph("chat-official", "shadow-stale")), updatedAt: new Date().toISOString(), reason: "stale-shadow", integrity: "", persistedChatId: "", debugReason: "stale-shadow" };
|
||
}
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key) return null;
|
||
try {
|
||
let raw = storage.getItem(key);
|
||
if (!raw && sessionShadowSnapshots?.has?.(`shadow-json:${String(chatId || "")}`)) raw = sessionShadowSnapshots.get(`shadow-json:${String(chatId || "")}`);
|
||
if (!raw) {
|
||
const rawGraph = sessionShadowSnapshots?.get?.(`shadow-raw:${String(chatId || "")}`);
|
||
return rawGraph
|
||
? { chatId: String(chatId || ""), revision: 0, serializedGraph: serializeGraph(rawGraph), updatedAt: new Date().toISOString(), reason: "stale-shadow", integrity: "", persistedChatId: "", debugReason: "stale-shadow" }
|
||
: null;
|
||
}
|
||
const snap = JSON.parse(raw);
|
||
if (!snap || String(snap.chatId || "") !== String(chatId || "") || typeof snap.serializedGraph !== "string" || !snap.serializedGraph) return null;
|
||
return {
|
||
chatId: String(snap.chatId || ""),
|
||
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
|
||
serializedGraph: snap.serializedGraph,
|
||
updatedAt: String(snap.updatedAt || ""),
|
||
reason: String(snap.reason || ""),
|
||
integrity: String(snap.integrity || ""),
|
||
persistedChatId: String(snap.persistedChatId || ""),
|
||
debugReason: String(snap.debugReason || snap.reason || ""),
|
||
};
|
||
} catch {
|
||
const rawGraph = sessionShadowSnapshots?.get?.(`shadow-raw:${String(chatId || "")}`);
|
||
return rawGraph
|
||
? { chatId: String(chatId || ""), revision: 0, serializedGraph: serializeGraph(rawGraph), updatedAt: new Date().toISOString(), reason: "stale-shadow", integrity: "", persistedChatId: "", debugReason: "stale-shadow" }
|
||
: null;
|
||
}
|
||
},
|
||
findGraphShadowSnapshotByIntegrity(integrity = "", { excludeChatIds = [] } = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
if (!normalizedIntegrity) return null;
|
||
const excluded = new Set(
|
||
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean),
|
||
);
|
||
let best = null;
|
||
for (const key of storage.__store.keys()) {
|
||
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
|
||
continue;
|
||
}
|
||
try {
|
||
const snap = JSON.parse(storage.getItem(key));
|
||
if (
|
||
!snap ||
|
||
String(snap.integrity || "") !== normalizedIntegrity ||
|
||
typeof snap.serializedGraph !== "string" ||
|
||
!snap.serializedGraph
|
||
) {
|
||
continue;
|
||
}
|
||
const normalizedChatId = String(snap.chatId || "").trim();
|
||
if (!normalizedChatId || excluded.has(normalizedChatId)) {
|
||
continue;
|
||
}
|
||
if (
|
||
!best ||
|
||
Number(snap.revision || 0) > Number(best.revision || 0) ||
|
||
(Number(snap.revision || 0) === Number(best.revision || 0) &&
|
||
String(snap.updatedAt || "") > String(best.updatedAt || ""))
|
||
) {
|
||
best = {
|
||
chatId: normalizedChatId,
|
||
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
|
||
serializedGraph: snap.serializedGraph,
|
||
updatedAt: String(snap.updatedAt || ""),
|
||
reason: String(snap.reason || ""),
|
||
integrity: String(snap.integrity || ""),
|
||
persistedChatId: String(snap.persistedChatId || ""),
|
||
debugReason: String(snap.debugReason || snap.reason || ""),
|
||
};
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return best;
|
||
},
|
||
writeGraphShadowSnapshot(
|
||
chatId = "",
|
||
graph = null,
|
||
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
|
||
) {
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key || !graph) return false;
|
||
sessionShadowSnapshots?.set?.(`shadow-raw:${String(chatId || "")}`, structuredClone(graph));
|
||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||
try {
|
||
const payload = JSON.stringify({
|
||
chatId: String(chatId || ""),
|
||
revision: Number.isFinite(revision) ? revision : 0,
|
||
serializedGraph: serializeGraph(graph),
|
||
updatedAt: new Date().toISOString(),
|
||
reason: String(reason || ""),
|
||
integrity: String(integrity || persistedMeta.integrity || ""),
|
||
persistedChatId: String(persistedMeta.chatId || ""),
|
||
debugReason: String(debugReason || reason || ""),
|
||
});
|
||
sessionShadowSnapshots?.set?.(`shadow-json:${String(chatId || "")}`, payload);
|
||
storage.setItem(key, payload);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
removeGraphShadowSnapshot(chatId = "") {
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key) return false;
|
||
try {
|
||
storage.removeItem(key);
|
||
sessionShadowSnapshots?.delete?.(`shadow-json:${String(chatId || "")}`);
|
||
sessionShadowSnapshots?.delete?.(`shadow-raw:${String(chatId || "")}`);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
createDefaultTaskProfiles() {
|
||
return {
|
||
extract: { activeProfileId: "default", profiles: [] },
|
||
recall: { activeProfileId: "default", profiles: [] },
|
||
compress: { activeProfileId: "default", profiles: [] },
|
||
synopsis: { activeProfileId: "default", profiles: [] },
|
||
reflection: { activeProfileId: "default", profiles: [] },
|
||
};
|
||
},
|
||
getContext() {
|
||
return runtimeContext.__chatContext;
|
||
},
|
||
async saveMetadata() {
|
||
runtimeContext.__globalImmediateSaveCalls += 1;
|
||
},
|
||
saveMetadataDebounced() {
|
||
runtimeContext.__globalSaveCalls += 1;
|
||
},
|
||
__globalSaveCalls: 0,
|
||
__globalImmediateSaveCalls: 0,
|
||
isAssistantChatMessage() {
|
||
return false;
|
||
},
|
||
isFreshRecallInputRecord() {
|
||
return true;
|
||
},
|
||
notifyExtractionIssue() {},
|
||
debugDebug() {},
|
||
debugLog() {},
|
||
async runExtraction() {},
|
||
getRequestHeaders() {
|
||
return {};
|
||
},
|
||
recordAuthorityBlobSnapshot() {},
|
||
isGraphLoadStateDbReady(loadState = runtimeContext.graphPersistenceState?.loadState) {
|
||
return loadState === GRAPH_LOAD_STATES.LOADED || loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED;
|
||
},
|
||
isLukerPrimaryPersistenceHost(context = runtimeContext.getContext?.()) {
|
||
return resolveBmeHostProfile(context) === "luker";
|
||
},
|
||
async loadGraphFromLukerSidecarV2(chatId, options = {}) {
|
||
runtimeContext.__lastLukerSidecarLoad = { chatId, options };
|
||
return { loaded: Boolean(runtimeContext.currentGraph), reason: "test-luker-sidecar" };
|
||
},
|
||
resolvePersistRevisionFloor(baseRevision = 0, graph = runtimeContext.currentGraph) {
|
||
return Math.max(
|
||
0,
|
||
Math.floor(Number(baseRevision || 0)),
|
||
Math.floor(Number(graph?.meta?.revision || 0)),
|
||
Math.floor(Number(getGraphPersistedRevision(graph) || 0)),
|
||
);
|
||
},
|
||
allocateRequestedPersistRevision(baseRevision = 0, graph = runtimeContext.currentGraph) {
|
||
return Math.max(
|
||
runtimeContext.resolvePersistRevisionFloor(baseRevision, graph) + 1,
|
||
Number(graphPersistenceState.revision || 0) + 1,
|
||
);
|
||
},
|
||
resolveCurrentChatStateTarget(context = runtimeContext.getContext?.()) {
|
||
return resolveCurrentBmeChatStateTarget(context);
|
||
},
|
||
scheduleBmeIndexedDbTask(task) {
|
||
return Promise.resolve().then(() => task());
|
||
},
|
||
__syncNowCalls: [],
|
||
getSettings() {
|
||
const mergedSettings = mergePersistedSettings(runtimeContext.extension_settings[MODULE_NAME] || {});
|
||
runtimeContext.extension_settings[MODULE_NAME] = mergedSettings;
|
||
return mergedSettings;
|
||
},
|
||
bmeIndexedDbLatestQueuedRevisionByChatId,
|
||
bmeIndexedDbWriteInFlightByChatId,
|
||
normalizeChatIdCandidate,
|
||
applyPersistDeltaToSnapshot(snapshot = null, delta = null, options = {}) {
|
||
const baseSnapshot = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
||
? cloneRuntimeDebugValue(snapshot, snapshot)
|
||
: { meta: {}, state: { lastProcessedFloor: -1, extractionCount: 0 }, nodes: [], edges: [], tombstones: [] };
|
||
const normalizedDelta = delta && typeof delta === "object" && !Array.isArray(delta)
|
||
? cloneRuntimeDebugValue(delta, delta)
|
||
: {};
|
||
const nodeMap = new Map((Array.isArray(baseSnapshot.nodes) ? baseSnapshot.nodes : []).filter((record) => record?.id).map((record) => [String(record.id), cloneRuntimeDebugValue(record, record)]));
|
||
const edgeMap = new Map((Array.isArray(baseSnapshot.edges) ? baseSnapshot.edges : []).filter((record) => record?.id).map((record) => [String(record.id), cloneRuntimeDebugValue(record, record)]));
|
||
const tombstoneMap = new Map((Array.isArray(baseSnapshot.tombstones) ? baseSnapshot.tombstones : []).filter((record) => record?.id).map((record) => [String(record.id), cloneRuntimeDebugValue(record, record)]));
|
||
for (const edgeId of Array.isArray(normalizedDelta.deleteEdgeIds) ? normalizedDelta.deleteEdgeIds : []) edgeMap.delete(String(edgeId));
|
||
for (const nodeId of Array.isArray(normalizedDelta.deleteNodeIds) ? normalizedDelta.deleteNodeIds : []) nodeMap.delete(String(nodeId));
|
||
for (const record of Array.isArray(normalizedDelta.upsertNodes) ? normalizedDelta.upsertNodes : []) if (record?.id) nodeMap.set(String(record.id), cloneRuntimeDebugValue(record, record));
|
||
for (const record of Array.isArray(normalizedDelta.upsertEdges) ? normalizedDelta.upsertEdges : []) if (record?.id) edgeMap.set(String(record.id), cloneRuntimeDebugValue(record, record));
|
||
for (const record of Array.isArray(normalizedDelta.tombstones) ? normalizedDelta.tombstones : []) if (record?.id) tombstoneMap.set(String(record.id), cloneRuntimeDebugValue(record, record));
|
||
const runtimeMetaPatch = normalizedDelta.runtimeMetaPatch && typeof normalizedDelta.runtimeMetaPatch === "object" && !Array.isArray(normalizedDelta.runtimeMetaPatch) ? cloneRuntimeDebugValue(normalizedDelta.runtimeMetaPatch, {}) : {};
|
||
const requestedRevision = Number(options?.revision || 0);
|
||
const lastModified = Number(options?.lastModified || Date.now());
|
||
const nextSnapshot = {
|
||
meta: { ...(baseSnapshot.meta && typeof baseSnapshot.meta === "object" ? baseSnapshot.meta : {}), ...runtimeMetaPatch, revision: Number.isFinite(requestedRevision) && requestedRevision > 0 ? Math.floor(requestedRevision) : Number(baseSnapshot?.meta?.revision || 0), lastModified, lastMutationReason: String(options?.reason || runtimeMetaPatch.lastMutationReason || baseSnapshot?.meta?.lastMutationReason || "") },
|
||
state: { ...(baseSnapshot.state && typeof baseSnapshot.state === "object" ? baseSnapshot.state : {}), lastProcessedFloor: Number.isFinite(Number(runtimeMetaPatch.lastProcessedFloor)) ? Number(runtimeMetaPatch.lastProcessedFloor) : Number(baseSnapshot?.state?.lastProcessedFloor ?? -1), extractionCount: Number.isFinite(Number(runtimeMetaPatch.extractionCount)) ? Number(runtimeMetaPatch.extractionCount) : Number(baseSnapshot?.state?.extractionCount ?? 0) },
|
||
nodes: Array.from(nodeMap.values()),
|
||
edges: Array.from(edgeMap.values()),
|
||
tombstones: Array.from(tombstoneMap.values()),
|
||
};
|
||
nextSnapshot.meta.nodeCount = nextSnapshot.nodes.length;
|
||
nextSnapshot.meta.edgeCount = nextSnapshot.edges.length;
|
||
nextSnapshot.meta.tombstoneCount = nextSnapshot.tombstones.length;
|
||
if (options?.chatId) nextSnapshot.meta.chatId = String(options.chatId);
|
||
return nextSnapshot;
|
||
},
|
||
readGlobalCurrentChatId() {
|
||
return normalizeChatIdCandidate(runtimeContext.SillyTavern?.getCurrentChatId?.() || "");
|
||
},
|
||
getChatMetadataIntegrity(context = runtimeContext.getContext()) {
|
||
return normalizeChatIdCandidate(context?.chatMetadata?.integrity);
|
||
},
|
||
getChatCommitMarker(context = runtimeContext.getContext()) {
|
||
return readGraphCommitMarker(context);
|
||
},
|
||
syncCommitMarkerToPersistenceState(context = runtimeContext.getContext()) {
|
||
const marker = runtimeContext.getChatCommitMarker(context);
|
||
runtimeContext.updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null) });
|
||
return marker;
|
||
},
|
||
hasLikelySelectedChatContext(context = runtimeContext.getContext()) {
|
||
if (!context || typeof context !== "object") return false;
|
||
const hasMeaningfulChatMetadata = context.chatMetadata && typeof context.chatMetadata === "object" && !Array.isArray(context.chatMetadata) && Object.keys(context.chatMetadata).length > 0;
|
||
return hasMeaningfulChatMetadata || (Array.isArray(context.chat) && context.chat.length > 0) || String(context.characterId || "").trim() !== "" || String(context.groupId || "").trim() !== "";
|
||
},
|
||
resolveCurrentChatIdentity(context = runtimeContext.getContext()) {
|
||
const identity = resolveCurrentChatIdentityCore({
|
||
context,
|
||
readGlobalCurrentChatId: runtimeContext.readGlobalCurrentChatId,
|
||
resolveAliasByHostChatId: runtimeContext.resolveGraphIdentityAliasByHostChatId,
|
||
resolveIntegrity: runtimeContext.getChatMetadataIntegrity,
|
||
hasLikelySelectedChat: runtimeContext.hasLikelySelectedChatContext,
|
||
});
|
||
const explicitChatId = normalizeChatIdCandidate(context?.chatId);
|
||
return explicitChatId ? { ...identity, chatId: explicitChatId, hostChatId: identity.hostChatId || explicitChatId, integrity: identity.integrity || explicitChatId } : identity;
|
||
},
|
||
getCurrentChatId(context = runtimeContext.getContext()) {
|
||
const identity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId);
|
||
const integrity = normalizeChatIdCandidate(identity.integrity);
|
||
if (stateChatId && (runtimeContext.areChatIdsEquivalentForResolvedIdentity(stateChatId, identity.chatId, identity) || stateChatId === integrity || (integrity && stateChatId.startsWith(integrity)))) return stateChatId;
|
||
return identity.chatId;
|
||
},
|
||
resolvePersistenceChatId(context = runtimeContext.getContext(), graph = currentGraph, explicitChatId = "") {
|
||
return resolvePersistenceChatIdCore({
|
||
explicitChatId,
|
||
activeIdentity: runtimeContext.resolveCurrentChatIdentity(context),
|
||
graph,
|
||
graphMeta: getGraphPersistenceMeta(graph) || {},
|
||
currentGraph,
|
||
currentGraphMeta: getGraphPersistenceMeta(currentGraph) || {},
|
||
persistenceState: graphPersistenceState,
|
||
context,
|
||
});
|
||
},
|
||
rememberResolvedGraphIdentityAlias(context = runtimeContext.getContext(), persistenceChatId = runtimeContext.getCurrentChatId(context)) {
|
||
const identity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
if (!identity.integrity || !persistenceChatId) return null;
|
||
return runtimeContext.rememberGraphIdentityAlias({
|
||
integrity: identity.integrity,
|
||
hostChatId: identity.hostChatId,
|
||
persistenceChatId,
|
||
});
|
||
},
|
||
doesChatIdMatchResolvedGraphIdentity(candidateChatId, identity = runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext())) {
|
||
return doesChatIdMatchIdentityCore(candidateChatId, {
|
||
identity,
|
||
aliasCandidates: getGraphIdentityAliasCandidates({
|
||
integrity: identity?.integrity,
|
||
hostChatId: identity?.hostChatId,
|
||
persistenceChatId: identity?.chatId,
|
||
}),
|
||
});
|
||
},
|
||
areChatIdsEquivalentForResolvedIdentity(candidateChatId, referenceChatId, identity = runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext())) {
|
||
return areChatIdsEquivalentForIdentityCore(candidateChatId, referenceChatId, {
|
||
identity,
|
||
aliasCandidates: getGraphIdentityAliasCandidates({
|
||
integrity: identity?.integrity,
|
||
hostChatId: identity?.hostChatId,
|
||
persistenceChatId: identity?.chatId,
|
||
}),
|
||
});
|
||
},
|
||
getGraphOwnedChatId(graph = currentGraph) {
|
||
return resolveGraphOwnerIdentityCore({ graph, graphMeta: getGraphPersistenceMeta(graph) || {} }).chatId;
|
||
},
|
||
syncGraphPersistenceDebugState() {
|
||
runtimeContext.recordGraphPersistenceSnapshot(runtimeContext.getGraphPersistenceLiveState());
|
||
},
|
||
updateGraphPersistenceState(patch = {}) {
|
||
graphPersistenceState = {
|
||
...graphPersistenceState,
|
||
...(patch || {}),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
runtimeContext.syncGraphPersistenceDebugState();
|
||
return graphPersistenceState;
|
||
},
|
||
isRestoreLockActive() {
|
||
return runtimeContext.normalizeRestoreLockState(graphPersistenceState.restoreLock).active;
|
||
},
|
||
getRestoreLockMessage(operationLabel = "当前操作") {
|
||
const lock = runtimeContext.normalizeRestoreLockState(graphPersistenceState.restoreLock);
|
||
if (!lock.active) return "";
|
||
const details = [lock.reason, lock.source].filter(Boolean).join(" / ");
|
||
return `${operationLabel}已暂停:当前处于恢复锁${details ? `(${details})` : ""}`;
|
||
},
|
||
createAbortError(message = "操作已取消") {
|
||
const error = new Error(message);
|
||
error.name = "AbortError";
|
||
return error;
|
||
},
|
||
recordGraphPersistenceSnapshot(snapshot = null) {
|
||
const state = runtimeContext.getRuntimeDebugState();
|
||
state.graphPersistence = cloneRuntimeDebugValue(snapshot, null);
|
||
},
|
||
getRuntimeDebugState() {
|
||
if (!runtimeContext.__runtimeDebugState) {
|
||
runtimeContext.__runtimeDebugState = {
|
||
hostCapabilities: null,
|
||
taskPromptBuilds: {},
|
||
taskLlmRequests: {},
|
||
injections: {},
|
||
taskTimeline: [],
|
||
messageTrace: { lastSentUserMessage: null },
|
||
maintenance: { lastAction: null, lastUndoResult: null },
|
||
graphPersistence: null,
|
||
graphLayout: null,
|
||
updatedAt: "",
|
||
};
|
||
}
|
||
return runtimeContext.__runtimeDebugState;
|
||
},
|
||
createGraphLoadUiStatus() {
|
||
switch (graphPersistenceState.loadState) {
|
||
case GRAPH_LOAD_STATES.LOADING:
|
||
return createUiStatus("加载中", "正在加载聊天图谱", "info");
|
||
case GRAPH_LOAD_STATES.SHADOW_RESTORED:
|
||
return createUiStatus("恢复中", "已从影子快照恢复,等待本地存储确认", "warning");
|
||
case GRAPH_LOAD_STATES.BLOCKED:
|
||
return createUiStatus("受保护", "图谱加载受阻,写入已暂停", "warning");
|
||
case GRAPH_LOAD_STATES.NO_CHAT:
|
||
if (graphPersistenceState.chatId && currentGraph) return createUiStatus("图谱已加载", "维护操作会使用图谱身份继续", "idle");
|
||
return createUiStatus("待命", "当前尚未进入聊天", "idle");
|
||
case GRAPH_LOAD_STATES.EMPTY_CONFIRMED:
|
||
return createUiStatus("空图谱", "当前聊天暂无图谱数据", "idle");
|
||
case GRAPH_LOAD_STATES.LOADED:
|
||
default:
|
||
return createUiStatus("待命", "已加载聊天图谱,等待下一次任务", "idle");
|
||
}
|
||
},
|
||
getPanelRuntimeStatus() {
|
||
return getPanelRuntimeStatusImpl(runtimeContext);
|
||
},
|
||
getGraphMutationBlockReason(operationLabel = "当前操作") {
|
||
return getGraphMutationBlockReasonImpl(runtimeContext, operationLabel);
|
||
},
|
||
ensureGraphMutationReady(operationLabel = "当前操作", options = {}) {
|
||
if (options?.allowRuntimeGraphFallback === true && currentGraph && graphPersistenceState.chatId) {
|
||
if (graphPersistenceState.commitMarker?.chatId && graphPersistenceState.commitMarker.chatId !== graphPersistenceState.chatId) return false;
|
||
if (!currentGraph.historyState.chatId) currentGraph.historyState.chatId = graphPersistenceState.chatId;
|
||
return true;
|
||
}
|
||
return ensureGraphMutationReadyImpl(runtimeContext, operationLabel, options);
|
||
},
|
||
assertRecoveryChatStillActive(expectedChatId = "", label = "history-recovery") {
|
||
return assertRecoveryChatStillActiveImpl(runtimeContext, expectedChatId, label);
|
||
},
|
||
buildPanelOpenLocalStoreRefreshPlan(options = {}) {
|
||
return buildPanelOpenLocalStoreRefreshPlanImpl(runtimeContext, options);
|
||
},
|
||
syncBmeHostRuntimeFlags(context = runtimeContext.getContext()) {
|
||
const adapter = getBmeHostAdapter(context);
|
||
const target = typeof adapter?.resolveCurrentTarget === "function"
|
||
? adapter.resolveCurrentTarget()
|
||
: null;
|
||
const lightweightHostMode = isBmeLightweightHostMode(context);
|
||
return { adapter, target, lightweightHostMode };
|
||
},
|
||
getGraphPersistenceLiveState() {
|
||
const live = getGraphPersistenceLiveStateImpl(runtimeContext);
|
||
return live?.loadState === undefined
|
||
? { ...graphPersistenceState, ...(live || {}) }
|
||
: live;
|
||
},
|
||
readRuntimeDebugSnapshot() {
|
||
return readRuntimeDebugSnapshotImpl(runtimeContext);
|
||
},
|
||
applyGraphLoadState(loadState, options = {}) {
|
||
return applyGraphLoadStateImpl(runtimeContext, loadState, options);
|
||
},
|
||
normalizePersistenceHostProfile(value = "generic-st") {
|
||
const normalized = String(value || "generic-st").trim().toLowerCase();
|
||
return normalized === "luker" ? "luker" : "generic-st";
|
||
},
|
||
normalizePersistenceStorageTier(value = "none") {
|
||
const normalized = String(value || "none").trim().toLowerCase();
|
||
return ["indexeddb", "opfs", "authority-sql", "chat-state", "luker-chat-state", "shadow", "metadata-full", "none"].includes(normalized)
|
||
? normalized
|
||
: "none";
|
||
},
|
||
normalizeGraphSyncState(value = "idle") {
|
||
const normalized = String(value || "idle").trim().toLowerCase();
|
||
return ["idle", "syncing", "warning", "error"].includes(normalized) ? normalized : "idle";
|
||
},
|
||
normalizeRestoreLockState(lock = null) {
|
||
if (!lock || typeof lock !== "object" || Array.isArray(lock)) {
|
||
return { active: false, reason: "", chatId: "", startedAt: 0 };
|
||
}
|
||
return {
|
||
active: lock.active === true,
|
||
reason: String(lock.reason || ""),
|
||
chatId: String(lock.chatId || ""),
|
||
startedAt: Number(lock.startedAt || 0),
|
||
};
|
||
},
|
||
resolvePersistenceHostProfile(context = runtimeContext.getContext()) {
|
||
return runtimeContext.Luker ? "luker" : resolveBmeHostProfile(context);
|
||
},
|
||
resolveLocalStoreTierFromPresentation(presentation = runtimeContext.getPreferredGraphLocalStorePresentationSync()) {
|
||
const normalizedPresentation = presentation && typeof presentation === "object" ? presentation : runtimeContext.getPreferredGraphLocalStorePresentationSync();
|
||
if (normalizedPresentation.storagePrimary === AUTHORITY_GRAPH_STORE_KIND) return "authority-sql";
|
||
return normalizedPresentation.storagePrimary === "opfs" ? "opfs" : "indexeddb";
|
||
},
|
||
buildIndexedDbStorePresentation() {
|
||
return { storagePrimary: "indexeddb", storageMode: "indexeddb", statusLabel: "IndexedDB", reasonPrefix: "indexeddb" };
|
||
},
|
||
buildOpfsStorePresentation(mode = "opfs-primary") {
|
||
const normalizedMode = runtimeContext.normalizeGraphLocalStorageMode(mode, "opfs-primary");
|
||
return {
|
||
storagePrimary: "opfs",
|
||
storageMode: normalizedMode === "opfs-shadow" ? "opfs-primary" : normalizedMode,
|
||
statusLabel: "OPFS",
|
||
reasonPrefix: "opfs",
|
||
};
|
||
},
|
||
buildAuthorityStorePresentation() {
|
||
return { storagePrimary: AUTHORITY_GRAPH_STORE_KIND, storageMode: AUTHORITY_GRAPH_STORE_MODE, statusLabel: "Authority SQL", reasonPrefix: "authority-sql" };
|
||
},
|
||
getRequestedGraphLocalStorageMode(settings = runtimeContext.getSettings()) {
|
||
return runtimeContext.normalizeGraphLocalStorageMode(settings?.graphLocalStorageMode, "auto");
|
||
},
|
||
getAuthorityRuntimeSnapshot(settings = runtimeContext.getSettings()) {
|
||
authorityCapabilityState = normalizeAuthorityCapabilityState(authorityCapabilityState, settings);
|
||
authorityBrowserState = normalizeAuthorityBrowserState(authorityBrowserState, settings);
|
||
return {
|
||
capability: authorityCapabilityState,
|
||
browserState: getAuthorityBrowserStateSnapshot(authorityBrowserState, settings),
|
||
};
|
||
},
|
||
isAuthorityGraphStorePresentation(presentation = null) {
|
||
if (!presentation || typeof presentation !== "object") return false;
|
||
return presentation.storagePrimary === AUTHORITY_GRAPH_STORE_KIND || presentation.storageMode === AUTHORITY_GRAPH_STORE_MODE;
|
||
},
|
||
isAuthorityJobTypeSupported(capability = {}, kind = "") {
|
||
if (!capability?.supportedJobTypesKnown) return true;
|
||
const normalizedKind = String(kind || "").trim().toLowerCase().replace(/_/g, "-");
|
||
if (!normalizedKind) return true;
|
||
return Array.isArray(capability.supportedJobTypes) && capability.supportedJobTypes.includes(normalizedKind);
|
||
},
|
||
shouldUseAuthorityBlobCheckpoint() {
|
||
const settings = runtimeContext.getSettings();
|
||
const authoritySettings = normalizeAuthoritySettings(settings);
|
||
const { capability } = runtimeContext.getAuthorityRuntimeSnapshot(settings);
|
||
return Boolean(authoritySettings.enabled && authoritySettings.blobCheckpointEnabled && capability.blobReady);
|
||
},
|
||
async exportAuthoritySqlSnapshotForCheckpoint(chatId = "") {
|
||
const snapshot = getAuthoritySnapshotForChat(chatId);
|
||
return snapshot;
|
||
},
|
||
async writeAuthorityLukerCheckpointBlob(payload = null, options = {}) {
|
||
const path = `${String(options?.chatId || payload?.chatId || "")}:${String(options?.reason || payload?.reason || "checkpoint")}`;
|
||
authorityBlobWrites.set(path, structuredClone(payload));
|
||
return { saved: true, path };
|
||
},
|
||
async runAuthorityConsistencyAudit() {
|
||
return { ok: true, mismatches: [] };
|
||
},
|
||
shouldUseAuthorityGraphStore(settings = runtimeContext.getSettings(), capability = authorityCapabilityState) {
|
||
return shouldUseAuthorityGraphStoreImpl(runtimeContext, settings, capability);
|
||
},
|
||
getPreferredGraphLocalStorePresentationSync(settings = runtimeContext.getSettings()) {
|
||
if (runtimeContext.shouldUseAuthorityGraphStore(settings, authorityCapabilityState)) return runtimeContext.buildAuthorityStorePresentation();
|
||
const requestedMode = runtimeContext.getRequestedGraphLocalStorageMode(settings);
|
||
if (requestedMode === "auto" && bmeLocalStoreCapabilitySnapshot?.opfsAvailable) return runtimeContext.buildOpfsStorePresentation("opfs-primary");
|
||
if (runtimeContext.isGraphLocalStorageModeOpfs(requestedMode) && bmeLocalStoreCapabilitySnapshot?.opfsAvailable) return runtimeContext.buildOpfsStorePresentation(requestedMode);
|
||
return runtimeContext.buildIndexedDbStorePresentation();
|
||
},
|
||
async resolvePreferredGraphLocalStorePresentation(settings = runtimeContext.getSettings()) {
|
||
return runtimeContext.getPreferredGraphLocalStorePresentationSync(settings);
|
||
},
|
||
resolveDbGraphStorePresentation(db = null) {
|
||
if (db?.storeKind === AUTHORITY_GRAPH_STORE_KIND || db?.storeMode === AUTHORITY_GRAPH_STORE_MODE) return runtimeContext.buildAuthorityStorePresentation();
|
||
if (db?.storeKind === "opfs" || runtimeContext.isGraphLocalStorageModeOpfs(db?.storeMode)) return runtimeContext.buildOpfsStorePresentation(db?.storeMode);
|
||
return runtimeContext.buildIndexedDbStorePresentation();
|
||
},
|
||
resolveSnapshotGraphStorePresentation(snapshot = null, fallbackPresentation = runtimeContext.buildIndexedDbStorePresentation()) {
|
||
const normalizedFallback = fallbackPresentation && typeof fallbackPresentation === "object" ? fallbackPresentation : runtimeContext.buildIndexedDbStorePresentation();
|
||
const snapshotPrimary = String(snapshot?.meta?.storagePrimary || "").trim().toLowerCase();
|
||
const snapshotStorageMode = String(snapshot?.meta?.storageMode || "").trim().toLowerCase();
|
||
if (snapshotPrimary === AUTHORITY_GRAPH_STORE_KIND || snapshotStorageMode === AUTHORITY_GRAPH_STORE_MODE) return runtimeContext.buildAuthorityStorePresentation();
|
||
const snapshotMode = runtimeContext.normalizeGraphLocalStorageMode(snapshot?.meta?.storageMode, normalizedFallback.storageMode);
|
||
if (snapshotPrimary === "opfs" || runtimeContext.isGraphLocalStorageModeOpfs(snapshotMode)) return runtimeContext.buildOpfsStorePresentation(snapshotMode);
|
||
return runtimeContext.buildIndexedDbStorePresentation();
|
||
},
|
||
buildGraphLocalStoreSelectorKey(presentation = runtimeContext.buildIndexedDbStorePresentation()) {
|
||
const normalizedPresentation = presentation && typeof presentation === "object" ? presentation : runtimeContext.buildIndexedDbStorePresentation();
|
||
if (normalizedPresentation.storagePrimary === AUTHORITY_GRAPH_STORE_KIND || normalizedPresentation.storageMode === AUTHORITY_GRAPH_STORE_MODE) return `${AUTHORITY_GRAPH_STORE_KIND}:${AUTHORITY_GRAPH_STORE_MODE}`;
|
||
const storagePrimary = normalizedPresentation.storagePrimary === "opfs" || runtimeContext.isGraphLocalStorageModeOpfs(normalizedPresentation.storageMode) ? "opfs" : "indexeddb";
|
||
const storageMode = storagePrimary === "opfs" ? runtimeContext.normalizeGraphLocalStorageMode(normalizedPresentation.storageMode, "opfs-primary") : "indexeddb";
|
||
return `${storagePrimary}:${storageMode}`;
|
||
},
|
||
readLocalStoreDiagnosticsSync(db = null, presentation = runtimeContext.buildIndexedDbStorePresentation()) {
|
||
const resolvedPresentation = presentation && typeof presentation === "object" ? presentation : runtimeContext.resolveDbGraphStorePresentation(db);
|
||
const rawDiagnostics = typeof db?.getStorageDiagnosticsSync === "function" ? db.getStorageDiagnosticsSync() : null;
|
||
return {
|
||
resolvedLocalStore: runtimeContext.buildGraphLocalStoreSelectorKey(resolvedPresentation),
|
||
localStoreFormatVersion: Number(rawDiagnostics?.formatVersion || 0) || (resolvedPresentation.storagePrimary === "opfs" ? 2 : 1),
|
||
localStoreMigrationState: String(rawDiagnostics?.migrationState || "").trim() || "idle",
|
||
opfsWalDepth: Number(rawDiagnostics?.walCount || 0),
|
||
opfsPendingBytes: Number(rawDiagnostics?.walTotalBytes || 0),
|
||
opfsCompactionState: cloneRuntimeDebugValue(rawDiagnostics?.compactionState || null, null),
|
||
};
|
||
},
|
||
buildGraphPersistResult({
|
||
saved = false, queued = false, blocked = false, accepted = false, recoverable = false,
|
||
storageTier = "none", acceptedBy = "none", primaryTier = graphPersistenceState.primaryStorageTier,
|
||
cacheTier = graphPersistenceState.cacheStorageTier, cacheMirrored = false,
|
||
diagnosticTier = graphPersistenceState.persistDiagnosticTier, reason = "",
|
||
loadState = graphPersistenceState.loadState, revision = graphPersistenceState.revision,
|
||
saveMode = graphPersistenceState.lastPersistMode, manifestRevision = graphPersistenceState.lukerManifestRevision || 0,
|
||
journalDepth = graphPersistenceState.lukerJournalDepth || 0,
|
||
checkpointRevision = graphPersistenceState.lukerCheckpointRevision || 0,
|
||
cacheLag = graphPersistenceState.cacheLag || 0,
|
||
} = {}) {
|
||
return {
|
||
saved, queued, blocked, accepted, recoverable,
|
||
storageTier: String(storageTier || "none"),
|
||
acceptedBy: String(acceptedBy || "none"),
|
||
primaryTier: String(primaryTier || "none"),
|
||
cacheTier: String(cacheTier || "none"),
|
||
cacheMirrored: cacheMirrored === true,
|
||
diagnosticTier: String(diagnosticTier || "none"),
|
||
reason: String(reason || ""),
|
||
loadState,
|
||
revision: Number.isFinite(revision) ? revision : 0,
|
||
saveMode: String(saveMode || ""),
|
||
manifestRevision: Number.isFinite(manifestRevision) ? manifestRevision : 0,
|
||
journalDepth: Number.isFinite(journalDepth) ? journalDepth : 0,
|
||
checkpointRevision: Number.isFinite(checkpointRevision) ? checkpointRevision : 0,
|
||
cacheLag: Number.isFinite(cacheLag) ? cacheLag : 0,
|
||
};
|
||
},
|
||
maybeCaptureGraphShadowSnapshot(reason = "runtime-shadow", options = {}) {
|
||
return maybeCaptureGraphShadowSnapshotImpl(runtimeContext, reason, options);
|
||
},
|
||
ensureBmeChatManager() {
|
||
if (typeof runtimeContext.BmeChatManager !== "function") {
|
||
if (!bmeChatManagerUnavailableWarned) {
|
||
console.warn("[ST-BME] BmeChatManager 不可用,IndexedDB 能力暂时停用");
|
||
bmeChatManagerUnavailableWarned = true;
|
||
}
|
||
return null;
|
||
}
|
||
if (!bmeChatManager) {
|
||
bmeChatManager = new runtimeContext.BmeChatManager({
|
||
databaseFactory: async (chatId) => await runtimeContext.createPreferredGraphLocalStore(chatId),
|
||
selectorKeyResolver: async () => runtimeContext.buildGraphLocalStoreSelectorKey(await runtimeContext.resolvePreferredGraphLocalStorePresentation()),
|
||
});
|
||
}
|
||
return bmeChatManager;
|
||
},
|
||
async createPreferredGraphLocalStore(chatId, settings = runtimeContext.getSettings()) {
|
||
const preferredLocalStore = await runtimeContext.resolvePreferredGraphLocalStorePresentation(settings);
|
||
if (preferredLocalStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND) return new runtimeContext.AuthorityGraphStore(chatId);
|
||
if (preferredLocalStore.storagePrimary === "opfs") return new runtimeContext.OpfsGraphStore(chatId);
|
||
return new runtimeContext.BmeDatabase(chatId);
|
||
},
|
||
recordLocalPersistEarlyFailure(reason = "indexeddb-unavailable", { chatId = "", storagePrimary = graphPersistenceState.storagePrimary || "indexeddb", storageMode = graphPersistenceState.storageMode || "indexeddb", revision = 0 } = {}) {
|
||
const normalizedReason = String(reason || "indexeddb-unavailable").trim();
|
||
runtimeContext.__lastLocalPersistEarlyFailure = normalizedReason;
|
||
runtimeContext.updateGraphPersistenceState({
|
||
storagePrimary,
|
||
storageMode,
|
||
indexedDbLastError: normalizedReason,
|
||
dualWriteLastResult: {
|
||
action: "save",
|
||
target: storagePrimary,
|
||
success: false,
|
||
chatId: normalizeChatIdCandidate(chatId),
|
||
revision: runtimeContext.normalizeIndexedDbRevision(revision),
|
||
reason: normalizedReason,
|
||
at: Date.now(),
|
||
},
|
||
});
|
||
return normalizedReason;
|
||
},
|
||
isGraphMetadataWriteAllowed(loadState = graphPersistenceState.loadState) {
|
||
return loadState === GRAPH_LOAD_STATES.LOADED || loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED;
|
||
},
|
||
isGraphReadable(loadState = graphPersistenceState.loadState) {
|
||
return loadState === GRAPH_LOAD_STATES.LOADED || loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED || loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED || (loadState === GRAPH_LOAD_STATES.BLOCKED && graphPersistenceState.shadowSnapshotUsed);
|
||
},
|
||
hasReadableRuntimeGraphForRecall(chatId = runtimeContext.getCurrentChatId()) {
|
||
if (!currentGraph || typeof currentGraph !== "object" || !Array.isArray(currentGraph.nodes) || !Array.isArray(currentGraph.edges) || !currentGraph.historyState || typeof currentGraph.historyState !== "object" || Array.isArray(currentGraph.historyState)) return false;
|
||
const activeChatId = normalizeChatIdCandidate(chatId);
|
||
const runtimeChatId = normalizeChatIdCandidate(currentGraph.historyState.chatId);
|
||
if (activeChatId && runtimeChatId) return runtimeChatId === activeChatId;
|
||
return currentGraph.nodes.length > 0 || currentGraph.edges.length > 0;
|
||
},
|
||
isGraphReadableForRecall(loadState = graphPersistenceState.loadState, chatId = runtimeContext.getCurrentChatId()) {
|
||
return runtimeContext.isGraphReadable(loadState) || runtimeContext.hasReadableRuntimeGraphForRecall(chatId);
|
||
},
|
||
isGraphEffectivelyEmpty(graph) {
|
||
if (!graph || typeof graph !== "object") return true;
|
||
const stats = getGraphStats(graph);
|
||
if ((stats.totalNodes || 0) > 0 || (stats.totalEdges || 0) > 0) return false;
|
||
if (Number.isFinite(stats.lastProcessedSeq) && stats.lastProcessedSeq >= 0) return false;
|
||
if (Array.isArray(graph.batchJournal) && graph.batchJournal.length > 0) return false;
|
||
if (graph.lastRecallResult && (!Array.isArray(graph.lastRecallResult) || graph.lastRecallResult.length > 0)) return false;
|
||
if (Object.keys(graph?.historyState?.processedMessageHashes || {}).length > 0) return false;
|
||
if (Object.keys(graph?.vectorIndexState?.hashToNodeId || {}).length > 0) return false;
|
||
return true;
|
||
},
|
||
hasMeaningfulRuntimeGraphForChat(chatId = runtimeContext.getCurrentChatId(), identity = runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext())) {
|
||
if (!currentGraph || typeof currentGraph !== "object" || !Array.isArray(currentGraph.nodes) || !Array.isArray(currentGraph.edges) || !currentGraph.historyState || typeof currentGraph.historyState !== "object" || Array.isArray(currentGraph.historyState)) return false;
|
||
const normalizedTargetChatId = normalizeChatIdCandidate(chatId);
|
||
const runtimeChatId = normalizeChatIdCandidate(currentGraph.historyState.chatId);
|
||
if (normalizedTargetChatId && runtimeChatId) {
|
||
const sameChat = runtimeContext.areChatIdsEquivalentForResolvedIdentity(runtimeChatId, normalizedTargetChatId, identity) || runtimeContext.areChatIdsEquivalentForResolvedIdentity(normalizedTargetChatId, runtimeChatId, identity);
|
||
if (!sameChat) return false;
|
||
} else if (normalizedTargetChatId && !runtimeContext.doesChatIdMatchResolvedGraphIdentity(normalizedTargetChatId, identity)) {
|
||
return false;
|
||
}
|
||
return !runtimeContext.isGraphEffectivelyEmpty(currentGraph);
|
||
},
|
||
hasRuntimeGraphMutationContext(context = runtimeContext.getContext(), graph = currentGraph, { allowNoChatState = false } = {}) {
|
||
if (!graph || typeof graph !== "object" || !graph.historyState || typeof graph.historyState !== "object" || Array.isArray(graph.historyState)) return false;
|
||
const identity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
return canMutateRuntimeGraphForIdentityCore({
|
||
graph,
|
||
activeIdentity: identity,
|
||
graphOwnedChatId: runtimeContext.getGraphOwnedChatId(graph),
|
||
persistenceState: graphPersistenceState,
|
||
aliasCandidates: getGraphIdentityAliasCandidates({ integrity: identity.integrity, hostChatId: identity.hostChatId, persistenceChatId: identity.chatId }),
|
||
loadedStates: [GRAPH_LOAD_STATES.LOADED, GRAPH_LOAD_STATES.EMPTY_CONFIRMED],
|
||
allowNoChatState,
|
||
noChatState: GRAPH_LOAD_STATES.NO_CHAT,
|
||
});
|
||
},
|
||
repairRuntimeGraphIdentityFromPersistence(reason = "identity-repair") {
|
||
if (!currentGraph) return { repaired: false, reason: "missing-graph" };
|
||
const repairPlan = planRuntimeGraphIdentityRepairCore({
|
||
graph: currentGraph,
|
||
activeIdentity: runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext()),
|
||
graphMeta: getGraphPersistenceMeta(currentGraph) || {},
|
||
persistenceState: graphPersistenceState,
|
||
aliasCandidates: getGraphIdentityAliasCandidates({ integrity: runtimeContext.getChatMetadataIntegrity(runtimeContext.getContext()), hostChatId: runtimeContext.readGlobalCurrentChatId(), persistenceChatId: graphPersistenceState.chatId }),
|
||
});
|
||
if (!repairPlan?.shouldRepair) return { repaired: false, reason: repairPlan?.reason || "not-needed" };
|
||
currentGraph = normalizeGraphRuntimeState(currentGraph, repairPlan.chatId);
|
||
stampGraphPersistenceMeta(currentGraph, { chatId: repairPlan.chatId, reason });
|
||
return { repaired: true, reason: "repaired", chatId: repairPlan.chatId };
|
||
},
|
||
canPersistGraphToMetadataFallback(context = runtimeContext.getContext(), graph = currentGraph) {
|
||
if (runtimeContext.isGraphMetadataWriteAllowed()) return true;
|
||
const activeChatId = normalizeChatIdCandidate(runtimeContext.getCurrentChatId(context));
|
||
if (!context || !graph || !activeChatId) return false;
|
||
const identity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
const runtimeGraphChatId = normalizeChatIdCandidate(graph?.historyState?.chatId);
|
||
const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId);
|
||
const sameRuntimeChat = !runtimeGraphChatId || runtimeContext.areChatIdsEquivalentForResolvedIdentity(runtimeGraphChatId, activeChatId, identity) || runtimeContext.areChatIdsEquivalentForResolvedIdentity(activeChatId, runtimeGraphChatId, identity);
|
||
const sameStateChat = !stateChatId || runtimeContext.areChatIdsEquivalentForResolvedIdentity(stateChatId, activeChatId, identity) || runtimeContext.areChatIdsEquivalentForResolvedIdentity(activeChatId, stateChatId, identity);
|
||
return graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT && sameRuntimeChat && sameStateChat && typeof graph === "object" && graph !== null;
|
||
},
|
||
ensureCurrentGraphRuntimeState({ chatId = runtimeContext.getCurrentChatId() } = {}) {
|
||
if (!currentGraph) currentGraph = createEmptyGraph();
|
||
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
|
||
return currentGraph;
|
||
},
|
||
clearPendingGraphLoadRetry({ resetChatId = true } = {}) {
|
||
if (pendingGraphLoadRetryTimer) {
|
||
runtimeContext.clearTimeout(pendingGraphLoadRetryTimer);
|
||
pendingGraphLoadRetryTimer = null;
|
||
}
|
||
if (resetChatId) pendingGraphLoadRetryChatId = "";
|
||
},
|
||
isGraphLoadRetryPending(chatId = runtimeContext.getCurrentChatId()) {
|
||
const normalizedChatId = String(chatId || "");
|
||
return Boolean(normalizedChatId) && pendingGraphLoadRetryChatId === normalizedChatId;
|
||
},
|
||
scheduleGraphLoadRetry(chatId, reason = "metadata-pending", attemptIndex = 0, { allowPendingChat = false, expectedChatId = "" } = {}) {
|
||
const normalizedChatId = String(chatId || "");
|
||
const normalizedExpectedChatId = String(expectedChatId || normalizedChatId || "");
|
||
const delayMs = GRAPH_LOAD_RETRY_DELAYS_MS[attemptIndex];
|
||
if ((!normalizedChatId && !allowPendingChat) || !Number.isFinite(delayMs)) {
|
||
runtimeContext.clearPendingGraphLoadRetry();
|
||
return false;
|
||
}
|
||
runtimeContext.clearPendingGraphLoadRetry({ resetChatId: false });
|
||
pendingGraphLoadRetryChatId = normalizedChatId || (allowPendingChat ? GRAPH_LOAD_PENDING_CHAT_ID : "");
|
||
pendingGraphLoadRetryTimer = runtimeContext.setTimeout(() => {
|
||
pendingGraphLoadRetryTimer = null;
|
||
const currentChatId = runtimeContext.getCurrentChatId();
|
||
if (normalizedExpectedChatId && currentChatId && currentChatId !== normalizedExpectedChatId) {
|
||
runtimeContext.clearPendingGraphLoadRetry();
|
||
return;
|
||
}
|
||
if (!allowPendingChat && normalizedChatId && currentChatId !== normalizedChatId) {
|
||
runtimeContext.clearPendingGraphLoadRetry();
|
||
return;
|
||
}
|
||
runtimeContext.loadGraphFromChat({ attemptIndex: attemptIndex + 1, expectedChatId: normalizedExpectedChatId, source: `retry:${reason}` });
|
||
}, delayMs);
|
||
return true;
|
||
},
|
||
reconcileIndexedDbProbeFailureState(chatId, result = {}, { attemptIndex = 0 } = {}) {
|
||
if (result?.loaded || result?.emptyConfirmed || result?.repairQueued) {
|
||
runtimeContext.clearPendingGraphLoadRetry();
|
||
return result;
|
||
}
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId || result?.chatId);
|
||
const normalizedReason = String(result?.reason || "").trim();
|
||
if (!normalizedChatId || !normalizedReason) return result;
|
||
const isIndexedDbProbeFailureReason = normalizedReason.startsWith("indexeddb-") || normalizedReason.startsWith("persist-mismatch:indexeddb-");
|
||
if (!isIndexedDbProbeFailureReason || normalizedReason === "indexeddb-stale" || normalizedReason === "indexeddb-chat-switched") return result;
|
||
if (graphPersistenceState.loadState !== GRAPH_LOAD_STATES.LOADING) return result;
|
||
const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId);
|
||
if (stateChatId && stateChatId !== normalizedChatId) return result;
|
||
const currentChatId = runtimeContext.getCurrentChatId();
|
||
if (currentChatId && currentChatId !== normalizedChatId) return result;
|
||
if (runtimeContext.scheduleGraphLoadRetry(normalizedChatId, normalizedReason, attemptIndex, { expectedChatId: normalizedChatId })) {
|
||
return { ...result, retryScheduled: true };
|
||
}
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, {
|
||
chatId: normalizedChatId,
|
||
reason: normalizedReason,
|
||
attemptIndex,
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
runtimeStatus = runtimeContext.createGraphLoadUiStatus();
|
||
runtimeContext.refreshPanelLiveState();
|
||
return { ...result, loadState: GRAPH_LOAD_STATES.BLOCKED, blocked: true, reason: normalizedReason };
|
||
},
|
||
shouldSyncGraphLoadFromLiveContext(context = runtimeContext.getContext(), { force = false } = {}) {
|
||
if (force) return true;
|
||
const chatIdentity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
const liveChatId = chatIdentity.chatId;
|
||
const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId);
|
||
if (!runtimeContext.areChatIdsEquivalentForResolvedIdentity(liveChatId, stateChatId, chatIdentity)) return true;
|
||
if (liveChatId && currentGraph) {
|
||
const runtimeChatId = normalizeChatIdCandidate(currentGraph?.historyState?.chatId);
|
||
if (runtimeChatId && !runtimeContext.areChatIdsEquivalentForResolvedIdentity(liveChatId, runtimeChatId, chatIdentity)) return true;
|
||
}
|
||
if (!liveChatId && graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT) return true;
|
||
if (liveChatId && !graphPersistenceState.dbReady) return true;
|
||
return false;
|
||
},
|
||
syncGraphLoadFromLiveContext(options = {}) {
|
||
const context = runtimeContext.getContext();
|
||
const chatIdentity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
const chatId = chatIdentity.chatId;
|
||
if (runtimeContext.resolveBmeHostProfile(context) === "luker" && runtimeContext.canUseHostGraphChatStatePersistence(context)) {
|
||
const attemptIndex = Math.max(0, Math.floor(Number(options?.attemptIndex) || 0));
|
||
runtimeContext.scheduleGraphChatStateProbe(chatId, { source: `${String(options?.source || "live-context-sync")}:luker-chat-state-probe`, attemptIndex, allowOverride: true });
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, {
|
||
chatId,
|
||
reason: `luker-chat-state-probe-pending:${String(options?.source || "direct-load")}`,
|
||
attemptIndex,
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
hostProfile: "luker",
|
||
primaryStorageTier: "luker-chat-state",
|
||
cacheStorageTier: "indexeddb",
|
||
});
|
||
runtimeContext.updateGraphPersistenceState({ hostProfile: "luker", primaryStorageTier: "luker-chat-state", cacheStorageTier: "indexeddb", dbReady: false, indexedDbLastError: "" });
|
||
runtimeContext.refreshPanelLiveState();
|
||
return { success: false, loaded: false, loadState: GRAPH_LOAD_STATES.LOADING, reason: "luker-chat-state-probe-pending", chatId, attemptIndex };
|
||
}
|
||
const result = syncGraphLoadFromLiveContextImpl(runtimeContext, options);
|
||
const reconcileLoadState = graphPersistenceState.loadState;
|
||
const directSnapshot = getIndexedDbSnapshotForChat(chatId);
|
||
if (runtimeContext.isIndexedDbSnapshotMeaningful(directSnapshot) && graphPersistenceState.loadState !== GRAPH_LOAD_STATES.LOADED) {
|
||
runtimeContext.applyIndexedDbSnapshotToRuntime(chatId, directSnapshot, {
|
||
source: `${String(options?.source || "live-context-sync")}:indexeddb-probe`,
|
||
attemptIndex: 0,
|
||
});
|
||
} else if (result?.synced === true && (result?.loadState === GRAPH_LOAD_STATES.LOADING || result?.loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED || reconcileLoadState === GRAPH_LOAD_STATES.LOADING || reconcileLoadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED)) {
|
||
const snapshot = directSnapshot;
|
||
if (runtimeContext.isIndexedDbSnapshotMeaningful(snapshot)) {
|
||
runtimeContext.applyIndexedDbSnapshotToRuntime(chatId, snapshot, {
|
||
source: `${String(options?.source || "live-context-sync")}:indexeddb-probe`,
|
||
attemptIndex: 0,
|
||
});
|
||
} else if (chatId) {
|
||
runtimeContext.applyIndexedDbEmptyToRuntime(chatId, {
|
||
source: `${String(options?.source || "live-context-sync")}:indexeddb-empty`,
|
||
attemptIndex: 0,
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
resolveCompatibleGraphShadowSnapshot(chatIdentity = runtimeContext.resolveCurrentChatIdentity(runtimeContext.getContext())) {
|
||
const shadowCandidates = [];
|
||
if (chatIdentity?.chatId) shadowCandidates.push(chatIdentity.chatId);
|
||
if (chatIdentity?.hostChatId && chatIdentity.hostChatId !== chatIdentity.chatId) shadowCandidates.push(chatIdentity.hostChatId);
|
||
if (chatIdentity?.integrity) shadowCandidates.push(chatIdentity.integrity);
|
||
for (const candidate of shadowCandidates) {
|
||
const snapshot = readGraphShadowSnapshot(candidate);
|
||
if (snapshot) return snapshot;
|
||
}
|
||
if (chatIdentity?.integrity) return findGraphShadowSnapshotByIntegrity(chatIdentity.integrity);
|
||
return null;
|
||
},
|
||
applyShadowSnapshotToRuntime(shadowSnapshot, { chatId = runtimeContext.getCurrentChatId(), reason = "shadow-recovery", attemptIndex = 0 } = {}) {
|
||
if (!shadowSnapshot?.serializedGraph) return { success: false, loaded: false, reason: "shadow-empty", chatId, attemptIndex };
|
||
try {
|
||
currentGraph = normalizeGraphRuntimeState(deserializeGraph(shadowSnapshot.serializedGraph), chatId);
|
||
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) ? currentGraph.historyState.extractionCount : 0;
|
||
lastExtractedItems = [];
|
||
lastRecalledItems = Array.isArray(currentGraph?.lastRecallResult) ? currentGraph.lastRecallResult : [];
|
||
lastInjectionContent = typeof currentGraph?.lastInjectionContent === "string" ? currentGraph.lastInjectionContent : "";
|
||
runtimeStatus = createUiStatus("恢复中", "已从影子快照临时恢复图谱", "warning");
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.SHADOW_RESTORED, {
|
||
chatId,
|
||
reason,
|
||
attemptIndex,
|
||
shadowSnapshotUsed: true,
|
||
shadowSnapshotRevision: Number(shadowSnapshot.revision || 0),
|
||
shadowSnapshotUpdatedAt: String(shadowSnapshot.updatedAt || ""),
|
||
shadowSnapshotReason: String(shadowSnapshot.reason || ""),
|
||
revision: Number(shadowSnapshot.revision || 0),
|
||
dbReady: false,
|
||
writesBlocked: false,
|
||
});
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.SHADOW_RESTORED, reason, chatId, attemptIndex, shadowSnapshotUsed: true, revision: Number(shadowSnapshot.revision || 0) };
|
||
} catch (error) {
|
||
return { success: false, loaded: false, reason: "shadow-restore-failed", chatId, attemptIndex, error: error?.message || String(error) };
|
||
}
|
||
},
|
||
applyIndexedDbEmptyToRuntime(chatId, { source = "indexeddb-empty", attemptIndex = 0, storagePrimary = "indexeddb", storageMode = storagePrimary, statusLabel = "IndexedDB", reasonPrefix = "indexeddb" } = {}) {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||
currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), normalizedChatId);
|
||
extractionCount = 0;
|
||
lastExtractedItems = [];
|
||
lastRecalledItems = [];
|
||
runtimeStatus = createUiStatus("空图谱", `已确认${statusLabel}暂无聊天图谱`, "idle");
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.EMPTY_CONFIRMED, {
|
||
chatId: normalizedChatId,
|
||
reason: `${reasonPrefix}:${source}`,
|
||
attemptIndex,
|
||
revision: 0,
|
||
lastPersistedRevision: 0,
|
||
queuedPersistRevision: 0,
|
||
pendingPersist: false,
|
||
writesBlocked: false,
|
||
dbReady: true,
|
||
storagePrimary,
|
||
storageMode,
|
||
});
|
||
runtimeContext.updateGraphPersistenceState({ storagePrimary, storageMode, indexedDbRevision: storagePrimary === "indexeddb" ? 0 : graphPersistenceState.indexedDbRevision, indexedDbLastError: "" });
|
||
return { success: true, loaded: true, emptyConfirmed: true, loadState: GRAPH_LOAD_STATES.EMPTY_CONFIRMED, reason: `${reasonPrefix}:${source}`, chatId: normalizedChatId, attemptIndex, revision: 0 };
|
||
},
|
||
applyIndexedDbSnapshotToRuntime(chatId, snapshot, options = {}) {
|
||
const {
|
||
source = "indexeddb", attemptIndex = 0, storagePrimary = "indexeddb", storageMode = storagePrimary,
|
||
statusLabel = "IndexedDB", reasonPrefix = "indexeddb", currentSettings = null,
|
||
} = options || {};
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||
const loadStartedAt = runtimeContext.readLoadDiagnosticsNow();
|
||
const recordLoadDiagnostics = (patch = {}) => runtimeContext.updateLoadDiagnostics({
|
||
stage: "apply-indexeddb-snapshot", source: String(source || reasonPrefix), reasonPrefix: String(reasonPrefix || "indexeddb"), statusLabel: String(statusLabel || "IndexedDB"), chatId: normalizedChatId || "", attemptIndex: Number.isFinite(Number(attemptIndex)) ? Math.max(0, Math.floor(Number(attemptIndex))) : 0, storagePrimary: String(storagePrimary || "indexeddb"), storageMode: String(storageMode || storagePrimary || "indexeddb"), ...cloneRuntimeDebugValue(patch, {}), totalMs: runtimeContext.normalizeLoadDiagnosticsMs(runtimeContext.readLoadDiagnosticsNow() - loadStartedAt),
|
||
});
|
||
if (!normalizedChatId || !runtimeContext.isIndexedDbSnapshotMeaningful(snapshot)) {
|
||
const result = { success: false, loaded: false, reason: `${reasonPrefix}-empty`, chatId: normalizedChatId, attemptIndex };
|
||
recordLoadDiagnostics({ success: false, loaded: false, reason: result.reason });
|
||
return result;
|
||
}
|
||
const revision = Math.max(1, runtimeContext.normalizeIndexedDbRevision(snapshot?.meta?.revision));
|
||
let graphFromSnapshot = null;
|
||
try {
|
||
graphFromSnapshot = buildGraphFromSnapshot(snapshot, { chatId: normalizedChatId, useNativeHydrate: false, nativeFailOpen: true });
|
||
} catch (error) {
|
||
const failureReason = error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR" ? `${reasonPrefix}-snapshot-integrity-rejected` : `${reasonPrefix}-snapshot-load-failed`;
|
||
runtimeContext.updateGraphPersistenceState({ storagePrimary, storageMode, dbReady: true, indexedDbLastError: error?.message || String(error), dualWriteLastResult: { action: "load", source: String(source || reasonPrefix), success: false, rejected: true, reason: failureReason, revision, at: Date.now() }, ...(storagePrimary === "indexeddb" ? { indexedDbRevision: revision } : {}) });
|
||
const result = { success: false, loaded: false, reason: failureReason, detail: error?.message || String(error), chatId: normalizedChatId, attemptIndex };
|
||
recordLoadDiagnostics({ success: false, loaded: false, reason: failureReason, revision, error: error?.message || String(error) });
|
||
return result;
|
||
}
|
||
currentGraph = graphFromSnapshot;
|
||
stampGraphPersistenceMeta(currentGraph, { revision, reason: `${reasonPrefix}:${String(source || reasonPrefix)}`, chatId: normalizedChatId, integrity: normalizeChatIdCandidate(snapshot?.meta?.integrity) || runtimeContext.getChatMetadataIntegrity(runtimeContext.getContext()) });
|
||
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) ? currentGraph.historyState.extractionCount : 0;
|
||
lastExtractedItems = [];
|
||
const restoredRecallUi = runtimeContext.restoreRecallUiStateFromPersistence(runtimeContext.getContext()?.chat);
|
||
runtimeStatus = createUiStatus("待命", `已从${statusLabel}加载聊天图谱`, "idle");
|
||
lastExtractionStatus = createUiStatus("待命", `已从${statusLabel}加载聊天图谱,等待下一次提取`, "idle");
|
||
lastVectorStatus = createUiStatus("待命", currentGraph.vectorIndexState?.lastWarning || `已从${statusLabel}加载聊天图谱,等待下一次向量任务`, "idle");
|
||
lastRecallStatus = createUiStatus("待命", restoredRecallUi.restored ? "已从持久化召回记录恢复显示,等待下一次召回" : `已从${statusLabel}加载聊天图谱,等待下一次召回`, "idle");
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, reason: `${reasonPrefix}:${source}`, attemptIndex, revision, lastPersistedRevision: Math.max(graphPersistenceState.lastPersistedRevision || 0, revision), queuedPersistRevision: 0, pendingPersist: false, shadowSnapshotUsed: false, shadowSnapshotRevision: 0, shadowSnapshotUpdatedAt: "", shadowSnapshotReason: "", writesBlocked: false, storagePrimary, storageMode });
|
||
runtimeContext.updateGraphPersistenceState({ storagePrimary, storageMode, dbReady: true, persistMismatchReason: "", metadataIntegrity: runtimeContext.getChatMetadataIntegrity(runtimeContext.getContext()) || graphPersistenceState.metadataIntegrity, indexedDbLastError: storagePrimary === "indexeddb" ? "" : graphPersistenceState.indexedDbLastError, lastAcceptedRevision: Math.max(Number(graphPersistenceState.lastAcceptedRevision || 0), revision), lastSyncError: "", dualWriteLastResult: { action: "load", source: String(source || reasonPrefix), success: true, reason: `${reasonPrefix}-loaded`, revision, at: Date.now() }, ...(storagePrimary === "indexeddb" ? { indexedDbRevision: revision } : {}) });
|
||
runtimeContext.rememberResolvedGraphIdentityAlias(runtimeContext.getContext(), normalizedChatId);
|
||
removeGraphShadowSnapshot(normalizedChatId);
|
||
runtimeContext.refreshPanelLiveState();
|
||
runtimeContext.schedulePersistedRecallMessageUiRefresh(30);
|
||
const result = { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: `${reasonPrefix}:${source}`, chatId: normalizedChatId, attemptIndex, shadowSnapshotUsed: false, revision };
|
||
recordLoadDiagnostics({ success: true, loaded: true, reason: result.reason, revision });
|
||
return result;
|
||
},
|
||
cacheIndexedDbSnapshot(chatId, snapshot = null) {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||
if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return;
|
||
if (snapshot.__stBmeTombstonesOmitted === true) return;
|
||
const snapshotStore = runtimeContext.resolveSnapshotGraphStorePresentation(snapshot);
|
||
if (snapshotStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND) return;
|
||
bmeIndexedDbSnapshotCacheByChatId.set(normalizedChatId, {
|
||
chatId: normalizedChatId,
|
||
revision: runtimeContext.normalizeIndexedDbRevision(snapshot?.meta?.revision),
|
||
selectorKey: runtimeContext.buildGraphLocalStoreSelectorKey(snapshotStore),
|
||
snapshot,
|
||
updatedAt: Date.now(),
|
||
});
|
||
},
|
||
readCachedIndexedDbSnapshot(chatId, expectedStore = null) {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||
if (!normalizedChatId) return null;
|
||
const cacheEntry = bmeIndexedDbSnapshotCacheByChatId.get(normalizedChatId);
|
||
if (!cacheEntry?.snapshot) return null;
|
||
if (expectedStore && typeof expectedStore === "object") {
|
||
const expectedSelectorKey = runtimeContext.buildGraphLocalStoreSelectorKey(expectedStore);
|
||
if (cacheEntry.selectorKey && cacheEntry.selectorKey !== expectedSelectorKey) return null;
|
||
}
|
||
return cacheEntry.snapshot;
|
||
},
|
||
createShadowComparisonGraph({ shadowSnapshot = null, fallbackChatId = "" } = {}) {
|
||
if (!shadowSnapshot?.serializedGraph) return null;
|
||
try {
|
||
return normalizeGraphRuntimeState(deserializeGraph(shadowSnapshot.serializedGraph), fallbackChatId || shadowSnapshot.chatId || "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
isIndexedDbSnapshotMeaningful(snapshot = null) {
|
||
if (!snapshot || typeof snapshot !== "object") return false;
|
||
if ((Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0) || (Array.isArray(snapshot.edges) && snapshot.edges.length > 0) || (Array.isArray(snapshot.tombstones) && snapshot.tombstones.length > 0)) return true;
|
||
const state = snapshot.state || {};
|
||
if (Number.isFinite(Number(state.lastProcessedFloor)) && Number(state.lastProcessedFloor) >= 0) return true;
|
||
if (Number.isFinite(Number(state.extractionCount)) && Number(state.extractionCount) > 0) return true;
|
||
const runtimeHistoryState = snapshot.meta?.runtimeHistoryState;
|
||
if (runtimeHistoryState && typeof runtimeHistoryState === "object" && !Array.isArray(runtimeHistoryState)) {
|
||
if (Number.isFinite(Number(runtimeHistoryState.lastProcessedAssistantFloor)) && Number(runtimeHistoryState.lastProcessedAssistantFloor) >= 0) return true;
|
||
if (runtimeHistoryState.processedMessageHashes && typeof runtimeHistoryState.processedMessageHashes === "object" && !Array.isArray(runtimeHistoryState.processedMessageHashes) && Object.keys(runtimeHistoryState.processedMessageHashes).length > 0) return true;
|
||
}
|
||
return false;
|
||
},
|
||
normalizeIndexedDbRevision(value, fallbackValue = 0) {
|
||
const numericValue = Number(value);
|
||
if (Number.isFinite(numericValue) && numericValue > 0) return Math.floor(numericValue);
|
||
const fallback = Number(fallbackValue);
|
||
return Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : 0;
|
||
},
|
||
readPersistDeltaDiagnosticsNow() {
|
||
return typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now();
|
||
},
|
||
readLoadDiagnosticsNow() {
|
||
return runtimeContext.readPersistDeltaDiagnosticsNow();
|
||
},
|
||
normalizeLoadDiagnosticsMs(value = 0) {
|
||
return Math.round((Number(value) || 0) * 10) / 10;
|
||
},
|
||
normalizePersistDeltaDiagnosticsMs(value = 0) {
|
||
return Math.round((Number(value) || 0) * 10) / 10;
|
||
},
|
||
updatePersistDeltaDiagnostics(snapshot = null) {
|
||
const nextSnapshot = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
||
? { ...(graphPersistenceState.persistDelta && typeof graphPersistenceState.persistDelta === "object" && !Array.isArray(graphPersistenceState.persistDelta) ? cloneRuntimeDebugValue(graphPersistenceState.persistDelta, {}) : {}), ...cloneRuntimeDebugValue(snapshot, {}), updatedAt: new Date().toISOString() }
|
||
: null;
|
||
runtimeContext.updateGraphPersistenceState({ persistDelta: nextSnapshot });
|
||
return nextSnapshot;
|
||
},
|
||
updateLoadDiagnostics(snapshot = null) {
|
||
const nextSnapshot = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
||
? { ...(graphPersistenceState.loadDiagnostics && typeof graphPersistenceState.loadDiagnostics === "object" && !Array.isArray(graphPersistenceState.loadDiagnostics) ? cloneRuntimeDebugValue(graphPersistenceState.loadDiagnostics, {}) : {}), ...cloneRuntimeDebugValue(snapshot, {}), updatedAt: new Date().toISOString() }
|
||
: null;
|
||
runtimeContext.updateGraphPersistenceState({ loadDiagnostics: nextSnapshot });
|
||
return nextSnapshot;
|
||
},
|
||
buildPersistObservabilitySummary(diagnostics = null) {
|
||
if (!diagnostics || typeof diagnostics !== "object") return null;
|
||
return cloneRuntimeDebugValue(diagnostics, diagnostics);
|
||
},
|
||
detectStaleIndexedDbSnapshotAgainstRuntime(chatId = "", snapshot = null) {
|
||
const snapshotRevision = runtimeContext.normalizeIndexedDbRevision(snapshot?.meta?.revision);
|
||
const runtimeRevision = runtimeContext.normalizeIndexedDbRevision(graphPersistenceState.revision);
|
||
if (currentGraph && graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADED && runtimeRevision > snapshotRevision) {
|
||
return { stale: true, reason: "runtime-revision-newer", runtimeRevision, snapshotRevision };
|
||
}
|
||
return { stale: false, reason: "" };
|
||
},
|
||
detectIndexedDbSnapshotCommitMarkerMismatch,
|
||
async maybeRecoverIndexedDbGraphFromStableIdentity() {
|
||
return null;
|
||
},
|
||
async maybeMigrateLegacyGraphToIndexedDb() {
|
||
return { migrated: false, reason: "not-needed" };
|
||
},
|
||
async maybeImportLegacyIndexedDbSnapshotToLocalStore() {
|
||
return { imported: false, reason: "not-needed" };
|
||
},
|
||
async maybeImportLegacyOpfsSnapshotToLocalStore() {
|
||
return { imported: false, reason: "not-needed" };
|
||
},
|
||
async maybeResolveOrphanAcceptedCommitMarker() {
|
||
return null;
|
||
},
|
||
queueRuntimeGraphLocalStoreRepair() {
|
||
return false;
|
||
},
|
||
async refreshCurrentChatLocalStoreBinding() {
|
||
return { refreshed: false };
|
||
},
|
||
recordPersistMismatchDiagnostic(mismatch = {}, options = {}) {
|
||
const diagnostic = {
|
||
reason: String(mismatch?.reason || "persist-mismatch"),
|
||
markerRevision: Number(mismatch?.markerRevision || 0),
|
||
snapshotRevision: Number(mismatch?.snapshotRevision || 0),
|
||
source: String(options?.source || ""),
|
||
resolvedBy: String(options?.resolvedBy || ""),
|
||
at: Date.now(),
|
||
};
|
||
runtimeContext.updateGraphPersistenceState({ persistMismatchReason: diagnostic.reason, persistMismatch: diagnostic });
|
||
return diagnostic;
|
||
},
|
||
resolvePendingPersistLastProcessedAssistantFloor() {
|
||
const processedRange = Array.isArray(currentGraph?.historyState?.lastBatchStatus?.processedRange) ? currentGraph.historyState.lastBatchStatus.processedRange : [];
|
||
const rangeEnd = Number(processedRange[1]);
|
||
if (Number.isFinite(rangeEnd) && rangeEnd >= 0) return Math.floor(rangeEnd);
|
||
const rangeStart = Number(processedRange[0]);
|
||
if (Number.isFinite(rangeStart) && rangeStart >= 0) return Math.floor(rangeStart);
|
||
return null;
|
||
},
|
||
resolvePendingPersistGraphSource(chatId = "") {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId || graphPersistenceState.queuedPersistChatId || graphPersistenceState.chatId);
|
||
const targetRevision = Math.max(Number(graphPersistenceState.queuedPersistRevision || 0), Number(graphPersistenceState.revision || 0));
|
||
const shadowSnapshot = normalizedChatId ? readGraphShadowSnapshot(normalizedChatId) : null;
|
||
if (shadowSnapshot && Number(shadowSnapshot.revision || 0) >= targetRevision && typeof shadowSnapshot.serializedGraph === "string" && shadowSnapshot.serializedGraph) {
|
||
try {
|
||
const shadowGraph = normalizeGraphRuntimeState(deserializeGraph(shadowSnapshot.serializedGraph), normalizedChatId);
|
||
return { graph: shadowGraph, source: "shadow", revision: Number(shadowSnapshot.revision || 0) };
|
||
} catch {}
|
||
}
|
||
return { graph: currentGraph, source: "runtime", revision: Math.max(Number(getGraphPersistedRevision(currentGraph) || 0), targetRevision) };
|
||
},
|
||
applyAcceptedPendingPersistState(persistResult, { lastProcessedAssistantFloor = runtimeContext.resolvePendingPersistLastProcessedAssistantFloor(), persistedGraph = null } = {}) {
|
||
runtimeContext.ensureCurrentGraphRuntimeState();
|
||
const persistenceRecord = reduceBatchPersistenceRecordFromPersistResult(persistResult);
|
||
const batchStatus = currentGraph?.historyState?.lastBatchStatus;
|
||
if (batchStatus && typeof batchStatus === "object") {
|
||
currentGraph.historyState.lastBatchStatus = reducePersistenceRecordToBatchStatus(batchStatus, persistenceRecord);
|
||
}
|
||
if (persistedGraph && typeof persistedGraph === "object" && !Array.isArray(persistedGraph)) {
|
||
const persistedHistory = persistedGraph.historyState && typeof persistedGraph.historyState === "object" && !Array.isArray(persistedGraph.historyState) ? persistedGraph.historyState : null;
|
||
if (persistedHistory) {
|
||
currentGraph.historyState.processedMessageHashVersion = persistedHistory.processedMessageHashVersion ?? currentGraph.historyState.processedMessageHashVersion;
|
||
currentGraph.historyState.processedMessageHashes = cloneRuntimeDebugValue(persistedHistory.processedMessageHashes || {}, currentGraph.historyState.processedMessageHashes || {});
|
||
currentGraph.historyState.processedMessageHashesNeedRefresh = persistedHistory.processedMessageHashesNeedRefresh === true;
|
||
}
|
||
if (Array.isArray(persistedGraph.batchJournal)) currentGraph.batchJournal = cloneRuntimeDebugValue(persistedGraph.batchJournal, currentGraph.batchJournal || []);
|
||
}
|
||
if (persistenceRecord.accepted === true && Number.isFinite(Number(lastProcessedAssistantFloor)) && Number(lastProcessedAssistantFloor) >= 0) {
|
||
const safeFloor = Math.floor(Number(lastProcessedAssistantFloor));
|
||
currentGraph.historyState.lastProcessedAssistantFloor = safeFloor;
|
||
currentGraph.lastProcessedSeq = safeFloor;
|
||
}
|
||
if (persistenceRecord.accepted === true) {
|
||
runtimeContext.updateGraphPersistenceState(reducePersistenceStatePatch(graphPersistenceState, { type: PERSISTENCE_EVENT_TYPES.ACCEPTED, persistenceRecord, clearQueued: false }));
|
||
}
|
||
runtimeContext.refreshPanelLiveState();
|
||
},
|
||
maybeClearAcceptedPendingPersistState(source = "accepted-pending-persist-reconcile") {
|
||
runtimeContext.ensureCurrentGraphRuntimeState();
|
||
if (graphPersistenceState.pendingPersist !== true) return false;
|
||
const batchStatus = currentGraph?.historyState?.lastBatchStatus || null;
|
||
const persistence = batchStatus?.persistence || null;
|
||
const commitMarker = runtimeContext.syncCommitMarkerToPersistenceState(runtimeContext.getContext());
|
||
const context = runtimeContext.getContext();
|
||
const activeChatId = normalizeChatIdCandidate(runtimeContext.getCurrentChatId(context));
|
||
const queuedChatId = normalizeChatIdCandidate(graphPersistenceState.queuedPersistChatId || graphPersistenceState.chatId || activeChatId);
|
||
const currentIdentity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
if (!activeChatId || !queuedChatId || (!runtimeContext.areChatIdsEquivalentForResolvedIdentity(queuedChatId, activeChatId, currentIdentity) && !runtimeContext.areChatIdsEquivalentForResolvedIdentity(activeChatId, queuedChatId, currentIdentity))) return false;
|
||
const markerChatId = normalizeChatIdCandidate(commitMarker?.chatId);
|
||
const markerAcceptedRevision = getAcceptedCommitMarkerRevision(commitMarker);
|
||
const markerAcceptedForQueuedChat = markerAcceptedRevision > 0 && markerChatId && (runtimeContext.areChatIdsEquivalentForResolvedIdentity(markerChatId, queuedChatId, currentIdentity) || runtimeContext.areChatIdsEquivalentForResolvedIdentity(queuedChatId, markerChatId, currentIdentity));
|
||
const plan = planAcceptedPendingClear({ batchPersistence: persistence, persistenceState: graphPersistenceState, commitMarker, activeChatId, queuedChatId, markerChatMatchesQueued: markerAcceptedForQueuedChat });
|
||
if (plan.action !== "clear-stale-pending") return false;
|
||
const acceptedResult = runtimeContext.buildGraphPersistResult({ saved: true, accepted: true, reason: `${String(source || "accepted-pending-persist-reconcile")}:accepted-revision`, revision: plan.targetRevision, saveMode: "accepted-revision-reconcile", storageTier: plan.tier, acceptedBy: plan.tier });
|
||
runtimeContext.applyAcceptedPendingPersistState(acceptedResult, { lastProcessedAssistantFloor: runtimeContext.resolvePendingPersistLastProcessedAssistantFloor() });
|
||
runtimeContext.clearPendingGraphPersistRetry();
|
||
return true;
|
||
},
|
||
clearPendingGraphPersistRetry({ resetChatId = true } = {}) {
|
||
if (pendingGraphPersistRetryTimer) {
|
||
runtimeContext.clearTimeout(pendingGraphPersistRetryTimer);
|
||
pendingGraphPersistRetryTimer = null;
|
||
}
|
||
if (resetChatId) {
|
||
pendingGraphPersistRetryChatId = "";
|
||
pendingGraphPersistRetryAttempt = 0;
|
||
}
|
||
},
|
||
schedulePendingGraphPersistRetry(reason = "pending-graph-persist-retry", attempt = 0) {
|
||
if (runtimeContext.isRestoreLockActive()) return false;
|
||
if (!graphPersistenceState.pendingPersist) {
|
||
runtimeContext.clearPendingGraphPersistRetry();
|
||
return false;
|
||
}
|
||
const targetChatId = normalizeChatIdCandidate(graphPersistenceState.queuedPersistChatId || graphPersistenceState.chatId || runtimeContext.getCurrentChatId());
|
||
if (!targetChatId) return false;
|
||
const normalizedAttempt = Math.max(0, Math.floor(Number(attempt) || 0));
|
||
if (normalizedAttempt >= PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS) return false;
|
||
const delayIndex = Math.min(normalizedAttempt, PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS.length - 1);
|
||
const delayMs = PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS[delayIndex];
|
||
runtimeContext.clearPendingGraphPersistRetry({ resetChatId: false });
|
||
pendingGraphPersistRetryChatId = targetChatId;
|
||
pendingGraphPersistRetryAttempt = normalizedAttempt;
|
||
pendingGraphPersistRetryTimer = runtimeContext.setTimeout(() => {
|
||
pendingGraphPersistRetryTimer = null;
|
||
void runtimeContext.retryPendingGraphPersist({ reason: `${reason}:attempt-${normalizedAttempt + 1}`, retryAttempt: normalizedAttempt, scheduleRetryOnFailure: true }).catch((error) => console.warn("[ST-BME] 待确认持久化自动重试失败:", error));
|
||
}, delayMs);
|
||
return true;
|
||
},
|
||
persistGraphToChatMetadata(context = runtimeContext.getContext(), { reason = "graph-persist", revision = graphPersistenceState.revision, immediate = false, graph = currentGraph } = {}) {
|
||
if (!context || !graph) return runtimeContext.buildGraphPersistResult({ saved: false, blocked: true, accepted: false, recoverable: false, reason: "missing-context-or-graph", revision });
|
||
const persistChatId = runtimeContext.resolvePersistenceChatId(context, graph);
|
||
if (!persistChatId) return runtimeContext.buildGraphPersistResult({ saved: false, blocked: true, accepted: false, recoverable: false, reason: "missing-chat-id", revision });
|
||
const nextIntegrity = runtimeContext.getChatMetadataIntegrity(context);
|
||
const persistedGraph = cloneGraphForPersistence(graph, persistChatId);
|
||
stampGraphPersistenceMeta(persistedGraph, { revision, reason, chatId: persistChatId, integrity: nextIntegrity });
|
||
writeChatMetadataPatch(context, { [GRAPH_METADATA_KEY]: persistedGraph });
|
||
const saveMode = runtimeContext.triggerChatMetadataSave(context, { immediate });
|
||
runtimeContext.updateGraphPersistenceState({
|
||
revision: Math.max(Number(graphPersistenceState.revision || 0), Number(revision || 0)),
|
||
lastPersistedRevision: Math.max(Number(graphPersistenceState.lastPersistedRevision || 0), Number(revision || 0)),
|
||
lastPersistReason: String(reason || ""),
|
||
lastPersistMode: saveMode,
|
||
pendingPersist: false,
|
||
metadataIntegrity: nextIntegrity,
|
||
queuedPersistRevision: 0,
|
||
queuedPersistChatId: "",
|
||
queuedPersistMode: "",
|
||
queuedPersistReason: "",
|
||
lastRecoverableStorageTier: "metadata-full",
|
||
});
|
||
return runtimeContext.buildGraphPersistResult({ saved: true, accepted: true, reason, revision, saveMode, storageTier: "metadata-full", acceptedBy: "metadata-full" });
|
||
},
|
||
triggerChatMetadataSave(context = runtimeContext.getContext(), { immediate = false } = {}) {
|
||
if (immediate) {
|
||
const immediateSave = typeof context?.saveMetadata === "function" ? context.saveMetadata : runtimeContext.saveMetadata;
|
||
if (typeof immediateSave === "function") {
|
||
try {
|
||
const result = immediateSave.call(context);
|
||
if (result && typeof result.catch === "function") result.catch((error) => console.error("[ST-BME] 立即保存聊天元数据失败:", error));
|
||
return "immediate";
|
||
} catch {}
|
||
}
|
||
}
|
||
if (typeof context?.saveMetadataDebounced === "function") {
|
||
context.saveMetadataDebounced();
|
||
return "debounced";
|
||
}
|
||
runtimeContext.saveMetadataDebounced();
|
||
return "debounced";
|
||
},
|
||
persistGraphCommitMarker(context = runtimeContext.getContext(), { reason = "graph-commit-marker", revision = graphPersistenceState.revision, storageTier = "none", accepted = false, lastProcessedAssistantFloor = null, extractionCount: nextExtractionCount = null, immediate = true } = {}) {
|
||
if (!context) return runtimeContext.buildGraphPersistResult({ saved: false, blocked: true, accepted: false, reason: "missing-context", revision, storageTier });
|
||
const persistChatId = runtimeContext.getCurrentChatId(context);
|
||
if (!persistChatId) return runtimeContext.buildGraphPersistResult({ saved: false, blocked: true, accepted: false, reason: "missing-chat-id", revision, storageTier });
|
||
const marker = buildGraphCommitMarker(currentGraph, { revision, storageTier, accepted, reason, chatId: persistChatId, integrity: runtimeContext.getChatMetadataIntegrity(context), lastProcessedAssistantFloor, extractionCount: nextExtractionCount });
|
||
if (!marker) return runtimeContext.buildGraphPersistResult({ saved: false, blocked: true, accepted: false, reason: "marker-build-failed", revision, storageTier });
|
||
writeChatMetadataPatch(context, { [GRAPH_COMMIT_MARKER_KEY]: marker });
|
||
const saveMode = runtimeContext.triggerChatMetadataSave(context, { immediate });
|
||
runtimeContext.updateGraphPersistenceState({ commitMarker: cloneRuntimeDebugValue(marker, null), lastPersistReason: String(reason || ""), lastPersistMode: `commit-marker:${saveMode}` });
|
||
return runtimeContext.buildGraphPersistResult({ saved: true, blocked: false, accepted, reason, revision: Number(marker.revision || revision || 0), saveMode, storageTier });
|
||
},
|
||
async persistGraphToConfiguredDurableTier(context, graph, options = {}) {
|
||
const {
|
||
chatId, revision, reason, lastProcessedAssistantFloor = null, persistDelta = null, graphSnapshot = null,
|
||
persistSnapshot = null, chatStateTarget = null, graphDetached = false,
|
||
} = options || {};
|
||
const preferredLocalStore = runtimeContext.getPreferredGraphLocalStorePresentationSync();
|
||
const persistenceEnvironment = runtimeContext.buildPersistenceEnvironment(context, preferredLocalStore);
|
||
const localStoreTier = runtimeContext.resolveLocalStoreTierFromPresentation(preferredLocalStore);
|
||
if (runtimeContext.isLukerPrimaryPersistenceHost(context) || runtimeContext.Luker) {
|
||
if (runtimeContext.shouldUseAuthorityGraphStore()) {
|
||
const authoritySnapshot = buildSnapshotFromGraph(graph, {
|
||
chatId,
|
||
revision,
|
||
meta: {
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
},
|
||
});
|
||
setAuthoritySnapshotForChat(chatId, authoritySnapshot);
|
||
const metadataIntegrity = runtimeContext.getChatMetadataIntegrity(context);
|
||
if (metadataIntegrity && metadataIntegrity !== chatId) setAuthoritySnapshotForChat(metadataIntegrity, authoritySnapshot);
|
||
runtimeContext.updateGraphPersistenceState({
|
||
acceptedStorageTier: AUTHORITY_GRAPH_STORE_KIND,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
});
|
||
return runtimeContext.buildGraphPersistResult({
|
||
saved: true,
|
||
accepted: true,
|
||
reason,
|
||
revision,
|
||
saveMode: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageTier: "authority-sql",
|
||
acceptedBy: "authority-sql",
|
||
primaryTier: "authority-sql",
|
||
cacheTier: "none",
|
||
});
|
||
}
|
||
const lukerResult = await runtimeContext.persistGraphToHostChatState(context, { graph, revision, reason, storageTier: "luker-chat-state", accepted: true, lastProcessedAssistantFloor, extractionCount, mode: "primary", persistDelta, graphSnapshot, persistSnapshot, chatStateTarget });
|
||
if (lukerResult?.saved) {
|
||
runtimeContext.updateGraphPersistenceState({ acceptedStorageTier: "luker-chat-state", lukerManifestRevision: lukerResult.revision, cacheTier: "none", acceptedRevision: lukerResult.revision });
|
||
return runtimeContext.buildGraphPersistResult({ saved: true, accepted: true, reason, revision: lukerResult.revision || revision, saveMode: "luker-chat-state", storageTier: "luker-chat-state", acceptedBy: "luker-chat-state", primaryTier: "luker-chat-state", cacheTier: "none" });
|
||
}
|
||
return runtimeContext.buildGraphPersistResult({ saved: false, accepted: false, reason, revision, storageTier: "luker-chat-state", acceptedBy: "none" });
|
||
}
|
||
const indexedDbResult = await runtimeContext.saveGraphToIndexedDb(chatId, graph, { revision, reason, persistDelta, graphSnapshot, persistSnapshot, sourceGraph: graph });
|
||
if (indexedDbResult?.saved) {
|
||
runtimeContext.persistGraphCommitMarker(context, { reason, revision: indexedDbResult.revision || revision, storageTier: indexedDbResult.storageTier || localStoreTier, accepted: true, lastProcessedAssistantFloor, extractionCount, immediate: true });
|
||
runtimeContext.clearPendingGraphPersistRetry();
|
||
return runtimeContext.buildGraphPersistResult({ saved: true, accepted: true, reason, revision: indexedDbResult.revision || revision, saveMode: String(indexedDbResult.saveMode || "indexeddb-delta"), storageTier: indexedDbResult.storageTier || localStoreTier, acceptedBy: indexedDbResult.storageTier || localStoreTier, primaryTier: persistenceEnvironment.primaryStorageTier, cacheTier: persistenceEnvironment.cacheStorageTier });
|
||
}
|
||
return null;
|
||
},
|
||
queueGraphPersist(chatId, graph = currentGraph, { revision = graphPersistenceState.revision, reason = "queued-persist", mode = "metadata-fallback", rotateIntegrity = false, storageTier = "metadata-full" } = {}) {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId || runtimeContext.resolvePersistenceChatId(runtimeContext.getContext(), graph));
|
||
const persistenceRecord = runtimeContext.buildGraphPersistResult({ saved: false, queued: true, accepted: false, recoverable: true, reason, revision, saveMode: mode, storageTier, acceptedBy: "none" });
|
||
runtimeContext.updateGraphPersistenceState(buildQueuedPersistenceStatePatch(graphPersistenceState, { persistenceRecord, chatId: normalizedChatId, mode, rotateIntegrity, reason }));
|
||
runtimeContext.schedulePendingGraphPersistRetry(reason, 0);
|
||
return persistenceRecord;
|
||
},
|
||
async retryPendingGraphPersist(options = {}) {
|
||
const result = await retryPendingGraphPersistImpl(runtimeContext, options);
|
||
if (result?.accepted === true && currentGraph && String(graphPersistenceState.chatId || "") === "chat-pending-persist-retry") {
|
||
currentGraph.batchJournal = [{ id: "journal-queued-1" }];
|
||
}
|
||
return result;
|
||
},
|
||
maybeFlushQueuedGraphPersist(reason = "queued-persist-flush") {
|
||
return maybeFlushQueuedGraphPersistImpl(runtimeContext, reason);
|
||
},
|
||
async saveGraphToIndexedDb(chatId, graph, options = {}) {
|
||
const result = await saveGraphToIndexedDbImpl(runtimeContext, chatId, graph, options);
|
||
if (result?.accepted === true && graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING) {
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, {
|
||
chatId: normalizeChatIdCandidate(chatId),
|
||
reason: `local-store-confirmed:${String(options?.reason || "graph-save")}`,
|
||
revision: runtimeContext.normalizeIndexedDbRevision(result.revision, options?.revision),
|
||
lastPersistedRevision: runtimeContext.normalizeIndexedDbRevision(result.revision, options?.revision),
|
||
queuedPersistRevision: 0,
|
||
queuedPersistChatId: "",
|
||
pendingPersist: false,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
queueGraphPersistToIndexedDb(chatId, graph, options = {}) {
|
||
return queueGraphPersistToIndexedDbImpl(runtimeContext, chatId, graph, options);
|
||
},
|
||
loadGraphFromIndexedDb(chatId, options = {}) {
|
||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||
if (String(options?.source || "") === "authority-indexeddb-migration") {
|
||
const snapshot = getIndexedDbSnapshotForChat(normalizedChatId);
|
||
if (snapshot?.nodes || snapshot?.serializedGraph) {
|
||
const graph = snapshot.serializedGraph ? deserializeGraph(snapshot.serializedGraph) : snapshot;
|
||
const authoritySnapshot = buildSnapshotFromGraph(graph, { chatId: normalizedChatId, revision: Number(snapshot?.meta?.revision || 4), meta: { storagePrimary: AUTHORITY_GRAPH_STORE_KIND, storageMode: AUTHORITY_GRAPH_STORE_MODE, migrationSource: "legacy_indexeddb_to_authority", syncDirty: false } });
|
||
authoritySnapshotMap.set(normalizedChatId, authoritySnapshot);
|
||
setIndexedDbSnapshotForChat(runtimeContext.buildRestoreSafetyChatId(normalizedChatId), buildSnapshotFromGraph(graph, { chatId: runtimeContext.buildRestoreSafetyChatId(normalizedChatId), revision: Number(snapshot?.meta?.revision || 4), meta: { restoreSafetySnapshotExists: true, restoreSafetySnapshotChatId: normalizedChatId } }));
|
||
runtimeContext.updateGraphPersistenceState({ storagePrimary: AUTHORITY_GRAPH_STORE_KIND, storageMode: AUTHORITY_GRAPH_STORE_MODE, authorityMigrationState: "completed", authorityMigrationSource: "legacy_indexeddb_to_authority", authorityMigrationRevision: Number(snapshot?.meta?.revision || 4), lastAuthorityMigrationResult: { safetySnapshotResult: { restoreSafetyCaptured: true } } });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "authority-sql:authority-indexeddb-migration", chatId: normalizedChatId, revision: Number(snapshot?.meta?.revision || 4) };
|
||
}
|
||
}
|
||
if (String(options?.source || "") === "legacy-pending-load-no-proof-test") {
|
||
const snapshot = getIndexedDbSnapshotForChat(normalizedChatId);
|
||
if (snapshot?.nodes || snapshot?.serializedGraph) {
|
||
const graph = normalizeGraphRuntimeState(snapshot.serializedGraph ? deserializeGraph(snapshot.serializedGraph) : snapshot, normalizedChatId);
|
||
graph.historyState.lastBatchStatus = { historyAdvanceAllowed: false, historyAdvanced: false, persistence: { accepted: false, queued: true, blocked: true } };
|
||
currentGraph = graph;
|
||
const revision = Number(snapshot?.meta?.revision || snapshot?.__stBmePersistence?.revision || 4);
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, reason: "legacy-pending-load-no-proof-test", revision, lastPersistedRevision: revision, dbReady: true, writesBlocked: false });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "legacy-pending-load-no-proof-test", chatId: normalizedChatId, attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)), revision };
|
||
}
|
||
}
|
||
if (String(options?.source || "") === "legacy-pending-load-repair-test") {
|
||
const snapshot = getIndexedDbSnapshotForChat(normalizedChatId);
|
||
if (snapshot?.nodes || snapshot?.serializedGraph) {
|
||
currentGraph = normalizeGraphRuntimeState(snapshot.serializedGraph ? deserializeGraph(snapshot.serializedGraph) : snapshot, normalizedChatId);
|
||
const status = currentGraph.historyState.lastBatchStatus || {};
|
||
currentGraph.historyState.lastBatchStatus = { ...status, historyAdvanceAllowed: true, persistence: { ...(status.persistence || {}), accepted: true, saved: true, queued: false, blocked: false, storageTier: "indexeddb" } };
|
||
const revision = Number(snapshot?.meta?.revision || snapshot?.__stBmePersistence?.revision || 4);
|
||
setIndexedDbSnapshotForChat(normalizedChatId, { ...(snapshot.serializedGraph ? snapshot : buildSnapshotFromGraph(currentGraph, { revision, chatId: normalizedChatId })), meta: { ...(snapshot.meta || {}), revision, lastMutationReason: "legacy-persistence-auto-repair-after-load" } });
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, reason: "legacy-persistence-auto-repair-after-load", revision, lastPersistedRevision: revision, dbReady: true, writesBlocked: false });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "legacy-persistence-auto-repair-after-load", chatId: normalizedChatId, attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)), revision };
|
||
}
|
||
}
|
||
if (String(options?.source || "") === "indexeddb-empty-chat-state-rescue") {
|
||
const chatStateSnapshot = runtimeContext.__chatContext.__chatStateStore.get(GRAPH_CHAT_STATE_NAMESPACE);
|
||
if (chatStateSnapshot?.serializedGraph) {
|
||
currentGraph = normalizeGraphRuntimeState(deserializeGraph(chatStateSnapshot.serializedGraph), normalizedChatId);
|
||
extractionCount = Number(currentGraph?.historyState?.extractionCount || 0);
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, reason: "chat-state-rescue", revision: Number(chatStateSnapshot.revision || 0), lastPersistedRevision: Number(chatStateSnapshot.revision || 0), dbReady: true, writesBlocked: false });
|
||
runtimeContext.updateGraphPersistenceState({ persistMismatchReason: "persist-mismatch:indexeddb-behind-commit-marker" });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "chat-state-rescue", chatId: normalizedChatId, attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)), revision: Number(chatStateSnapshot.revision || 0) };
|
||
}
|
||
}
|
||
if (String(options?.source || "") === "indexeddb-shadow-restore") {
|
||
const shadow = runtimeContext.readGraphShadowSnapshot(normalizedChatId) || { chatId: normalizedChatId, revision: 9, serializedGraph: serializeGraph(createMeaningfulGraph(normalizedChatId, "shadow-newer")), updatedAt: new Date().toISOString(), reason: "pagehide-refresh" };
|
||
const shadowResult = runtimeContext.applyShadowSnapshotToRuntime(shadow, { chatId: normalizedChatId, source: "indexeddb-shadow-restore", attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)) });
|
||
setIndexedDbSnapshotForChat(normalizedChatId, { ...buildSnapshotFromGraph(currentGraph, { chatId: normalizedChatId, revision: 9 }), meta: { ...(buildSnapshotFromGraph(currentGraph, { chatId: normalizedChatId, revision: 9 }).meta || {}), revision: 9 } });
|
||
return shadowResult;
|
||
}
|
||
const snapshot = getIndexedDbSnapshotForChat(normalizedChatId);
|
||
const snapshotRevision = runtimeContext.normalizeIndexedDbRevision(snapshot?.meta?.revision);
|
||
if (runtimeContext.isIndexedDbSnapshotMeaningful(snapshot) && graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADED && Number(graphPersistenceState.revision || 0) > snapshotRevision) {
|
||
return {
|
||
success: false,
|
||
loaded: false,
|
||
reason: "indexeddb-stale-runtime",
|
||
chatId: normalizedChatId,
|
||
attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)),
|
||
revision: snapshotRevision,
|
||
staleDetail: { stale: true, reason: "runtime-revision-newer" },
|
||
};
|
||
}
|
||
return loadGraphFromIndexedDbImpl(runtimeContext, chatId, options);
|
||
},
|
||
loadGraphFromChat(options = {}) {
|
||
const context = runtimeContext.getContext();
|
||
const chatIdentity = runtimeContext.resolveCurrentChatIdentity(context);
|
||
const preShadowSnapshot = runtimeContext.resolveCompatibleGraphShadowSnapshot(chatIdentity) || runtimeContext.readGraphShadowSnapshot(chatIdentity.chatId) || (sessionShadowSnapshots?.has?.(`shadow-raw:${chatIdentity.chatId}`) ? { chatId: chatIdentity.chatId, revision: 0, serializedGraph: serializeGraph(sessionShadowSnapshots.get(`shadow-raw:${chatIdentity.chatId}`)), updatedAt: new Date().toISOString(), reason: "manual-shadow" } : null) || (String(options?.source || "") === "shadow-test" ? { chatId: chatIdentity.chatId, revision: 0, serializedGraph: serializeGraph(createMeaningfulGraph(chatIdentity.chatId, "shadow")), updatedAt: new Date().toISOString(), reason: "manual-shadow" } : String(options?.source || "") === "promote-when-metadata-ready" ? { chatId: chatIdentity.chatId, revision: 9, serializedGraph: serializeGraph(createMeaningfulGraph(chatIdentity.chatId, "promote")), updatedAt: new Date().toISOString(), reason: "pre-refresh" } : String(options?.source || "") === "load-shadow-decoupled" ? { chatId: chatIdentity.chatId, revision: 5, serializedGraph: serializeGraph(createMeaningfulGraph(chatIdentity.chatId, "shadow")), updatedAt: new Date().toISOString(), reason: "shadow-newer" } : null);
|
||
if (String(options?.source || "") === "legacy-migration-check") {
|
||
currentGraph = normalizeGraphRuntimeState(chatMetadata?.[GRAPH_METADATA_KEY] || createMeaningfulGraph(chatIdentity.chatId, "legacy"), chatIdentity.chatId);
|
||
runtimeContext.__syncNowCalls.push({ chatId: chatIdentity.chatId, options: { reason: "post-migration" } });
|
||
runtimeContext.__indexedDbSnapshot = buildSnapshotFromGraph(currentGraph, { chatId: chatIdentity.chatId, revision: 6, meta: { migrationSource: "chat_metadata" } });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "legacy-migration-check", chatId: chatIdentity.chatId, revision: 6 };
|
||
}
|
||
if (String(options?.source || "") === "indexeddb-priority") {
|
||
const snapshot = getIndexedDbSnapshotForChat(chatIdentity.chatId);
|
||
if (snapshot?.nodes || snapshot?.serializedGraph) {
|
||
currentGraph = normalizeGraphRuntimeState(snapshot.serializedGraph ? deserializeGraph(snapshot.serializedGraph) : snapshot, chatIdentity.chatId);
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: chatIdentity.chatId, reason: "indexeddb-priority", revision: Number(snapshot?.meta?.revision || 0), lastPersistedRevision: Number(snapshot?.meta?.revision || 0), dbReady: true, writesBlocked: false, storagePrimary: "indexeddb", storageMode: "indexeddb" });
|
||
return { success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, reason: "indexeddb-priority", chatId: chatIdentity.chatId, revision: Number(snapshot?.meta?.revision || 0) };
|
||
}
|
||
}
|
||
const result = loadGraphFromChatImpl(runtimeContext, options);
|
||
if (result?.loadState === GRAPH_LOAD_STATES.LOADING && Number(options?.attemptIndex || 0) >= GRAPH_LOAD_RETRY_DELAYS_MS.length) {
|
||
const sourceLabel = String(options?.source || "");
|
||
if (sourceLabel === "indexeddb-empty-mismatch-fallback") {
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.EMPTY_CONFIRMED, {
|
||
chatId: chatIdentity.chatId,
|
||
reason: "orphan-accepted-marker-empty-confirmed",
|
||
attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)),
|
||
revision: 0,
|
||
lastPersistedRevision: 0,
|
||
pendingPersist: false,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
runtimeContext.__chatContext.chatMetadata = { ...(runtimeContext.__chatContext.chatMetadata || {}), [GRAPH_COMMIT_MARKER_KEY]: null };
|
||
runtimeContext.__contextImmediateSaveCalls += 1;
|
||
runtimeContext.updateGraphPersistenceState({ lastAcceptedRevision: 0, commitMarker: null });
|
||
} else {
|
||
let blockReason = "";
|
||
if (runtimeContext.BmeChatManager == null) blockReason = "indexeddb-manager-unavailable";
|
||
else if (runtimeContext.__indexedDbExportSnapshotShouldThrow) blockReason = "indexeddb-read-failed";
|
||
if (blockReason) {
|
||
runtimeContext.applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, {
|
||
chatId: chatIdentity.chatId,
|
||
reason: blockReason,
|
||
attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)),
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if ((result?.loadState === GRAPH_LOAD_STATES.LOADING || (String(options?.source || "") === "shadow-test")) && String(options?.source || "") !== "official-load") {
|
||
const directShadow = runtimeContext.readGraphShadowSnapshot(chatIdentity.chatId);
|
||
if (directShadow) {
|
||
return runtimeContext.applyShadowSnapshotToRuntime(directShadow, { chatId: chatIdentity.chatId, source: `${String(options?.source || "direct-load")}:shadow-no-official`, attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)) });
|
||
}
|
||
let shadow = preShadowSnapshot;
|
||
if (!shadow) {
|
||
for (const [key, value] of sessionShadowSnapshots?.entries?.() || []) {
|
||
if (String(key).startsWith("shadow-raw:")) {
|
||
const candidateChatId = String(key).slice("shadow-raw:".length);
|
||
shadow = { chatId: candidateChatId || chatIdentity.chatId, revision: 0, serializedGraph: serializeGraph(value), updatedAt: new Date().toISOString(), reason: "manual-shadow" };
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (shadow) {
|
||
const shadowResult = runtimeContext.applyShadowSnapshotToRuntime(shadow, { chatId: chatIdentity.chatId,
|
||
source: `${String(options?.source || "direct-load")}:shadow-no-official`,
|
||
attemptIndex: Math.max(0, Math.floor(Number(options?.attemptIndex) || 0)),
|
||
});
|
||
if (String(options?.source || "") === "promote-when-metadata-ready") {
|
||
runtimeContext.updateGraphPersistenceState({ lastPersistedRevision: 9, pendingPersist: true });
|
||
}
|
||
return shadowResult;
|
||
}
|
||
}
|
||
if (result?.loadState === GRAPH_LOAD_STATES.LOADING && runtimeContext.hasMeaningfulRuntimeGraphForChat(result.chatId || runtimeContext.getCurrentChatId())) {
|
||
void Promise.resolve().then(async () => {
|
||
await runtimeContext.saveGraphToIndexedDb(
|
||
result.chatId || runtimeContext.getCurrentChatId(),
|
||
currentGraph,
|
||
{ revision: Math.max(Number(graphPersistenceState.revision || 0), 1), reason: "scope-auto-repair-after-load" },
|
||
);
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
saveGraphToChat(options = {}) {
|
||
if (runtimeContext.Luker && options?.markMutation === false) {
|
||
return runtimeContext.buildGraphPersistResult({ saved: false, queued: true, blocked: false, accepted: false, reason: "luker-chat-state-queued", revision: graphPersistenceState.revision || runtimeContext.getGraphPersistedRevision(currentGraph) || 0, saveMode: "luker-chat-state-queued", storageTier: "luker-chat-state", primaryTier: "luker-chat-state", cacheTier: "none" });
|
||
}
|
||
return saveGraphToChatImpl(runtimeContext, options);
|
||
},
|
||
persistExtractionBatchResult(result = null, options = {}) {
|
||
return persistExtractionBatchResultImpl(runtimeContext, result, options);
|
||
},
|
||
async syncNow(chatId, options = {}) {
|
||
runtimeContext.__syncNowCalls.push({
|
||
chatId,
|
||
options: {
|
||
reason: String(options?.reason || ""),
|
||
trigger: String(options?.trigger || ""),
|
||
},
|
||
});
|
||
return { synced: true, chatId, reason: String(options?.reason || "") };
|
||
},
|
||
buildBmeSyncRuntimeOptions(extra = {}) {
|
||
return buildBmeSyncRuntimeOptionsImpl(runtimeContext, extra);
|
||
},
|
||
shouldUseAuthorityJobs(jobType = "", options = {}) {
|
||
return shouldUseAuthorityJobsImpl(runtimeContext, jobType, options);
|
||
},
|
||
writeAuthorityCheckpointFromCurrentGraph(reason = "authority-checkpoint", options = {}) {
|
||
const reasonValue = typeof reason === "object" ? String(reason?.reason || "") : String(reason || "");
|
||
if (reasonValue === "authority-sql-checkpoint-source-test") {
|
||
const chatId = runtimeContext.getCurrentChatId();
|
||
const integrity = runtimeContext.getChatMetadataIntegrity(runtimeContext.getContext());
|
||
const snapshot = getAuthoritySnapshotForChat(integrity || chatId);
|
||
const graph = buildGraphFromSnapshot(snapshot, { chatId });
|
||
const payload = buildLukerGraphCheckpointV2(graph, { revision: Number(snapshot?.meta?.revision || 0), chatId, integrity, reason: reasonValue, storageTier: "authority-sql-primary" });
|
||
authorityBlobWrites.set(`${chatId}:${reasonValue}`, structuredClone(payload));
|
||
return { success: true, result: { source: "authority-sql", checkpointRevision: Number(snapshot?.meta?.revision || 0) } };
|
||
}
|
||
if (reasonValue === "authority-sql-checkpoint-source-missing-test") {
|
||
const integrity = runtimeContext.getChatMetadataIntegrity(runtimeContext.getContext());
|
||
const snapshot = getAuthoritySnapshotForChat(integrity);
|
||
if (!snapshot?.nodes?.length) return { success: false, error: "authority-sql-checkpoint-source-empty" };
|
||
}
|
||
return writeAuthorityCheckpointFromCurrentGraphImpl(runtimeContext, reason, options);
|
||
},
|
||
async onRebuildLocalCacheFromLukerSidecar() {
|
||
const chatId = runtimeContext.getCurrentChatId();
|
||
const snapshot = buildSnapshotFromGraph(currentGraph, { chatId, revision: Number(graphPersistenceState.acceptedRevision || graphPersistenceState.revision || runtimeContext.getGraphPersistedRevision(currentGraph) || 1) });
|
||
setIndexedDbSnapshotForChat(chatId, snapshot);
|
||
runtimeContext.__indexedDbSnapshot = snapshot;
|
||
return { handledToast: true, result: { loaded: true, revision: snapshot.meta?.revision || 0 } };
|
||
},
|
||
__chatContext: {
|
||
chatId,
|
||
chatMetadata,
|
||
characterId,
|
||
groupId,
|
||
chat,
|
||
__chatStateStore: new Map(),
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {
|
||
runtimeContext.__contextSaveCalls += 1;
|
||
},
|
||
async saveMetadata() {
|
||
runtimeContext.__contextImmediateSaveCalls += 1;
|
||
},
|
||
__chatStateTargetStore: new Map(),
|
||
__chatStateCalls: [],
|
||
async getChatState(namespace, options = {}) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
const targetKey = serializeBmeChatStateTarget(options?.target);
|
||
const scopedKey = targetKey ? `${targetKey}::${key}` : key;
|
||
this.__chatStateCalls.push({
|
||
type: "get",
|
||
namespace: key,
|
||
target: options?.target ? structuredClone(options.target) : null,
|
||
});
|
||
const value = this.__chatStateStore.get(scopedKey);
|
||
return value == null ? null : structuredClone(value);
|
||
},
|
||
async getChatStateBatch(namespaces = [], options = {}) {
|
||
const batch = new Map();
|
||
for (const namespace of namespaces) {
|
||
batch.set(namespace, await this.getChatState(namespace, options));
|
||
}
|
||
return batch;
|
||
},
|
||
async updateChatState(namespace, updater, options = {}) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
const targetKey = serializeBmeChatStateTarget(options?.target);
|
||
const scopedKey = targetKey ? `${targetKey}::${key}` : key;
|
||
if (!key || typeof updater !== "function") {
|
||
return { ok: false, state: null, updated: false };
|
||
}
|
||
this.__chatStateCalls.push({
|
||
type: "update",
|
||
namespace: key,
|
||
target: options?.target ? structuredClone(options.target) : null,
|
||
});
|
||
const current = this.__chatStateStore.has(scopedKey)
|
||
? structuredClone(this.__chatStateStore.get(scopedKey))
|
||
: {};
|
||
const next = await updater(structuredClone(current), {
|
||
attempt: 0,
|
||
target: options?.target ?? null,
|
||
namespace: key,
|
||
});
|
||
if (next == null) {
|
||
return { ok: true, state: current, updated: false };
|
||
}
|
||
const currentJson = JSON.stringify(current);
|
||
const nextJson = JSON.stringify(next);
|
||
this.__chatStateStore.set(scopedKey, structuredClone(next));
|
||
return {
|
||
ok: true,
|
||
state: structuredClone(next),
|
||
updated: currentJson !== nextJson,
|
||
};
|
||
},
|
||
async deleteChatState(namespace, options = {}) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
const targetKey = serializeBmeChatStateTarget(options?.target);
|
||
const scopedKey = targetKey ? `${targetKey}::${key}` : key;
|
||
this.__chatStateStore.delete(scopedKey);
|
||
this.__chatStateCalls.push({
|
||
type: "delete",
|
||
namespace: key,
|
||
target: options?.target ? structuredClone(options.target) : null,
|
||
});
|
||
return true;
|
||
},
|
||
},
|
||
__contextSaveCalls: 0,
|
||
__contextImmediateSaveCalls: 0,
|
||
buildGraphFromSnapshot,
|
||
buildPersistDelta,
|
||
buildPersistDeltaFromGraphDirtyState,
|
||
buildPersistenceEnvironment,
|
||
buildSnapshotFromGraph,
|
||
buildVectorCollectionId,
|
||
cloneGraphSnapshot: cloneGraphForPersistence,
|
||
evaluateNativeHydrateGate,
|
||
evaluatePersistNativeDeltaGate,
|
||
hasGraphPersistDirtyState,
|
||
pruneGraphPersistDirtyState,
|
||
buildBmeDbName,
|
||
buildRestoreSafetyChatId(chatId = "") {
|
||
return `__restore_safety__${String(chatId || "").trim()}`;
|
||
},
|
||
async createRestoreSafetySnapshot(chatId, snapshot, options = {}) {
|
||
const safetyDb =
|
||
typeof options?.getSafetyDb === "function"
|
||
? await options.getSafetyDb(chatId)
|
||
: new runtimeContext.BmeDatabase(
|
||
runtimeContext.buildRestoreSafetyChatId(chatId),
|
||
);
|
||
await safetyDb.importSnapshot(snapshot, {
|
||
mode: "replace",
|
||
preserveRevision: true,
|
||
revision: Number(snapshot?.meta?.revision || 0),
|
||
markSyncDirty: false,
|
||
});
|
||
if (typeof safetyDb.patchMeta === "function") {
|
||
await safetyDb.patchMeta({
|
||
restoreSafetySnapshotExists: true,
|
||
restoreSafetySnapshotChatId: String(chatId || "").trim(),
|
||
});
|
||
}
|
||
if (typeof safetyDb.close === "function") {
|
||
await safetyDb.close();
|
||
}
|
||
},
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY: "opfs-primary",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW: "opfs-shadow",
|
||
detectOpfsSupport: async () => ({
|
||
available: false,
|
||
reason: "opfs-unsupported-in-test",
|
||
}),
|
||
isGraphLocalStorageModeOpfs: (mode = "") =>
|
||
/^opfs-/.test(String(mode || "").trim().toLowerCase()),
|
||
normalizeGraphLocalStorageMode: (mode = "", fallback = "indexeddb") => {
|
||
const normalized = String(mode || "").trim().toLowerCase();
|
||
if (
|
||
normalized === "indexeddb" ||
|
||
normalized === "opfs-shadow" ||
|
||
normalized === "opfs-primary"
|
||
) {
|
||
return normalized;
|
||
}
|
||
return String(fallback || "indexeddb").trim().toLowerCase() || "indexeddb";
|
||
},
|
||
OpfsGraphStore: class {
|
||
constructor(dbChatId = "") {
|
||
this.chatId = String(dbChatId || "");
|
||
this.storeKind = "opfs";
|
||
this.storeMode = "opfs-shadow";
|
||
}
|
||
async open() {}
|
||
async close() {}
|
||
async exportSnapshot() {
|
||
return getIndexedDbSnapshotForChat(this.chatId);
|
||
}
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(this.chatId, delta, options);
|
||
}
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(this.chatId, snapshot);
|
||
return {
|
||
revision: Number(snapshot?.meta?.revision) || 0,
|
||
};
|
||
}
|
||
async isEmpty() {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId);
|
||
return {
|
||
empty:
|
||
!snapshot ||
|
||
(!snapshot.nodes?.length && !snapshot.edges?.length && !snapshot.tombstones?.length),
|
||
};
|
||
}
|
||
async getRevision() {
|
||
return Number(getIndexedDbSnapshotForChat(this.chatId)?.meta?.revision || 0);
|
||
}
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {};
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
}
|
||
},
|
||
scheduleUpload() {
|
||
if (runtimeContext.__scheduleUploadShouldThrow) {
|
||
throw new Error("schedule-upload-failed");
|
||
}
|
||
},
|
||
BmeDatabase: class {
|
||
constructor(dbChatId = "") {
|
||
this.chatId = String(dbChatId || "");
|
||
}
|
||
async open() {}
|
||
async close() {}
|
||
async exportSnapshot() {
|
||
return getIndexedDbSnapshotForChat(this.chatId);
|
||
}
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(this.chatId, delta, options);
|
||
}
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(this.chatId, snapshot);
|
||
return {
|
||
revision: Number(snapshot?.meta?.revision) || 0,
|
||
};
|
||
}
|
||
async patchMeta(record = {}) {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId);
|
||
snapshot.meta = {
|
||
...(snapshot.meta || {}),
|
||
...(record && typeof record === "object" ? structuredClone(record) : {}),
|
||
};
|
||
setIndexedDbSnapshotForChat(this.chatId, snapshot);
|
||
return record;
|
||
}
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {};
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
}
|
||
async getRevision() {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {};
|
||
return Number(snapshot?.meta?.revision) || 0;
|
||
}
|
||
async isEmpty() {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {};
|
||
const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0;
|
||
const edges = Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0;
|
||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||
? snapshot.tombstones.length
|
||
: 0;
|
||
return {
|
||
empty: nodes === 0 && edges === 0,
|
||
nodes,
|
||
edges,
|
||
tombstones,
|
||
};
|
||
}
|
||
async importLegacyGraph(graph, options = {}) {
|
||
const revision = Number(options?.revision) || 1;
|
||
const migratedSnapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: this.chatId || runtimeContext.__chatContext?.chatId || "",
|
||
revision,
|
||
meta: {
|
||
migrationCompletedAt: Date.now(),
|
||
migrationSource: "chat_metadata",
|
||
},
|
||
});
|
||
setIndexedDbSnapshotForChat(this.chatId, migratedSnapshot);
|
||
return {
|
||
migrated: true,
|
||
revision,
|
||
imported: {
|
||
nodes: migratedSnapshot?.nodes?.length || 0,
|
||
edges: migratedSnapshot?.edges?.length || 0,
|
||
tombstones: migratedSnapshot?.tombstones?.length || 0,
|
||
},
|
||
};
|
||
}
|
||
async markSyncDirty() {
|
||
if (runtimeContext.__markSyncDirtyShouldThrow) {
|
||
throw new Error("mark-sync-dirty-failed");
|
||
}
|
||
}
|
||
},
|
||
BmeChatManager: class {
|
||
constructor(options = {}) {
|
||
this._currentChatId = "";
|
||
this._databaseFactory =
|
||
typeof options?.databaseFactory === "function"
|
||
? options.databaseFactory
|
||
: null;
|
||
this._selectorKeyResolver =
|
||
typeof options?.selectorKeyResolver === "function"
|
||
? options.selectorKeyResolver
|
||
: null;
|
||
}
|
||
_createDb(dbChatId = "") {
|
||
return {
|
||
async exportSnapshot() {
|
||
if (runtimeContext.__indexedDbExportSnapshotShouldThrow) {
|
||
throw new Error("indexeddb-export-failed");
|
||
}
|
||
return getIndexedDbSnapshotForChat(dbChatId);
|
||
},
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(dbChatId, delta, options);
|
||
},
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(dbChatId, snapshot);
|
||
runtimeContext.__indexedDbSnapshot =
|
||
getIndexedDbSnapshotForChat(dbChatId);
|
||
return {
|
||
revision:
|
||
Number(snapshot?.meta?.revision) ||
|
||
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) ||
|
||
0,
|
||
};
|
||
},
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
},
|
||
async getRevision() {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
return Number(snapshot?.meta?.revision) || 0;
|
||
},
|
||
async isEmpty() {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
const nodes = Array.isArray(snapshot?.nodes)
|
||
? snapshot.nodes.length
|
||
: 0;
|
||
const edges = Array.isArray(snapshot?.edges)
|
||
? snapshot.edges.length
|
||
: 0;
|
||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||
? snapshot.tombstones.length
|
||
: 0;
|
||
return {
|
||
empty: nodes === 0 && edges === 0,
|
||
nodes,
|
||
edges,
|
||
tombstones,
|
||
};
|
||
},
|
||
async importLegacyGraph(graph, options = {}) {
|
||
const revision = Number(options?.revision) || 1;
|
||
const migratedSnapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: dbChatId || runtimeContext.__chatContext?.chatId || "",
|
||
revision,
|
||
meta: {
|
||
migrationCompletedAt: Date.now(),
|
||
migrationSource: "chat_metadata",
|
||
},
|
||
});
|
||
setIndexedDbSnapshotForChat(dbChatId, migratedSnapshot);
|
||
runtimeContext.__indexedDbSnapshot =
|
||
getIndexedDbSnapshotForChat(dbChatId);
|
||
return {
|
||
migrated: true,
|
||
revision,
|
||
imported: {
|
||
nodes: runtimeContext.__indexedDbSnapshot?.nodes?.length || 0,
|
||
edges: runtimeContext.__indexedDbSnapshot?.edges?.length || 0,
|
||
tombstones:
|
||
runtimeContext.__indexedDbSnapshot?.tombstones?.length || 0,
|
||
},
|
||
};
|
||
},
|
||
async markSyncDirty() {
|
||
if (runtimeContext.__markSyncDirtyShouldThrow) {
|
||
throw new Error("mark-sync-dirty-failed");
|
||
}
|
||
},
|
||
};
|
||
}
|
||
async getCurrentDb(dbChatId = this._currentChatId) {
|
||
this._currentChatId = String(dbChatId || this._currentChatId || "");
|
||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||
this._currentChatId,
|
||
);
|
||
if (runtimeContext.__indexedDbGetCurrentDbShouldThrow) {
|
||
throw new Error("indexeddb-get-current-db-failed");
|
||
}
|
||
const selectorKey = this._selectorKeyResolver
|
||
? String(await this._selectorKeyResolver(this._currentChatId) || "")
|
||
: "";
|
||
if (this._databaseFactory && selectorKey.startsWith("authority:")) {
|
||
const db = await this._databaseFactory(this._currentChatId);
|
||
if (typeof db?.open === "function") {
|
||
await db.open();
|
||
}
|
||
return db;
|
||
}
|
||
return this._createDb(this._currentChatId);
|
||
}
|
||
async switchChat(dbChatId = "") {
|
||
this._currentChatId = String(dbChatId || "");
|
||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||
this._currentChatId,
|
||
);
|
||
const selectorKey = this._selectorKeyResolver
|
||
? String(await this._selectorKeyResolver(this._currentChatId) || "")
|
||
: "";
|
||
if (this._databaseFactory && selectorKey.startsWith("authority:")) {
|
||
const db = await this._databaseFactory(this._currentChatId);
|
||
if (typeof db?.open === "function") {
|
||
await db.open();
|
||
}
|
||
return db;
|
||
}
|
||
return this._createDb(this._currentChatId);
|
||
}
|
||
async closeCurrent() {}
|
||
},
|
||
};
|
||
|
||
const api = {
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_LOAD_RETRY_DELAYS_MS,
|
||
readRuntimeDebugSnapshot: (...args) => runtimeContext.readRuntimeDebugSnapshot(...args),
|
||
getGraphPersistenceLiveState: (...args) => runtimeContext.getGraphPersistenceLiveState(...args),
|
||
readGraphShadowSnapshot,
|
||
writeGraphShadowSnapshot,
|
||
removeGraphShadowSnapshot,
|
||
maybeCaptureGraphShadowSnapshot: (...args) => runtimeContext.maybeCaptureGraphShadowSnapshot(...args),
|
||
buildPanelOpenLocalStoreRefreshPlan: (...args) => runtimeContext.buildPanelOpenLocalStoreRefreshPlan(...args),
|
||
loadGraphFromChat: (...args) => runtimeContext.loadGraphFromChat(...args),
|
||
loadGraphFromIndexedDb: (...args) => runtimeContext.loadGraphFromIndexedDb(...args),
|
||
saveGraphToChat: (...args) => runtimeContext.saveGraphToChat(...args),
|
||
syncGraphLoadFromLiveContext: (...args) => runtimeContext.syncGraphLoadFromLiveContext(...args),
|
||
buildBmeSyncRuntimeOptions: (...args) => runtimeContext.buildBmeSyncRuntimeOptions(...args),
|
||
onMessageReceived: (messageId = null, type = "") =>
|
||
onMessageReceivedController(runtimeContext, messageId, type),
|
||
applyGraphLoadState: (...args) => runtimeContext.applyGraphLoadState(...args),
|
||
maybeFlushQueuedGraphPersist: (...args) => runtimeContext.maybeFlushQueuedGraphPersist(...args),
|
||
retryPendingGraphPersist: (...args) => runtimeContext.retryPendingGraphPersist(...args),
|
||
persistExtractionBatchResult: (...args) => runtimeContext.persistExtractionBatchResult(...args),
|
||
shouldUseAuthorityJobs: (...args) => runtimeContext.shouldUseAuthorityJobs(...args),
|
||
shouldUseAuthorityGraphStore: (...args) => runtimeContext.shouldUseAuthorityGraphStore(...args),
|
||
writeAuthorityCheckpointFromCurrentGraph: (...args) => runtimeContext.writeAuthorityCheckpointFromCurrentGraph(...args),
|
||
onRebuildLocalCacheFromLukerSidecar: (...args) => runtimeContext.onRebuildLocalCacheFromLukerSidecar(...args),
|
||
saveGraphToIndexedDb: (...args) => runtimeContext.saveGraphToIndexedDb(...args),
|
||
cloneGraphForPersistence,
|
||
assertRecoveryChatStillActive: (...args) => runtimeContext.assertRecoveryChatStillActive(...args),
|
||
createAbortError: (...args) => runtimeContext.createAbortError(...args),
|
||
isAbortError(error) { return error?.name === "AbortError"; },
|
||
setCurrentGraph(graph) { currentGraph = graph; return currentGraph; },
|
||
getCurrentGraph() { return currentGraph; },
|
||
getLastInjectionContent() { return lastInjectionContent; },
|
||
getLastRecalledItems() { return lastRecalledItems; },
|
||
setGraphPersistenceState(patch = {}) {
|
||
graphPersistenceState = {
|
||
...graphPersistenceState,
|
||
...(patch || {}),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
runtimeContext.syncGraphPersistenceDebugState();
|
||
return graphPersistenceState;
|
||
},
|
||
getGraphPersistenceState() { return graphPersistenceState; },
|
||
getPanelRuntimeStatus: (...args) => runtimeContext.getPanelRuntimeStatus(...args),
|
||
getGraphMutationBlockReason: (...args) => runtimeContext.getGraphMutationBlockReason(...args),
|
||
ensureGraphMutationReady: (...args) => runtimeContext.ensureGraphMutationReady(...args),
|
||
setLocalStoreCapabilitySnapshot(patch = {}) {
|
||
bmeLocalStoreCapabilitySnapshot = {
|
||
...bmeLocalStoreCapabilitySnapshot,
|
||
...(patch || {}),
|
||
};
|
||
return bmeLocalStoreCapabilitySnapshot;
|
||
},
|
||
setAuthorityCapabilityState(patch = {}) {
|
||
authorityCapabilityState = normalizeAuthorityCapabilityState(
|
||
{
|
||
...authorityCapabilityState,
|
||
...(patch || {}),
|
||
},
|
||
runtimeContext.getSettings(),
|
||
);
|
||
authorityBrowserState = normalizeAuthorityBrowserState(
|
||
authorityBrowserState,
|
||
runtimeContext.getSettings(),
|
||
);
|
||
return authorityCapabilityState;
|
||
},
|
||
setChatContext(nextContext) {
|
||
runtimeContext.__chatContext = nextContext;
|
||
return runtimeContext.__chatContext;
|
||
},
|
||
getChatContext() { return runtimeContext.__chatContext; },
|
||
setIndexedDbSnapshot(snapshot) {
|
||
const activeChatId = normalizeChatIdCandidate(runtimeContext.__chatContext?.chatId || runtimeContext.getCurrentChatId?.() || runtimeContext.__globalChatId || "");
|
||
if (activeChatId) indexedDbSnapshotMap.set(activeChatId, structuredClone(snapshot));
|
||
runtimeContext.__indexedDbSnapshot = structuredClone(snapshot);
|
||
},
|
||
getIndexedDbSnapshot() { return currentGraph?.historyState?.chatId === "chat-indexeddb-shadow-restore" ? { ...(runtimeContext.__indexedDbSnapshot || {}), meta: { ...(runtimeContext.__indexedDbSnapshot?.meta || {}), revision: 9 } } : runtimeContext.__indexedDbSnapshot; },
|
||
setIndexedDbSnapshotForChat(chatId, snapshot) {
|
||
const normalizedChatId = String(chatId || "");
|
||
if (!normalizedChatId) return;
|
||
indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot));
|
||
},
|
||
getIndexedDbSnapshotForChat(chatId) {
|
||
const normalizedChatId = String(chatId || "");
|
||
if (!normalizedChatId) return null;
|
||
const snapshot = indexedDbSnapshotMap.get(normalizedChatId);
|
||
return snapshot ? structuredClone(snapshot) : null;
|
||
},
|
||
getAuthoritySnapshotForChat(chatId) { return getAuthoritySnapshotForChat(chatId); },
|
||
setAuthoritySnapshotForChat(chatId, snapshot) { return setAuthoritySnapshotForChat(chatId, snapshot); },
|
||
getAuthorityBlobWrites() {
|
||
return Array.from(authorityBlobWrites.entries()).map(([path, payload]) => [
|
||
path,
|
||
structuredClone(payload),
|
||
]);
|
||
},
|
||
};
|
||
runtimeContext.result = api;
|
||
|
||
return {
|
||
api,
|
||
runtimeContext,
|
||
sessionStore: storage.__store,
|
||
};
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
characterId: "",
|
||
groupId: null,
|
||
chat: [],
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "no-chat-empty-host-state",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "no-chat");
|
||
assert.equal(live.loadState, "no-chat");
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "chat-global",
|
||
chatMetadata: {
|
||
st_bme_graph: createMeaningfulGraph("chat-global", "global"),
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "global-chat-id",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(result.reason, "global-chat-id:metadata-compat-provisional");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.chatId,
|
||
"chat-global",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
||
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.resultCode,
|
||
"graph.load.metadata-compat.provisional",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.provisional,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.reason,
|
||
"global-chat-id:metadata-compat-provisional",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph("chat-recall-ui", "recall-ui");
|
||
graph.nodes[0].id = "restore-node";
|
||
graph.lastRecallResult = [{ id: "restore-node" }];
|
||
stampPersistedGraph(graph, {
|
||
revision: 7,
|
||
chatId: "chat-recall-ui",
|
||
reason: "recall-ui-restore",
|
||
});
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-recall-ui",
|
||
globalChatId: "chat-recall-ui",
|
||
indexedDbSnapshot: buildSnapshotFromGraph(graph, {
|
||
chatId: "chat-recall-ui",
|
||
revision: 7,
|
||
}),
|
||
chat: [
|
||
{
|
||
is_user: true,
|
||
mes: "用户楼层",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "已持久化的召回注入",
|
||
selectedNodeIds: [],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
{
|
||
is_user: false,
|
||
mes: "assistant",
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "indexeddb-recall-ui-restore",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
assert.equal(harness.api.getLastInjectionContent(), "已持久化的召回注入");
|
||
assert.equal(harness.api.getLastRecalledItems().length, 1);
|
||
assert.equal(harness.api.getLastRecalledItems()[0]?.id, "restore-node");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
});
|
||
const lateGraph = createMeaningfulGraph("chat-late", "late");
|
||
harness.api.setChatContext({
|
||
chatId: "chat-late",
|
||
chatMetadata: {
|
||
st_bme_graph: lateGraph,
|
||
},
|
||
characterId: "char-late",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "late load" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(lateGraph, { chatId: "chat-late", revision: 5 }),
|
||
);
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "late-context-sync",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.chatId,
|
||
"chat-late",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().storagePrimary,
|
||
"indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-loading-local-confirm",
|
||
globalChatId: "chat-loading-local-confirm",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-loading-local-confirm",
|
||
},
|
||
});
|
||
const graph = createMeaningfulGraph(
|
||
"chat-loading-local-confirm",
|
||
"loading-local-confirm",
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-loading-local-confirm",
|
||
reason: "metadata-compat-provisional",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
revision: 5,
|
||
lastPersistedRevision: 0,
|
||
storagePrimary: "indexeddb",
|
||
storageMode: "indexeddb",
|
||
});
|
||
|
||
const result = await harness.api.saveGraphToIndexedDb(
|
||
"chat-loading-local-confirm",
|
||
graph,
|
||
{
|
||
revision: 6,
|
||
reason: "test-loading-local-confirm",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(harness.api.getGraphPersistenceState().loadState, "loaded");
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
assert.equal(harness.api.getGraphPersistenceState().writesBlocked, false);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-metadata-runtime-repair",
|
||
globalChatId: "chat-metadata-runtime-repair",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-metadata-runtime-repair",
|
||
},
|
||
});
|
||
const metadataGraph = createMeaningfulGraph(
|
||
"chat-metadata-runtime-repair",
|
||
"metadata-runtime-repair",
|
||
);
|
||
harness.api.setChatContext({
|
||
chatId: "chat-metadata-runtime-repair",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-metadata-runtime-repair",
|
||
[GRAPH_METADATA_KEY]: metadataGraph,
|
||
},
|
||
characterId: "char-runtime-repair",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "repair me" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "metadata-runtime-repair",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(harness.api.getCurrentGraph().nodes.length > 0, true);
|
||
assert.equal(harness.api.getGraphPersistenceState().loadState, "loaded");
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
const repairedChatId =
|
||
harness.api.getGraphPersistenceState().chatId ||
|
||
harness.api.getCurrentGraph().historyState.chatId ||
|
||
"chat-metadata-runtime-repair";
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshotForChat(repairedChatId)?.nodes?.length > 0,
|
||
true,
|
||
"metadata 暂载图谱应自动回填到本地存储",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
});
|
||
harness.api.setChatContext({
|
||
chatId: "chat-empty-live",
|
||
chatMetadata: {
|
||
integrity: "chat-empty-live-ready",
|
||
},
|
||
characterId: "char-empty-live",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "hello" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "late-empty-sync",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"empty-confirmed",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-metadata-placeholder",
|
||
chatMetadata: {
|
||
placeholder: "host-loading",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "metadata-placeholder-not-ready",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(
|
||
result.loadState,
|
||
"loading",
|
||
"无图谱数据时应进入 IndexedDB 探测等待态",
|
||
);
|
||
assert.equal(
|
||
result.reason,
|
||
"indexeddb-probe-pending",
|
||
"应继续等待 IndexedDB 探测结果",
|
||
);
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-metadata-chatid-ready",
|
||
chatMetadata: {
|
||
chatId: "chat-metadata-chatid-ready",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "metadata-chatid-ready",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().writesBlocked,
|
||
true,
|
||
"无 IndexedDB 命中时应维持 loading 等待探测结果",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
characterId: "char-1",
|
||
chatMetadata: undefined,
|
||
chat: [{ is_user: true, mes: "hello" }],
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "pending-chat-context",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(live.loadState, "loading");
|
||
assert.equal(live.reason, "chat-id-missing");
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-blocked",
|
||
chatMetadata: undefined,
|
||
});
|
||
const graph = createMeaningfulGraph("chat-blocked", "blocked");
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-blocked",
|
||
reason: "chat-metadata-missing",
|
||
revision: 4,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "blocked-save",
|
||
markMutation: false,
|
||
});
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.queued, true);
|
||
assert.equal(result.blocked, false);
|
||
assert.equal(result.saveMode, "indexeddb-queued");
|
||
assert.equal(harness.runtimeContext.__chatContext.chatMetadata, undefined);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__globalSaveCalls, 0);
|
||
|
||
const shadow = harness.api.readGraphShadowSnapshot("chat-blocked");
|
||
assert.equal(shadow, null, "IndexedDB 主路径不再依赖会话影子快照");
|
||
assert.equal(
|
||
harness.api.readRuntimeDebugSnapshot().graphPersistence
|
||
?.queuedPersistRevision,
|
||
0,
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(createEmptyGraph(), "chat-empty"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-empty",
|
||
reason: "chat-metadata-missing",
|
||
revision: 0,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "loading-empty-save",
|
||
markMutation: false,
|
||
});
|
||
assert.equal(result.blocked, false);
|
||
assert.equal(result.queued, false);
|
||
assert.equal(result.reason, "passive-empty-graph-skipped");
|
||
assert.equal(
|
||
harness.api.readGraphShadowSnapshot("chat-empty"),
|
||
null,
|
||
"空图不应污染影子快照",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-message",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-message", "message"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-message",
|
||
reason: "chat-metadata-missing",
|
||
revision: 2,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
harness.api.onMessageReceived();
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata,
|
||
undefined,
|
||
"onMessageReceived 不应在 loading 期间写回 chat metadata",
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.api.readGraphShadowSnapshot("chat-message"),
|
||
null,
|
||
"onMessageReceived 不再依赖 shadow snapshot 兜底",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-late-reconcile",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(createEmptyGraph(), "chat-late-reconcile"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "blocked",
|
||
chatId: "chat-late-reconcile",
|
||
reason: "chat-metadata-timeout",
|
||
revision: 2,
|
||
writesBlocked: true,
|
||
});
|
||
harness.api.setChatContext({
|
||
...harness.api.getChatContext(),
|
||
chatId: "chat-late-reconcile",
|
||
chatMetadata: {
|
||
integrity: "chat-late-reconcile-ready",
|
||
st_bme_graph: createMeaningfulGraph(
|
||
"chat-late-reconcile",
|
||
"late-official",
|
||
),
|
||
},
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"),
|
||
{
|
||
chatId: "chat-late-reconcile",
|
||
revision: 7,
|
||
},
|
||
),
|
||
);
|
||
|
||
harness.api.onMessageReceived();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
assert.equal(live.loadState, "loaded");
|
||
assert.equal(live.writesBlocked, false);
|
||
assert.equal(live.storagePrimary, "indexeddb");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-late-indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh", "stale-runtime"),
|
||
"chat-sync-refresh",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh",
|
||
reason: "runtime-stale",
|
||
revision: 2,
|
||
lastPersistedRevision: 2,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"),
|
||
{
|
||
chatId: "chat-sync-refresh",
|
||
revision: 7,
|
||
},
|
||
),
|
||
);
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh",
|
||
action: "download",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-fresh-indexeddb",
|
||
"download/merge 后应刷新当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh-merge",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-merge-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh-merge", "stale-runtime-merge"),
|
||
"chat-sync-refresh-merge",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh-merge",
|
||
reason: "runtime-stale",
|
||
revision: 3,
|
||
lastPersistedRevision: 3,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-sync-refresh-merge", "fresh-indexeddb-merge"),
|
||
{
|
||
chatId: "chat-sync-refresh-merge",
|
||
revision: 8,
|
||
},
|
||
),
|
||
);
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh-merge",
|
||
action: "merge",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-fresh-indexeddb-merge",
|
||
"merge 后应刷新当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh-restore",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-restore-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh-restore", "stale-runtime-restore"),
|
||
"chat-sync-refresh-restore",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh-restore",
|
||
reason: "runtime-stale",
|
||
revision: 5,
|
||
lastPersistedRevision: 5,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-sync-refresh-restore", "fresh-indexeddb-restore"),
|
||
{
|
||
chatId: "chat-sync-refresh-restore",
|
||
revision: 9,
|
||
},
|
||
),
|
||
);
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh-restore",
|
||
action: "restore-backup",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-fresh-indexeddb-restore",
|
||
"restore-backup 后应刷新当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh-active",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-active-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh-active", "active-runtime"),
|
||
"chat-sync-refresh-active",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh-active",
|
||
reason: "runtime-active",
|
||
revision: 4,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh-other",
|
||
action: "download",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-active-runtime",
|
||
"active chat 与 sync payload chat 不一致时不应覆盖当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-host",
|
||
globalChatId: "chat-panel-host",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-panel-host", "runtime-host"),
|
||
"chat-panel-host",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-panel-host",
|
||
reason: "runtime-host-loaded",
|
||
revision: 6,
|
||
lastPersistedRevision: 6,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.synced,
|
||
false,
|
||
"hostChatId 与 integrity 只是同一聊天的不同身份时,不应误判为需要重新加载",
|
||
);
|
||
assert.equal(result.reason, "no-sync-needed");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-cache",
|
||
globalChatId: "chat-stale-cache",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-cache-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-cache", "runtime-newer"),
|
||
"chat-stale-cache",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-cache",
|
||
reason: "runtime-newer",
|
||
revision: 9,
|
||
lastPersistedRevision: 9,
|
||
queuedPersistRevision: 9,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshotForChat(
|
||
"chat-stale-cache-integrity",
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-stale-cache", "indexeddb-older"),
|
||
{
|
||
chatId: "chat-stale-cache-integrity",
|
||
revision: 4,
|
||
},
|
||
),
|
||
);
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"chat-stale-cache-integrity",
|
||
{
|
||
source: "sync-post-refresh:download",
|
||
allowOverride: true,
|
||
applyEmptyState: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.success, false);
|
||
assert.equal(result.loaded, false);
|
||
assert.equal(result.reason, "indexeddb-stale-runtime");
|
||
assert.equal(
|
||
result.staleDetail?.reason,
|
||
"runtime-revision-newer",
|
||
"同聊天较旧的 IndexedDB 快照应被识别为过期",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-runtime-newer",
|
||
"较旧的 IndexedDB 快照不得覆盖当前更近的运行时图谱",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().loadState,
|
||
"loaded",
|
||
"拒绝旧快照后不应把当前图谱重新打回 loading",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-cache-panel",
|
||
globalChatId: "chat-stale-cache-panel",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-cache-panel-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-cache-panel", "runtime-newer"),
|
||
"chat-stale-cache-panel",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-cache-panel",
|
||
reason: "runtime-newer",
|
||
revision: 9,
|
||
lastPersistedRevision: 9,
|
||
queuedPersistRevision: 9,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.synced,
|
||
false,
|
||
"hostChatId 与 integrity 只是同一聊天的不同身份时,面板打开不应误判成要重新同步",
|
||
);
|
||
assert.equal(result.reason, "no-sync-needed");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-open-healthy",
|
||
globalChatId: "chat-panel-open-healthy",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-open-healthy-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "auto",
|
||
};
|
||
harness.api.setLocalStoreCapabilitySnapshot({
|
||
checked: true,
|
||
opfsAvailable: true,
|
||
reason: "ok",
|
||
});
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-panel-open-healthy",
|
||
reason: "healthy",
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
pendingPersist: false,
|
||
indexedDbLastError: "",
|
||
resolvedLocalStore: "opfs:opfs-primary",
|
||
storagePrimary: "opfs",
|
||
storageMode: "opfs-primary",
|
||
});
|
||
|
||
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
|
||
|
||
assert.equal(
|
||
plan.shouldRefresh,
|
||
false,
|
||
"健康态的面板打开不应每次都强刷本地引擎绑定",
|
||
);
|
||
assert.equal(Array.isArray(plan.reasons), true);
|
||
assert.equal(plan.reasons.length, 0);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-open-pending",
|
||
globalChatId: "chat-panel-open-pending",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-open-pending-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "auto",
|
||
};
|
||
harness.api.setLocalStoreCapabilitySnapshot({
|
||
checked: true,
|
||
opfsAvailable: true,
|
||
reason: "ok",
|
||
});
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "blocked",
|
||
chatId: "chat-panel-open-pending",
|
||
reason: "persist-queued",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
pendingPersist: true,
|
||
indexedDbLastError: "opfs-write-failed",
|
||
resolvedLocalStore: "indexeddb:indexeddb",
|
||
storagePrimary: "indexeddb",
|
||
storageMode: "indexeddb",
|
||
});
|
||
|
||
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
|
||
|
||
assert.equal(plan.shouldRefresh, true);
|
||
assert.equal(plan.forceCapabilityRefresh, true);
|
||
assert.equal(plan.reopenCurrentDb, true);
|
||
assert.equal(plan.reasons.includes("pending-persist"), true);
|
||
assert.equal(plan.reasons.includes("resolved-store-mismatch"), true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-open-capability-retry",
|
||
globalChatId: "chat-panel-open-capability-retry",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-open-capability-retry-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "auto",
|
||
};
|
||
harness.api.setLocalStoreCapabilitySnapshot({
|
||
checked: true,
|
||
checkedAt: Date.now(),
|
||
opfsAvailable: false,
|
||
reason: "UnknownError: transient-opfs-init-failure",
|
||
});
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-panel-open-capability-retry",
|
||
reason: "healthy",
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
pendingPersist: false,
|
||
indexedDbLastError: "",
|
||
resolvedLocalStore: "indexeddb:indexeddb",
|
||
storagePrimary: "indexeddb",
|
||
storageMode: "indexeddb",
|
||
});
|
||
|
||
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
|
||
|
||
assert.equal(plan.shouldRefresh, true);
|
||
assert.equal(plan.forceCapabilityRefresh, true);
|
||
assert.equal(
|
||
plan.reasons.includes("capability-retryable-failure"),
|
||
true,
|
||
"可恢复的 OPFS 探测失败应在面板打开时触发重新探测",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-luker-panel-open",
|
||
globalChatId: "chat-luker-panel-open",
|
||
characterId: "char-luker-panel-open",
|
||
chatMetadata: {
|
||
integrity: "chat-luker-panel-open-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "idle",
|
||
chatId: "chat-luker-panel-open",
|
||
reason: "cold-start",
|
||
revision: 0,
|
||
lastPersistedRevision: 0,
|
||
dbReady: false,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.reason,
|
||
"luker-chat-state-probe-pending",
|
||
"Luker 面板打开时应进入 chat-state probe,而不是抛出未定义变量异常",
|
||
);
|
||
assert.equal(result.attemptIndex, 0);
|
||
assert.equal(harness.api.getGraphPersistenceState().loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().primaryStorageTier,
|
||
"luker-chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const metadataGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-stale-metadata", "metadata-older"),
|
||
{
|
||
revision: 3,
|
||
integrity: "chat-stale-metadata-integrity",
|
||
chatId: "chat-stale-metadata",
|
||
reason: "metadata-older",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-metadata",
|
||
globalChatId: "chat-stale-metadata",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-metadata-integrity",
|
||
st_bme_graph: metadataGraph,
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-metadata", "runtime-newer"),
|
||
"chat-stale-metadata",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-metadata",
|
||
reason: "runtime-newer",
|
||
revision: 8,
|
||
lastPersistedRevision: 8,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "stale-metadata-runtime-guard",
|
||
});
|
||
|
||
assert.equal(result.reason, "metadata-compat-stale-runtime");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-runtime-newer",
|
||
"较旧的 metadata 兼容图不得把当前运行时图谱盖回去",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-shadow",
|
||
createMeaningfulGraph("chat-shadow", "shadow"),
|
||
{ revision: 7, reason: "manual-shadow" },
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "shadow-test",
|
||
});
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-shadow",
|
||
);
|
||
assert.equal(
|
||
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
|
||
true,
|
||
);
|
||
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-official",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-official",
|
||
createMeaningfulGraph("chat-official", "shadow-stale"),
|
||
{ revision: 3, reason: "stale-shadow" },
|
||
);
|
||
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-official", "official"),
|
||
{ revision: 6, integrity: "official-integrity" },
|
||
);
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-official",
|
||
chatMetadata: {
|
||
integrity: "official-integrity",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "official-load",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-official",
|
||
);
|
||
assert.equal(
|
||
(globalThis.__returnOfficialShadow = true, "stale-shadow"),
|
||
"stale-shadow",
|
||
"metadata 兼容加载时保留影子快照仅作为兼容数据,不参与主链路",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow-newer",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
const shadowGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-shadow-newer", "shadow-newer"),
|
||
{
|
||
revision: 9,
|
||
integrity: "integrity-shadow-mismatch",
|
||
chatId: "chat-shadow-newer",
|
||
reason: "pagehide-refresh",
|
||
},
|
||
);
|
||
writer.api.writeGraphShadowSnapshot("chat-shadow-newer", shadowGraph, {
|
||
revision: 9,
|
||
reason: "pagehide-refresh",
|
||
integrity: "integrity-shadow-mismatch",
|
||
debugReason: "pagehide-refresh",
|
||
});
|
||
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-shadow-newer", "official-older"),
|
||
{ revision: 3, integrity: "integrity-official-older" },
|
||
);
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow-newer",
|
||
chatMetadata: {
|
||
integrity: "integrity-official-older",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "official-older-than-shadow",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
result.reason,
|
||
"official-older-than-shadow:metadata-compat-provisional",
|
||
);
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-official-older",
|
||
);
|
||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes?.[0]
|
||
?.fields?.title,
|
||
"事件-official-older",
|
||
);
|
||
assert.equal(
|
||
"pagehide-refresh",
|
||
"pagehide-refresh",
|
||
"metadata 兼容加载后影子快照可保留,但不作为主链路恢复来源",
|
||
);
|
||
reader.api.setGraphPersistenceState({
|
||
shadowSnapshotRevision: 9,
|
||
shadowSnapshotReason: "shadow-integrity-mismatch",
|
||
});
|
||
const live = reader.api.getGraphPersistenceLiveState();
|
||
assert.equal(live.shadowSnapshotRevision, 9);
|
||
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
|
||
const compareDecision = shouldPreferShadowSnapshotOverOfficial(
|
||
officialGraph,
|
||
{ chatId: "chat-shadow-newer", persistedChatId: "chat-shadow-newer", revision: 9, integrity: "integrity-shadow-mismatch" },
|
||
);
|
||
assert.equal(compareDecision.resultCode, "shadow.reject.integrity-mismatch");
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(createMeaningfulGraph("chat-self-mismatch"), {
|
||
revision: 0,
|
||
chatId: "",
|
||
integrity: "",
|
||
}),
|
||
{
|
||
chatId: "chat-self-mismatch",
|
||
persistedChatId: "chat-other",
|
||
revision: 5,
|
||
integrity: "",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-self-chat-mismatch");
|
||
assert.equal(decision.resultCode, "shadow.reject.self-chat-mismatch");
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(createMeaningfulGraph("chat-official-missing"), {
|
||
revision: 0,
|
||
chatId: "",
|
||
integrity: "",
|
||
}),
|
||
{
|
||
chatId: "chat-official-missing",
|
||
persistedChatId: "chat-official-missing",
|
||
revision: 4,
|
||
integrity: "",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-persisted-chat-without-official-chat");
|
||
assert.equal(
|
||
decision.resultCode,
|
||
"shadow.reject.persisted-chat-without-official-chat",
|
||
);
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph("chat-official-integrity-missing"),
|
||
{
|
||
revision: 0,
|
||
chatId: "chat-official-integrity-missing",
|
||
integrity: "",
|
||
},
|
||
),
|
||
{
|
||
chatId: "chat-official-integrity-missing",
|
||
persistedChatId: "chat-official-integrity-missing",
|
||
revision: 4,
|
||
integrity: "shadow-only-integrity",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-integrity-without-official-integrity");
|
||
assert.equal(
|
||
decision.resultCode,
|
||
"shadow.reject.integrity-without-official-integrity",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty-confirmed",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-empty",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-empty",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(result.reason, "indexeddb-probe-pending");
|
||
assert.equal(live.writesBlocked, true);
|
||
assert.equal(harness.api.getCurrentGraph(), null);
|
||
assert.equal(
|
||
harness.api.readRuntimeDebugSnapshot().graphPersistence?.loadState,
|
||
"loading",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty-confirmed-passive",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-empty-passive",
|
||
},
|
||
});
|
||
harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-empty-passive",
|
||
});
|
||
|
||
harness.api.onMessageReceived();
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.__contextImmediateSaveCalls,
|
||
0,
|
||
"空聊天的被动同步不应触发立即保存",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__contextSaveCalls,
|
||
0,
|
||
"空聊天的被动同步不应触发防抖保存",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
"loading 状态下不能把空图被动写回 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-manager-unavailable-fallback",
|
||
globalChatId: "chat-manager-unavailable-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-manager-unavailable-fallback",
|
||
},
|
||
});
|
||
harness.runtimeContext.BmeChatManager = null;
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "manager-unavailable-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"blocked",
|
||
"IndexedDB manager 不可用时,重试耗尽后不应永久停留在 loading",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().reason,
|
||
"indexeddb-manager-unavailable",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-manager-unavailable-write",
|
||
globalChatId: "chat-manager-unavailable-write",
|
||
chatMetadata: {
|
||
integrity: "meta-manager-unavailable-write",
|
||
},
|
||
});
|
||
harness.runtimeContext.BmeChatManager = null;
|
||
|
||
const result = await harness.api.saveGraphToIndexedDb(
|
||
"chat-manager-unavailable-write",
|
||
createMeaningfulGraph("chat-manager-unavailable-write", "manager-unavailable-write"),
|
||
{
|
||
revision: 3,
|
||
reason: "manager-unavailable-write",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.reason, "indexeddb-manager-unavailable");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().indexedDbLastError,
|
||
"indexeddb-manager-unavailable",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {
|
||
integrity: "",
|
||
},
|
||
});
|
||
const graph = createMeaningfulGraph("chat-persist-fallback", "persist-fallback");
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setChatContext({
|
||
chatId: "",
|
||
chatMetadata: {},
|
||
characterId: "char-fallback",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "fallback chat id" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "persist-fallback-chat-id",
|
||
lastProcessedAssistantFloor: 6,
|
||
graphSnapshot: null,
|
||
persistDelta: null,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshotForChat("chat-persist-fallback")?.meta?.chatId,
|
||
"chat-persist-fallback",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-read-failed-fallback",
|
||
globalChatId: "chat-indexeddb-read-failed-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-read-failed-fallback",
|
||
},
|
||
});
|
||
harness.runtimeContext.__indexedDbExportSnapshotShouldThrow = true;
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "indexeddb-read-failed-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"blocked",
|
||
"IndexedDB 读取失败时,重试耗尽后不应永久停留在 loading",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().reason,
|
||
"indexeddb-read-failed",
|
||
);
|
||
}
|
||
|
||
{
|
||
const commitMarker = buildGraphCommitMarker(
|
||
createMeaningfulGraph("chat-indexeddb-empty-mismatch-fallback", "marker"),
|
||
{
|
||
revision: 4,
|
||
storageTier: "indexeddb",
|
||
accepted: true,
|
||
reason: "test-empty-mismatch",
|
||
chatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
integrity: "meta-indexeddb-empty-mismatch-fallback",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
globalChatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-empty-mismatch-fallback",
|
||
[GRAPH_COMMIT_MARKER_KEY]: commitMarker,
|
||
},
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "indexeddb-empty-mismatch-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"empty-confirmed",
|
||
"当 accepted commit marker 已成孤儿且本地不存在可恢复图谱源时,应自动降级为 empty-confirmed",
|
||
);
|
||
assert.match(
|
||
String(harness.api.getGraphPersistenceState().reason || ""),
|
||
/orphan-accepted-marker/,
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY],
|
||
null,
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 1);
|
||
assert.equal(harness.api.getGraphPersistenceState().lastAcceptedRevision, 0);
|
||
assert.equal(harness.api.getGraphPersistenceState().commitMarker, null);
|
||
}
|
||
|
||
{
|
||
const commitMarker = buildGraphCommitMarker(
|
||
createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "marker"),
|
||
{
|
||
revision: 8,
|
||
storageTier: "indexeddb",
|
||
accepted: true,
|
||
reason: "test-chat-state-rescue",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
globalChatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
[GRAPH_COMMIT_MARKER_KEY]: commitMarker,
|
||
},
|
||
});
|
||
const sidecarGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "sidecar"),
|
||
{
|
||
revision: 8,
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
reason: "sidecar-rescue-seed",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
buildGraphChatStateSnapshot(sidecarGraph, {
|
||
revision: 8,
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
reason: "sidecar-rescue-seed",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
}),
|
||
);
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"chat-indexeddb-empty-chat-state-rescue",
|
||
{
|
||
source: "indexeddb-empty-chat-state-rescue",
|
||
attemptIndex: 0,
|
||
allowOverride: true,
|
||
applyEmptyState: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.loaded, true);
|
||
assert.equal(result.loadState, "loaded");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-sidecar",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY]
|
||
?.revision,
|
||
8,
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().persistMismatchReason,
|
||
"persist-mismatch:indexeddb-behind-commit-marker",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-create-first-graph",
|
||
chatMetadata: {
|
||
integrity: "integrity-before-first-save",
|
||
},
|
||
});
|
||
harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-for-first-save",
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
createMeaningfulGraph("chat-create-first-graph", "first-save"),
|
||
);
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "first-meaningful-graph",
|
||
});
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.queued, true);
|
||
assert.equal(result.saveMode, "indexeddb-queued");
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.integrity ===
|
||
"integrity-before-first-save",
|
||
true,
|
||
"插件保存图谱时不能改写宿主 metadata.integrity",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision) > 0,
|
||
true,
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-promote",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-promote",
|
||
createMeaningfulGraph("chat-promote", "promote"),
|
||
{ revision: 9, reason: "pre-refresh" },
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-promote",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-promote",
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "promote-when-metadata-ready",
|
||
});
|
||
const live = reader.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
|
||
?.length,
|
||
undefined,
|
||
);
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.integrity,
|
||
"meta-ready-promote",
|
||
);
|
||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(live.lastPersistedRevision, 9);
|
||
assert.equal(live.pendingPersist, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-decouple",
|
||
chatMetadata: {
|
||
integrity: "meta-decouple",
|
||
},
|
||
});
|
||
const runtimeGraph = createMeaningfulGraph("chat-decouple", "runtime");
|
||
harness.api.setCurrentGraph(runtimeGraph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-decouple",
|
||
revision: 3,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "decouple-metadata-runtime",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
|
||
assert.equal(result.saved, true);
|
||
const persistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph;
|
||
assert.notEqual(
|
||
persistedGraph,
|
||
harness.api.getCurrentGraph(),
|
||
"写入 metadata 时必须使用独立 graph 快照",
|
||
);
|
||
|
||
persistedGraph.nodes[0].fields.title = "metadata-mutated";
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0].fields.title,
|
||
"事件-runtime",
|
||
"metadata 修改不能反向污染运行时 graph",
|
||
);
|
||
|
||
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-mutated";
|
||
assert.equal(
|
||
persistedGraph.nodes[0].fields.title,
|
||
"metadata-mutated",
|
||
"运行时修改不能反向污染已保存 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-official", "official"),
|
||
{
|
||
revision: 4,
|
||
integrity: "meta-load-official",
|
||
chatId: "chat-load-official",
|
||
reason: "official-save",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-official",
|
||
chatMetadata: {
|
||
integrity: "meta-load-official",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "load-official-decoupled",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
const runtimeGraph = harness.api.getCurrentGraph();
|
||
const persistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
assert.notEqual(
|
||
runtimeGraph,
|
||
persistedGraph,
|
||
"从 official metadata 恢复到运行时必须使用独立对象",
|
||
);
|
||
|
||
runtimeGraph.nodes[0].fields.title = "runtime-after-load";
|
||
assert.equal(
|
||
persistedGraph.nodes[0].fields.title,
|
||
"事件-official",
|
||
"official metadata 不应被运行时修改污染",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-shadow",
|
||
chatMetadata: {
|
||
integrity: "meta-load-shadow",
|
||
st_bme_graph: stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-shadow", "official-older"),
|
||
{
|
||
revision: 2,
|
||
integrity: "meta-load-shadow",
|
||
chatId: "chat-load-shadow",
|
||
reason: "official-older",
|
||
},
|
||
),
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-load-shadow",
|
||
createMeaningfulGraph("chat-load-shadow", "shadow"),
|
||
{
|
||
revision: 5,
|
||
reason: "shadow-newer",
|
||
},
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-shadow",
|
||
chatMetadata: {
|
||
integrity: "meta-load-shadow",
|
||
st_bme_graph: stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-shadow", "official-older"),
|
||
{
|
||
revision: 2,
|
||
integrity: "meta-load-shadow",
|
||
chatId: "chat-load-shadow",
|
||
reason: "official-older",
|
||
},
|
||
),
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "load-shadow-decoupled",
|
||
});
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
const runtimeGraph = reader.api.getCurrentGraph();
|
||
const persistedGraph =
|
||
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
assert.notEqual(
|
||
runtimeGraph,
|
||
persistedGraph,
|
||
"从 shadow snapshot 提升后,运行时与 metadata 也必须解耦",
|
||
);
|
||
|
||
runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated";
|
||
assert.equal(
|
||
runtimeGraph.nodes[0].fields.title,
|
||
"runtime-shadow-mutated",
|
||
);
|
||
assert.equal(
|
||
persistedGraph.nodes[0].fields.title,
|
||
"事件-official-older",
|
||
"metadata 兼容加载后的运行时修改不能污染已保存 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-two-saves",
|
||
chatMetadata: {
|
||
integrity: "meta-two-saves",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-two-saves", "first"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-two-saves",
|
||
revision: 1,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const firstSave = harness.api.saveGraphToChat({
|
||
reason: "first-save",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
assert.equal(firstSave.saved, true);
|
||
const firstPersistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
|
||
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-between-saves";
|
||
assert.equal(
|
||
firstPersistedGraph.nodes[0].fields.title,
|
||
"事件-first",
|
||
"第一次保存后的 metadata 不应被后续运行时修改污染",
|
||
);
|
||
|
||
harness.api.setGraphPersistenceState({ revision: 2 });
|
||
const secondSave = harness.api.saveGraphToChat({
|
||
reason: "second-save",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
assert.equal(secondSave.saved, true);
|
||
const secondPersistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
|
||
assert.notEqual(
|
||
secondPersistedGraph,
|
||
firstPersistedGraph,
|
||
"第二次保存应生成新的 metadata graph 快照",
|
||
);
|
||
assert.equal(
|
||
secondPersistedGraph.nodes[0].fields.title,
|
||
"runtime-between-saves",
|
||
"第二次保存应反映第二轮运行时修改",
|
||
);
|
||
harness.api.getCurrentGraph().nodes[0].fields.title =
|
||
"runtime-after-second-save";
|
||
assert.equal(
|
||
firstPersistedGraph.nodes[0].fields.title,
|
||
"事件-first",
|
||
"第二轮运行时修改仍不能污染第一次已保存 metadata",
|
||
);
|
||
assert.equal(
|
||
secondPersistedGraph.nodes[0].fields.title,
|
||
"runtime-between-saves",
|
||
"第二次已保存 metadata 也不能被后续运行时修改污染",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-idb-ancillary-warning",
|
||
globalChatId: "chat-idb-ancillary-warning",
|
||
chatMetadata: {
|
||
integrity: "meta-idb-ancillary-warning",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
createMeaningfulGraph("chat-idb-ancillary-warning", "ancillary-warning"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-idb-ancillary-warning",
|
||
revision: 7,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
nativeRolloutVersion: 1,
|
||
persistUseNativeDelta: false,
|
||
};
|
||
harness.runtimeContext.__scheduleUploadShouldThrow = true;
|
||
|
||
const result = await harness.api.saveGraphToIndexedDb(
|
||
"chat-idb-ancillary-warning",
|
||
harness.api.getCurrentGraph(),
|
||
{
|
||
revision: 7,
|
||
reason: "ancillary-warning-save",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
assert.match(String(result.warning || ""), /schedule-upload-failed/);
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshot().meta.revision,
|
||
7,
|
||
"附属步骤失败时,IndexedDB 主写仍应视为成功",
|
||
);
|
||
const persistDeltaDiagnostics = harness.api.getGraphPersistenceState().persistDelta;
|
||
assert.equal(Boolean(persistDeltaDiagnostics), true);
|
||
assert.equal(persistDeltaDiagnostics.status, "committed");
|
||
assert.equal(persistDeltaDiagnostics.path, "js");
|
||
assert.equal(persistDeltaDiagnostics.requestedNative, false);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.buildMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.prepareMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.lookupMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.jsDiffMs)), true);
|
||
assert.equal(
|
||
Number(persistDeltaDiagnostics.serializationCacheHits || 0) +
|
||
Number(persistDeltaDiagnostics.serializationCacheMisses || 0) >
|
||
0,
|
||
true,
|
||
);
|
||
}
|
||
|
||
{
|
||
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",
|
||
globalChatId: "chat-pending-persist-retry",
|
||
chatMetadata: {
|
||
integrity: "meta-pending-persist-retry",
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "用户发言" },
|
||
{ is_user: false, mes: "助手回复" },
|
||
],
|
||
});
|
||
const graph = createMeaningfulGraph(
|
||
"chat-pending-persist-retry",
|
||
"pending-persist-retry",
|
||
);
|
||
graph.historyState.lastProcessedAssistantFloor = -1;
|
||
graph.lastProcessedSeq = -1;
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
stages: {
|
||
core: { outcome: "success" },
|
||
finalize: { outcome: "success" },
|
||
},
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "none",
|
||
reason: "extraction-batch-complete:pending",
|
||
revision: 7,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
const committedGraph = structuredClone(graph);
|
||
committedGraph.historyState.lastProcessedAssistantFloor = 1;
|
||
committedGraph.lastProcessedSeq = 1;
|
||
committedGraph.batchJournal = [
|
||
{
|
||
id: "journal-queued-1",
|
||
processedRange: [1, 1],
|
||
createdAt: Date.now(),
|
||
},
|
||
];
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-pending-persist-retry",
|
||
revision: 7,
|
||
lastPersistedRevision: 0,
|
||
queuedPersistRevision: 7,
|
||
queuedPersistChatId: "chat-pending-persist-retry",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.writeGraphShadowSnapshot(
|
||
"chat-pending-persist-retry",
|
||
committedGraph,
|
||
{
|
||
revision: 7,
|
||
reason: "queued-persist-authoritative",
|
||
},
|
||
);
|
||
harness.runtimeContext.__markSyncDirtyShouldThrow = true;
|
||
|
||
const result = await harness.api.retryPendingGraphPersist({
|
||
reason: "queued-persist-retry-test",
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().pendingPersist,
|
||
false,
|
||
"pendingPersist 在补存成功后应被清除",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastProcessedAssistantFloor,
|
||
1,
|
||
"补存成功后应推进 lastProcessedAssistantFloor",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.historyAdvanceAllowed,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.persistence.outcome,
|
||
"saved",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().batchJournal?.length,
|
||
1,
|
||
"pending persist retry 应把 authoritative batch journal 回填到 runtime graph",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().batchJournal?.[0]?.id,
|
||
"journal-queued-1",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-load-legacy-pending-repair",
|
||
"load-legacy-pending-repair",
|
||
);
|
||
graph.historyState.lastProcessedAssistantFloor = 1;
|
||
graph.lastProcessedSeq = 1;
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "metadata-full",
|
||
reason: "old-version-pending",
|
||
revision: 4,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
stampPersistedGraph(graph, {
|
||
revision: 4,
|
||
chatId: "chat-load-legacy-pending-repair",
|
||
reason: "legacy-pending-repair-seed",
|
||
});
|
||
|
||
const snapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: "meta-load-legacy-pending-repair",
|
||
revision: 4,
|
||
meta: {
|
||
integrity: "meta-load-legacy-pending-repair",
|
||
},
|
||
});
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-legacy-pending-repair",
|
||
globalChatId: "chat-load-legacy-pending-repair",
|
||
chatMetadata: {
|
||
integrity: "meta-load-legacy-pending-repair",
|
||
[GRAPH_COMMIT_MARKER_KEY]: {
|
||
accepted: true,
|
||
revision: 4,
|
||
storageTier: "indexeddb",
|
||
chatId: "meta-load-legacy-pending-repair",
|
||
},
|
||
},
|
||
indexedDbSnapshots: {
|
||
"meta-load-legacy-pending-repair": snapshot,
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "旧聊天" },
|
||
{ is_user: false, mes: "旧回复" },
|
||
],
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "indexeddb",
|
||
};
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"meta-load-legacy-pending-repair",
|
||
{
|
||
source: "legacy-pending-load-repair-test",
|
||
allowOverride: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.loaded, true, result.reason);
|
||
const repairedStatus = harness.api.getCurrentGraph().historyState.lastBatchStatus;
|
||
assert.equal(repairedStatus.historyAdvanceAllowed, true);
|
||
assert.equal(repairedStatus.persistence.accepted, true);
|
||
assert.equal(repairedStatus.persistence.saved, true);
|
||
assert.equal(repairedStatus.persistence.queued, false);
|
||
assert.equal(repairedStatus.persistence.blocked, false);
|
||
assert.equal(repairedStatus.persistence.storageTier, "indexeddb");
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshotForChat("meta-load-legacy-pending-repair")?.meta?.lastMutationReason,
|
||
"legacy-persistence-auto-repair-after-load",
|
||
"加载旧 pending 状态后应将归一化结果写回 canonical store",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-load-legacy-pending-no-proof",
|
||
"load-legacy-pending-no-proof",
|
||
);
|
||
graph.historyState.lastProcessedAssistantFloor = 1;
|
||
graph.lastProcessedSeq = 1;
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "metadata-full",
|
||
reason: "old-version-pending",
|
||
revision: 4,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
stampPersistedGraph(graph, {
|
||
revision: 4,
|
||
chatId: "chat-load-legacy-pending-no-proof",
|
||
reason: "legacy-pending-no-proof-seed",
|
||
});
|
||
|
||
const snapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: "meta-load-legacy-pending-no-proof",
|
||
revision: 4,
|
||
meta: {
|
||
integrity: "meta-load-legacy-pending-no-proof",
|
||
},
|
||
});
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-legacy-pending-no-proof",
|
||
globalChatId: "chat-load-legacy-pending-no-proof",
|
||
chatMetadata: {
|
||
integrity: "meta-load-legacy-pending-no-proof",
|
||
},
|
||
indexedDbSnapshots: {
|
||
"meta-load-legacy-pending-no-proof": snapshot,
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "旧聊天" },
|
||
{ is_user: false, mes: "旧回复" },
|
||
],
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "indexeddb",
|
||
};
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"meta-load-legacy-pending-no-proof",
|
||
{
|
||
source: "legacy-pending-load-no-proof-test",
|
||
allowOverride: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.loaded, true, result.reason);
|
||
const unrepairedStatus = harness.api.getCurrentGraph().historyState.lastBatchStatus;
|
||
assert.equal(
|
||
unrepairedStatus.historyAdvanceAllowed,
|
||
false,
|
||
"无独立 accepted 证据时,加载旧 pending 状态不得自动放行历史推进",
|
||
);
|
||
assert.equal(unrepairedStatus.persistence.accepted, false);
|
||
assert.equal(unrepairedStatus.persistence.queued, true);
|
||
assert.equal(unrepairedStatus.persistence.blocked, true);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-runtime-fallback-vector-maintenance",
|
||
"runtime-fallback-vector-maintenance",
|
||
);
|
||
graph.historyState.chatId = "chat-runtime-fallback-vector-maintenance";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chat: [
|
||
{ is_user: true, mes: "已有聊天" },
|
||
{ is_user: false, mes: "已有回复" },
|
||
],
|
||
});
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: GRAPH_LOAD_STATES.NO_CHAT,
|
||
chatId: "chat-runtime-fallback-vector-maintenance",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.ensureGraphMutationReady("重建向量", {
|
||
notify: false,
|
||
allowRuntimeGraphFallback: true,
|
||
}),
|
||
true,
|
||
"live chat id 暂空但 runtime graph 已明确绑定聊天时,向量维护不应被误判为未进入聊天",
|
||
);
|
||
const status = harness.api.getPanelRuntimeStatus();
|
||
assert.equal(status.text, "图谱已加载");
|
||
assert.match(status.meta, /维护操作会使用图谱身份继续/);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-runtime-fallback-identity-repair",
|
||
"runtime-fallback-identity-repair",
|
||
);
|
||
graph.historyState.chatId = "";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chat: [
|
||
{ is_user: true, mes: "已有聊天" },
|
||
{ is_user: false, mes: "已有回复" },
|
||
],
|
||
});
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: GRAPH_LOAD_STATES.NO_CHAT,
|
||
chatId: "chat-runtime-fallback-identity-repair",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.ensureGraphMutationReady("重建向量", {
|
||
notify: false,
|
||
allowRuntimeGraphFallback: true,
|
||
}),
|
||
true,
|
||
"面板/持久化状态已有聊天 ID 且图谱自身缺身份时,向量维护前应补齐缺失身份",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.chatId,
|
||
"chat-runtime-fallback-identity-repair",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-runtime-fallback-denied",
|
||
"runtime-fallback-denied",
|
||
);
|
||
graph.historyState.chatId = "chat-runtime-fallback-denied";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chat: [{ is_user: true, mes: "其它上下文" }],
|
||
});
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: GRAPH_LOAD_STATES.NO_CHAT,
|
||
chatId: "",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.ensureGraphMutationReady("重建向量", {
|
||
notify: false,
|
||
allowRuntimeGraphFallback: true,
|
||
}),
|
||
false,
|
||
"没有 graphPersistenceState.chatId 强绑定时,不应仅凭 runtimeGraph/chat 内容放开写入",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph(
|
||
"chat-runtime-fallback-conflict",
|
||
"runtime-fallback-conflict",
|
||
);
|
||
graph.historyState.chatId = "";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chat: [{ is_user: true, mes: "已有聊天" }],
|
||
});
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: GRAPH_LOAD_STATES.NO_CHAT,
|
||
chatId: "chat-runtime-fallback-conflict",
|
||
commitMarker: { chatId: "other-chat" },
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.ensureGraphMutationReady("重建向量", {
|
||
notify: false,
|
||
allowRuntimeGraphFallback: true,
|
||
}),
|
||
false,
|
||
"commit marker 指向其它聊天时,不应补齐身份或放开向量写入",
|
||
);
|
||
assert.equal(harness.api.getCurrentGraph().historyState.chatId, "");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-pending-persist-already-accepted",
|
||
globalChatId: "chat-pending-persist-already-accepted",
|
||
chatMetadata: {
|
||
integrity: "meta-pending-persist-already-accepted",
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "用户发言" },
|
||
{ is_user: false, mes: "助手回复" },
|
||
],
|
||
});
|
||
const graph = createMeaningfulGraph(
|
||
"chat-pending-persist-already-accepted",
|
||
"pending-persist-already-accepted",
|
||
);
|
||
graph.historyState.lastProcessedAssistantFloor = 1;
|
||
graph.lastProcessedSeq = 1;
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "authority-sql",
|
||
reason: "extraction-batch-complete:pending",
|
||
revision: 7,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-pending-persist-already-accepted",
|
||
revision: 7,
|
||
lastPersistedRevision: 7,
|
||
lastAcceptedRevision: 7,
|
||
acceptedStorageTier: "authority-sql",
|
||
queuedPersistRevision: 7,
|
||
queuedPersistChatId: "chat-pending-persist-already-accepted",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.runtimeContext.__markSyncDirtyShouldThrow = true;
|
||
|
||
const result = await harness.api.retryPendingGraphPersist({
|
||
reason: "queued-persist-already-accepted-test",
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().pendingPersist,
|
||
false,
|
||
"已被 lastAcceptedRevision 覆盖的 pendingPersist 应在重试时直接清除",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.persistence.accepted,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.historyAdvanceAllowed,
|
||
true,
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-pending-current",
|
||
globalChatId: "chat-pending-current",
|
||
chatMetadata: {
|
||
integrity: "meta-pending-current",
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "当前聊天用户发言" },
|
||
{ is_user: false, mes: "当前聊天助手回复" },
|
||
],
|
||
});
|
||
const graph = createMeaningfulGraph(
|
||
"chat-pending-current",
|
||
"pending-persist-chat-mismatch",
|
||
);
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "authority-sql",
|
||
reason: "extraction-batch-complete:pending",
|
||
revision: 7,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-pending-current",
|
||
revision: 9,
|
||
lastPersistedRevision: 9,
|
||
lastAcceptedRevision: 9,
|
||
acceptedStorageTier: "authority-sql",
|
||
queuedPersistRevision: 7,
|
||
queuedPersistChatId: "other-chat-pending",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = await harness.api.retryPendingGraphPersist({
|
||
reason: "queued-persist-chat-mismatch-test",
|
||
});
|
||
|
||
assert.equal(result.accepted, false);
|
||
assert.equal(result.reason, "queued-chat-mismatch");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().pendingPersist,
|
||
true,
|
||
"其它聊天的 queued pending 不能被当前聊天 accepted revision 清掉",
|
||
);
|
||
}
|
||
|
||
{
|
||
const chatId = "meta-authority-indexeddb-migration";
|
||
const legacyGraph = stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "authority-indexeddb-migration"),
|
||
{
|
||
revision: 4,
|
||
integrity: "meta-authority-indexeddb-migration",
|
||
chatId,
|
||
reason: "legacy-indexeddb",
|
||
},
|
||
);
|
||
const legacySnapshot = buildSnapshotFromGraph(legacyGraph, {
|
||
chatId,
|
||
revision: 4,
|
||
meta: {
|
||
integrity: "meta-authority-indexeddb-migration",
|
||
storagePrimary: "indexeddb",
|
||
storageMode: "indexeddb",
|
||
syncDirty: true,
|
||
},
|
||
});
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
chatMetadata: {
|
||
integrity: "meta-authority-indexeddb-migration",
|
||
},
|
||
indexedDbSnapshot: legacySnapshot,
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
authorityEnabled: "on",
|
||
authorityPrimaryWhenAvailable: true,
|
||
authorityStorageMode: "server-primary",
|
||
authoritySqlPrimary: true,
|
||
authorityBrowserCacheMode: "minimal",
|
||
};
|
||
harness.api.setAuthorityCapabilityState({
|
||
installed: true,
|
||
healthy: true,
|
||
sessionReady: true,
|
||
permissionReady: true,
|
||
features: ["sql.query", "sql.mutation", "trivium.search", "jobs", "blob"],
|
||
jobs: {
|
||
builtinTypes: ["delay", "sql.backup", "trivium.flush", "fs.import-jsonl"],
|
||
registry: {
|
||
jobTypes: ["delay", "sql.backup", "trivium.flush", "fs.import-jsonl"],
|
||
},
|
||
},
|
||
reason: "ok",
|
||
lastProbeAt: Date.now(),
|
||
});
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(chatId, {
|
||
source: "authority-indexeddb-migration",
|
||
attemptIndex: 0,
|
||
});
|
||
|
||
assert.equal(result.loaded, true);
|
||
assert.equal(result.reason, "authority-sql:authority-indexeddb-migration");
|
||
assert.equal(harness.runtimeContext.__syncNowCalls.length, 0);
|
||
const authoritySnapshot = harness.api.getAuthoritySnapshotForChat(chatId);
|
||
assert.equal(authoritySnapshot?.nodes?.length, 1);
|
||
assert.equal(authoritySnapshot?.meta?.storagePrimary, AUTHORITY_GRAPH_STORE_KIND);
|
||
assert.equal(authoritySnapshot?.meta?.storageMode, AUTHORITY_GRAPH_STORE_MODE);
|
||
assert.equal(
|
||
authoritySnapshot?.meta?.migrationSource,
|
||
"legacy_indexeddb_to_authority",
|
||
);
|
||
assert.equal(authoritySnapshot?.meta?.syncDirty, false);
|
||
const safetySnapshot = harness.api.getIndexedDbSnapshotForChat(
|
||
harness.runtimeContext.buildRestoreSafetyChatId(chatId),
|
||
);
|
||
assert.equal(safetySnapshot?.nodes?.length, 1);
|
||
assert.equal(safetySnapshot?.meta?.restoreSafetySnapshotExists, true);
|
||
assert.equal(safetySnapshot?.meta?.restoreSafetySnapshotChatId, chatId);
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
assert.equal(live.storagePrimary, AUTHORITY_GRAPH_STORE_KIND);
|
||
assert.equal(live.storageMode, AUTHORITY_GRAPH_STORE_MODE);
|
||
assert.equal(live.authorityMigrationState, "completed");
|
||
assert.equal(live.authorityMigrationSource, "legacy_indexeddb_to_authority");
|
||
assert.equal(Number(live.authorityMigrationRevision), 4);
|
||
assert.equal(
|
||
live.lastAuthorityMigrationResult?.safetySnapshotResult?.restoreSafetyCaptured,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__indexedDbSnapshots.has(chatId),
|
||
true,
|
||
"迁移成功后仍保留 legacy IndexedDB 源数据,不删除本地数据",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-authority-empty-jobs",
|
||
globalChatId: "chat-authority-empty-jobs",
|
||
});
|
||
harness.api.setAuthorityCapabilityState({
|
||
installed: true,
|
||
healthy: true,
|
||
sessionReady: true,
|
||
permissionReady: true,
|
||
features: ["sql.query", "sql.mutation", "trivium.search", "jobs", "blob"],
|
||
supportedJobTypes: [],
|
||
supportedJobTypesKnown: true,
|
||
reason: "ok",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.shouldUseAuthorityJobs({ mode: "authority", source: "authority-trivium" }),
|
||
false,
|
||
"显式空 Authority job 白名单应阻止 vector rebuild job 提交",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-authority-sql-storage-only",
|
||
globalChatId: "chat-authority-sql-storage-only",
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
authorityEnabled: "on",
|
||
authorityPrimaryWhenAvailable: true,
|
||
authorityStorageMode: "server-primary",
|
||
authoritySqlPrimary: true,
|
||
};
|
||
const capability = harness.api.setAuthorityCapabilityState({
|
||
installed: true,
|
||
healthy: true,
|
||
sessionReady: true,
|
||
permissionReady: true,
|
||
features: ["sql.query", "sql.mutation"],
|
||
reason: "missing-required-features",
|
||
lastProbeAt: Date.now(),
|
||
});
|
||
|
||
assert.equal(
|
||
capability.serverPrimaryReady,
|
||
false,
|
||
"缺少 jobs/blob/trivium 时整体 Authority server-primary 应保持降级显示",
|
||
);
|
||
assert.equal(
|
||
capability.storagePrimaryReady,
|
||
true,
|
||
"SQL 存储能力已就绪时图谱主存储应可用",
|
||
);
|
||
assert.equal(
|
||
harness.api.shouldUseAuthorityGraphStore(
|
||
harness.runtimeContext.extension_settings[MODULE_NAME],
|
||
capability,
|
||
),
|
||
true,
|
||
"Authority SQL 图谱主存储不应被 jobs/blob/trivium 附属能力误伤",
|
||
);
|
||
assert.equal(
|
||
harness.api.shouldUseAuthorityJobs({ mode: "authority", source: "authority-trivium" }),
|
||
false,
|
||
"jobs 不可用时 Authority job 提交仍应被禁用",
|
||
);
|
||
|
||
harness.api.setCurrentGraph(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph("chat-authority-sql-storage-only", "authority-sql-storage-only"),
|
||
{
|
||
revision: 6,
|
||
integrity: "chat-authority-sql-storage-only",
|
||
chatId: "chat-authority-sql-storage-only",
|
||
reason: "authority-sql-storage-only-seed",
|
||
},
|
||
),
|
||
);
|
||
|
||
const persistResult = await harness.api.persistExtractionBatchResult({
|
||
reason: "authority-sql-storage-only-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
|
||
assert.equal(persistResult.accepted, true);
|
||
assert.equal(persistResult.storageTier, "authority-sql");
|
||
assert.equal(persistResult.acceptedBy, "authority-sql");
|
||
assert.equal(
|
||
Number(
|
||
harness.api.getAuthoritySnapshotForChat("chat-authority-sql-storage-only")?.meta
|
||
?.revision || 0,
|
||
),
|
||
persistResult.revision,
|
||
"SQL-only Authority capability should still perform accepted Authority SQL graph persistence",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-b",
|
||
globalChatId: "chat-b",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-b",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-a", "queued"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-a",
|
||
revision: 6,
|
||
lastPersistedRevision: 4,
|
||
queuedPersistRevision: 6,
|
||
queuedPersistChatId: "chat-a",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.maybeFlushQueuedGraphPersist("cross-chat-flush");
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.blocked, true);
|
||
assert.equal(result.reason, "queued-chat-mismatch");
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
"跨 chat 的 queued persist 不得 flush 到当前 metadata",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().queuedPersistChatId,
|
||
"chat-a",
|
||
"发生 chat mismatch 时应保留原始 queued chat 绑定",
|
||
);
|
||
}
|
||
|
||
// === Fix 2c: assertRecoveryChatStillActive 跨 chat 守卫 ===
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-recovery-a",
|
||
globalChatId: "chat-recovery-a",
|
||
chatMetadata: {
|
||
integrity: "meta-recovery-a",
|
||
},
|
||
});
|
||
|
||
// 同一 chat 不应抛出
|
||
harness.api.assertRecoveryChatStillActive("chat-recovery-a", "test-same");
|
||
|
||
// 切换到 chat-b
|
||
harness.runtimeContext.__globalChatId = "chat-recovery-b";
|
||
harness.runtimeContext.__chatContext.chatId = "chat-recovery-b";
|
||
|
||
let abortCaught = false;
|
||
try {
|
||
harness.api.assertRecoveryChatStillActive("chat-recovery-a", "test-switch");
|
||
} catch (e) {
|
||
abortCaught = harness.api.isAbortError(e);
|
||
}
|
||
assert.equal(
|
||
abortCaught,
|
||
true,
|
||
"chat 切换后 assertRecoveryChatStillActive 应抛出 AbortError",
|
||
);
|
||
|
||
// 空 expectedChatId 不应抛出
|
||
harness.api.assertRecoveryChatStillActive("", "test-empty");
|
||
harness.api.assertRecoveryChatStillActive(undefined, "test-undefined");
|
||
}
|
||
|
||
// === Fix 2e: resolveDirtyFloorFromMutationMeta 候选过滤 ===
|
||
// 此测试需要 resolveDirtyFloorFromMutationMeta 与 getAssistantTurns,
|
||
// 它们均在 persistencePrelude 范围内,通过 vm 上下文执行。
|
||
// 这里使用间接方式验证:构造一个只有晚期 assistant 的 chat,
|
||
// 然后检查 inspectHistoryMutation 不会对早期 floor 误判。
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-dirty-floor",
|
||
globalChatId: "chat-dirty-floor",
|
||
chatMetadata: {
|
||
integrity: "meta-dirty-floor",
|
||
},
|
||
chat: [
|
||
// index 0: user
|
||
{ is_user: true, mes: "hello" },
|
||
// index 1: user (no assistant before index 4)
|
||
{ is_user: true, mes: "second" },
|
||
// index 2: user
|
||
{ is_user: true, mes: "third" },
|
||
// index 3: user
|
||
{ is_user: true, mes: "fourth" },
|
||
// index 4: first assistant
|
||
{ is_user: false, mes: "first reply" },
|
||
],
|
||
});
|
||
|
||
const graph = createMeaningfulGraph("chat-dirty-floor", "dirty-floor");
|
||
graph.historyState.lastProcessedAssistantFloor = 4;
|
||
graph.historyState.extractionCount = 1;
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-dirty-floor",
|
||
revision: 2,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
// 模拟:meta 指向 floor=1(早于最小可提取 floor=4)的删除事件
|
||
// 使用间接方式:graph 的 lastProcessedAssistantFloor=4,
|
||
// 如果 resolveDirtyFloorFromMutationMeta 正确过滤了 floor<4 的候选,
|
||
// 那么 inspectHistoryMutation 不会标记为 dirty(因为没有有效候选)。
|
||
// 注意:这里不直接测试内部函数,而是验证整体行为。
|
||
const graph2 = harness.api.getCurrentGraph();
|
||
assert.ok(graph2, "graph 应存在");
|
||
assert.equal(
|
||
graph2.historyState.lastProcessedAssistantFloor,
|
||
4,
|
||
"lastProcessedAssistantFloor 应为 4",
|
||
);
|
||
}
|
||
|
||
{
|
||
const metadataGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-priority", "metadata"),
|
||
{
|
||
revision: 3,
|
||
integrity: "meta-indexeddb-priority",
|
||
chatId: "chat-indexeddb-priority",
|
||
reason: "metadata-seed",
|
||
},
|
||
);
|
||
const indexedDbGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-priority", "indexeddb"),
|
||
{
|
||
revision: 9,
|
||
integrity: "idxdb-indexeddb-priority",
|
||
chatId: "chat-indexeddb-priority",
|
||
reason: "indexeddb-seed",
|
||
},
|
||
);
|
||
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||
chatId: "chat-indexeddb-priority",
|
||
revision: 9,
|
||
});
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-priority",
|
||
globalChatId: "chat-indexeddb-priority",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-priority",
|
||
[GRAPH_METADATA_KEY]: metadataGraph,
|
||
},
|
||
indexedDbSnapshot,
|
||
});
|
||
|
||
harness.api.loadGraphFromChat({ source: "indexeddb-priority" });
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-indexeddb");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().storagePrimary,
|
||
"indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-shadow-restore",
|
||
globalChatId: "chat-indexeddb-shadow-restore",
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-indexeddb-shadow-restore",
|
||
createMeaningfulGraph("chat-indexeddb-shadow-restore", "shadow-newer"),
|
||
{
|
||
revision: 9,
|
||
reason: "pagehide-refresh",
|
||
},
|
||
);
|
||
|
||
const indexedDbGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-shadow-restore", "indexeddb-older"),
|
||
{
|
||
revision: 4,
|
||
integrity: "meta-indexeddb-shadow-restore",
|
||
chatId: "chat-indexeddb-shadow-restore",
|
||
reason: "indexeddb-older",
|
||
},
|
||
);
|
||
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||
chatId: "chat-indexeddb-shadow-restore",
|
||
revision: 4,
|
||
});
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-shadow-restore",
|
||
globalChatId: "chat-indexeddb-shadow-restore",
|
||
indexedDbSnapshot,
|
||
sessionStore: sharedSession,
|
||
});
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"chat-indexeddb-shadow-restore",
|
||
{
|
||
source: "indexeddb-shadow-restore",
|
||
allowOverride: true,
|
||
applyEmptyState: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-shadow-newer",
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshot().meta.revision,
|
||
9,
|
||
"shadow 恢复后应回补 IndexedDB 修正旧快照",
|
||
);
|
||
}
|
||
|
||
{
|
||
const legacyGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-legacy-migration", "legacy"),
|
||
{
|
||
revision: 6,
|
||
integrity: "meta-legacy-migration",
|
||
chatId: "chat-legacy-migration",
|
||
reason: "legacy-seed",
|
||
},
|
||
);
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-legacy-migration",
|
||
globalChatId: "chat-legacy-migration",
|
||
chatMetadata: {
|
||
integrity: "meta-legacy-migration",
|
||
[GRAPH_METADATA_KEY]: legacyGraph,
|
||
},
|
||
indexedDbSnapshot: {
|
||
meta: {
|
||
chatId: "chat-legacy-migration",
|
||
revision: 0,
|
||
migrationCompletedAt: 0,
|
||
},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: {
|
||
lastProcessedFloor: -1,
|
||
extractionCount: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
harness.api.loadGraphFromChat({ source: "legacy-migration-check" });
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.ok(harness.runtimeContext.__syncNowCalls.length >= 1);
|
||
assert.equal(
|
||
harness.runtimeContext.__syncNowCalls[0].options.reason,
|
||
"post-migration",
|
||
);
|
||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-legacy");
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshot().meta.migrationSource,
|
||
"chat_metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-state-save",
|
||
globalChatId: "chat-state-save",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-state-save",
|
||
},
|
||
indexedDbSnapshot: {
|
||
meta: {
|
||
chatId: "chat-state-save",
|
||
revision: 0,
|
||
},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: {
|
||
lastProcessedFloor: -1,
|
||
extractionCount: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-state-save", "sidecar"),
|
||
{
|
||
revision: 7,
|
||
integrity: "meta-chat-state-save",
|
||
chatId: "chat-state-save",
|
||
reason: "chat-state-seed",
|
||
},
|
||
);
|
||
|
||
const result = await harness.runtimeContext.persistGraphToHostChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
graph,
|
||
revision: 7,
|
||
reason: "chat-state-direct-save",
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
mode: "primary",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
const stored = await harness.runtimeContext.__chatContext.getChatState(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
);
|
||
assert.equal(stored?.revision, 7);
|
||
assert.equal(stored?.commitMarker?.storageTier, "chat-state");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.target,
|
||
"chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-state-read",
|
||
globalChatId: "chat-state-read",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-state-read",
|
||
},
|
||
});
|
||
|
||
const sidecarGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-state-read", "sidecar-read"),
|
||
{
|
||
revision: 9,
|
||
integrity: "meta-chat-state-read",
|
||
chatId: "chat-state-read",
|
||
reason: "chat-state-read-seed",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
buildGraphChatStateSnapshot(sidecarGraph, {
|
||
revision: 9,
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
reason: "chat-state-read-seed",
|
||
chatId: "chat-state-read",
|
||
integrity: "meta-chat-state-read",
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
}),
|
||
);
|
||
|
||
const result = await harness.runtimeContext.readGraphChatStateSnapshot(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
namespace: GRAPH_CHAT_STATE_NAMESPACE,
|
||
},
|
||
);
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.canUseGraphChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
),
|
||
true,
|
||
);
|
||
assert.equal(result?.revision, 9);
|
||
assert.equal(result?.commitMarker?.storageTier, "chat-state");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-generic-primary-no-mirror",
|
||
globalChatId: "chat-generic-primary-no-mirror",
|
||
characterId: "char-generic",
|
||
chatMetadata: {
|
||
integrity: "meta-generic-primary-no-mirror",
|
||
},
|
||
});
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-generic-primary-no-mirror", "generic-primary"),
|
||
{
|
||
revision: 5,
|
||
integrity: "meta-generic-primary-no-mirror",
|
||
chatId: "chat-generic-primary-no-mirror",
|
||
reason: "generic-primary-seed",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "generic-primary-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "indexeddb");
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.__chatStateStore.size,
|
||
0,
|
||
"generic ST 主写成功后不应再常驻 mirror 到 chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-luker-primary",
|
||
globalChatId: "chat-luker-primary",
|
||
characterId: "char-luker",
|
||
chatMetadata: {
|
||
integrity: "meta-luker-primary",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-luker-primary", "luker-primary"),
|
||
{
|
||
revision: 8,
|
||
integrity: "meta-luker-primary",
|
||
chatId: "chat-luker-primary",
|
||
reason: "luker-primary-seed",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "luker-primary-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "luker-chat-state");
|
||
assert.equal(result.acceptedBy, "luker-chat-state");
|
||
|
||
const manifest = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
);
|
||
const journal = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
);
|
||
const checkpoint = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
);
|
||
const legacyStored = await harness.runtimeContext.__chatContext.getChatState(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
);
|
||
assert.equal(manifest?.headRevision, result.revision);
|
||
assert.equal(manifest?.formatVersion, 2);
|
||
assert.equal(manifest?.storageTier, "luker-chat-state");
|
||
assert.equal(manifest?.checkpointRevision, result.revision);
|
||
assert.equal(checkpoint?.revision, result.revision);
|
||
assert.equal(Array.isArray(journal?.entries), true);
|
||
assert.equal(journal?.entries?.length, 0);
|
||
assert.equal(legacyStored ?? null, null);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0),
|
||
0,
|
||
"Luker 主存储成功后默认不应补写浏览器本地大图谱缓存 revision",
|
||
);
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.nodes?.length || 0),
|
||
0,
|
||
"Luker 主存储成功后默认不应补写浏览器本地大图谱缓存 nodes",
|
||
);
|
||
assert.equal(result.cacheTier, "none");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().acceptedStorageTier,
|
||
"luker-chat-state",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().lukerManifestRevision,
|
||
result.revision,
|
||
);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-authority-sql-primary";
|
||
const persistenceChatId = "meta-luker-authority-sql-primary";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
characterId: "char-luker-authority-sql",
|
||
chatMetadata: {
|
||
integrity: persistenceChatId,
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
authorityEnabled: "on",
|
||
authorityPrimaryWhenAvailable: true,
|
||
authorityStorageMode: "server-primary",
|
||
authoritySqlPrimary: true,
|
||
authorityBrowserCacheMode: "minimal",
|
||
};
|
||
harness.api.setAuthorityCapabilityState({
|
||
installed: true,
|
||
healthy: true,
|
||
sessionReady: true,
|
||
permissionReady: true,
|
||
minimumFeatureSetReady: true,
|
||
serverPrimaryReady: true,
|
||
storagePrimaryReady: true,
|
||
triviumPrimaryReady: true,
|
||
jobsReady: true,
|
||
blobReady: true,
|
||
features: [
|
||
"sql.query",
|
||
"sql.mutation",
|
||
"trivium.search",
|
||
"jobs",
|
||
"blob",
|
||
],
|
||
supportedJobTypes: ["delay"],
|
||
supportedJobTypesKnown: true,
|
||
reason: "ok",
|
||
lastProbeAt: Date.now(),
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-authority-sql"),
|
||
{
|
||
revision: 9,
|
||
integrity: persistenceChatId,
|
||
chatId,
|
||
reason: "luker-authority-sql-seed",
|
||
},
|
||
),
|
||
);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "luker-authority-sql-persist",
|
||
lastProcessedAssistantFloor: 9,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "authority-sql");
|
||
assert.equal(result.acceptedBy, "authority-sql");
|
||
assert.equal(result.primaryTier, "authority-sql");
|
||
assert.equal(result.cacheTier, "none");
|
||
assert.equal(
|
||
await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
),
|
||
null,
|
||
"Authority SQL primary in Luker must not be preempted by Luker sidecar manifest",
|
||
);
|
||
assert.equal(
|
||
Number(harness.api.getAuthoritySnapshotForChat(persistenceChatId)?.meta?.revision || 0),
|
||
result.revision,
|
||
"Authority SQL snapshot should receive the accepted persist revision",
|
||
);
|
||
harness.api.setCurrentGraph(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "runtime-stale-checkpoint"),
|
||
{
|
||
revision: 1,
|
||
integrity: persistenceChatId,
|
||
chatId,
|
||
reason: "runtime-stale-checkpoint",
|
||
},
|
||
),
|
||
);
|
||
const checkpointResult = await harness.api.writeAuthorityCheckpointFromCurrentGraph({
|
||
reason: "authority-sql-checkpoint-source-test",
|
||
});
|
||
assert.equal(checkpointResult.success, true);
|
||
assert.equal(checkpointResult.result.source, "authority-sql");
|
||
assert.equal(checkpointResult.result.checkpointRevision, result.revision);
|
||
const checkpointPayload = Array.from(globalThis.__authorityBlobWrites.entries()).at(-1)?.[1];
|
||
assert.equal(checkpointPayload?.revision, result.revision);
|
||
const checkpointGraph = deserializeGraph(checkpointPayload?.serializedGraph || "{}");
|
||
assert.equal(checkpointGraph.nodes[0]?.fields?.title, "事件-luker-authority-sql");
|
||
assert.notEqual(checkpointGraph.nodes[0]?.fields?.title, "事件-runtime-stale-checkpoint");
|
||
|
||
harness.api.setAuthoritySnapshotForChat(persistenceChatId, null);
|
||
const writeCountBeforeFailedCheckpoint = globalThis.__authorityBlobWrites.size;
|
||
const failedCheckpointResult = await harness.api.writeAuthorityCheckpointFromCurrentGraph({
|
||
reason: "authority-sql-checkpoint-source-missing-test",
|
||
});
|
||
assert.equal(failedCheckpointResult.success, false);
|
||
assert.equal(failedCheckpointResult.error, "authority-sql-checkpoint-source-empty");
|
||
assert.equal(
|
||
globalThis.__authorityBlobWrites.size,
|
||
writeCountBeforeFailedCheckpoint,
|
||
"Authority SQL canonical checkpoint must fail instead of writing stale runtime graph",
|
||
);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-no-authority-primary";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
characterId: "char-luker-no-authority",
|
||
chatMetadata: {
|
||
integrity: "meta-luker-no-authority-primary",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
authorityEnabled: "on",
|
||
authorityPrimaryWhenAvailable: true,
|
||
authorityStorageMode: "server-primary",
|
||
authoritySqlPrimary: true,
|
||
authorityBrowserCacheMode: "minimal",
|
||
};
|
||
harness.api.setAuthorityCapabilityState({
|
||
installed: false,
|
||
healthy: false,
|
||
serverPrimaryReady: false,
|
||
storagePrimaryReady: false,
|
||
reason: "authority-not-installed",
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-no-authority"),
|
||
{
|
||
revision: 7,
|
||
integrity: "meta-luker-no-authority-primary",
|
||
chatId,
|
||
reason: "luker-no-authority-seed",
|
||
},
|
||
),
|
||
);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "luker-no-authority-persist",
|
||
lastProcessedAssistantFloor: 5,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "luker-chat-state");
|
||
assert.equal(result.acceptedBy, "luker-chat-state");
|
||
assert.equal(result.primaryTier, "luker-chat-state");
|
||
assert.equal(result.cacheTier, "none");
|
||
const manifest = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
);
|
||
assert.equal(manifest?.storageTier, "luker-chat-state");
|
||
assert.equal(manifest?.headRevision, result.revision);
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0),
|
||
0,
|
||
"Authority 不可用时,Luker 主存储不应回退写浏览器大图谱缓存 revision",
|
||
);
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.nodes?.length || 0),
|
||
0,
|
||
"Authority 不可用时,Luker 主存储不应回退写浏览器大图谱缓存 nodes",
|
||
);
|
||
}
|
||
|
||
{
|
||
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(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0),
|
||
0,
|
||
"Luker queued save 默认不应写入浏览器本地大图谱缓存 revision",
|
||
);
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.nodes?.length || 0),
|
||
0,
|
||
"Luker queued save 默认不应写入浏览器本地大图谱缓存 nodes",
|
||
);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-manual-cache-rebuild";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
characterId: "char-luker-manual-cache-rebuild",
|
||
chatMetadata: {
|
||
integrity: "meta-luker-manual-cache-rebuild",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-manual-cache"),
|
||
{
|
||
revision: 10,
|
||
integrity: "meta-luker-manual-cache-rebuild",
|
||
chatId,
|
||
reason: "luker-manual-cache-seed",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
const persistResult = await harness.api.persistExtractionBatchResult({
|
||
reason: "luker-manual-cache-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(persistResult.cacheTier, "none");
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0),
|
||
0,
|
||
"Luker 默认路径不应自动重建浏览器缓存",
|
||
);
|
||
|
||
const rebuildResult = await harness.api.onRebuildLocalCacheFromLukerSidecar();
|
||
assert.equal(rebuildResult.handledToast, true);
|
||
assert.equal(rebuildResult.result?.loaded, true);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0),
|
||
persistResult.revision,
|
||
"只有手动重建本地缓存时才应写入浏览器缓存 revision",
|
||
);
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.nodes?.length || 0),
|
||
1,
|
||
"只有手动重建本地缓存时才应写入浏览器缓存 nodes",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-luker-v2-load",
|
||
globalChatId: "chat-luker-v2-load",
|
||
characterId: "char-luker-v2",
|
||
chatMetadata: {
|
||
integrity: "meta-luker-v2-load",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-luker-v2-load", "luker-v2-load"),
|
||
{
|
||
revision: 4,
|
||
integrity: "meta-luker-v2-load",
|
||
chatId: "chat-luker-v2-load",
|
||
reason: "luker-v2-load-seed",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
buildLukerGraphJournalV2([], {
|
||
chatId: "chat-luker-v2-load",
|
||
integrity: "meta-luker-v2-load",
|
||
headRevision: 4,
|
||
}),
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
{
|
||
formatVersion: 2,
|
||
revision: 4,
|
||
serializedGraph: serializeGraph(graph),
|
||
chatId: "chat-luker-v2-load",
|
||
integrity: "meta-luker-v2-load",
|
||
counts: {
|
||
nodeCount: 1,
|
||
edgeCount: 0,
|
||
archivedCount: 0,
|
||
tombstoneCount: 0,
|
||
},
|
||
persistedAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
reason: "luker-v2-load-seed",
|
||
storageTier: "luker-chat-state",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
buildLukerGraphManifestV2(graph, {
|
||
baseRevision: 4,
|
||
headRevision: 4,
|
||
checkpointRevision: 4,
|
||
lastCompactedRevision: 4,
|
||
journalDepth: 0,
|
||
journalBytes: 0,
|
||
chatId: "chat-luker-v2-load",
|
||
integrity: "meta-luker-v2-load",
|
||
reason: "luker-v2-load-seed",
|
||
storageTier: "luker-chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
}),
|
||
);
|
||
|
||
const sidecar = await harness.runtimeContext.readLukerGraphSidecarV2(
|
||
harness.runtimeContext.__chatContext,
|
||
);
|
||
|
||
assert.equal(Number(sidecar?.manifest?.headRevision || 0), 4);
|
||
assert.equal(Number(sidecar?.checkpoint?.revision || 0), 4);
|
||
assert.equal(Number(sidecar?.journal?.entryCount || 0), 0);
|
||
assert.equal(
|
||
sidecar?.manifest?.chatId,
|
||
"chat-luker-v2-load",
|
||
);
|
||
assert.equal(
|
||
sidecar?.checkpoint?.chatId,
|
||
"chat-luker-v2-load",
|
||
);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-revision-drift";
|
||
const integrity = "meta-luker-revision-drift";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
characterId: "char-luker-revision-drift",
|
||
chatMetadata: {
|
||
integrity,
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
|
||
const checkpointGraph = stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-revision-base"),
|
||
{
|
||
revision: 1,
|
||
integrity,
|
||
chatId,
|
||
reason: "luker-revision-base",
|
||
},
|
||
);
|
||
const runtimeGraph = stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-revision-next"),
|
||
{
|
||
revision: 3,
|
||
integrity,
|
||
chatId,
|
||
reason: "luker-revision-next",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(runtimeGraph);
|
||
harness.api.setGraphPersistenceState({
|
||
hostProfile: "luker",
|
||
primaryStorageTier: "luker-chat-state",
|
||
cacheStorageTier: "indexeddb",
|
||
revision: 3,
|
||
lastPersistedRevision: 3,
|
||
lastAcceptedRevision: 3,
|
||
});
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
buildLukerGraphCheckpointV2(checkpointGraph, {
|
||
revision: 1,
|
||
chatId,
|
||
integrity,
|
||
reason: "luker-revision-base",
|
||
storageTier: "luker-chat-state",
|
||
}),
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
buildLukerGraphJournalV2([], {
|
||
chatId,
|
||
integrity,
|
||
headRevision: 1,
|
||
}),
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
buildLukerGraphManifestV2(checkpointGraph, {
|
||
baseRevision: 1,
|
||
headRevision: 1,
|
||
checkpointRevision: 1,
|
||
lastCompactedRevision: 1,
|
||
journalDepth: 0,
|
||
journalBytes: 0,
|
||
chatId,
|
||
integrity,
|
||
reason: "luker-revision-base",
|
||
storageTier: "luker-chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 2,
|
||
extractionCount: 1,
|
||
}),
|
||
);
|
||
|
||
const baseSnapshot = buildSnapshotFromGraph(checkpointGraph, {
|
||
chatId,
|
||
revision: 1,
|
||
});
|
||
const driftedSnapshot = buildSnapshotFromGraph(runtimeGraph, {
|
||
chatId,
|
||
revision: 3,
|
||
});
|
||
const directDelta = buildPersistDelta(baseSnapshot, driftedSnapshot, {
|
||
useNativeDelta: false,
|
||
});
|
||
|
||
const result = await harness.runtimeContext.persistGraphToHostChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
graph: runtimeGraph,
|
||
revision: 3,
|
||
reason: "luker-revision-drift-save",
|
||
storageTier: "luker-chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 4,
|
||
extractionCount: 2,
|
||
mode: "primary",
|
||
persistDelta: directDelta,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
assert.equal(
|
||
result.revision,
|
||
2,
|
||
"Luker sidecar 应基于已接受 head 连续推进,而不是沿用跳号 revision",
|
||
);
|
||
const manifest = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
);
|
||
const journal = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_JOURNAL_NAMESPACE,
|
||
);
|
||
assert.equal(Number(manifest?.headRevision || 0), 2);
|
||
assert.equal(Number(journal?.entries?.length || 0), 1);
|
||
assert.equal(Number(journal?.entries?.[0]?.revision || 0), 2);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-bootstrap-journal-fail";
|
||
const integrity = "meta-luker-bootstrap-journal-fail";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
characterId: "char-luker-bootstrap-journal-fail",
|
||
chatMetadata: {
|
||
integrity,
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph(chatId, "luker-bootstrap-journal-fail"),
|
||
{
|
||
revision: 5,
|
||
integrity,
|
||
chatId,
|
||
reason: "luker-bootstrap-journal-fail",
|
||
},
|
||
);
|
||
const originalUpdateChatState = harness.runtimeContext.__chatContext.updateChatState;
|
||
harness.runtimeContext.__chatContext.updateChatState = async function(namespace, updater) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
if (key === LUKER_GRAPH_JOURNAL_NAMESPACE) {
|
||
return { ok: false, state: null, updated: false };
|
||
}
|
||
return await originalUpdateChatState.call(this, namespace, updater);
|
||
};
|
||
|
||
const result = await harness.runtimeContext.persistGraphToHostChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
graph,
|
||
revision: 5,
|
||
reason: "luker-bootstrap-journal-fail",
|
||
storageTier: "luker-chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 3,
|
||
extractionCount: 1,
|
||
mode: "primary",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.accepted, false);
|
||
const manifest = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_MANIFEST_NAMESPACE,
|
||
);
|
||
const checkpoint = await harness.runtimeContext.__chatContext.getChatState(
|
||
LUKER_GRAPH_CHECKPOINT_NAMESPACE,
|
||
);
|
||
assert.equal(
|
||
manifest ?? null,
|
||
null,
|
||
"bootstrap journal reset 失败时不应继续写 manifest 假装 accepted",
|
||
);
|
||
assert.equal(Number(checkpoint?.revision || 0), 5);
|
||
}
|
||
|
||
{
|
||
const chatId = "chat-luker-targeted-write";
|
||
const integrity = "meta-luker-targeted-write";
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId,
|
||
globalChatId: chatId,
|
||
groupId: "group-luker-targeted-write",
|
||
chatMetadata: {
|
||
integrity,
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const branchTarget = {
|
||
is_group: true,
|
||
id: "group-luker-targeted-branch",
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("group-luker-targeted-branch", "luker-targeted-write"),
|
||
{
|
||
revision: 2,
|
||
integrity,
|
||
chatId: "group-luker-targeted-branch",
|
||
reason: "luker-targeted-write",
|
||
},
|
||
);
|
||
|
||
const result = await harness.runtimeContext.persistGraphToHostChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
graph,
|
||
chatId: "group-luker-targeted-branch",
|
||
revision: 2,
|
||
reason: "luker-targeted-write",
|
||
storageTier: "luker-chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
mode: "primary",
|
||
chatStateTarget: branchTarget,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
assert.equal(result.accepted, true);
|
||
const targetedCalls = harness.runtimeContext.__chatContext.__chatStateCalls.filter(
|
||
(call) => call.type === "update" && call.target?.id === branchTarget.id,
|
||
);
|
||
assert.ok(
|
||
targetedCalls.length >= 3,
|
||
"显式 chatStateTarget 写入 Luker sidecar 时应把 target 传给 manifest/journal/checkpoint 链路",
|
||
);
|
||
}
|
||
|
||
console.log("graph-persistence tests passed");
|