Files
adolf/extensions/memory-wiki/src/source-sync-state.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

287 lines
9.4 KiB
TypeScript

// Memory Wiki plugin module implements source sync state behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
export type MemoryWikiImportedSourceStateEntry = {
group: MemoryWikiImportedSourceGroup;
pagePath: string;
sourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
};
export type MemoryWikiImportedSourceState = {
version: 1;
entries: Record<string, MemoryWikiImportedSourceStateEntry>;
};
type MemoryWikiSourceSyncStateStore = {
read: (vaultRoot: string) => Promise<MemoryWikiImportedSourceState>;
write: (vaultRoot: string, state: MemoryWikiImportedSourceState) => Promise<void>;
};
type MemoryWikiSourceSyncStateRecord = MemoryWikiImportedSourceStateEntry & {
vaultRootKey: string;
syncKey: string;
};
export const MEMORY_WIKI_SOURCE_SYNC_STATE_NAMESPACE = "source-sync";
export const MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES = 20_000;
const EMPTY_STATE: MemoryWikiImportedSourceState = {
version: 1,
entries: {},
};
let configuredSourceSyncStore: MemoryWikiSourceSyncStateStore | undefined;
const memorySourceSyncStateByVault = new Map<string, MemoryWikiImportedSourceState>();
export function resolveMemoryWikiSourceSyncStatePath(vaultRoot: string): string {
return path.join(vaultRoot, ".openclaw-wiki", "source-sync.json");
}
function cloneSourceSyncState(state: MemoryWikiImportedSourceState): MemoryWikiImportedSourceState {
return {
version: 1,
entries: Object.fromEntries(
Object.entries(state.entries).map(([key, value]) => [key, { ...value }]),
),
};
}
function normalizeSourceSyncState(value: unknown): MemoryWikiImportedSourceState {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return EMPTY_STATE;
}
const parsed = value as Partial<MemoryWikiImportedSourceState>;
if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
return EMPTY_STATE;
}
const entries: Record<string, MemoryWikiImportedSourceStateEntry> = {};
for (const [syncKey, entry] of Object.entries(parsed.entries)) {
if (
!entry ||
typeof entry !== "object" ||
Array.isArray(entry) ||
(entry.group !== "bridge" && entry.group !== "unsafe-local") ||
typeof entry.pagePath !== "string" ||
typeof entry.sourcePath !== "string" ||
typeof entry.sourceUpdatedAtMs !== "number" ||
typeof entry.sourceSize !== "number" ||
typeof entry.renderFingerprint !== "string"
) {
continue;
}
entries[syncKey] = {
group: entry.group,
pagePath: entry.pagePath,
sourcePath: entry.sourcePath,
sourceUpdatedAtMs: entry.sourceUpdatedAtMs,
sourceSize: entry.sourceSize,
renderFingerprint: entry.renderFingerprint,
};
}
return { version: 1, entries };
}
function resolveVaultRootKey(vaultRoot: string): string {
return createHash("sha256").update(path.resolve(vaultRoot), "utf8").digest("hex").slice(0, 32);
}
function resolveStateEntryKey(vaultRootKey: string, syncKey: string): string {
return createHash("sha256").update(`${vaultRootKey}\0${syncKey}`, "utf8").digest("hex");
}
function createMemoryFallbackStateStore(): MemoryWikiSourceSyncStateStore {
return {
async read(vaultRoot) {
const vaultRootKey = resolveVaultRootKey(vaultRoot);
return cloneSourceSyncState(memorySourceSyncStateByVault.get(vaultRootKey) ?? EMPTY_STATE);
},
async write(vaultRoot, state) {
assertSourceSyncStateWithinLimit(state);
const vaultRootKey = resolveVaultRootKey(vaultRoot);
memorySourceSyncStateByVault.set(vaultRootKey, cloneSourceSyncState(state));
},
};
}
function assertSourceSyncStateWithinLimit(state: MemoryWikiImportedSourceState): void {
const count = Object.keys(state.entries).length;
if (count > MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES) {
throw new Error(
`Memory Wiki source sync state exceeds SQLite entry limit (${count}/${MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES})`,
);
}
}
export function assertMemoryWikiSourceSyncStateCapacity(params: {
state: MemoryWikiImportedSourceState;
group: MemoryWikiImportedSourceGroup;
incomingCount: number;
}): void {
const retainedOtherGroupCount = Object.values(params.state.entries).filter(
(entry) => entry.group !== params.group,
).length;
const projectedCount = retainedOtherGroupCount + params.incomingCount;
if (projectedCount > MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES) {
throw new Error(
`Memory Wiki source sync state exceeds SQLite entry limit (${projectedCount}/${MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES})`,
);
}
}
export function createMemoryWikiSourceSyncStateStore(
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
): MemoryWikiSourceSyncStateStore {
const openStore = () =>
openKeyedStore<MemoryWikiSourceSyncStateRecord>({
namespace: MEMORY_WIKI_SOURCE_SYNC_STATE_NAMESPACE,
maxEntries: MEMORY_WIKI_SOURCE_SYNC_STATE_MAX_ENTRIES,
});
return {
async read(vaultRoot) {
const vaultRootKey = resolveVaultRootKey(vaultRoot);
const entries: MemoryWikiImportedSourceState["entries"] = {};
for (const row of await openStore().entries()) {
const value = row.value;
if (value.vaultRootKey !== vaultRootKey || typeof value.syncKey !== "string") {
continue;
}
const normalized = normalizeSourceSyncState({
version: 1,
entries: { [value.syncKey]: value },
});
const entry = normalized.entries[value.syncKey];
if (entry) {
entries[value.syncKey] = entry;
}
}
return { version: 1, entries };
},
async write(vaultRoot, state) {
assertSourceSyncStateWithinLimit(state);
const vaultRootKey = resolveVaultRootKey(vaultRoot);
const store = openStore();
const normalized = normalizeSourceSyncState(state);
const nextKeys = new Set<string>();
for (const [syncKey, entry] of Object.entries(normalized.entries)) {
const key = resolveStateEntryKey(vaultRootKey, syncKey);
nextKeys.add(key);
await store.register(key, {
...entry,
vaultRootKey,
syncKey,
});
}
for (const row of await store.entries()) {
if (row.value.vaultRootKey === vaultRootKey && !nextKeys.has(row.key)) {
await store.delete(row.key);
}
}
},
};
}
export function configureMemoryWikiSourceSyncStateStore(
store: MemoryWikiSourceSyncStateStore | undefined,
): void {
configuredSourceSyncStore = store;
}
function resolveSourceSyncStore(
store?: MemoryWikiSourceSyncStateStore,
): MemoryWikiSourceSyncStateStore {
return store ?? configuredSourceSyncStore ?? createMemoryFallbackStateStore();
}
export async function readMemoryWikiSourceSyncState(
vaultRoot: string,
store?: MemoryWikiSourceSyncStateStore,
): Promise<MemoryWikiImportedSourceState> {
return await resolveSourceSyncStore(store).read(vaultRoot);
}
export async function readLegacyMemoryWikiSourceSyncState(
vaultRoot: string,
): Promise<MemoryWikiImportedSourceState> {
const statePath = resolveMemoryWikiSourceSyncStatePath(vaultRoot);
const { value: parsed } = await readJsonFileWithFallback<unknown>(statePath, EMPTY_STATE);
return normalizeSourceSyncState(parsed);
}
export async function writeMemoryWikiSourceSyncState(
vaultRoot: string,
state: MemoryWikiImportedSourceState,
store?: MemoryWikiSourceSyncStateStore,
): Promise<void> {
await resolveSourceSyncStore(store).write(vaultRoot, state);
}
export async function shouldSkipImportedSourceWrite(params: {
vaultRoot: string;
syncKey: string;
expectedPagePath: string;
expectedSourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
state: MemoryWikiImportedSourceState;
}): Promise<boolean> {
const entry = params.state.entries[params.syncKey];
if (!entry) {
return false;
}
if (
entry.pagePath !== params.expectedPagePath ||
entry.sourcePath !== params.expectedSourcePath ||
entry.sourceUpdatedAtMs !== params.sourceUpdatedAtMs ||
entry.sourceSize !== params.sourceSize ||
entry.renderFingerprint !== params.renderFingerprint
) {
return false;
}
const pagePath = path.join(params.vaultRoot, params.expectedPagePath);
return await fs
.access(pagePath)
.then(() => true)
.catch(() => false);
}
export async function pruneImportedSourceEntries(params: {
vaultRoot: string;
group: MemoryWikiImportedSourceGroup;
activeKeys: Set<string>;
state: MemoryWikiImportedSourceState;
}): Promise<number> {
let removedCount = 0;
for (const [syncKey, entry] of Object.entries(params.state.entries)) {
if (entry.group !== params.group || params.activeKeys.has(syncKey)) {
continue;
}
const pageAbsPath = path.join(params.vaultRoot, entry.pagePath);
await fs.rm(pageAbsPath, { force: true }).catch(() => undefined);
delete params.state.entries[syncKey];
removedCount += 1;
}
return removedCount;
}
export function setImportedSourceEntry(params: {
syncKey: string;
entry: MemoryWikiImportedSourceStateEntry;
state: MemoryWikiImportedSourceState;
}): void {
params.state.entries[params.syncKey] = params.entry;
}