diff --git a/bme-db.js b/bme-db.js index 9599dbd..c36b534 100644 --- a/bme-db.js +++ b/bme-db.js @@ -1,5 +1,8 @@ import { createEmptyGraph, deserializeGraph } from "./graph.js"; -import { buildVectorCollectionId, normalizeGraphRuntimeState } from "./runtime-state.js"; +import { + buildVectorCollectionId, + normalizeGraphRuntimeState, +} from "./runtime-state.js"; const DEXIE_LOAD_PROMISE_KEY = "__stBmeDexieLoadPromise"; const DEXIE_SCRIPT_MARKER = "data-st-bme-dexie"; @@ -16,7 +19,8 @@ export const BME_RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; export const BME_RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; -export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; +export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = + "runtimeLastProcessedSeq"; export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; export const BME_DB_TABLE_SCHEMAS = Object.freeze({ @@ -125,11 +129,15 @@ function sanitizeSnapshot(snapshot = {}) { } const safeMeta = - snapshot.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta) + snapshot.meta && + typeof snapshot.meta === "object" && + !Array.isArray(snapshot.meta) ? { ...snapshot.meta } : {}; const safeState = - snapshot.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state) + snapshot.state && + typeof snapshot.state === "object" && + !Array.isArray(snapshot.state) ? { ...snapshot.state } : {}; @@ -138,13 +146,17 @@ function sanitizeSnapshot(snapshot = {}) { state: safeState, nodes: toArray(snapshot.nodes).map((item) => ({ ...(item || {}) })), edges: toArray(snapshot.edges).map((item) => ({ ...(item || {}) })), - tombstones: toArray(snapshot.tombstones).map((item) => ({ ...(item || {}) })), + tombstones: toArray(snapshot.tombstones).map((item) => ({ + ...(item || {}), + })), }; } function normalizeStateSnapshot(snapshot = {}) { const state = - snapshot?.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state) + snapshot?.state && + typeof snapshot.state === "object" && + !Array.isArray(snapshot.state) ? { ...snapshot.state } : {}; @@ -228,7 +240,10 @@ export function buildSnapshotFromGraph(graph, options = {}) { if (!graphInput.historyState || typeof graphInput.historyState !== "object") { graphInput.historyState = {}; } - if (!graphInput.vectorIndexState || typeof graphInput.vectorIndexState !== "object") { + if ( + !graphInput.vectorIndexState || + typeof graphInput.vectorIndexState !== "object" + ) { graphInput.vectorIndexState = {}; } if (chatId) { @@ -269,7 +284,8 @@ export function buildSnapshotFromGraph(graph, options = {}) { const tombstones = toArray(options.tombstones ?? baseSnapshot.tombstones) .map((record) => { - if (!record || typeof record !== "object" || Array.isArray(record)) return null; + if (!record || typeof record !== "object" || Array.isArray(record)) + return null; const id = normalizeRecordId(record.id); if (!id) return null; return { @@ -290,8 +306,12 @@ export function buildSnapshotFromGraph(graph, options = {}) { Number(runtimeGraph?.historyState?.lastProcessedAssistantFloor), ) ? Number(runtimeGraph.historyState.lastProcessedAssistantFloor) - : Number(runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR), - extractionCount: Number.isFinite(Number(runtimeGraph?.historyState?.extractionCount)) + : Number( + runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR, + ), + extractionCount: Number.isFinite( + Number(runtimeGraph?.historyState?.extractionCount), + ) ? Number(runtimeGraph.historyState.extractionCount) : META_DEFAULT_EXTRACTION_COUNT, }; @@ -301,7 +321,9 @@ export function buildSnapshotFromGraph(graph, options = {}) { ...(options.meta || {}), schemaVersion: BME_DB_SCHEMA_VERSION, chatId, - revision: normalizeRevision(options.revision ?? baseSnapshot.meta?.revision), + revision: normalizeRevision( + options.revision ?? baseSnapshot.meta?.revision, + ), lastModified: normalizeTimestamp( options.lastModified ?? baseSnapshot.meta?.lastModified, nowMs, @@ -309,9 +331,18 @@ export function buildSnapshotFromGraph(graph, options = {}) { nodeCount: nodes.length, edgeCount: edges.length, tombstoneCount: tombstones.length, - [BME_RUNTIME_HISTORY_META_KEY]: toPlainData(runtimeGraph?.historyState || {}, {}), - [BME_RUNTIME_VECTOR_META_KEY]: toPlainData(runtimeGraph?.vectorIndexState || {}, {}), - [BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData(runtimeGraph?.batchJournal || [], []), + [BME_RUNTIME_HISTORY_META_KEY]: toPlainData( + runtimeGraph?.historyState || {}, + {}, + ), + [BME_RUNTIME_VECTOR_META_KEY]: toPlainData( + runtimeGraph?.vectorIndexState || {}, + {}, + ), + [BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData( + runtimeGraph?.batchJournal || [], + [], + ), [BME_RUNTIME_LAST_RECALL_META_KEY]: toPlainData( runtimeGraph?.lastRecallResult ?? null, null, @@ -321,7 +352,9 @@ export function buildSnapshotFromGraph(graph, options = {}) { ) ? Number(runtimeGraph.lastProcessedSeq) : state.lastProcessedFloor, - [BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite(Number(runtimeGraph?.version)) + [BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite( + Number(runtimeGraph?.version), + ) ? Number(runtimeGraph.version) : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), }; @@ -348,8 +381,12 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { ) ? Number(normalizedSnapshot.meta[BME_RUNTIME_GRAPH_VERSION_META_KEY]) : runtimeGraph.version; - runtimeGraph.nodes = toArray(normalizedSnapshot.nodes).map((node) => ({ ...(node || {}) })); - runtimeGraph.edges = toArray(normalizedSnapshot.edges).map((edge) => ({ ...(edge || {}) })); + runtimeGraph.nodes = toArray(normalizedSnapshot.nodes).map((node) => ({ + ...(node || {}), + })); + runtimeGraph.edges = toArray(normalizedSnapshot.edges).map((edge) => ({ + ...(edge || {}), + })); runtimeGraph.batchJournal = toArray( normalizedSnapshot.meta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY], ); @@ -361,17 +398,21 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { runtimeGraph.historyState = { ...(runtimeGraph.historyState || {}), ...(normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] || {}), - lastProcessedAssistantFloor: Number.isFinite(Number(normalizedSnapshot.state?.lastProcessedFloor)) + lastProcessedAssistantFloor: Number.isFinite( + Number(normalizedSnapshot.state?.lastProcessedFloor), + ) ? Number(normalizedSnapshot.state.lastProcessedFloor) : Number( normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] ?.lastProcessedAssistantFloor ?? META_DEFAULT_LAST_PROCESSED_FLOOR, ), - extractionCount: Number.isFinite(Number(normalizedSnapshot.state?.extractionCount)) + extractionCount: Number.isFinite( + Number(normalizedSnapshot.state?.extractionCount), + ) ? Number(normalizedSnapshot.state.extractionCount) : Number( - normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]?.extractionCount ?? - META_DEFAULT_EXTRACTION_COUNT, + normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] + ?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT, ), }; runtimeGraph.vectorIndexState = { @@ -391,7 +432,54 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { ? Number(normalizedSnapshot.meta[BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]) : Number(runtimeGraph.historyState.lastProcessedAssistantFloor); - return normalizeGraphRuntimeState(runtimeGraph, chatId); + const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId); + const historyState = normalizedGraph.historyState || {}; + const vectorState = normalizedGraph.vectorIndexState || {}; + const resolvedLastProcessedFloor = Number.isFinite( + Number(historyState.lastProcessedAssistantFloor), + ) + ? Number(historyState.lastProcessedAssistantFloor) + : META_DEFAULT_LAST_PROCESSED_FLOOR; + const resolvedLastProcessedSeq = Number.isFinite( + Number(normalizedGraph.lastProcessedSeq), + ) + ? Number(normalizedGraph.lastProcessedSeq) + : resolvedLastProcessedFloor; + const collectionId = String(vectorState.collectionId || ""); + const expectedCollectionId = buildVectorCollectionId( + chatId || historyState.chatId || "", + ); + const inconsistentReasons = []; + + if ( + Number.isFinite(resolvedLastProcessedFloor) && + Number.isFinite(resolvedLastProcessedSeq) && + resolvedLastProcessedFloor !== resolvedLastProcessedSeq + ) { + inconsistentReasons.push("last-processed-seq-mismatch"); + } + if ( + chatId && + historyState.chatId && + String(historyState.chatId) !== String(chatId) + ) { + inconsistentReasons.push("history-chat-id-mismatch"); + } + if (collectionId && collectionId !== expectedCollectionId) { + inconsistentReasons.push("vector-collection-mismatch"); + } + + if (inconsistentReasons.length > 0) { + const error = new Error( + `图谱快照完整性校验失败: ${inconsistentReasons.join(", ")}`, + ); + error.code = "BME_SNAPSHOT_INTEGRITY_ERROR"; + error.reasons = inconsistentReasons; + error.snapshotChatId = chatId; + throw error; + } + + return normalizedGraph; } async function loadDexieFromNodeFallback() { @@ -417,7 +505,9 @@ async function loadDexieByScriptInjection() { } await new Promise((resolve, reject) => { - const existingScript = doc.querySelector?.(`script[${DEXIE_SCRIPT_MARKER}="true"]`); + const existingScript = doc.querySelector?.( + `script[${DEXIE_SCRIPT_MARKER}="true"]`, + ); if (existingScript) { existingScript.addEventListener("load", () => resolve(), { once: true }); existingScript.addEventListener( @@ -519,7 +609,9 @@ export class BmeDatabase { if (!this._openPromise) { this._openPromise = (async () => { const DexieCtor = - this.options.dexieClass || globalThis.Dexie || (await ensureDexieLoaded()); + this.options.dexieClass || + globalThis.Dexie || + (await ensureDexieLoaded()); if (typeof DexieCtor !== "function") { throw new Error("Dexie 构造函数不可用"); } @@ -588,7 +680,9 @@ export class BmeDatabase { const db = await this.open(); const nowMs = Date.now(); - const entries = Object.entries(record).filter(([key]) => normalizeRecordId(key)); + const entries = Object.entries(record).filter(([key]) => + normalizeRecordId(key), + ); if (!entries.length) { return {}; @@ -624,7 +718,12 @@ export class BmeDatabase { const nowMs = Date.now(); await db.transaction("rw", db.table("meta"), async () => { await this._setMetaInTx(db, "syncDirty", true, nowMs); - await this._setMetaInTx(db, "syncDirtyReason", String(reason || "mutation"), nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + String(reason || "mutation"), + nowMs, + ); }); return true; } @@ -649,11 +748,20 @@ export class BmeDatabase { db.table("tombstones"), db.table("meta"), async () => { - await db.table("nodes").bulkPut(records); - await this._updateCountMetaInTx(db, nowMs); - nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertNodes", nowMs); - await this._setMetaInTx(db, "syncDirty", true, nowMs); - await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertNodes", nowMs); + await db.table("nodes").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx( + db, + "bulkUpsertNodes", + nowMs, + ); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + "bulkUpsertNodes", + nowMs, + ); }, ); @@ -683,11 +791,20 @@ export class BmeDatabase { db.table("tombstones"), db.table("meta"), async () => { - await db.table("edges").bulkPut(records); - await this._updateCountMetaInTx(db, nowMs); - nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertEdges", nowMs); - await this._setMetaInTx(db, "syncDirty", true, nowMs); - await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertEdges", nowMs); + await db.table("edges").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx( + db, + "bulkUpsertEdges", + nowMs, + ); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + "bulkUpsertEdges", + nowMs, + ); }, ); @@ -717,11 +834,20 @@ export class BmeDatabase { db.table("tombstones"), db.table("meta"), async () => { - await db.table("tombstones").bulkPut(records); - await this._updateCountMetaInTx(db, nowMs); - nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertTombstones", nowMs); - await this._setMetaInTx(db, "syncDirty", true, nowMs); - await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertTombstones", nowMs); + await db.table("tombstones").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx( + db, + "bulkUpsertTombstones", + nowMs, + ); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + "bulkUpsertTombstones", + nowMs, + ); }, ); @@ -739,7 +865,9 @@ export class BmeDatabase { let records = await db.table("nodes").toArray(); if (!includeDeleted) { - records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt))); + records = records.filter( + (item) => !Number.isFinite(Number(item?.deletedAt)), + ); } if (!includeArchived) { @@ -747,7 +875,9 @@ export class BmeDatabase { } if (typeof options.type === "string" && options.type.trim()) { - records = records.filter((item) => String(item?.type || "") === options.type); + records = records.filter( + (item) => String(item?.type || "") === options.type, + ); } return this._applyListOptions(records, options); @@ -760,7 +890,9 @@ export class BmeDatabase { let records = await db.table("edges").toArray(); if (!includeDeleted) { - records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt))); + records = records.filter( + (item) => !Number.isFinite(Number(item?.deletedAt)), + ); } if (typeof options.relation === "string" && options.relation.trim()) { @@ -777,7 +909,9 @@ export class BmeDatabase { let records = await db.table("tombstones").toArray(); if (typeof options.kind === "string" && options.kind.trim()) { - records = records.filter((item) => String(item?.kind || "") === options.kind); + records = records.filter( + (item) => String(item?.kind || "") === options.kind, + ); } if (typeof options.targetId === "string" && options.targetId.trim()) { @@ -849,16 +983,23 @@ export class BmeDatabase { }); const nodeSourceFloorById = new Map(); - const nodes = this._normalizeNodeRecords(snapshot.nodes, nowMs).map((node) => { - const sourceFloor = deriveNodeSourceFloor(node); - nodeSourceFloorById.set(node.id, sourceFloor); - return sourceFloor == null ? node : { ...node, sourceFloor }; - }); - const edges = this._normalizeEdgeRecords(snapshot.edges, nowMs).map((edge) => { - const sourceFloor = deriveEdgeSourceFloor(edge, nodeSourceFloorById); - return sourceFloor == null ? edge : { ...edge, sourceFloor }; - }); - const tombstones = this._normalizeTombstoneRecords(snapshot.tombstones, nowMs); + const nodes = this._normalizeNodeRecords(snapshot.nodes, nowMs).map( + (node) => { + const sourceFloor = deriveNodeSourceFloor(node); + nodeSourceFloorById.set(node.id, sourceFloor); + return sourceFloor == null ? node : { ...node, sourceFloor }; + }, + ); + const edges = this._normalizeEdgeRecords(snapshot.edges, nowMs).map( + (edge) => { + const sourceFloor = deriveEdgeSourceFloor(edge, nodeSourceFloorById); + return sourceFloor == null ? edge : { ...edge, sourceFloor }; + }, + ); + const tombstones = this._normalizeTombstoneRecords( + snapshot.tombstones, + nowMs, + ); let migrated = false; let skipReason = ""; @@ -882,7 +1023,9 @@ export class BmeDatabase { ); if (migrationCompletedAt > 0) { skipReason = "migration-already-completed"; - nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + nextRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); counts = { nodes: await db.table("nodes").count(), edges: await db.table("edges").count(), @@ -897,7 +1040,9 @@ export class BmeDatabase { ]); if (nodeCount > 0 || edgeCount > 0) { skipReason = "indexeddb-not-empty"; - nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + nextRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); counts = { nodes: nodeCount, edges: edgeCount, @@ -953,9 +1098,19 @@ export class BmeDatabase { nextRevision = Math.max(currentRevision + 1, requestedRevision, 1); await this._setMetaInTx(db, "revision", nextRevision, nowMs); await this._setMetaInTx(db, "lastModified", nowMs, nowMs); - await this._setMetaInTx(db, "lastMutationReason", "importLegacyGraph", nowMs); + await this._setMetaInTx( + db, + "lastMutationReason", + "importLegacyGraph", + nowMs, + ); await this._setMetaInTx(db, "syncDirty", true, nowMs); - await this._setMetaInTx(db, "syncDirtyReason", "legacy-migration", nowMs); + await this._setMetaInTx( + db, + "syncDirtyReason", + "legacy-migration", + nowMs, + ); migrated = true; }, @@ -1043,7 +1198,9 @@ export class BmeDatabase { db.table("tombstones"), db.table("meta"), async () => { - revisionFloor = normalizeRevision((await db.table("meta").get("revision"))?.value); + revisionFloor = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); if (mode === "replace") { await Promise.all([ @@ -1054,8 +1211,14 @@ export class BmeDatabase { ]); } - const nodes = this._normalizeNodeRecords(normalizedSnapshot.nodes, nowMs); - const edges = this._normalizeEdgeRecords(normalizedSnapshot.edges, nowMs); + const nodes = this._normalizeNodeRecords( + normalizedSnapshot.nodes, + nowMs, + ); + const edges = this._normalizeEdgeRecords( + normalizedSnapshot.edges, + nowMs, + ); const tombstones = this._normalizeTombstoneRecords( normalizedSnapshot.tombstones, nowMs, @@ -1072,7 +1235,9 @@ export class BmeDatabase { } const metaPatch = { - ...(mode === "replace" ? createDefaultMetaValues(this.chatId, nowMs) : {}), + ...(mode === "replace" + ? createDefaultMetaValues(this.chatId, nowMs) + : {}), ...normalizedSnapshot.meta, ...(normalizedSnapshot.state || {}), chatId: this.chatId, @@ -1092,9 +1257,13 @@ export class BmeDatabase { (await db.table("meta").get("revision"))?.value, ); const currentRevision = - mode === "replace" ? Math.max(revisionFloor, persistedRevision) : persistedRevision; + mode === "replace" + ? Math.max(revisionFloor, persistedRevision) + : persistedRevision; - const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision); + const incomingRevision = normalizeRevision( + normalizedSnapshot.meta?.revision, + ); const explicitRevision = normalizeRevision(options.revision); const requestedRevision = Number.isFinite(Number(options.revision)) ? explicitRevision @@ -1105,7 +1274,12 @@ export class BmeDatabase { nextRevision = Math.max(currentRevision + 1, requestedRevision); await this._setMetaInTx(db, "revision", nextRevision, nowMs); await this._setMetaInTx(db, "lastModified", nowMs, nowMs); - await this._setMetaInTx(db, "lastMutationReason", "importSnapshot", nowMs); + await this._setMetaInTx( + db, + "lastMutationReason", + "importSnapshot", + nowMs, + ); await this._setMetaInTx(db, "syncDirty", shouldMarkSyncDirty, nowMs); await this._setMetaInTx(db, "syncDirtyReason", "importSnapshot", nowMs); @@ -1148,7 +1322,12 @@ export class BmeDatabase { await this._setMetaInTx(db, "revision", nextRevision, nowMs); await this._setMetaInTx(db, "chatId", this.chatId, nowMs); - await this._setMetaInTx(db, "schemaVersion", BME_DB_SCHEMA_VERSION, nowMs); + await this._setMetaInTx( + db, + "schemaVersion", + BME_DB_SCHEMA_VERSION, + nowMs, + ); await this._setMetaInTx(db, "nodeCount", 0, nowMs); await this._setMetaInTx(db, "edgeCount", 0, nowMs); await this._setMetaInTx(db, "tombstoneCount", 0, nowMs); @@ -1192,32 +1371,32 @@ export class BmeDatabase { db.table("tombstones"), db.table("meta"), async () => { - const staleIds = await db - .table("tombstones") - .where("deletedAt") - .below(cutoffMs) - .primaryKeys(); + const staleIds = await db + .table("tombstones") + .where("deletedAt") + .below(cutoffMs) + .primaryKeys(); - if (!staleIds.length) { - return; - } + if (!staleIds.length) { + return; + } - await db.table("tombstones").bulkDelete(staleIds); - removedCount = staleIds.length; + await db.table("tombstones").bulkDelete(staleIds); + removedCount = staleIds.length; - await this._updateCountMetaInTx(db, normalizedNow); - nextRevision = await this._bumpRevisionInTx( - db, - "pruneExpiredTombstones", - normalizedNow, - ); - await this._setMetaInTx(db, "syncDirty", true, normalizedNow); - await this._setMetaInTx( - db, - "syncDirtyReason", - "pruneExpiredTombstones", - normalizedNow, - ); + await this._updateCountMetaInTx(db, normalizedNow); + nextRevision = await this._bumpRevisionInTx( + db, + "pruneExpiredTombstones", + normalizedNow, + ); + await this._setMetaInTx(db, "syncDirty", true, normalizedNow); + await this._setMetaInTx( + db, + "syncDirtyReason", + "pruneExpiredTombstones", + normalizedNow, + ); }, ); @@ -1254,12 +1433,24 @@ export class BmeDatabase { } async _bumpRevisionInTx(db, reason = "mutation", nowMs = Date.now()) { - const currentRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); const nextRevision = currentRevision + 1; await this._setMetaInTx(db, "revision", nextRevision, nowMs); - await this._setMetaInTx(db, "lastModified", normalizeTimestamp(nowMs), nowMs); - await this._setMetaInTx(db, "lastMutationReason", String(reason || "mutation"), nowMs); + await this._setMetaInTx( + db, + "lastModified", + normalizeTimestamp(nowMs), + nowMs, + ); + await this._setMetaInTx( + db, + "lastMutationReason", + String(reason || "mutation"), + nowMs, + ); return nextRevision; } @@ -1309,7 +1500,8 @@ export class BmeDatabase { const nowMs = normalizeTimestamp(fallbackNowMs); return toArray(nodes) .map((node) => { - if (!node || typeof node !== "object" || Array.isArray(node)) return null; + if (!node || typeof node !== "object" || Array.isArray(node)) + return null; const id = normalizeRecordId(node.id); if (!id) return null; @@ -1326,7 +1518,8 @@ export class BmeDatabase { const nowMs = normalizeTimestamp(fallbackNowMs); return toArray(edges) .map((edge) => { - if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null; + if (!edge || typeof edge !== "object" || Array.isArray(edge)) + return null; const id = normalizeRecordId(edge.id); if (!id) return null; @@ -1345,7 +1538,8 @@ export class BmeDatabase { const nowMs = normalizeTimestamp(fallbackNowMs); return toArray(tombstones) .map((record) => { - if (!record || typeof record !== "object" || Array.isArray(record)) return null; + if (!record || typeof record !== "object" || Array.isArray(record)) + return null; const id = normalizeRecordId(record.id); if (!id) return null; diff --git a/event-binding.js b/event-binding.js index 44cedff..a8736f8 100644 --- a/event-binding.js +++ b/event-binding.js @@ -96,28 +96,65 @@ export function installSendIntentHooksController(runtime) { export function registerCoreEventHooksController(runtime) { const { eventSource, eventTypes, handlers } = runtime; + const registrationState = runtime.getCoreEventBindingState?.() || {}; - eventSource.on(eventTypes.CHAT_CHANGED, handlers.onChatChanged); + if (registrationState.registered) { + runtime.console?.warn?.("[ST-BME] 核心事件已注册,跳过重复绑定"); + return registrationState; + } + + const cleanups = []; + const bind = (eventName, listener) => { + if (!eventName || typeof listener !== "function") return; + eventSource.on(eventName, listener); + if (typeof eventSource.off === "function") { + cleanups.push(() => eventSource.off(eventName, listener)); + } else if (typeof eventSource.removeListener === "function") { + cleanups.push(() => eventSource.removeListener(eventName, listener)); + } + }; + + bind(eventTypes.CHAT_CHANGED, handlers.onChatChanged); if (eventTypes.CHAT_LOADED) { - eventSource.on(eventTypes.CHAT_LOADED, handlers.onChatLoaded); + bind(eventTypes.CHAT_LOADED, handlers.onChatLoaded); } if (eventTypes.MESSAGE_SENT) { - eventSource.on(eventTypes.MESSAGE_SENT, handlers.onMessageSent); + bind(eventTypes.MESSAGE_SENT, handlers.onMessageSent); } - runtime.registerGenerationAfterCommands(handlers.onGenerationAfterCommands); - runtime.registerBeforeCombinePrompts(handlers.onBeforeCombinePrompts); + const beforeCombineCleanup = runtime.registerBeforeCombinePrompts( + handlers.onBeforeCombinePrompts, + ); + if (typeof beforeCombineCleanup === "function") { + cleanups.push(beforeCombineCleanup); + } - eventSource.on(eventTypes.MESSAGE_RECEIVED, handlers.onMessageReceived); - eventSource.on(eventTypes.MESSAGE_DELETED, handlers.onMessageDeleted); - eventSource.on(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited); - eventSource.on(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped); + const afterCommandsCleanup = runtime.registerGenerationAfterCommands( + handlers.onGenerationAfterCommands, + ); + if (typeof afterCommandsCleanup === "function") { + cleanups.push(afterCommandsCleanup); + } + + bind(eventTypes.MESSAGE_RECEIVED, handlers.onMessageReceived); + bind(eventTypes.MESSAGE_DELETED, handlers.onMessageDeleted); + bind(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited); + bind(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped); if (eventTypes.MESSAGE_UPDATED) { - eventSource.on(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited); + bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited); } + + const nextState = { + registered: true, + cleanups, + registeredAt: Date.now(), + }; + runtime.setCoreEventBindingState?.(nextState); + return nextState; } export function onChatChangedController(runtime) { + runtime.clearCoreEventBindingState?.(); runtime.clearPendingHistoryMutationChecks(); runtime.clearTimeout(runtime.getPendingHistoryRecoveryTimer()); runtime.setPendingHistoryRecoveryTimer(null); diff --git a/graph-persistence.js b/graph-persistence.js index af7e941..54d72c8 100644 --- a/graph-persistence.js +++ b/graph-persistence.js @@ -1,10 +1,7 @@ // ST-BME: 图谱持久化常量与纯工具函数 // 不依赖 index.js 模块级可变状态(currentGraph / graphPersistenceState 等) -import { - deserializeGraph, - serializeGraph, -} from "./graph.js"; +import { deserializeGraph, serializeGraph } from "./graph.js"; import { normalizeGraphRuntimeState } from "./runtime-state.js"; // ═══════════════════════════════════════════════════════════ @@ -167,6 +164,9 @@ export function readGraphShadowSnapshot(chatId = "") { serializedGraph: snapshot.serializedGraph, updatedAt: String(snapshot.updatedAt || ""), reason: String(snapshot.reason || ""), + integrity: String(snapshot.integrity || ""), + persistedChatId: String(snapshot.persistedChatId || ""), + debugReason: String(snapshot.debugReason || snapshot.reason || ""), }; } catch { return null; @@ -183,13 +183,14 @@ export function readGraphShadowSnapshot(chatId = "") { export function writeGraphShadowSnapshot( chatId, graph, - { revision = 0, reason = "" } = {}, + { revision = 0, reason = "", integrity = "", debugReason = "" } = {}, ) { const storageKey = getGraphShadowSnapshotStorageKey(chatId); if (!storageKey || !graph) return false; try { const serializedGraph = serializeGraph(graph); + const persistedMeta = getGraphPersistenceMeta(graph) || {}; globalThis.sessionStorage?.setItem( storageKey, JSON.stringify({ @@ -198,6 +199,9 @@ export function writeGraphShadowSnapshot( serializedGraph, updatedAt: new Date().toISOString(), reason: String(reason || ""), + integrity: String(integrity || persistedMeta.integrity || ""), + persistedChatId: String(persistedMeta.chatId || ""), + debugReason: String(debugReason || reason || ""), }), ); return true; @@ -230,9 +234,89 @@ export function cloneGraphForPersistence(graph, chatId = "") { ); } -export function shouldPreferShadowSnapshotOverOfficial(officialGraph, shadowSnapshot) { - if (!shadowSnapshot) return false; +export function shouldPreferShadowSnapshotOverOfficial( + officialGraph, + shadowSnapshot, +) { + if (!shadowSnapshot) { + return { + prefer: false, + reason: "shadow-missing", + }; + } + const shadowRevision = Number(shadowSnapshot.revision || 0); const officialRevision = getGraphPersistedRevision(officialGraph); - return shadowRevision > 0 && shadowRevision > officialRevision; + 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", + shadowRevision, + officialRevision, + }; + } + + if ( + normalizedOfficialChatId && + normalizedShadowPersistedChatId && + normalizedOfficialChatId !== normalizedShadowPersistedChatId + ) { + return { + prefer: false, + reason: "shadow-persisted-chat-mismatch", + shadowRevision, + officialRevision, + officialChatId: normalizedOfficialChatId, + shadowPersistedChatId: normalizedShadowPersistedChatId, + }; + } + + if ( + normalizedOfficialChatId && + normalizedShadowChatId && + normalizedOfficialChatId !== normalizedShadowChatId + ) { + return { + prefer: false, + reason: "shadow-chat-mismatch", + shadowRevision, + officialRevision, + officialChatId: normalizedOfficialChatId, + shadowChatId: normalizedShadowChatId, + }; + } + + if ( + officialIntegrity && + shadowIntegrity && + officialIntegrity !== shadowIntegrity + ) { + return { + prefer: false, + reason: "shadow-integrity-mismatch", + shadowRevision, + officialRevision, + officialIntegrity, + shadowIntegrity, + }; + } + + return { + prefer: shadowRevision > 0 && shadowRevision > officialRevision, + reason: + shadowRevision > officialRevision + ? "shadow-newer-than-official" + : "shadow-not-newer-than-official", + shadowRevision, + officialRevision, + }; } diff --git a/graph.js b/graph.js index 8739088..757b562 100644 --- a/graph.js +++ b/graph.js @@ -136,8 +136,13 @@ export function updateNode(graph, nodeId, updates) { * @param {string} nodeId * @returns {boolean} */ -export function removeNode(graph, nodeId) { - const node = getNode(graph, nodeId); +export function removeNode(graph, nodeId, visited = new Set()) { + const normalizedNodeId = String(nodeId || ""); + if (!normalizedNodeId) return false; + if (visited.has(normalizedNodeId)) return false; + visited.add(normalizedNodeId); + + const node = getNode(graph, normalizedNodeId); if (!node) return false; // 修复时间链表 @@ -150,26 +155,39 @@ export function removeNode(graph, nodeId) { if (next) next.prevId = node.prevId; } - // 递归删除子节点 + // 递归删除子节点(带环保护) for (const childId of node.childIds) { - removeNode(graph, childId); + removeNode(graph, childId, visited); } // 从父节点中移除引用 if (node.parentId) { const parent = getNode(graph, node.parentId); if (parent) { - parent.childIds = parent.childIds.filter((id) => id !== nodeId); + parent.childIds = parent.childIds.filter((id) => id !== normalizedNodeId); } } + // 同时清理其它节点上可能残留的脏 child 引用,避免导入脏图残留环 + for (const candidate of graph.nodes) { + if ( + !Array.isArray(candidate?.childIds) || + candidate.id === normalizedNodeId + ) { + continue; + } + candidate.childIds = candidate.childIds.filter( + (id) => id !== normalizedNodeId, + ); + } + // 删除相关边 graph.edges = graph.edges.filter( - (e) => e.fromId !== nodeId && e.toId !== nodeId, + (e) => e.fromId !== normalizedNodeId && e.toId !== normalizedNodeId, ); // 删除节点本身 - graph.nodes = graph.nodes.filter((n) => n.id !== nodeId); + graph.nodes = graph.nodes.filter((n) => n.id !== normalizedNodeId); return true; } @@ -545,7 +563,9 @@ export function deserializeGraph(json) { extractionCount: Number.isFinite(data?.historyState?.extractionCount) ? data.historyState.extractionCount : 0, - lastMutationSource: String(data?.historyState?.lastMutationSource || ""), + lastMutationSource: String( + data?.historyState?.lastMutationSource || "", + ), }; data.batchJournal = Array.isArray(data.batchJournal) ? data.batchJournal @@ -623,7 +643,9 @@ export function exportGraph(graph) { historyState: { ...createDefaultHistoryState(graph?.historyState?.chatId || ""), lastProcessedAssistantFloor: - graph?.historyState?.lastProcessedAssistantFloor ?? graph?.lastProcessedSeq ?? -1, + graph?.historyState?.lastProcessedAssistantFloor ?? + graph?.lastProcessedSeq ?? + -1, }, vectorIndexState: { ...createDefaultVectorIndexState(graph?.historyState?.chatId || ""), diff --git a/index.js b/index.js index deb1d08..1751853 100644 --- a/index.js +++ b/index.js @@ -447,6 +447,11 @@ const lastStatusToastAt = {}; let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); let pendingHostGenerationInputSnapshot = createRecallInputRecord(); +let coreEventBindingState = { + registered: false, + cleanups: [], + registeredAt: 0, +}; let sendIntentHookCleanup = []; let sendIntentHookRetryTimer = null; let pendingHistoryRecoveryTimer = null; @@ -1031,6 +1036,41 @@ function clearRecallInputTracking() { pendingHostGenerationInputSnapshot = createRecallInputRecord(); } +function getCoreEventBindingState() { + return coreEventBindingState; +} + +function setCoreEventBindingState(nextState = {}) { + coreEventBindingState = { + registered: Boolean(nextState?.registered), + cleanups: Array.isArray(nextState?.cleanups) ? nextState.cleanups : [], + registeredAt: Number(nextState?.registeredAt) || 0, + }; + return coreEventBindingState; +} + +function clearCoreEventBindingState() { + const cleanups = Array.isArray(coreEventBindingState?.cleanups) + ? coreEventBindingState.cleanups.splice( + 0, + coreEventBindingState.cleanups.length, + ) + : []; + for (const cleanup of cleanups) { + try { + cleanup?.(); + } catch (error) { + console.warn("[ST-BME] 清理核心事件绑定失败:", error); + } + } + coreEventBindingState = { + registered: false, + cleanups: [], + registeredAt: 0, + }; + return coreEventBindingState; +} + function freezeHostGenerationInputSnapshot( text, source = "host-generation-lifecycle", @@ -1071,19 +1111,36 @@ function getPendingHostGenerationInputSnapshot() { function recordRecallSendIntent(text, source = "dom-intent") { const normalized = normalizeRecallInputText(text); - if (!normalized) return; + if (!normalized) return createRecallInputRecord(); + + const hash = hashRecallInput(normalized); + const previousRecord = isFreshRecallInputRecord(pendingRecallSendIntent) + ? pendingRecallSendIntent + : null; + const previousHash = String(previousRecord?.hash || ""); + const previousText = String(previousRecord?.text || ""); + + if (previousHash && previousHash === hash && previousText === normalized) { + pendingRecallSendIntent = createRecallInputRecord({ + ...previousRecord, + at: Date.now(), + source: String(source || previousRecord.source || "dom-intent"), + }); + return pendingRecallSendIntent; + } pendingRecallSendIntent = createRecallInputRecord({ text: normalized, - hash: hashRecallInput(normalized), + hash, source, at: Date.now(), }); + return pendingRecallSendIntent; } function recordRecallSentUserMessage(messageId, text, source = "message-sent") { const normalized = normalizeRecallInputText(text); - if (!normalized) return; + if (!normalized) return createRecallInputRecord(); const hash = hashRecallInput(normalized); lastRecallSentUserMessage = createRecallInputRecord({ @@ -1103,6 +1160,13 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") { ) { pendingHostGenerationInputSnapshot = createRecallInputRecord(); } + + const activeChatId = getCurrentChatId(); + if (activeChatId) { + clearGenerationRecallTransactionsForChat(activeChatId); + } + + return lastRecallSentUserMessage; } function getMessageRecallRecord(messageIndex) { @@ -2678,9 +2742,50 @@ function applyIndexedDbSnapshotToRuntime( 1, normalizeIndexedDbRevision(snapshot?.meta?.revision), ); - const graphFromSnapshot = buildGraphFromSnapshot(snapshot, { - chatId: normalizedChatId, - }); + let graphFromSnapshot = null; + try { + graphFromSnapshot = buildGraphFromSnapshot(snapshot, { + chatId: normalizedChatId, + }); + } catch (error) { + const failureReason = + error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR" + ? "indexeddb-snapshot-integrity-rejected" + : "indexeddb-snapshot-load-failed"; + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbRevision: revision, + indexedDbLastError: error?.message || String(error), + dualWriteLastResult: { + action: "load", + source: String(source || "indexeddb"), + success: false, + rejected: true, + reason: failureReason, + revision, + at: Date.now(), + }, + }); + console.warn("[ST-BME] IndexedDB 图谱快照已拒绝加载", { + chatId: normalizedChatId, + source, + revision, + reason: failureReason, + detail: error?.message || String(error), + integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [], + }); + return { + success: false, + loaded: false, + reason: failureReason, + detail: error?.message || String(error), + integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [], + chatId: normalizedChatId, + attemptIndex, + }; + } currentGraph = cloneGraphForPersistence( normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), normalizedChatId, @@ -2740,6 +2845,8 @@ function applyIndexedDbSnapshotToRuntime( dualWriteLastResult: { action: "load", source: String(source || "indexeddb"), + success: true, + reason: "indexeddb-loaded", revision, at: Date.now(), }, @@ -4249,11 +4356,30 @@ function loadGraphFromChat(options = {}) { normalizeGraphRuntimeState(deserializeGraph(savedData), chatId), chatId, ); + const shadowSnapshot = readGraphShadowSnapshot(chatId); + const shadowDecision = shouldPreferShadowSnapshotOverOfficial( + officialGraph, + shadowSnapshot, + ); const officialRevision = Math.max( 1, getGraphPersistedRevision(officialGraph), ); + if (shadowSnapshot && shadowDecision?.reason) { + updateGraphPersistenceState({ + dualWriteLastResult: { + action: "shadow-compare", + source: `${source}:metadata-shadow-compare`, + success: Boolean(shadowDecision.prefer), + reason: shadowDecision.reason, + shadowRevision: Number(shadowSnapshot.revision || 0), + officialRevision, + at: Date.now(), + }, + }); + } + clearPendingGraphLoadRetry(); currentGraph = officialGraph; extractionCount = Number.isFinite( @@ -4295,9 +4421,11 @@ function loadGraphFromChat(options = {}) { queuedPersistChatId: "", pendingPersist: false, shadowSnapshotUsed: false, - shadowSnapshotRevision: 0, - shadowSnapshotUpdatedAt: "", - shadowSnapshotReason: "", + shadowSnapshotRevision: Number(shadowSnapshot?.revision || 0), + shadowSnapshotUpdatedAt: String(shadowSnapshot?.updatedAt || ""), + shadowSnapshotReason: String( + shadowDecision?.reason || shadowSnapshot?.reason || "", + ), dbReady: false, writesBlocked: true, }); @@ -5313,6 +5441,7 @@ function createGenerationRecallContext({ transaction: null, recallOptions: null, shouldRun: false, + guardReason: "missing-frozen-recall-options", }; } @@ -5327,6 +5456,18 @@ function createGenerationRecallContext({ userMessage: frozenRecallOptions.overrideUserMessage, }); + if (!normalizedChatId || !String(fallbackRecallKey || "").trim()) { + return { + hookName, + generationType: transactionGenerationType, + recallKey: "", + transaction: null, + recallOptions: null, + shouldRun: false, + guardReason: !normalizedChatId ? "missing-chat-id" : "missing-recall-key", + }; + } + const now = Date.now(); const recentTransaction = findRecentGenerationRecallTransactionForChat( normalizedChatId, @@ -5352,11 +5493,32 @@ function createGenerationRecallContext({ if (!transaction) { return { hookName, - generationType, + generationType: transactionGenerationType, recallKey: "", transaction: null, recallOptions: null, shouldRun: false, + guardReason: "transaction-unavailable", + }; + } + + const normalizedTransactionChatId = normalizeChatIdCandidate( + transaction.chatId, + ); + const transactionRecallKey = String(transaction.recallKey || "").trim(); + if ( + normalizedTransactionChatId !== normalizedChatId || + !transactionRecallKey || + transactionRecallKey !== String(fallbackRecallKey) + ) { + return { + hookName, + generationType: transactionGenerationType, + recallKey: String(fallbackRecallKey || ""), + transaction, + recallOptions: null, + shouldRun: false, + guardReason: "transaction-mismatch", }; } @@ -5368,9 +5530,6 @@ function createGenerationRecallContext({ ...frozenRecallOptions, }; } - if (!String(transaction.recallKey || "").trim()) { - transaction.recallKey = fallbackRecallKey; - } if (!String(transaction.generationType || "").trim()) { transaction.generationType = transactionGenerationType; } @@ -5384,7 +5543,7 @@ function createGenerationRecallContext({ transaction.frozenRecallOptions?.generationType || generationType, }; - const recallKey = String(transaction.recallKey || fallbackRecallKey || ""); + const recallKey = transactionRecallKey; const shouldRun = shouldRunRecallForTransaction(transaction, hookName); return { @@ -5394,6 +5553,7 @@ function createGenerationRecallContext({ transaction, recallOptions: boundRecallOptions, shouldRun, + guardReason: shouldRun ? "" : "transaction-not-runnable", }; } @@ -5944,21 +6104,35 @@ function applyRecoveryPlanToVectorState( if (nodeId) replayRequiredNodeIds.add(nodeId); } + const fallbackFloor = Number.isFinite(dirtyFallbackFloor) + ? dirtyFallbackFloor + : currentGraph.historyState?.historyDirtyFrom; + const pendingRepairFromFloor = Number.isFinite( + recoveryPlan?.pendingRepairFromFloor, + ) + ? recoveryPlan.pendingRepairFromFloor + : Number.isFinite(fallbackFloor) + ? fallbackFloor + : null; + vectorState.replayRequiredNodeIds = [...replayRequiredNodeIds]; vectorState.dirty = true; vectorState.dirtyReason = recoveryPlan?.dirtyReason || vectorState.dirtyReason || "history-recovery-replay"; - const fallbackFloor = Number.isFinite(dirtyFallbackFloor) - ? dirtyFallbackFloor - : currentGraph.historyState?.historyDirtyFrom; - vectorState.pendingRepairFromFloor = Number.isFinite( - recoveryPlan?.pendingRepairFromFloor, - ) - ? recoveryPlan.pendingRepairFromFloor - : Number.isFinite(fallbackFloor) - ? fallbackFloor + vectorState.pendingRepairFromFloor = pendingRepairFromFloor; + vectorState.lastIntegrityIssue = + recoveryPlan?.valid === false + ? { + scope: "history-recovery-plan", + reason: String(recoveryPlan.invalidReason || "invalid-recovery-plan"), + dirtyFallbackFloor: Number.isFinite(fallbackFloor) + ? fallbackFloor + : null, + pendingRepairFromFloor, + at: Date.now(), + } : null; vectorState.lastWarning = recoveryPlan?.legacyGapFallback ? "历史恢复检测到 legacy-gap,向量索引需按受影响后缀修复" @@ -5996,6 +6170,18 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { recoveryPoint.affectedJournals, targetFloor, ); + if (recoveryPlan?.valid === false) { + return { + success: false, + rollbackPerformed: false, + extractionTriggered: false, + requestedFloor: targetFloor, + effectiveFromFloor: null, + recoveryPath: "reverse-journal-rejected", + affectedBatchCount, + error: `回滚计划完整性校验失败: ${recoveryPlan.invalidReason || "unknown"}`, + }; + } rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); extractionCount = currentGraph.historyState.extractionCount || 0; @@ -6131,6 +6317,13 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { recoveryPoint.affectedJournals, initialDirtyFrom, ); + if (recoveryPlan?.valid === false) { + throw new Error( + `reverse-journal recovery plan invalid: ${ + recoveryPlan.invalidReason || "unknown" + }`, + ); + } rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); extractionCount = currentGraph.historyState.extractionCount || 0; @@ -6466,6 +6659,7 @@ async function runRecall(options = {}) { function onChatChanged() { const result = onChatChangedController({ abortAllRunningStages, + clearCoreEventBindingState, clearGenerationRecallTransactionsForChat, clearInjectionState, clearPendingGraphLoadRetry, @@ -6914,8 +7108,10 @@ async function onReembedDirect() { // 注册事件钩子 registerCoreEventHooksController({ + console, eventSource, eventTypes: event_types, + getCoreEventBindingState, handlers: { onBeforeCombinePrompts, onChatChanged, @@ -6929,6 +7125,7 @@ async function onReembedDirect() { }, registerBeforeCombinePrompts, registerGenerationAfterCommands, + setCoreEventBindingState, }); // 加载当前聊天的图谱 diff --git a/runtime-state.js b/runtime-state.js index 089b881..4b7d207 100644 --- a/runtime-state.js +++ b/runtime-state.js @@ -41,6 +41,7 @@ export function createDefaultVectorIndexState(chatId = "") { pending: 0, }, lastWarning: "", + lastIntegrityIssue: null, }; } @@ -133,6 +134,13 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { if (!Number.isFinite(vectorIndexState.pendingRepairFromFloor)) { vectorIndexState.pendingRepairFromFloor = null; } + if ( + vectorIndexState.lastIntegrityIssue != null && + (typeof vectorIndexState.lastIntegrityIssue !== "object" || + Array.isArray(vectorIndexState.lastIntegrityIssue)) + ) { + vectorIndexState.lastIntegrityIssue = null; + } const previousCollectionId = vectorIndexState.collectionId; vectorIndexState.collectionId = buildVectorCollectionId( @@ -253,7 +261,12 @@ export function detectHistoryMutation(chat, historyState) { } for (let floor = 0; floor <= lastProcessedAssistantFloor; floor++) { - if (!Object.prototype.hasOwnProperty.call(processedMessageHashes, String(floor))) { + if ( + !Object.prototype.hasOwnProperty.call( + processedMessageHashes, + String(floor), + ) + ) { return { dirty: true, earliestAffectedFloor: floor, @@ -679,6 +692,11 @@ export function buildReverseJournalRecoveryPlan( let minProcessedFloor = Number.isFinite(dirtyFromFloor) ? dirtyFromFloor : null; + let invalidJournalReason = ""; + + if (!Array.isArray(affectedJournals) || affectedJournals.length === 0) { + invalidJournalReason = "affected-journals-empty"; + } for (const journal of affectedJournals) { const vectorDelta = journal?.vectorDelta || {}; @@ -698,6 +716,13 @@ export function buildReverseJournalRecoveryPlan( ? journal.processedRange : [-1, -1]; + if ( + !invalidJournalReason && + (!Number.isFinite(range[0]) || !Number.isFinite(range[1])) + ) { + invalidJournalReason = "processed-range-missing"; + } + if (Number.isFinite(range[0])) { minProcessedFloor = Number.isFinite(minProcessedFloor) ? Math.min(minProcessedFloor, range[0]) @@ -751,6 +776,17 @@ export function buildReverseJournalRecoveryPlan( pendingRepairFromFloor, legacyGapFallback: hasLegacyGap, dirtyReason: hasLegacyGap ? "legacy-gap" : "history-recovery-replay", + valid: + !invalidJournalReason && + Number.isFinite(pendingRepairFromFloor) && + pendingRepairFromFloor >= 0, + invalidReason: + invalidJournalReason || + (!Number.isFinite(pendingRepairFromFloor) + ? "pending-repair-floor-missing" + : pendingRepairFromFloor < 0 + ? "pending-repair-floor-negative" + : ""), }; } @@ -758,6 +794,12 @@ export function buildRecoveryResult(status, extra = {}) { return { status, at: Date.now(), + debugReason: + typeof extra?.debugReason === "string" && extra.debugReason.trim() + ? extra.debugReason.trim() + : typeof extra?.reason === "string" + ? extra.reason + : "", ...extra, }; } diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 07b1358..1cde864 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -4,34 +4,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; -import { - createEmptyGraph, - deserializeGraph, - getGraphStats, - getNode, - serializeGraph, -} from "../graph.js"; -import { normalizeGraphRuntimeState } from "../runtime-state.js"; -import { - createUiStatus, - createGraphPersistenceState, - createRecallInputRecord, - createRecallRunResult, - normalizeStageNoticeLevel, - getStageNoticeTitle, - getStageNoticeDuration, - normalizeRecallInputText, - hashRecallInput, - isFreshRecallInputRecord, - clampInt, - clampFloat, - formatRecallContextLine, -} from "../ui-status.js"; +import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js"; +import { onMessageReceivedController } from "../event-binding.js"; import { cloneGraphForPersistence, cloneRuntimeDebugValue, - getGraphPersistenceMeta, getGraphPersistedRevision, + getGraphPersistenceMeta, getGraphShadowSnapshotStorageKey, GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_STATES, @@ -48,8 +27,29 @@ import { writeChatMetadataPatch, writeGraphShadowSnapshot, } from "../graph-persistence.js"; -import { onMessageReceivedController } from "../event-binding.js"; -import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js"; +import { + createEmptyGraph, + deserializeGraph, + getGraphStats, + getNode, + serializeGraph, +} from "../graph.js"; +import { normalizeGraphRuntimeState } from "../runtime-state.js"; +import { + clampFloat, + clampInt, + createGraphPersistenceState, + createRecallInputRecord, + createRecallRunResult, + createUiStatus, + formatRecallContextLine, + getStageNoticeDuration, + getStageNoticeTitle, + hashRecallInput, + isFreshRecallInputRecord, + normalizeRecallInputText, + normalizeStageNoticeLevel, +} from "../ui-status.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const indexPath = path.resolve(moduleDir, "../index.js"); @@ -258,35 +258,63 @@ async function createGraphPersistenceHarness({ const raw = storage.getItem(key); if (!raw) return null; const snap = JSON.parse(raw); - if (!snap || String(snap.chatId || "") !== String(chatId || "") || - typeof snap.serializedGraph !== "string" || !snap.serializedGraph) return null; + 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 { return null; } + } catch { + return null; + } }, - writeGraphShadowSnapshot(chatId = "", graph = null, { revision = 0, reason = "" } = {}) { + writeGraphShadowSnapshot( + chatId = "", + graph = null, + { revision = 0, reason = "", integrity = "", debugReason = "" } = {}, + ) { const key = getGraphShadowSnapshotStorageKey(chatId); if (!key || !graph) return false; + const persistedMeta = getGraphPersistenceMeta(graph) || {}; try { - storage.setItem(key, JSON.stringify({ - chatId: String(chatId || ""), - revision: Number.isFinite(revision) ? revision : 0, - serializedGraph: serializeGraph(graph), - updatedAt: new Date().toISOString(), - reason: String(reason || ""), - })); + storage.setItem( + key, + 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 || ""), + }), + ); return true; - } catch { return false; } + } catch { + return false; + } }, removeGraphShadowSnapshot(chatId = "") { const key = getGraphShadowSnapshotStorageKey(chatId); if (!key) return false; - try { storage.removeItem(key); return true; } catch { return false; } + try { + storage.removeItem(key); + return true; + } catch { + return false; + } }, createDefaultTaskProfiles() { return { @@ -398,8 +426,12 @@ async function createGraphPersistenceHarness({ }, async isEmpty() { const snapshot = runtimeContext.__indexedDbSnapshot || {}; - const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0; - const edges = Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0; + 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; @@ -420,7 +452,16 @@ async function createGraphPersistenceHarness({ migrationSource: "chat_metadata", }, }); - return { migrated: true, revision, imported: { nodes: runtimeContext.__indexedDbSnapshot.nodes.length, edges: runtimeContext.__indexedDbSnapshot.edges.length, tombstones: runtimeContext.__indexedDbSnapshot.tombstones.length } }; + return { + migrated: true, + revision, + imported: { + nodes: runtimeContext.__indexedDbSnapshot.nodes.length, + edges: runtimeContext.__indexedDbSnapshot.edges.length, + tombstones: + runtimeContext.__indexedDbSnapshot.tombstones.length, + }, + }; }, async markSyncDirty() {}, }; @@ -549,10 +590,7 @@ result = { "chat-global", ); assert.equal(harness.api.getGraphPersistenceState().dbReady, false); - assert.equal( - harness.api.getGraphPersistenceLiveState().writesBlocked, - true, - ); + assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true); } { @@ -599,7 +637,10 @@ result = { 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"); + assert.equal( + harness.api.getGraphPersistenceState().storagePrimary, + "indexeddb", + ); } { @@ -638,7 +679,10 @@ result = { assert.equal(result.synced, true); assert.equal(result.loadState, "loading"); - assert.equal(harness.api.getGraphPersistenceState().loadState, "empty-confirmed"); + assert.equal( + harness.api.getGraphPersistenceState().loadState, + "empty-confirmed", + ); assert.equal(harness.api.getGraphPersistenceState().dbReady, true); } @@ -823,14 +867,20 @@ result = { chatId: "chat-late-reconcile", chatMetadata: { integrity: "chat-late-reconcile-ready", - st_bme_graph: createMeaningfulGraph("chat-late-reconcile", "late-official"), + 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, - }), + buildSnapshotFromGraph( + createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"), + { + chatId: "chat-late-reconcile", + revision: 7, + }, + ), ); harness.api.onMessageReceived(); @@ -854,7 +904,10 @@ result = { }, }); harness.api.setCurrentGraph( - normalizeGraphRuntimeState(createMeaningfulGraph("chat-sync-refresh", "stale-runtime"), "chat-sync-refresh"), + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-sync-refresh", "stale-runtime"), + "chat-sync-refresh", + ), ); harness.api.setGraphPersistenceState({ loadState: "loaded", @@ -866,14 +919,20 @@ result = { writesBlocked: false, }); harness.api.setIndexedDbSnapshot( - buildSnapshotFromGraph(createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"), { - chatId: "chat-sync-refresh", - revision: 7, - }), + 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" }); + await runtimeOptions.onSyncApplied({ + chatId: "chat-sync-refresh", + action: "download", + }); assert.equal( harness.api.getCurrentGraph().nodes[0]?.fields?.title, @@ -1043,11 +1102,21 @@ result = { chatMetadata: undefined, sessionStore: sharedSession, }); - writer.api.writeGraphShadowSnapshot( - "chat-shadow-newer", + const shadowGraph = stampPersistedGraph( createMeaningfulGraph("chat-shadow-newer", "shadow-newer"), - { revision: 9, reason: "pagehide-refresh" }, + { + 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"), @@ -1067,7 +1136,10 @@ result = { }); assert.equal(result.loadState, "loading"); - assert.equal(result.reason, "official-older-than-shadow:metadata-compat-provisional"); + assert.equal( + result.reason, + "official-older-than-shadow:metadata-compat-provisional", + ); assert.equal( reader.api.getCurrentGraph().nodes[0]?.fields?.title, "事件-official-older", @@ -1083,6 +1155,9 @@ result = { "pagehide-refresh", "metadata 兼容加载后影子快照可保留,但不作为主链路恢复来源", ); + const live = reader.api.getGraphPersistenceLiveState(); + assert.equal(live.shadowSnapshotRevision, 9); + assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch"); } { @@ -1212,7 +1287,10 @@ result = { ?.length, undefined, ); - assert.equal(reader.runtimeContext.__chatContext.chatMetadata?.integrity, "meta-ready-promote"); + 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, 0); @@ -1603,7 +1681,10 @@ result = { await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-indexeddb"); - assert.equal(harness.api.getGraphPersistenceState().storagePrimary, "indexeddb"); + assert.equal( + harness.api.getGraphPersistenceState().storagePrimary, + "indexeddb", + ); } { @@ -1644,9 +1725,15 @@ result = { 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.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"); + assert.equal( + harness.api.getIndexedDbSnapshot().meta.migrationSource, + "chat_metadata", + ); } console.log("graph-persistence tests passed"); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index ceed6d0..0faf181 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -8,6 +8,7 @@ import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js"; import { onBeforeCombinePromptsController, onGenerationAfterCommandsController, + registerCoreEventHooksController, } from "../event-binding.js"; import { onRerollController } from "../extraction-controller.js"; import { @@ -123,8 +124,14 @@ globalThis.__p0ExtensionSettings = { globalThis.__stBmeTestOverrides = {}; globalThis.require = require; -const { createEmptyGraph, createNode, addNode, createEdge, addEdge } = - await import("../graph.js"); +const { + createEmptyGraph, + createNode, + addNode, + createEdge, + addEdge, + removeNode, +} = await import("../graph.js"); const { compressType } = await import("../compressor.js"); const { syncGraphVectorIndex } = await import("../vector-index.js"); const { extractMemories } = await import("../extractor.js"); @@ -1699,6 +1706,8 @@ async function testReverseJournalRecoveryPlanLegacyFallback() { assert.equal(recoveryPlan.legacyGapFallback, true); assert.equal(recoveryPlan.dirtyReason, "legacy-gap"); assert.equal(recoveryPlan.pendingRepairFromFloor, 5); + assert.equal(recoveryPlan.valid, true); + assert.equal(recoveryPlan.invalidReason, ""); assert.deepEqual(recoveryPlan.backendDeleteHashes, ["hash_1"]); assert.deepEqual(recoveryPlan.replayRequiredNodeIds, []); } @@ -1741,6 +1750,8 @@ async function testReverseJournalRecoveryPlanAggregatesDeletesAndReplay() { assert.equal(recoveryPlan.legacyGapFallback, false); assert.equal(recoveryPlan.dirtyReason, "history-recovery-replay"); assert.equal(recoveryPlan.pendingRepairFromFloor, 4); + assert.equal(recoveryPlan.valid, true); + assert.equal(recoveryPlan.invalidReason, ""); assert.deepEqual(recoveryPlan.backendDeleteHashes.sort(), [ "hash_backend", "hash_new", @@ -1956,6 +1967,8 @@ async function testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairS assert.equal(recoveryPlan.legacyGapFallback, true); assert.equal(recoveryPlan.dirtyReason, "legacy-gap"); assert.equal(recoveryPlan.pendingRepairFromFloor, 7); + assert.equal(recoveryPlan.valid, true); + assert.equal(recoveryPlan.invalidReason, ""); assert.deepEqual(recoveryPlan.replayRequiredNodeIds.sort(), [ "node-current", "node-extra", @@ -2399,6 +2412,107 @@ async function testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine() { assert.equal(transaction.hookStates.GENERATION_AFTER_COMMANDS, "skipped"); } +async function testGenerationRecallSentMessageClearsStaleTransactionForSameKey() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "同 key 发送后重开" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.result.generationRecallTransactions.size, 1); + + harness.recordRecallSentUserMessage(0, "同 key 发送后重开"); + await harness.result.onGenerationAfterCommands("normal", {}, false); + + assert.equal(harness.runRecallCalls.length, 2); +} + +async function testRegisterCoreEventHooksIsIdempotent() { + const eventRegistrations = []; + const makeFirstRegistrations = []; + const bindingState = { registered: false, cleanups: [], registeredAt: 0 }; + const eventSource = { + on(eventName, listener) { + eventRegistrations.push({ eventName, listener }); + }, + off() {}, + }; + const runtime = { + console: { warn() {} }, + eventSource, + eventTypes: { + CHAT_CHANGED: "chat-changed", + CHAT_LOADED: "chat-loaded", + MESSAGE_SENT: "message-sent", + MESSAGE_RECEIVED: "message-received", + MESSAGE_DELETED: "message-deleted", + MESSAGE_EDITED: "message-edited", + MESSAGE_SWIPED: "message-swiped", + MESSAGE_UPDATED: "message-updated", + }, + handlers: { + onChatChanged() {}, + onChatLoaded() {}, + onMessageSent() {}, + onGenerationAfterCommands() {}, + onBeforeCombinePrompts() {}, + onMessageReceived() {}, + onMessageDeleted() {}, + onMessageEdited() {}, + onMessageSwiped() {}, + }, + registerGenerationAfterCommands(listener) { + makeFirstRegistrations.push({ hook: "after", listener }); + return () => {}; + }, + registerBeforeCombinePrompts(listener) { + makeFirstRegistrations.push({ hook: "before", listener }); + return () => {}; + }, + getCoreEventBindingState: () => bindingState, + setCoreEventBindingState(nextState) { + bindingState.registered = Boolean(nextState?.registered); + bindingState.cleanups = Array.isArray(nextState?.cleanups) + ? nextState.cleanups + : []; + bindingState.registeredAt = Number(nextState?.registeredAt) || 0; + return bindingState; + }, + }; + + registerCoreEventHooksController(runtime); + registerCoreEventHooksController(runtime); + + assert.equal(eventRegistrations.length, 8); + assert.equal(makeFirstRegistrations.length, 2); + assert.equal(bindingState.registered, true); +} + +async function testRemoveNodeHandlesCyclicChildGraph() { + const graph = createEmptyGraph(); + const nodeA = addNode( + graph, + createNode({ type: "event", fields: { title: "A" }, seq: 0 }), + ); + const nodeB = addNode( + graph, + createNode({ type: "event", fields: { title: "B" }, seq: 1 }), + ); + nodeA.childIds = [nodeB.id]; + nodeB.parentId = nodeA.id; + nodeB.childIds = [nodeA.id]; + nodeA.parentId = nodeB.id; + addEdge( + graph, + createEdge({ fromId: nodeA.id, toId: nodeB.id, relation: "cycle" }), + ); + + const removed = removeNode(graph, nodeA.id); + + assert.equal(removed, true); + assert.equal(graph.nodes.length, 0); + assert.equal(graph.edges.length, 0); +} + async function testGenerationRecallAppliesFinalInjectionOncePerTransaction() { const harness = await createGenerationRecallHarness(); harness.chat = [{ is_user: true, mes: "同一轮仅一次最终注入" }]; @@ -3059,6 +3173,9 @@ await testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable() await testGenerationRecallBeforeCombineRunsStandalone(); await testGenerationRecallDifferentKeyCanRunAgain(); await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine(); +await testGenerationRecallSentMessageClearsStaleTransactionForSameKey(); +await testRegisterCoreEventHooksIsIdempotent(); +await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting();