Files
ST-Bionic-Memory-Ecology/graph/graph-persistence.js
2026-04-15 13:46:27 +08:00

1672 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ST-BME: 图谱持久化常量与纯工具函数
// 不依赖 index.js 模块级可变状态currentGraph / graphPersistenceState 等)
import { deserializeGraph, getGraphStats, serializeGraph } from "./graph.js";
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
// ═══════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════
export const MODULE_NAME = "st_bme";
export const GRAPH_METADATA_KEY = "st_bme_graph";
export const GRAPH_COMMIT_MARKER_KEY = "st_bme_commit_marker";
export const GRAPH_CHAT_STATE_NAMESPACE = `${MODULE_NAME}_graph_state`;
export const GRAPH_CHAT_STATE_VERSION = 1;
export const GRAPH_CHAT_STATE_MAX_OPERATIONS = 4000;
export const LUKER_GRAPH_MANIFEST_NAMESPACE = `${MODULE_NAME}_graph_manifest`;
export const LUKER_GRAPH_JOURNAL_NAMESPACE = `${MODULE_NAME}_graph_journal`;
export const LUKER_GRAPH_CHECKPOINT_NAMESPACE = `${MODULE_NAME}_graph_checkpoint`;
export const LUKER_GRAPH_SIDECAR_V2_FORMAT = 2;
export const LUKER_GRAPH_JOURNAL_COMPACTION_DEPTH = 32;
export const LUKER_GRAPH_JOURNAL_COMPACTION_BYTES = 2 * 1024 * 1024;
export const LUKER_GRAPH_JOURNAL_COMPACTION_REVISION_GAP = 64;
export const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence";
export const GRAPH_LOAD_STATES = Object.freeze({
NO_CHAT: "no-chat",
LOADING: "loading",
LOADED: "loaded",
SHADOW_RESTORED: "shadow-restored",
EMPTY_CONFIRMED: "empty-confirmed",
BLOCKED: "blocked",
});
export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__";
export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`;
export const GRAPH_IDENTITY_ALIAS_STORAGE_KEY = `${MODULE_NAME}:chat-identity-aliases`;
export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000];
// ═══════════════════════════════════════════════════════════
// 纯工具
// ═══════════════════════════════════════════════════════════
export function cloneRuntimeDebugValue(value, fallback = null) {
if (value == null) {
return fallback;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallback ?? value;
}
}
export function createLocalIntegritySlug() {
const nativeUuid = globalThis.crypto?.randomUUID?.();
if (nativeUuid) return nativeUuid;
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16);
const value = char === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug();
function normalizeIdentityValue(value) {
return String(value ?? "").trim();
}
function getLocalStorageSafe() {
const storage = globalThis.localStorage;
if (
!storage ||
typeof storage.getItem !== "function" ||
typeof storage.setItem !== "function"
) {
return null;
}
return storage;
}
function getSessionStorageSafe() {
const storage = globalThis.sessionStorage;
if (!storage || typeof storage.getItem !== "function") {
return null;
}
return storage;
}
function listStorageKeys(storage) {
if (!storage) return [];
if (typeof storage.length === "number" && typeof storage.key === "function") {
const keys = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (typeof key === "string" && key) {
keys.push(key);
}
}
return keys;
}
if (storage.__store instanceof Map) {
return Array.from(storage.__store.keys()).map((key) => String(key));
}
return [];
}
function readGraphIdentityAliasRegistryRaw() {
const storage = getLocalStorageSafe();
if (!storage) {
return {
byIntegrity: {},
};
}
try {
const raw = storage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
if (!raw) {
return {
byIntegrity: {},
};
}
const parsed = JSON.parse(raw);
const byIntegrity =
parsed?.byIntegrity &&
typeof parsed.byIntegrity === "object" &&
!Array.isArray(parsed.byIntegrity)
? parsed.byIntegrity
: {};
return {
byIntegrity,
};
} catch {
return {
byIntegrity: {},
};
}
}
function writeGraphIdentityAliasRegistryRaw(registry = null) {
const storage = getLocalStorageSafe();
if (!storage) return false;
try {
storage.setItem(
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
JSON.stringify({
byIntegrity:
registry?.byIntegrity &&
typeof registry.byIntegrity === "object" &&
!Array.isArray(registry.byIntegrity)
? registry.byIntegrity
: {},
}),
);
return true;
} catch {
return false;
}
}
function normalizeGraphIdentityAliasEntry(entry = {}, integrity = "") {
const normalizedIntegrity = normalizeIdentityValue(integrity || entry.integrity);
const normalizedPersistenceChatId = normalizeIdentityValue(
entry.persistenceChatId || normalizedIntegrity,
);
const normalizedHostChatIds = Array.from(
new Set(
(Array.isArray(entry.hostChatIds) ? entry.hostChatIds : [])
.map((value) => normalizeIdentityValue(value))
.filter(Boolean),
),
).slice(-16);
return {
integrity: normalizedIntegrity,
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
hostChatIds: normalizedHostChatIds,
updatedAt: String(entry.updatedAt || ""),
};
}
export function rememberGraphIdentityAlias({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
if (!normalizedIntegrity) return null;
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
const normalizedPersistenceChatId = normalizeIdentityValue(
persistenceChatId || normalizedIntegrity,
);
const registry = readGraphIdentityAliasRegistryRaw();
const existingEntry = normalizeGraphIdentityAliasEntry(
registry.byIntegrity?.[normalizedIntegrity] || {},
normalizedIntegrity,
);
const hostChatIds = Array.from(
new Set(
[normalizedHostChatId, ...existingEntry.hostChatIds].filter(Boolean),
),
).slice(-16);
const nextEntry = {
integrity: normalizedIntegrity,
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
hostChatIds,
updatedAt: new Date().toISOString(),
};
registry.byIntegrity[normalizedIntegrity] = nextEntry;
writeGraphIdentityAliasRegistryRaw(registry);
return nextEntry;
}
export function resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
if (!normalizedHostChatId) return "";
const registry = readGraphIdentityAliasRegistryRaw();
let bestEntry = null;
for (const [integrity, value] of Object.entries(registry.byIntegrity || {})) {
const entry = normalizeGraphIdentityAliasEntry(value, integrity);
if (!entry.hostChatIds.includes(normalizedHostChatId)) {
continue;
}
if (!bestEntry) {
bestEntry = entry;
continue;
}
if (String(entry.updatedAt || "") > String(bestEntry.updatedAt || "")) {
bestEntry = entry;
}
}
return normalizeIdentityValue(bestEntry?.persistenceChatId || "");
}
export function getGraphIdentityAliasCandidates({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
const normalizedPersistenceChatId = normalizeIdentityValue(persistenceChatId);
const registry = readGraphIdentityAliasRegistryRaw();
const candidates = [];
const seen = new Set();
const pushCandidate = (value) => {
const normalized = normalizeIdentityValue(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
candidates.push(normalized);
};
if (normalizedIntegrity) {
const entry = normalizeGraphIdentityAliasEntry(
registry.byIntegrity?.[normalizedIntegrity] || {},
normalizedIntegrity,
);
pushCandidate(entry.persistenceChatId);
for (const value of entry.hostChatIds) {
pushCandidate(value);
}
} else if (normalizedHostChatId) {
pushCandidate(resolveGraphIdentityAliasByHostChatId(normalizedHostChatId));
}
pushCandidate(normalizedHostChatId);
pushCandidate(normalizedPersistenceChatId);
return candidates;
}
function normalizeShadowSnapshotPayload(snapshot = null) {
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const serializedGraph = String(snapshot.serializedGraph || "");
const chatId = normalizeIdentityValue(snapshot.chatId);
if (!chatId || !serializedGraph) {
return null;
}
return {
chatId,
revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0,
serializedGraph,
updatedAt: String(snapshot.updatedAt || ""),
reason: String(snapshot.reason || ""),
integrity: normalizeIdentityValue(snapshot.integrity),
persistedChatId: normalizeIdentityValue(snapshot.persistedChatId),
debugReason: String(snapshot.debugReason || snapshot.reason || ""),
};
}
// ═══════════════════════════════════════════════════════════
// 图谱持久化元数据
// ═══════════════════════════════════════════════════════════
/**
* @param {object} graph
* @returns {object|null}
*/
export function getGraphPersistenceMeta(graph) {
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
return null;
}
const meta = graph[GRAPH_PERSISTENCE_META_KEY];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return null;
}
return meta;
}
/**
* @param {object} graph
* @returns {number}
*/
export function getGraphPersistedRevision(graph) {
const revision = Number(getGraphPersistenceMeta(graph)?.revision);
return Number.isFinite(revision) && revision > 0 ? revision : 0;
}
/**
* @param {object} graph
* @param {object} opts
* @param {number} [opts.revision]
* @param {string} [opts.reason]
* @param {string} [opts.chatId]
* @param {string} [opts.integrity]
*/
export function stampGraphPersistenceMeta(
graph,
{ revision = 0, reason = "", chatId = "", integrity = "" } = {},
) {
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
return null;
}
const existingMeta = getGraphPersistenceMeta(graph) || {};
const nextMeta = {
...existingMeta,
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
updatedAt: new Date().toISOString(),
sessionId: GRAPH_PERSISTENCE_SESSION_ID,
reason: String(reason || ""),
chatId: String(chatId || existingMeta.chatId || ""),
integrity: String(integrity || existingMeta.integrity || ""),
};
graph[GRAPH_PERSISTENCE_META_KEY] = nextMeta;
return nextMeta;
}
// ═══════════════════════════════════════════════════════════
// 聊天元数据
// ═══════════════════════════════════════════════════════════
export function writeChatMetadataPatch(context, patch = {}) {
if (!context) return false;
if (typeof context.updateChatMetadata === "function") {
context.updateChatMetadata(patch);
return true;
}
if (
!context.chatMetadata ||
typeof context.chatMetadata !== "object" ||
Array.isArray(context.chatMetadata)
) {
context.chatMetadata = {};
}
Object.assign(context.chatMetadata, patch || {});
return true;
}
export function canUseGraphChatState(context = null) {
return (
!!context &&
typeof context.getChatState === "function" &&
typeof context.updateChatState === "function"
);
}
function canBatchReadGraphChatState(context = null) {
return (
!!context &&
typeof context.getChatStateBatch === "function"
);
}
function normalizeGraphCountSummary(value = {}) {
const nodeCount = Number(value?.nodeCount ?? value?.nodes);
const edgeCount = Number(value?.edgeCount ?? value?.edges);
const archivedCount = Number(value?.archivedCount ?? value?.archivedNodes);
const tombstoneCount = Number(value?.tombstoneCount ?? value?.tombstones);
return {
nodeCount: Number.isFinite(nodeCount) && nodeCount >= 0 ? Math.floor(nodeCount) : 0,
edgeCount: Number.isFinite(edgeCount) && edgeCount >= 0 ? Math.floor(edgeCount) : 0,
archivedCount:
Number.isFinite(archivedCount) && archivedCount >= 0
? Math.floor(archivedCount)
: 0,
tombstoneCount:
Number.isFinite(tombstoneCount) && tombstoneCount >= 0
? Math.floor(tombstoneCount)
: 0,
};
}
function normalizeGraphCountSummaryFromGraph(graph = null) {
const stats = graph ? getGraphStats(graph) : null;
return normalizeGraphCountSummary({
nodeCount: Number(stats?.activeNodes || 0),
edgeCount: Number(stats?.totalEdges || 0),
archivedCount: Number(stats?.archivedNodes || 0),
tombstoneCount: Number(stats?.tombstones || 0),
});
}
function clonePlainObjectArray(value) {
return Array.isArray(value)
? value
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
.map((item) => cloneRuntimeDebugValue(item, item))
: [];
}
function cloneStringArray(value) {
return Array.isArray(value)
? value.map((item) => String(item || "").trim()).filter(Boolean)
: [];
}
function normalizeChatStatePersistDelta(delta = null) {
if (!delta || typeof delta !== "object" || Array.isArray(delta)) {
return {
upsertNodes: [],
upsertEdges: [],
deleteNodeIds: [],
deleteEdgeIds: [],
tombstones: [],
runtimeMetaPatch: {},
countDelta: null,
};
}
const runtimeMetaPatch =
delta.runtimeMetaPatch &&
typeof delta.runtimeMetaPatch === "object" &&
!Array.isArray(delta.runtimeMetaPatch)
? cloneRuntimeDebugValue(delta.runtimeMetaPatch, {})
: {};
const countDelta =
delta.countDelta &&
typeof delta.countDelta === "object" &&
!Array.isArray(delta.countDelta)
? cloneRuntimeDebugValue(delta.countDelta, {})
: null;
return {
upsertNodes: clonePlainObjectArray(delta.upsertNodes),
upsertEdges: clonePlainObjectArray(delta.upsertEdges),
deleteNodeIds: cloneStringArray(delta.deleteNodeIds),
deleteEdgeIds: cloneStringArray(delta.deleteEdgeIds),
tombstones: clonePlainObjectArray(delta.tombstones),
runtimeMetaPatch,
countDelta,
};
}
function stringifyJsonByteLength(value) {
try {
return JSON.stringify(value).length;
} catch {
return 0;
}
}
export function normalizeLukerGraphJournalEntry(entry = null) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
const revision = Number(entry.revision);
const reason = String(entry.reason || "");
const persistedAt = String(entry.persistedAt || entry.updatedAt || "");
const storageTier = String(entry.storageTier || "luker-chat-state");
const chatId = normalizeIdentityValue(entry.chatId);
const integrity = normalizeIdentityValue(entry.integrity);
const persistDelta = normalizeChatStatePersistDelta(entry.persistDelta);
const hasDeltaPayload =
persistDelta.upsertNodes.length > 0 ||
persistDelta.upsertEdges.length > 0 ||
persistDelta.deleteNodeIds.length > 0 ||
persistDelta.deleteEdgeIds.length > 0 ||
persistDelta.tombstones.length > 0 ||
Object.keys(persistDelta.runtimeMetaPatch).length > 0;
if (!Number.isFinite(revision) || revision <= 0 || !hasDeltaPayload) {
return null;
}
return {
revision: Math.floor(revision),
reason,
persistedAt,
storageTier,
chatId,
integrity,
persistDelta,
countDelta:
persistDelta.countDelta &&
typeof persistDelta.countDelta === "object" &&
!Array.isArray(persistDelta.countDelta)
? cloneRuntimeDebugValue(persistDelta.countDelta, {})
: null,
byteLength: stringifyJsonByteLength({
revision: Math.floor(revision),
reason,
persistedAt,
storageTier,
chatId,
integrity,
persistDelta,
}),
};
}
export function normalizeLukerGraphJournalV2(payload = null) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const formatVersion = Number(payload.formatVersion || payload.version);
const chatId = normalizeIdentityValue(payload.chatId);
const integrity = normalizeIdentityValue(payload.integrity);
const entries = Array.isArray(payload.entries)
? payload.entries
.map((entry) => normalizeLukerGraphJournalEntry(entry))
.filter(Boolean)
.sort((left, right) => left.revision - right.revision)
: [];
const latestEntry = entries.length > 0 ? entries[entries.length - 1] : null;
const headRevision = Number(payload.headRevision || latestEntry?.revision || 0);
return {
formatVersion:
Number.isFinite(formatVersion) && formatVersion > 0
? Math.floor(formatVersion)
: LUKER_GRAPH_SIDECAR_V2_FORMAT,
chatId,
integrity,
headRevision:
Number.isFinite(headRevision) && headRevision >= 0
? Math.floor(headRevision)
: Number(latestEntry?.revision || 0),
updatedAt: String(payload.updatedAt || latestEntry?.persistedAt || ""),
entries,
entryCount: entries.length,
totalBytes: entries.reduce(
(sum, entry) => sum + Number(entry?.byteLength || 0),
0,
),
};
}
export function buildLukerGraphJournalEntry(
delta = null,
{
revision = 0,
reason = "",
storageTier = "luker-chat-state",
chatId = "",
integrity = "",
persistedAt = "",
} = {},
) {
return normalizeLukerGraphJournalEntry({
revision,
reason,
persistedAt: String(persistedAt || new Date().toISOString()),
storageTier,
chatId,
integrity,
persistDelta: normalizeChatStatePersistDelta(delta),
});
}
export function buildLukerGraphJournalV2(entries = [], metadata = {}) {
return normalizeLukerGraphJournalV2({
formatVersion: LUKER_GRAPH_SIDECAR_V2_FORMAT,
chatId: metadata.chatId,
integrity: metadata.integrity,
headRevision: metadata.headRevision,
updatedAt: metadata.updatedAt || new Date().toISOString(),
entries: Array.isArray(entries) ? entries : [],
});
}
export function normalizeLukerGraphCheckpointV2(payload = null) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const formatVersion = Number(payload.formatVersion || payload.version);
const revision = Number(payload.revision);
const serializedGraph = String(payload.serializedGraph || "");
const chatId = normalizeIdentityValue(payload.chatId);
const integrity = normalizeIdentityValue(payload.integrity);
const counts = normalizeGraphCountSummary(payload.counts);
if (!serializedGraph) {
return null;
}
return {
formatVersion:
Number.isFinite(formatVersion) && formatVersion > 0
? Math.floor(formatVersion)
: LUKER_GRAPH_SIDECAR_V2_FORMAT,
revision: Number.isFinite(revision) && revision > 0 ? Math.floor(revision) : 0,
serializedGraph,
chatId,
integrity,
counts,
persistedAt: String(payload.persistedAt || payload.updatedAt || ""),
updatedAt: String(payload.updatedAt || payload.persistedAt || ""),
reason: String(payload.reason || ""),
storageTier: String(payload.storageTier || "luker-chat-state"),
};
}
export function buildLukerGraphCheckpointV2(
graph,
{
revision = 0,
chatId = "",
integrity = "",
reason = "",
storageTier = "luker-chat-state",
persistedAt = "",
} = {},
) {
if (!graph) return null;
return normalizeLukerGraphCheckpointV2({
formatVersion: LUKER_GRAPH_SIDECAR_V2_FORMAT,
revision,
chatId,
integrity,
reason,
storageTier,
counts: normalizeGraphCountSummaryFromGraph(graph),
persistedAt: String(persistedAt || new Date().toISOString()),
updatedAt: String(persistedAt || new Date().toISOString()),
serializedGraph: serializeGraph(graph),
});
}
export function normalizeLukerGraphManifestV2(payload = null) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const formatVersion = Number(payload.formatVersion || payload.version);
const baseRevision = Number(payload.baseRevision);
const headRevision = Number(payload.headRevision);
const checkpointRevision = Number(payload.checkpointRevision);
const lastCompactedRevision = Number(payload.lastCompactedRevision);
const lastProcessedAssistantFloor = Number(payload.lastProcessedAssistantFloor);
const extractionCount = Number(payload.extractionCount);
const journalDepth = Number(payload.journalDepth);
const journalBytes = Number(payload.journalBytes);
const chatId = normalizeIdentityValue(payload.chatId);
const integrity = normalizeIdentityValue(payload.integrity);
const counts = normalizeGraphCountSummary(payload.counts);
return {
formatVersion:
Number.isFinite(formatVersion) && formatVersion > 0
? Math.floor(formatVersion)
: LUKER_GRAPH_SIDECAR_V2_FORMAT,
baseRevision: Number.isFinite(baseRevision) && baseRevision >= 0 ? Math.floor(baseRevision) : 0,
headRevision: Number.isFinite(headRevision) && headRevision >= 0 ? Math.floor(headRevision) : 0,
checkpointRevision:
Number.isFinite(checkpointRevision) && checkpointRevision >= 0
? Math.floor(checkpointRevision)
: 0,
lastCompactedRevision:
Number.isFinite(lastCompactedRevision) && lastCompactedRevision >= 0
? Math.floor(lastCompactedRevision)
: 0,
journalDepth:
Number.isFinite(journalDepth) && journalDepth >= 0 ? Math.floor(journalDepth) : 0,
journalBytes:
Number.isFinite(journalBytes) && journalBytes >= 0 ? Math.floor(journalBytes) : 0,
lastProcessedAssistantFloor:
Number.isFinite(lastProcessedAssistantFloor)
? Math.floor(lastProcessedAssistantFloor)
: -1,
extractionCount:
Number.isFinite(extractionCount) && extractionCount >= 0
? Math.floor(extractionCount)
: 0,
chatId,
integrity,
counts,
storageTier: String(payload.storageTier || "luker-chat-state"),
accepted: payload.accepted === true,
persistedAt: String(payload.persistedAt || payload.updatedAt || ""),
updatedAt: String(payload.updatedAt || payload.persistedAt || ""),
reason: String(payload.reason || ""),
compactionState:
payload.compactionState && typeof payload.compactionState === "object"
? cloneRuntimeDebugValue(payload.compactionState, {})
: {
state: "idle",
lastAt: 0,
lastReason: "",
error: "",
},
};
}
export function buildLukerGraphManifestV2(
graph,
{
baseRevision = 0,
headRevision = 0,
checkpointRevision = 0,
lastCompactedRevision = 0,
journalDepth = 0,
journalBytes = 0,
chatId = "",
integrity = "",
reason = "",
storageTier = "luker-chat-state",
accepted = true,
persistedAt = "",
updatedAt = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
compactionState = null,
} = {},
) {
const stats = graph ? getGraphStats(graph) : null;
const historyState = graph?.historyState || {};
const nextCounts =
graph != null
? normalizeGraphCountSummaryFromGraph(graph)
: normalizeGraphCountSummary();
return normalizeLukerGraphManifestV2({
formatVersion: LUKER_GRAPH_SIDECAR_V2_FORMAT,
baseRevision,
headRevision,
checkpointRevision,
lastCompactedRevision,
journalDepth,
journalBytes,
chatId,
integrity,
counts: nextCounts,
storageTier,
accepted,
persistedAt: String(persistedAt || new Date().toISOString()),
updatedAt: String(updatedAt || persistedAt || new Date().toISOString()),
reason,
lastProcessedAssistantFloor:
lastProcessedAssistantFloor != null
? lastProcessedAssistantFloor
: Number.isFinite(Number(historyState.lastProcessedAssistantFloor))
? Number(historyState.lastProcessedAssistantFloor)
: Number.isFinite(Number(stats?.lastProcessedSeq))
? Number(stats.lastProcessedSeq)
: -1,
extractionCount:
extractionCount != null
? extractionCount
: Number.isFinite(Number(historyState.extractionCount))
? Number(historyState.extractionCount)
: 0,
compactionState,
});
}
async function readGraphChatStateNamespaces(
context = null,
namespaces = [],
) {
if (!canUseGraphChatState(context) || !Array.isArray(namespaces) || namespaces.length === 0) {
return new Map();
}
try {
if (canBatchReadGraphChatState(context)) {
const batch = await context.getChatStateBatch(namespaces);
if (batch instanceof Map) {
return batch;
}
if (batch && typeof batch === "object") {
return new Map(Object.entries(batch));
}
}
} catch (error) {
console.warn("[ST-BME] 批量读取聊天侧车失败,回退逐项读取:", error);
}
const result = new Map();
for (const namespace of namespaces) {
try {
result.set(namespace, await context.getChatState(namespace));
} catch {
result.set(namespace, null);
}
}
return result;
}
async function writeGraphChatStatePayload(
context = null,
namespace = "",
payload = null,
{ maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, asyncDiff = false } = {},
) {
if (!canUseGraphChatState(context) || !namespace || !payload) {
return {
ok: false,
updated: false,
reason: "chat-state-unavailable",
payload: null,
};
}
try {
const result = await context.updateChatState(
namespace,
() => cloneRuntimeDebugValue(payload, payload),
{
maxOperations,
asyncDiff,
maxRetries: 1,
},
);
return {
ok: result?.ok === true,
updated: result?.updated !== false,
reason:
result?.ok === true
? result?.updated === false
? "chat-state-noop"
: "chat-state-saved"
: "chat-state-save-failed",
payload,
};
} catch (error) {
console.warn(`[ST-BME] 写入聊天侧车 ${namespace} 失败:`, error);
return {
ok: false,
updated: false,
reason: "chat-state-save-failed",
error,
payload,
};
}
}
export async function readLukerGraphSidecarV2(
context = null,
{
manifestNamespace = LUKER_GRAPH_MANIFEST_NAMESPACE,
journalNamespace = LUKER_GRAPH_JOURNAL_NAMESPACE,
checkpointNamespace = LUKER_GRAPH_CHECKPOINT_NAMESPACE,
} = {},
) {
if (!canUseGraphChatState(context)) {
return {
manifest: null,
journal: null,
checkpoint: null,
};
}
const payloads = await readGraphChatStateNamespaces(context, [
manifestNamespace,
journalNamespace,
checkpointNamespace,
]);
return {
manifest: normalizeLukerGraphManifestV2(payloads.get(manifestNamespace) || null),
journal: normalizeLukerGraphJournalV2(payloads.get(journalNamespace) || null),
checkpoint: normalizeLukerGraphCheckpointV2(payloads.get(checkpointNamespace) || null),
};
}
export async function writeLukerGraphManifestV2(
context = null,
manifest = null,
{
namespace = LUKER_GRAPH_MANIFEST_NAMESPACE,
maxOperations = 512,
} = {},
) {
const normalizedManifest = normalizeLukerGraphManifestV2(manifest);
if (!normalizedManifest) {
return {
ok: false,
updated: false,
reason: "chat-state-build-failed",
manifest: null,
};
}
const result = await writeGraphChatStatePayload(context, namespace, normalizedManifest, {
maxOperations,
asyncDiff: false,
});
return {
...result,
manifest: normalizedManifest,
};
}
export async function appendLukerGraphJournalEntryV2(
context = null,
entry = null,
{
namespace = LUKER_GRAPH_JOURNAL_NAMESPACE,
chatId = "",
integrity = "",
maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS,
} = {},
) {
const normalizedEntry = normalizeLukerGraphJournalEntry(entry);
if (!normalizedEntry || !canUseGraphChatState(context)) {
return {
ok: false,
updated: false,
reason: "chat-state-build-failed",
journal: null,
entry: null,
};
}
try {
const result = await context.updateChatState(
namespace,
(current = {}) => {
const normalizedCurrent =
normalizeLukerGraphJournalV2(current) ||
buildLukerGraphJournalV2([], {
chatId,
integrity,
headRevision: 0,
});
const existingEntries = Array.isArray(normalizedCurrent.entries)
? normalizedCurrent.entries.filter(
(candidate) => Number(candidate?.revision || 0) !== normalizedEntry.revision,
)
: [];
const nextEntries = [...existingEntries, normalizedEntry].sort(
(left, right) => left.revision - right.revision,
);
const nextJournal = buildLukerGraphJournalV2(nextEntries, {
chatId:
normalizeIdentityValue(chatId) ||
normalizedCurrent.chatId ||
normalizedEntry.chatId,
integrity:
normalizeIdentityValue(integrity) ||
normalizedCurrent.integrity ||
normalizedEntry.integrity,
headRevision: normalizedEntry.revision,
updatedAt: normalizedEntry.persistedAt,
});
return nextJournal;
},
{
maxOperations,
asyncDiff: false,
maxRetries: 1,
},
);
const journal = await readGraphChatStateNamespaces(context, [namespace]);
return {
ok: result?.ok === true,
updated: result?.updated !== false,
reason:
result?.ok === true
? result?.updated === false
? "chat-state-noop"
: "chat-state-saved"
: "chat-state-save-failed",
journal: normalizeLukerGraphJournalV2(journal.get(namespace) || null),
entry: normalizedEntry,
};
} catch (error) {
console.warn("[ST-BME] 追加 Luker graph journal 失败:", error);
return {
ok: false,
updated: false,
reason: "chat-state-save-failed",
error,
journal: null,
entry: normalizedEntry,
};
}
}
export async function replaceLukerGraphJournalV2(
context = null,
journal = null,
{
namespace = LUKER_GRAPH_JOURNAL_NAMESPACE,
maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS,
} = {},
) {
const normalizedJournal = normalizeLukerGraphJournalV2(journal);
if (!normalizedJournal) {
return {
ok: false,
updated: false,
reason: "chat-state-build-failed",
journal: null,
};
}
const result = await writeGraphChatStatePayload(context, namespace, normalizedJournal, {
maxOperations,
asyncDiff: false,
});
return {
...result,
journal: normalizedJournal,
};
}
export async function writeLukerGraphCheckpointV2(
context = null,
checkpoint = null,
{
namespace = LUKER_GRAPH_CHECKPOINT_NAMESPACE,
maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS,
} = {},
) {
const normalizedCheckpoint = normalizeLukerGraphCheckpointV2(checkpoint);
if (!normalizedCheckpoint) {
return {
ok: false,
updated: false,
reason: "chat-state-build-failed",
checkpoint: null,
};
}
const result = await writeGraphChatStatePayload(context, namespace, normalizedCheckpoint, {
maxOperations,
asyncDiff: false,
});
return {
...result,
checkpoint: normalizedCheckpoint,
};
}
export function normalizeGraphChatStateSnapshot(snapshot = null) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
return null;
}
const version = Number(snapshot.version);
const revision = Number(snapshot.revision);
const serializedGraph = String(snapshot.serializedGraph || "");
const storageTier = String(snapshot.storageTier || "chat-state");
const chatId = normalizeIdentityValue(snapshot.chatId);
const integrity = normalizeIdentityValue(snapshot.integrity);
const commitMarker = normalizeGraphCommitMarker(snapshot.commitMarker);
if (!serializedGraph) {
return null;
}
return {
version: Number.isFinite(version) && version > 0 ? version : GRAPH_CHAT_STATE_VERSION,
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
serializedGraph,
persistedAt: String(snapshot.persistedAt || ""),
updatedAt: String(snapshot.updatedAt || snapshot.persistedAt || ""),
reason: String(snapshot.reason || ""),
storageTier,
chatId,
integrity,
commitMarker,
};
}
export function buildGraphChatStateSnapshot(
graph,
{
revision = 0,
storageTier = "chat-state",
accepted = true,
reason = "",
persistedAt = "",
updatedAt = "",
chatId = "",
integrity = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
} = {},
) {
if (!graph) {
return null;
}
const commitMarker = buildGraphCommitMarker(graph, {
revision,
storageTier,
accepted,
reason,
persistedAt,
chatId,
integrity,
lastProcessedAssistantFloor,
extractionCount,
});
return normalizeGraphChatStateSnapshot({
version: GRAPH_CHAT_STATE_VERSION,
revision,
serializedGraph: serializeGraph(graph),
persistedAt: String(persistedAt || new Date().toISOString()),
updatedAt: String(updatedAt || persistedAt || new Date().toISOString()),
reason: String(reason || ""),
storageTier: String(storageTier || "chat-state"),
chatId,
integrity,
commitMarker,
});
}
export async function readGraphChatStateSnapshot(
context = null,
{ namespace = GRAPH_CHAT_STATE_NAMESPACE } = {},
) {
if (!canUseGraphChatState(context)) {
return null;
}
try {
const payload = await context.getChatState(namespace);
return normalizeGraphChatStateSnapshot(payload);
} catch (error) {
console.warn("[ST-BME] 读取聊天侧车图谱失败:", error);
return null;
}
}
export async function writeGraphChatStateSnapshot(
context = null,
graph = null,
{
namespace = GRAPH_CHAT_STATE_NAMESPACE,
revision = 0,
storageTier = "chat-state",
accepted = true,
reason = "",
chatId = "",
integrity = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS,
} = {},
) {
if (!canUseGraphChatState(context) || !graph) {
return {
ok: false,
updated: false,
snapshot: null,
reason: "chat-state-unavailable",
};
}
const snapshot = buildGraphChatStateSnapshot(graph, {
revision,
storageTier,
accepted,
reason,
chatId,
integrity,
lastProcessedAssistantFloor,
extractionCount,
});
if (!snapshot) {
return {
ok: false,
updated: false,
snapshot: null,
reason: "chat-state-build-failed",
};
}
try {
const result = await context.updateChatState(
namespace,
() => snapshot,
{
maxOperations,
asyncDiff: false,
maxRetries: 1,
},
);
return {
ok: result?.ok === true,
updated: result?.updated !== false,
snapshot,
reason:
result?.ok === true
? result?.updated === false
? "chat-state-noop"
: "chat-state-saved"
: "chat-state-save-failed",
};
} catch (error) {
console.warn("[ST-BME] 写入聊天侧车图谱失败:", error);
return {
ok: false,
updated: false,
snapshot,
reason: "chat-state-save-failed",
error,
};
}
}
export function normalizeGraphCommitMarker(marker = null) {
if (!marker || typeof marker !== "object" || Array.isArray(marker)) {
return null;
}
const revision = Number(marker.revision);
const lastProcessedAssistantFloor = Number(marker.lastProcessedAssistantFloor);
const extractionCount = Number(marker.extractionCount);
const nodeCount = Number(marker.nodeCount);
const edgeCount = Number(marker.edgeCount);
const archivedCount = Number(marker.archivedCount);
return {
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
lastProcessedAssistantFloor:
Number.isFinite(lastProcessedAssistantFloor)
? Math.floor(lastProcessedAssistantFloor)
: -1,
extractionCount:
Number.isFinite(extractionCount) && extractionCount >= 0
? Math.floor(extractionCount)
: 0,
nodeCount:
Number.isFinite(nodeCount) && nodeCount >= 0 ? Math.floor(nodeCount) : 0,
edgeCount:
Number.isFinite(edgeCount) && edgeCount >= 0 ? Math.floor(edgeCount) : 0,
archivedCount:
Number.isFinite(archivedCount) && archivedCount >= 0
? Math.floor(archivedCount)
: 0,
persistedAt: String(marker.persistedAt || ""),
storageTier: String(marker.storageTier || "none"),
accepted: marker.accepted === true,
reason: String(marker.reason || ""),
chatId: normalizeIdentityValue(marker.chatId),
integrity: normalizeIdentityValue(marker.integrity),
};
}
export function buildGraphCommitMarker(
graph,
{
revision = 0,
storageTier = "none",
accepted = false,
reason = "",
persistedAt = "",
chatId = "",
integrity = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
} = {},
) {
const stats = graph ? getGraphStats(graph) : null;
const historyState = graph?.historyState || {};
const hasExplicitLastProcessedFloor =
lastProcessedAssistantFloor !== null &&
lastProcessedAssistantFloor !== undefined &&
lastProcessedAssistantFloor !== "";
const hasExplicitExtractionCount =
extractionCount !== null &&
extractionCount !== undefined &&
extractionCount !== "";
return normalizeGraphCommitMarker({
revision,
lastProcessedAssistantFloor:
hasExplicitLastProcessedFloor &&
Number.isFinite(Number(lastProcessedAssistantFloor))
? Number(lastProcessedAssistantFloor)
: Number.isFinite(Number(historyState.lastProcessedAssistantFloor))
? Number(historyState.lastProcessedAssistantFloor)
: Number.isFinite(Number(stats?.lastProcessedSeq))
? Number(stats.lastProcessedSeq)
: -1,
extractionCount:
hasExplicitExtractionCount &&
Number.isFinite(Number(extractionCount))
? Number(extractionCount)
: Number.isFinite(Number(historyState.extractionCount))
? Number(historyState.extractionCount)
: 0,
nodeCount: Number(stats?.activeNodes || 0),
edgeCount: Number(stats?.totalEdges || 0),
archivedCount: Number(stats?.archivedNodes || 0),
persistedAt: String(persistedAt || new Date().toISOString()),
storageTier: String(storageTier || "none"),
accepted: accepted === true,
reason: String(reason || ""),
chatId,
integrity,
});
}
export function readGraphCommitMarker(context = null) {
const rawMarker =
context?.chatMetadata &&
typeof context.chatMetadata === "object" &&
!Array.isArray(context.chatMetadata)
? context.chatMetadata[GRAPH_COMMIT_MARKER_KEY]
: null;
const marker = normalizeGraphCommitMarker(rawMarker);
return marker?.revision ? marker : null;
}
export function getAcceptedCommitMarkerRevision(marker = null) {
const normalizedMarker = normalizeGraphCommitMarker(marker);
return normalizedMarker?.accepted === true
? Number(normalizedMarker.revision || 0)
: 0;
}
export function detectIndexedDbSnapshotCommitMarkerMismatch(
snapshot = null,
marker = null,
) {
const normalizedMarker = normalizeGraphCommitMarker(marker);
if (!normalizedMarker || normalizedMarker.accepted !== true) {
return {
mismatched: false,
reason: "",
markerRevision: 0,
snapshotRevision: Number.isFinite(Number(snapshot?.meta?.revision))
? Number(snapshot.meta.revision)
: 0,
};
}
const snapshotRevision = Number.isFinite(Number(snapshot?.meta?.revision))
? Number(snapshot.meta.revision)
: 0;
const markerRevision = Number(normalizedMarker.revision || 0);
if (markerRevision <= 0 || snapshotRevision >= markerRevision) {
return {
mismatched: false,
reason: "",
markerRevision,
snapshotRevision,
};
}
return {
mismatched: true,
reason: "persist-mismatch:indexeddb-behind-commit-marker",
markerRevision,
snapshotRevision,
marker: normalizedMarker,
};
}
// ═══════════════════════════════════════════════════════════
// Shadow Snapshot会话存储
// ═══════════════════════════════════════════════════════════
export function getGraphShadowSnapshotStorageKey(chatId = "") {
const normalizedChatId = String(chatId || "").trim();
if (!normalizedChatId) return "";
return `${GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX}${encodeURIComponent(normalizedChatId)}`;
}
export function readGraphShadowSnapshot(chatId = "") {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey) return null;
try {
const raw = getSessionStorageSafe()?.getItem(storageKey);
if (!raw) return null;
const snapshot = normalizeShadowSnapshotPayload(JSON.parse(raw));
if (!snapshot || snapshot.chatId !== String(chatId || "")) {
return null;
}
return snapshot;
} catch {
return null;
}
}
export function findGraphShadowSnapshotByIntegrity(
integrity = "",
{ excludeChatIds = [] } = {},
) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
if (!normalizedIntegrity) return null;
const storage = getSessionStorageSafe();
if (!storage) return null;
const excludedChatIds = new Set(
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
.map((value) => normalizeIdentityValue(value))
.filter(Boolean),
);
let bestSnapshot = null;
for (const key of listStorageKeys(storage)) {
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
continue;
}
try {
const snapshot = normalizeShadowSnapshotPayload(
JSON.parse(storage.getItem(key)),
);
if (!snapshot || snapshot.integrity !== normalizedIntegrity) {
continue;
}
if (excludedChatIds.has(snapshot.chatId)) {
continue;
}
const bestRevision = Number(bestSnapshot?.revision || 0);
const nextRevision = Number(snapshot.revision || 0);
if (!bestSnapshot || nextRevision > bestRevision) {
bestSnapshot = snapshot;
continue;
}
if (
nextRevision === bestRevision &&
String(snapshot.updatedAt || "") > String(bestSnapshot.updatedAt || "")
) {
bestSnapshot = snapshot;
}
} catch {
// ignore broken shadow snapshot payloads
}
}
return bestSnapshot;
}
/**
* @param {string} chatId
* @param {object} graph
* @param {object} [opts]
* @param {number} [opts.revision]
* @param {string} [opts.reason]
*/
export function writeGraphShadowSnapshot(
chatId,
graph,
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
) {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey || !graph) return false;
try {
const serializedGraph = serializeGraph(graph);
const persistedMeta = getGraphPersistenceMeta(graph) || {};
getSessionStorageSafe()?.setItem(
storageKey,
JSON.stringify({
chatId: String(chatId || ""),
revision: Number.isFinite(revision) ? revision : 0,
serializedGraph,
updatedAt: new Date().toISOString(),
reason: String(reason || ""),
integrity: String(integrity || persistedMeta.integrity || ""),
persistedChatId: String(persistedMeta.chatId || ""),
debugReason: String(debugReason || reason || ""),
}),
);
return true;
} catch (error) {
console.warn("[ST-BME] 写入会话图谱临时快照失败:", error);
return false;
}
}
export function removeGraphShadowSnapshot(chatId = "") {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey) return false;
try {
getSessionStorageSafe()?.removeItem(storageKey);
return true;
} catch {
return false;
}
}
// ═══════════════════════════════════════════════════════════
// 图谱克隆 / 比较
// ═══════════════════════════════════════════════════════════
export function cloneGraphForPersistence(graph, chatId = "") {
return normalizeGraphRuntimeState(
deserializeGraph(serializeGraph(graph)),
chatId,
);
}
export function shouldPreferShadowSnapshotOverOfficial(
officialGraph,
shadowSnapshot,
) {
if (!shadowSnapshot) {
return {
prefer: false,
reason: "shadow-missing",
resultCode: "shadow.missing",
};
}
const shadowRevision = Number(shadowSnapshot.revision || 0);
const officialRevision = getGraphPersistedRevision(officialGraph);
const officialMeta = getGraphPersistenceMeta(officialGraph) || {};
const normalizedOfficialChatId = String(officialMeta.chatId || "").trim();
const normalizedShadowChatId = String(shadowSnapshot.chatId || "").trim();
const normalizedShadowPersistedChatId = String(
shadowSnapshot.persistedChatId || "",
).trim();
const officialIntegrity = String(officialMeta.integrity || "").trim();
const shadowIntegrity = String(shadowSnapshot.integrity || "").trim();
if (shadowRevision <= 0) {
return {
prefer: false,
reason: "shadow-revision-invalid",
resultCode: "shadow.reject.revision-invalid",
shadowRevision,
officialRevision,
};
}
if (
normalizedOfficialChatId &&
normalizedShadowPersistedChatId &&
normalizedOfficialChatId !== normalizedShadowPersistedChatId
) {
return {
prefer: false,
reason: "shadow-persisted-chat-mismatch",
resultCode: "shadow.reject.persisted-chat-mismatch",
shadowRevision,
officialRevision,
officialChatId: normalizedOfficialChatId,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (
normalizedOfficialChatId &&
normalizedShadowChatId &&
normalizedOfficialChatId !== normalizedShadowChatId
) {
return {
prefer: false,
reason: "shadow-chat-mismatch",
resultCode: "shadow.reject.chat-mismatch",
shadowRevision,
officialRevision,
officialChatId: normalizedOfficialChatId,
shadowChatId: normalizedShadowChatId,
};
}
if (
officialIntegrity &&
shadowIntegrity &&
officialIntegrity !== shadowIntegrity
) {
return {
prefer: false,
reason: "shadow-integrity-mismatch",
resultCode: "shadow.reject.integrity-mismatch",
shadowRevision,
officialRevision,
officialIntegrity,
shadowIntegrity,
};
}
if (
normalizedShadowPersistedChatId &&
normalizedShadowChatId &&
normalizedShadowPersistedChatId !== normalizedShadowChatId
) {
return {
prefer: false,
reason: "shadow-self-chat-mismatch",
resultCode: "shadow.reject.self-chat-mismatch",
shadowRevision,
officialRevision,
shadowChatId: normalizedShadowChatId,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) {
return {
prefer: false,
reason: "shadow-persisted-chat-without-official-chat",
resultCode: "shadow.reject.persisted-chat-without-official-chat",
shadowRevision,
officialRevision,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (shadowIntegrity && !officialIntegrity) {
return {
prefer: false,
reason: "shadow-integrity-without-official-integrity",
resultCode: "shadow.reject.integrity-without-official-integrity",
shadowRevision,
officialRevision,
shadowIntegrity,
};
}
return {
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
reason:
shadowRevision > officialRevision
? "shadow-newer-than-official"
: "shadow-not-newer-than-official",
resultCode:
shadowRevision > officialRevision
? "shadow.accept.newer-than-official"
: "shadow.keep.official-not-older",
shadowRevision,
officialRevision,
};
}