fix: finalize phase0 and phase1 persistence regressions

This commit is contained in:
Youzini-afk
2026-03-31 20:04:42 +08:00
parent d5b4b7e1dc
commit 1098c33a93
8 changed files with 996 additions and 216 deletions

386
bme-db.js
View File

@@ -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;

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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 || ""),

243
index.js
View File

@@ -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,
});
// 加载当前聊天的图谱

View File

@@ -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,
};
}

View File

@@ -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");

View File

@@ -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();