From cdfd09554f4e12d7d2cda9d0c593dde4e43dd640 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 19:47:14 +0100 Subject: [PATCH] refactor: store memory wiki digests in sqlite --- docs/channels/imessage-from-bluebubbles.md | 2 +- docs/channels/telegram.md | 6 +- docs/cli/wiki.md | 8 +- docs/plugins/memory-wiki.md | 13 +- docs/refactor/database-first.md | 10 +- docs/web/control-ui.md | 11 +- extensions/memory-wiki/README.md | 6 +- extensions/memory-wiki/src/compile.test.ts | 37 ++-- extensions/memory-wiki/src/compile.ts | 30 +-- .../memory-wiki/src/digest-state.test.ts | 76 ++++++++ extensions/memory-wiki/src/digest-state.ts | 173 ++++++++++++++++++ .../memory-wiki/src/prompt-section.test.ts | 51 ++++-- extensions/memory-wiki/src/prompt-section.ts | 10 +- extensions/memory-wiki/src/query.test.ts | 10 + extensions/memory-wiki/src/query.ts | 9 +- .../memory-wiki/src/source-sync-migration.ts | 30 +++ extensions/memory-wiki/src/vault.ts | 5 +- .../check-database-first-legacy-stores.mjs | 6 + src/infra/push-web.ts | 2 +- src/plugin-sdk/plugin-state-runtime.ts | 2 + src/plugin-state/plugin-blob-store.test.ts | 24 ++- src/plugin-state/plugin-blob-store.ts | 48 ++++- 22 files changed, 463 insertions(+), 106 deletions(-) create mode 100644 extensions/memory-wiki/src/digest-state.test.ts create mode 100644 extensions/memory-wiki/src/digest-state.ts diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index df8c1dcab71..148268a28b4 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -232,7 +232,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover. -The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate. +The reply cache lives in SQLite plugin state under `~/.openclaw/state/openclaw.sqlite`. Run `openclaw doctor --fix` after updating if an older `imessage/reply-cache.jsonl` file is still present. ## Related diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 864515bd341..9bc6b18b1d3 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -646,9 +646,9 @@ curl "https://api.telegram.org/bot/getUpdates" - `Sticker.fileUniqueId` - `Sticker.cachedDescription` - Sticker cache file: + Sticker cache storage: - - `~/.openclaw/telegram/sticker-cache.json` + - SQLite plugin state in `~/.openclaw/state/openclaw.sqlite` Stickers are described once (when possible) and cached to reduce repeated vision calls. @@ -773,7 +773,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely. - `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts. - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. - - reply/quote/forward supplemental context is normalized into a nearest-first reply chain when the gateway has observed the parent messages; the observed-message cache is persisted beside the session store. Telegram only includes one shallow `reply_to_message` in updates, so chains older than the cache are limited to Telegram's current update payload. + - reply/quote/forward supplemental context is normalized into a nearest-first reply chain when the gateway has observed the parent messages; the observed-message cache is persisted in SQLite plugin state. Telegram only includes one shallow `reply_to_message` in updates, so chains older than the cache are limited to Telegram's current update payload. - Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary. - DM history controls: - `channels.telegram.dmHistoryLimit` diff --git a/docs/cli/wiki.md b/docs/cli/wiki.md index 50901f0aa28..e149b86de8b 100644 --- a/docs/cli/wiki.md +++ b/docs/cli/wiki.md @@ -106,12 +106,10 @@ Notes: ### `wiki compile` -Rebuild indexes, related blocks, dashboards, and compiled digests. +Rebuild indexes, related blocks, dashboards, and SQLite-backed compiled digests. -This writes stable machine-facing artifacts under: - -- `.openclaw-wiki/cache/agent-digest.json` -- `.openclaw-wiki/cache/claims.jsonl` +The stable machine-facing digests live in OpenClaw's SQLite plugin state so +agents and runtime code do not have to scrape Markdown pages. If `render.createDashboards` is enabled, compile also refreshes report pages. diff --git a/docs/plugins/memory-wiki.md b/docs/plugins/memory-wiki.md index 17b04a102b9..dad21b34a3a 100644 --- a/docs/plugins/memory-wiki.md +++ b/docs/plugins/memory-wiki.md @@ -236,14 +236,9 @@ claims: ## Compile pipeline -The compile step reads wiki pages, normalizes summaries, and emits stable -machine-facing artifacts under: - -- `.openclaw-wiki/cache/agent-digest.json` -- `.openclaw-wiki/cache/claims.jsonl` - -These digests exist so agents and runtime code do not have to scrape Markdown -pages. +The compile step reads wiki pages, normalizes summaries, and stores stable +machine-facing digests in SQLite plugin state. These digests exist so agents +and runtime code do not have to scrape Markdown pages. Compiled output also powers: @@ -353,7 +348,7 @@ plugin supports corpus selection. ## Prompt and context behavior When `context.includeCompiledDigestPrompt` is enabled, memory prompt sections -append a compact compiled snapshot from `agent-digest.json`. +append a compact compiled snapshot from SQLite plugin state. That snapshot is intentionally small and high-signal: diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 64a6f28d8c4..e1b484649bd 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -302,8 +302,8 @@ The remaining cleanup is mostly consolidation and deletion: than OpenClaw runtime cache rows. - Memory Wiki activity logs now use SQLite plugin state instead of `.openclaw-wiki/log.jsonl`. The Memory Wiki migration provider imports old - JSONL logs; wiki markdown, generated digest files, and user vault content stay - file-backed as workspace content. + JSONL logs; wiki markdown and user vault content stay file-backed as + workspace content. - Crestodian audit entries now use core SQLite plugin state instead of `audit/crestodian.jsonl`. Doctor imports the legacy JSONL audit log and removes it after successful import. @@ -428,6 +428,10 @@ The remaining cleanup is mostly consolidation and deletion: per vault/run id instead of writing `.openclaw-wiki/import-runs/*.json`. Rollback snapshots remain explicit vault files until import-run snapshot archival is moved into blob storage. +- Memory Wiki compiled digests now store SQLite plugin blob rows instead of + writing `.openclaw-wiki/cache/agent-digest.json` and + `.openclaw-wiki/cache/claims.jsonl`. The migration provider imports old cache + files and removes the cache directory when it becomes empty. - ClawHub skill install tracking now stores one SQLite plugin-state row per workspace/skill instead of writing or reading `.clawhub/lock.json` and `.clawhub/origin.json` sidecars at runtime. Doctor/migrate imports the legacy @@ -1077,6 +1081,8 @@ Add a repo check that fails new runtime writes to legacy state paths: - Memory Wiki `.openclaw-wiki/log.jsonl` - Memory Wiki `.openclaw-wiki/source-sync.json` - Memory Wiki `.openclaw-wiki/import-runs/*.json` +- Memory Wiki `.openclaw-wiki/cache/agent-digest.json` +- Memory Wiki `.openclaw-wiki/cache/claims.jsonl` - ClawHub `.clawhub/lock.json` - ClawHub `.clawhub/origin.json` diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index c615f44f3d9..7f9e3f85b48 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -199,12 +199,11 @@ Imported themes are stored only in the current browser profile. They are not wri The Control UI ships a `manifest.webmanifest` and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open. -| Surface | What it does | -| ----------------------------------------------------- | ------------------------------------------------------------------ | -| `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. | -| `ui/public/sw.js` | Service worker that handles `push` events and notification clicks. | -| `push/vapid-keys.json` (under the OpenClaw state dir) | Auto-generated VAPID keypair used to sign Web Push payloads. | -| `push/web-push-subscriptions.json` | Persisted browser subscription endpoints. | +| Surface | What it does | +| -------------------------------- | ------------------------------------------------------------------ | +| `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. | +| `ui/public/sw.js` | Service worker that handles `push` events and notification clicks. | +| `state/openclaw.sqlite` | SQLite-backed VAPID keys and browser subscription endpoints. | Override the VAPID keypair through env vars on the Gateway process when you want to pin keys (for multi-host deployments, secrets rotation, or tests): diff --git a/extensions/memory-wiki/README.md b/extensions/memory-wiki/README.md index d2564ed7b3a..f14b4d1b4d2 100644 --- a/extensions/memory-wiki/README.md +++ b/extensions/memory-wiki/README.md @@ -93,7 +93,7 @@ The plugin initializes a vault like this: Generated content stays inside managed blocks. Human note blocks are preserved. -Key beliefs can live in structured `claims` frontmatter with per-claim evidence, confidence, and status. Compile also emits machine-readable digests under `.openclaw-wiki/cache/` so agent/runtime consumers do not have to scrape markdown pages. +Key beliefs can live in structured `claims` frontmatter with per-claim evidence, confidence, and status. Compile also stores machine-readable digests in SQLite plugin state so agent/runtime consumers do not have to scrape markdown pages. When `render.createBacklinks` is enabled, compile adds deterministic `## Related` blocks to pages. Those blocks list source pages, pages that reference the current page, and nearby pages that share the same source ids. @@ -142,7 +142,7 @@ The plugin also registers a non-exclusive memory corpus supplement, so shared `m `wiki_apply` accepts structured `claims` payloads for synthesis and metadata updates, so the wiki can store claim-level evidence instead of only page-level prose. -When `context.includeCompiledDigestPrompt` is enabled, the memory prompt supplement also appends a compact snapshot from `.openclaw-wiki/cache/agent-digest.json`. Legacy prompt assembly sees that automatically, and non-legacy context engines can pick it up when they explicitly consume memory prompt supplements via `buildActiveMemoryPromptSection(...)`. +When `context.includeCompiledDigestPrompt` is enabled, the memory prompt supplement also appends a compact snapshot from the SQLite-backed compiled digest. Legacy prompt assembly sees that automatically, and non-legacy context engines can pick it up when they explicitly consume memory prompt supplements via `buildActiveMemoryPromptSection(...)`. ## Gateway RPC @@ -173,5 +173,5 @@ Write methods: - `unsafe-local` is intentionally experimental and non-portable. - Bridge mode reads the active memory plugin through public seams only. - Wiki pages are compiled artifacts, not the ultimate source of truth. Keep provenance attached to raw sources, memory artifacts, and daily notes. -- The compiled agent digests in `.openclaw-wiki/cache/agent-digest.json` and `.openclaw-wiki/cache/claims.jsonl` are the stable machine-facing view of the wiki. +- The compiled agent digests in SQLite plugin state are the stable machine-facing view of the wiki. - Obsidian CLI support requires the official `obsidian` CLI to be installed and available on `PATH`. diff --git a/extensions/memory-wiki/src/compile.test.ts b/extensions/memory-wiki/src/compile.test.ts index 02f1ac6bd3f..21e273fd5e9 100644 --- a/extensions/memory-wiki/src/compile.test.ts +++ b/extensions/memory-wiki/src/compile.test.ts @@ -1,8 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { compileMemoryWikiVault } from "./compile.js"; +import { readMemoryWikiCompiledDigestBundle } from "./digest-state.js"; import { renderWikiMarkdown } from "./markdown.js"; import { createMemoryWikiTestHarness } from "./test-helpers.js"; @@ -11,12 +13,21 @@ const { createVault } = createMemoryWikiTestHarness(); describe("compileMemoryWikiVault", () => { let suiteRoot = ""; let caseId = 0; + let previousStateDir: string | undefined; beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-suite-")); + previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = path.join(suiteRoot, "state"); }); afterAll(async () => { + resetPluginBlobStoreForTests(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } if (suiteRoot) { await fs.rm(suiteRoot, { recursive: true, force: true }); } @@ -70,9 +81,8 @@ describe("compileMemoryWikiVault", () => { await expect(fs.readFile(path.join(rootDir, "sources", "index.md"), "utf8")).resolves.toContain( "[Alpha](sources/alpha.md)", ); - const agentDigest = JSON.parse( - await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), - ) as { + const digestBundle = await readMemoryWikiCompiledDigestBundle(rootDir); + const agentDigest = JSON.parse(digestBundle.agentDigest ?? "") as { claimCount: number; pages: Array<{ path: string; claimCount: number; topClaims: Array<{ text: string }> }>; }; @@ -84,9 +94,10 @@ describe("compileMemoryWikiVault", () => { topClaims: [expect.objectContaining({ text: "Alpha is the canonical source page." })], }), ); - await expect( - fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "claims.jsonl"), "utf8"), - ).resolves.toContain('"text":"Alpha is the canonical source page."'); + expect(digestBundle.claimsDigest).toContain('"text":"Alpha is the canonical source page."'); + await expect(fs.stat(path.join(rootDir, ".openclaw-wiki", "cache"))).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("renders obsidian-friendly links when configured", async () => { @@ -333,9 +344,8 @@ describe("compileMemoryWikiVault", () => { await expect( fs.readFile(path.join(rootDir, "reports", "stale-pages.md"), "utf8"), ).resolves.toContain("[Alpha](entities/alpha.md): missing updatedAt"); - const agentDigest = JSON.parse( - await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), - ) as { + const digestBundle = await readMemoryWikiCompiledDigestBundle(rootDir); + const agentDigest = JSON.parse(digestBundle.agentDigest ?? "") as { claimHealth: { missingEvidence: number; freshness: { unknown: number } }; contradictionClusters: Array<{ key: string }>; }; @@ -445,9 +455,8 @@ describe("compileMemoryWikiVault", () => { fs.readFile(path.join(rootDir, "reports", "privacy-review.md"), "utf8"), ).resolves.toContain("confirm-before-use"); - const agentDigest = JSON.parse( - await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), - ) as { + const digestBundle = await readMemoryWikiCompiledDigestBundle(rootDir); + const agentDigest = JSON.parse(digestBundle.agentDigest ?? "") as { pages: Array<{ path: string; canonicalId?: string; @@ -465,9 +474,7 @@ describe("compileMemoryWikiVault", () => { relationshipCount: 1, }), ); - await expect( - fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "claims.jsonl"), "utf8"), - ).resolves.toContain('"evidenceKinds":["maintainer-whois"]'); + expect(digestBundle.claimsDigest).toContain('"evidenceKinds":["maintainer-whois"]'); }); it("ignores generated related links when computing backlinks on repeated compile", async () => { diff --git a/extensions/memory-wiki/src/compile.ts b/extensions/memory-wiki/src/compile.ts index 7d59bb22d6b..4f224b0b0a8 100644 --- a/extensions/memory-wiki/src/compile.ts +++ b/extensions/memory-wiki/src/compile.ts @@ -22,6 +22,7 @@ import { type WikiPageContradictionCluster, } from "./claim-health.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { writeMemoryWikiCompiledDigests } from "./digest-state.js"; import { appendMemoryWikiLog } from "./log.js"; import { formatWikiLink, @@ -45,8 +46,6 @@ const COMPILE_PAGE_GROUPS: Array<{ kind: WikiPageKind; dir: string; heading: str { kind: "synthesis", dir: "syntheses", heading: "Syntheses" }, { kind: "report", dir: "reports", heading: "Reports" }, ]; -const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json"; -const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl"; const MAX_RELATED_PAGES_PER_SECTION = 12; const MAX_SHARED_SOURCE_FANOUT = 24; @@ -1254,10 +1253,7 @@ async function writeAgentDigestArtifacts(params: { rootDir: string; pages: WikiPageSummary[]; pageCounts: Record; -}): Promise { - const updatedFiles: string[] = []; - const agentDigestPath = path.join(params.rootDir, AGENT_DIGEST_PATH); - const claimsDigestPath = path.join(params.rootDir, CLAIMS_DIGEST_PATH); +}): Promise { const agentDigest = `${JSON.stringify( buildAgentDigest({ pages: params.pages, @@ -1270,20 +1266,11 @@ async function writeAgentDigestArtifacts(params: { buildClaimsDigestLines({ pages: params.pages }).join("\n"), ); - for (const [filePath, content] of [ - [agentDigestPath, agentDigest], - [claimsDigestPath, claimsDigest], - ] as const) { - const relativePath = path.relative(params.rootDir, filePath); - const root = await fsRoot(params.rootDir); - const existing = await root.readText(relativePath).catch(() => ""); - if (existing === content) { - continue; - } - await root.write(relativePath, content); - updatedFiles.push(filePath); - } - return updatedFiles; + await writeMemoryWikiCompiledDigests({ + vaultRoot: params.rootDir, + agentDigest, + claimsDigest, + }); } export async function compileMemoryWikiVault( @@ -1302,12 +1289,11 @@ export async function compileMemoryWikiVault( pages = await readPageSummaries(rootDir); } const counts = buildPageCounts(pages); - const digestUpdatedFiles = await writeAgentDigestArtifacts({ + await writeAgentDigestArtifacts({ rootDir, pages, pageCounts: counts, }); - updatedFiles.push(...digestUpdatedFiles); const rootIndexPath = path.join(rootDir, "index.md"); if ( diff --git a/extensions/memory-wiki/src/digest-state.test.ts b/extensions/memory-wiki/src/digest-state.test.ts new file mode 100644 index 00000000000..13f2e6876b5 --- /dev/null +++ b/extensions/memory-wiki/src/digest-state.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { + importMemoryWikiLegacyDigestFiles, + legacyMemoryWikiDigestFilesExist, + readMemoryWikiAgentDigestSync, + readMemoryWikiCompiledDigestBundle, + resolveMemoryWikiLegacyDigestPath, + writeMemoryWikiCompiledDigests, +} from "./digest-state.js"; + +describe("memory wiki compiled digest state", () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const roots: string[] = []; + + afterEach(async () => { + resetPluginBlobStoreForTests(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + async function createVaultRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-digest-")); + roots.push(root); + process.env.OPENCLAW_STATE_DIR = path.join(root, "state"); + return root; + } + + it("stores compiled digests in SQLite plugin blob state", async () => { + const vaultRoot = await createVaultRoot(); + + await writeMemoryWikiCompiledDigests({ + vaultRoot, + agentDigest: '{"claimCount":1,"pages":[]}\n', + claimsDigest: '{"text":"Alpha"}\n', + }); + + expect(readMemoryWikiAgentDigestSync(vaultRoot)).toBe('{"claimCount":1,"pages":[]}\n'); + await expect(readMemoryWikiCompiledDigestBundle(vaultRoot)).resolves.toEqual({ + agentDigest: '{"claimCount":1,"pages":[]}\n', + claimsDigest: '{"text":"Alpha"}\n', + }); + await expect( + fs.stat(resolveMemoryWikiLegacyDigestPath(vaultRoot, "agent-digest")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("imports legacy cache files through the migration helper", async () => { + const vaultRoot = await createVaultRoot(); + const agentPath = resolveMemoryWikiLegacyDigestPath(vaultRoot, "agent-digest"); + const claimsPath = resolveMemoryWikiLegacyDigestPath(vaultRoot, "claims-digest"); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(agentPath, '{"claimCount":2,"pages":[]}\n', "utf8"); + await fs.writeFile(claimsPath, '{"text":"Beta"}\n', "utf8"); + + await expect(legacyMemoryWikiDigestFilesExist(vaultRoot)).resolves.toBe(true); + await expect(importMemoryWikiLegacyDigestFiles({ vaultRoot })).resolves.toMatchObject({ + imported: 2, + warnings: [], + }); + + await expect(readMemoryWikiCompiledDigestBundle(vaultRoot)).resolves.toEqual({ + agentDigest: '{"claimCount":2,"pages":[]}\n', + claimsDigest: '{"text":"Beta"}\n', + }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(claimsPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/extensions/memory-wiki/src/digest-state.ts b/extensions/memory-wiki/src/digest-state.ts new file mode 100644 index 00000000000..dc4f88aac1a --- /dev/null +++ b/extensions/memory-wiki/src/digest-state.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + createPluginBlobStore, + createPluginBlobSyncStore, +} from "openclaw/plugin-sdk/plugin-state-runtime"; + +export const MEMORY_WIKI_AGENT_DIGEST_LEGACY_PATH = ".openclaw-wiki/cache/agent-digest.json"; +export const MEMORY_WIKI_CLAIMS_DIGEST_LEGACY_PATH = ".openclaw-wiki/cache/claims.jsonl"; + +type MemoryWikiDigestKind = "agent-digest" | "claims-digest"; + +type MemoryWikiDigestMetadata = { + vaultHash: string; + kind: MemoryWikiDigestKind; + contentType: "application/json" | "application/x-ndjson"; +}; + +const digestStore = createPluginBlobStore("memory-wiki", { + namespace: "compiled-digest", + maxEntries: 2000, +}); + +const syncDigestStore = createPluginBlobSyncStore("memory-wiki", { + namespace: "compiled-digest", + maxEntries: 2000, +}); + +function hashSegment(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +function resolveVaultHash(vaultRoot: string): string { + return hashSegment(path.resolve(vaultRoot)); +} + +function resolveDigestKey(vaultRoot: string, kind: MemoryWikiDigestKind): string { + return `${resolveVaultHash(vaultRoot)}:${kind}`; +} + +function contentTypeForDigestKind( + kind: MemoryWikiDigestKind, +): MemoryWikiDigestMetadata["contentType"] { + return kind === "agent-digest" ? "application/json" : "application/x-ndjson"; +} + +async function writeDigest(params: { + vaultRoot: string; + kind: MemoryWikiDigestKind; + content: string; +}): Promise { + const key = resolveDigestKey(params.vaultRoot, params.kind); + const existing = await digestStore.lookup(key); + if (existing?.blob.toString("utf8") === params.content) { + return false; + } + await digestStore.register( + key, + { + vaultHash: resolveVaultHash(params.vaultRoot), + kind: params.kind, + contentType: contentTypeForDigestKind(params.kind), + }, + Buffer.from(params.content, "utf8"), + ); + return true; +} + +export async function writeMemoryWikiCompiledDigests(params: { + vaultRoot: string; + agentDigest: string; + claimsDigest: string; +}): Promise<{ agentDigestChanged: boolean; claimsDigestChanged: boolean }> { + const [agentDigestChanged, claimsDigestChanged] = await Promise.all([ + writeDigest({ + vaultRoot: params.vaultRoot, + kind: "agent-digest", + content: params.agentDigest, + }), + writeDigest({ + vaultRoot: params.vaultRoot, + kind: "claims-digest", + content: params.claimsDigest, + }), + ]); + return { agentDigestChanged, claimsDigestChanged }; +} + +export function readMemoryWikiAgentDigestSync(vaultRoot: string): string | null { + return ( + syncDigestStore.lookup(resolveDigestKey(vaultRoot, "agent-digest"))?.blob.toString("utf8") ?? + null + ); +} + +export async function readMemoryWikiCompiledDigestBundle(vaultRoot: string): Promise<{ + agentDigest: string | null; + claimsDigest: string | null; +}> { + const [agentDigest, claimsDigest] = await Promise.all([ + digestStore.lookup(resolveDigestKey(vaultRoot, "agent-digest")), + digestStore.lookup(resolveDigestKey(vaultRoot, "claims-digest")), + ]); + return { + agentDigest: agentDigest?.blob.toString("utf8") ?? null, + claimsDigest: claimsDigest?.blob.toString("utf8") ?? null, + }; +} + +export function resolveMemoryWikiLegacyDigestPath( + vaultRoot: string, + kind: MemoryWikiDigestKind, +): string { + return path.join( + vaultRoot, + kind === "agent-digest" + ? MEMORY_WIKI_AGENT_DIGEST_LEGACY_PATH + : MEMORY_WIKI_CLAIMS_DIGEST_LEGACY_PATH, + ); +} + +async function importLegacyDigest(params: { + vaultRoot: string; + kind: MemoryWikiDigestKind; +}): Promise<{ imported: boolean; sourcePath: string }> { + const sourcePath = resolveMemoryWikiLegacyDigestPath(params.vaultRoot, params.kind); + const content = await fs.readFile(sourcePath, "utf8"); + await writeDigest({ + vaultRoot: params.vaultRoot, + kind: params.kind, + content, + }); + await fs.rm(sourcePath, { force: true }); + return { imported: true, sourcePath }; +} + +export async function legacyMemoryWikiDigestFilesExist(vaultRoot: string): Promise { + const results = await Promise.all( + (["agent-digest", "claims-digest"] as const).map((kind) => + fs + .stat(resolveMemoryWikiLegacyDigestPath(vaultRoot, kind)) + .then((stat) => stat.isFile()) + .catch(() => false), + ), + ); + return results.some(Boolean); +} + +export async function importMemoryWikiLegacyDigestFiles(params: { + vaultRoot: string; +}): Promise<{ imported: number; warnings: string[]; sourcePaths: string[] }> { + const warnings: string[] = []; + const sourcePaths: string[] = []; + let imported = 0; + for (const kind of ["agent-digest", "claims-digest"] as const) { + try { + const result = await importLegacyDigest({ vaultRoot: params.vaultRoot, kind }); + imported += result.imported ? 1 : 0; + sourcePaths.push(result.sourcePath); + } catch (error) { + const sourcePath = resolveMemoryWikiLegacyDigestPath(params.vaultRoot, kind); + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + continue; + } + warnings.push(`Failed importing Memory Wiki ${kind}: ${String(error)}`); + sourcePaths.push(sourcePath); + } + } + const cacheDir = path.join(params.vaultRoot, ".openclaw-wiki", "cache"); + await fs.rmdir(cacheDir).catch(() => undefined); + return { imported, warnings, sourcePaths }; +} diff --git a/extensions/memory-wiki/src/prompt-section.test.ts b/extensions/memory-wiki/src/prompt-section.test.ts index 51ffd181e57..8f4ae9f6e59 100644 --- a/extensions/memory-wiki/src/prompt-section.test.ts +++ b/extensions/memory-wiki/src/prompt-section.test.ts @@ -1,17 +1,28 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { resolveMemoryWikiConfig } from "./config.js"; +import { writeMemoryWikiCompiledDigests } from "./digest-state.js"; import { buildWikiPromptSection, createWikiPromptSectionBuilder } from "./prompt-section.js"; let suiteRoot = ""; +let previousStateDir: string | undefined; beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-prompt-suite-")); + previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = path.join(suiteRoot, "state"); }); afterAll(async () => { + resetPluginBlobStoreForTests(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } if (suiteRoot) { await fs.rm(suiteRoot, { recursive: true, force: true }); } @@ -34,10 +45,9 @@ describe("buildWikiPromptSection", () => { it("can append a compact compiled digest snapshot when enabled", async () => { const rootDir = path.join(suiteRoot, "digest-enabled"); - await fs.mkdir(path.join(rootDir, ".openclaw-wiki", "cache"), { recursive: true }); - await fs.writeFile( - path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), - JSON.stringify( + await writeMemoryWikiCompiledDigests({ + vaultRoot: rootDir, + agentDigest: `${JSON.stringify( { claimCount: 8, contradictionClusters: [{ key: "claim.alpha.db" }], @@ -61,9 +71,9 @@ describe("buildWikiPromptSection", () => { }, null, 2, - ), - "utf8", - ); + )}\n`, + claimsDigest: "", + }); const builder = createWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: rootDir }, @@ -82,15 +92,14 @@ describe("buildWikiPromptSection", () => { it("keeps the digest snapshot disabled by default", async () => { const rootDir = path.join(suiteRoot, "digest-disabled"); - await fs.mkdir(path.join(rootDir, ".openclaw-wiki", "cache"), { recursive: true }); - await fs.writeFile( - path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), - JSON.stringify({ + await writeMemoryWikiCompiledDigests({ + vaultRoot: rootDir, + agentDigest: `${JSON.stringify({ claimCount: 1, pages: [{ title: "Alpha", kind: "entity", claimCount: 1, topClaims: [] }], - }), - "utf8", - ); + })}\n`, + claimsDigest: "", + }); const builder = createWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: rootDir }, @@ -102,8 +111,6 @@ describe("buildWikiPromptSection", () => { it("stabilizes digest prompt ordering for prompt-cache-friendly output", async () => { const rootDir = path.join(suiteRoot, "digest-stable"); - const digestPath = path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"); - await fs.mkdir(path.dirname(digestPath), { recursive: true }); const builder = createWikiPromptSectionBuilder( resolveMemoryWikiConfig({ @@ -162,10 +169,18 @@ describe("buildWikiPromptSection", () => { ], }; - await fs.writeFile(digestPath, JSON.stringify(firstDigest, null, 2), "utf8"); + await writeMemoryWikiCompiledDigests({ + vaultRoot: rootDir, + agentDigest: `${JSON.stringify(firstDigest, null, 2)}\n`, + claimsDigest: "", + }); const firstLines = builder({ availableTools: new Set(["web_search"]) }); - await fs.writeFile(digestPath, JSON.stringify(secondDigest, null, 2), "utf8"); + await writeMemoryWikiCompiledDigests({ + vaultRoot: rootDir, + agentDigest: `${JSON.stringify(secondDigest, null, 2)}\n`, + claimsDigest: "", + }); const secondLines = builder({ availableTools: new Set(["web_search"]) }); expect(firstLines).toEqual(secondLines); diff --git a/extensions/memory-wiki/src/prompt-section.ts b/extensions/memory-wiki/src/prompt-section.ts index c4c4b22f168..b523bb57740 100644 --- a/extensions/memory-wiki/src/prompt-section.ts +++ b/extensions/memory-wiki/src/prompt-section.ts @@ -1,9 +1,7 @@ -import fs from "node:fs"; -import path from "node:path"; import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-host-core"; import { resolveMemoryWikiConfig, type ResolvedMemoryWikiConfig } from "./config.js"; +import { readMemoryWikiAgentDigestSync } from "./digest-state.js"; -const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json"; const DIGEST_MAX_PAGES = 4; const DIGEST_MAX_CLAIMS_PER_PAGE = 2; @@ -31,9 +29,11 @@ type PromptDigest = { }; function tryReadPromptDigest(config: ResolvedMemoryWikiConfig): PromptDigest | null { - const digestPath = path.join(config.vault.path, AGENT_DIGEST_PATH); + const raw = readMemoryWikiAgentDigestSync(config.vault.path); + if (!raw) { + return null; + } try { - const raw = fs.readFileSync(digestPath, "utf8"); const parsed = JSON.parse(raw) as PromptDigest; if (!parsed || typeof parsed !== "object") { return null; diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index c8658d5401e..3de636b6aeb 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../api.js"; import { compileMemoryWikiVault } from "./compile.js"; @@ -44,6 +45,7 @@ vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => const { createVault } = createMemoryWikiTestHarness(); let suiteRoot = ""; let caseIndex = 0; +let previousStateDir: string | undefined; function collectWikiResultPaths(results: readonly { corpus: string; path: string }[]): string[] { const paths: string[] = []; @@ -66,9 +68,17 @@ beforeEach(() => { beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-suite-")); + previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = path.join(suiteRoot, "state"); }); afterAll(async () => { + resetPluginBlobStoreForTests(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } if (suiteRoot) { await fs.rm(suiteRoot, { recursive: true, force: true }); } diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index bed2c18c858..7b06ed438a6 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -17,6 +17,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim import type { OpenClawConfig } from "../api.js"; import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js"; import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js"; +import { readMemoryWikiCompiledDigestBundle } from "./digest-state.js"; import { parseWikiMarkdown, toWikiPageSummary, @@ -27,8 +28,6 @@ import { import { initializeMemoryWikiVault } from "./vault.js"; const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const; -const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json"; -const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl"; const RELATED_BLOCK_PATTERN = /[\s\S]*?/g; const MARKDOWN_FRONTMATTER_PATTERN = /^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/; @@ -286,10 +285,8 @@ function parseClaimsDigest(raw: string): QueryDigestClaim[] { } async function readQueryDigestBundle(rootDir: string): Promise { - const [agentDigestRaw, claimsDigestRaw] = await Promise.all([ - fs.readFile(path.join(rootDir, AGENT_DIGEST_PATH), "utf8").catch(() => null), - fs.readFile(path.join(rootDir, CLAIMS_DIGEST_PATH), "utf8").catch(() => null), - ]); + const { agentDigest: agentDigestRaw, claimsDigest: claimsDigestRaw } = + await readMemoryWikiCompiledDigestBundle(rootDir); if (!agentDigestRaw && !claimsDigestRaw) { return null; } diff --git a/extensions/memory-wiki/src/source-sync-migration.ts b/extensions/memory-wiki/src/source-sync-migration.ts index 6cdf954b33c..24db3c3d46c 100644 --- a/extensions/memory-wiki/src/source-sync-migration.ts +++ b/extensions/memory-wiki/src/source-sync-migration.ts @@ -3,6 +3,10 @@ import path from "node:path"; import type { MigrationProviderPlugin } from "openclaw/plugin-sdk/migration"; import { createMigrationItem, summarizeMigrationItems } from "openclaw/plugin-sdk/migration"; import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { + importMemoryWikiLegacyDigestFiles, + legacyMemoryWikiDigestFilesExist, +} from "./digest-state.js"; import { writeMemoryWikiImportRunRecord } from "./import-runs.js"; import { importMemoryWikiLegacyLog, resolveMemoryWikiLegacyLogPath } from "./log.js"; import { @@ -83,6 +87,7 @@ export function createMemoryWikiSourceSyncMigrationProvider( const buildPlan: MigrationProviderPlugin["plan"] = async () => { const hasSourceSync = await legacySourceExists(config.vault.path); const hasLegacyLog = await legacyLogExists(config.vault.path); + const hasLegacyDigests = await legacyMemoryWikiDigestFilesExist(config.vault.path); const importRunFiles = await listLegacyImportRunJsonFiles(config.vault.path); const items = [ ...(hasSourceSync @@ -122,6 +127,18 @@ export function createMemoryWikiSourceSyncMigrationProvider( }), ] : []), + ...(hasLegacyDigests + ? [ + createMigrationItem({ + id: "memory-wiki-compiled-digest-cache", + kind: "state", + action: "import", + source: path.join(config.vault.path, ".openclaw-wiki", "cache"), + target: "global SQLite plugin_blob_entries(memory-wiki/compiled-digest)", + message: "Import Memory Wiki compiled digest cache into SQLite plugin state.", + }), + ] + : []), ]; return { providerId: PROVIDER_ID, @@ -140,6 +157,7 @@ export function createMemoryWikiSourceSyncMigrationProvider( const found = (await legacySourceExists(config.vault.path)) || (await legacyLogExists(config.vault.path)) || + (await legacyMemoryWikiDigestFilesExist(config.vault.path)) || (await listLegacyImportRunJsonFiles(config.vault.path)).length > 0; return { found, @@ -196,6 +214,18 @@ export function createMemoryWikiSourceSyncMigrationProvider( imported: result.imported, }, }; + } else if (item.id === "memory-wiki-compiled-digest-cache") { + const result = await importMemoryWikiLegacyDigestFiles({ + vaultRoot: config.vault.path, + }); + warnings.push(...result.warnings); + items[itemIndex] = { + ...item, + status: "migrated", + details: { + imported: result.imported, + }, + }; } } catch (error) { items[itemIndex] = { diff --git a/extensions/memory-wiki/src/vault.ts b/extensions/memory-wiki/src/vault.ts index c4d42e3fdbc..ec2afbb6f6c 100644 --- a/extensions/memory-wiki/src/vault.ts +++ b/extensions/memory-wiki/src/vault.ts @@ -18,7 +18,6 @@ export const WIKI_VAULT_DIRECTORIES = [ "_views", ".openclaw-wiki", ".openclaw-wiki/locks", - ".openclaw-wiki/cache", ] as const; type InitializeMemoryWikiVaultResult = { @@ -48,7 +47,7 @@ function buildAgentsMarkdown(): string { - Preserve human notes outside managed markers. - Prefer source-backed claims over wiki-to-wiki citation loops. - Prefer structured \`claims\` with evidence over burying key beliefs only in prose. -- Use \`.openclaw-wiki/cache/agent-digest.json\` and \`claims.jsonl\` for machine reads; markdown pages are the human view. +- Use \`wiki_search\` and \`wiki_get\` for machine reads; markdown pages are the human view. `); } @@ -65,7 +64,7 @@ This vault is maintained by the OpenClaw memory-wiki plugin. ## Architecture - Raw sources remain the evidence layer. - Wiki pages are the human-readable synthesis layer. -- \`.openclaw-wiki/cache/agent-digest.json\` is the agent-facing compiled digest. +- Compiled digests live in SQLite plugin state and are refreshed by \`openclaw wiki compile\`. ## Notes diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 8d6e8265251..8c0a990e1e4 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -77,6 +77,10 @@ const legacyStoreMarkers = [ label: "Memory Wiki import run JSON", pattern: /\bimport-runs[/\\][A-Za-z0-9._-]+\.json\b/u, }, + { + label: "Memory Wiki compiled digest cache JSON", + pattern: /\b\.openclaw-wiki[/\\]cache[/\\](?:agent-digest\.json|claims\.jsonl)\b/u, + }, { label: "ClawHub skill lock JSON", pattern: /\b\.clawhub[/\\]lock\.json\b/u }, { label: "ClawHub skill origin JSON", pattern: /\b\.clawhub[/\\]origin\.json\b/u }, { label: "installed plugin index JSON", pattern: /\bplugins[/\\]installs\.json\b/u }, @@ -141,7 +145,9 @@ const allowedExactPaths = new Set([ "extensions/imessage/src/state-migrations.ts", "extensions/matrix/src/state-migrations.ts", "extensions/matrix/src/legacy-state.ts", + "extensions/memory-wiki/src/digest-state.ts", "extensions/memory-wiki/src/source-sync-state.ts", + "extensions/memory-wiki/src/source-sync-migration.ts", "extensions/msteams/src/state-migrations.ts", "extensions/nostr/src/state-migrations.ts", "extensions/skill-workshop/src/state-migrations.ts", diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts index 6b44eda5c9a..b34b09f4835 100644 --- a/src/infra/push-web.ts +++ b/src/infra/push-web.ts @@ -202,7 +202,7 @@ export async function resolveVapidKeys(baseDir?: string): Promise return { publicKey: existing.publicKey, privateKey: existing.privateKey, - // Env var always wins so operators can change subject without deleting vapid-keys.json. + // Env var always wins so operators can change subject without touching persisted keys. subject: resolveVapidSubjectFromEnv(), }; } diff --git a/src/plugin-sdk/plugin-state-runtime.ts b/src/plugin-sdk/plugin-state-runtime.ts index 371da15f223..53c6a481b80 100644 --- a/src/plugin-sdk/plugin-state-runtime.ts +++ b/src/plugin-sdk/plugin-state-runtime.ts @@ -1,8 +1,10 @@ export { createPluginBlobStore, + createPluginBlobSyncStore, resetPluginBlobStoreForTests, type PluginBlobEntry, type PluginBlobStore, + type PluginBlobSyncStore, } from "../plugin-state/plugin-blob-store.js"; export { createCorePluginStateKeyedStore, diff --git a/src/plugin-state/plugin-blob-store.test.ts b/src/plugin-state/plugin-blob-store.test.ts index 8a4d6ac5f7f..1e33a10d7b4 100644 --- a/src/plugin-state/plugin-blob-store.test.ts +++ b/src/plugin-state/plugin-blob-store.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; -import { createPluginBlobStore, resetPluginBlobStoreForTests } from "./plugin-blob-store.js"; +import { + createPluginBlobStore, + createPluginBlobSyncStore, + resetPluginBlobStoreForTests, +} from "./plugin-blob-store.js"; afterEach(() => { resetPluginBlobStoreForTests(); @@ -30,4 +34,22 @@ describe("plugin blob store", () => { await expect(store.entries()).resolves.toEqual([]); }); }); + + it("reads and consumes entries through the sync SQLite API", async () => { + await withOpenClawTestState({ label: "plugin-blob-store-sync" }, async () => { + const store = createPluginBlobSyncStore<{ contentType: string }>("memory-wiki", { + namespace: "compiled-digest", + maxEntries: 10, + }); + + store.register("agent-digest", { contentType: "application/json" }, Buffer.from("{}\n")); + + expect(store.lookup("agent-digest")).toMatchObject({ + key: "agent-digest", + metadata: { contentType: "application/json" }, + }); + expect(store.consume("agent-digest")?.blob.toString("utf8")).toBe("{}\n"); + expect(store.lookup("agent-digest")).toBeUndefined(); + }); + }); }); diff --git a/src/plugin-state/plugin-blob-store.ts b/src/plugin-state/plugin-blob-store.ts index c948be41ace..ef883f08e3a 100644 --- a/src/plugin-state/plugin-blob-store.ts +++ b/src/plugin-state/plugin-blob-store.ts @@ -34,6 +34,15 @@ export type PluginBlobStore> = { clear(): Promise; }; +export type PluginBlobSyncStore> = { + register(key: string, metadata: TMetadata, blob: Buffer, opts?: { ttlMs?: number }): void; + lookup(key: string): PluginBlobEntry | undefined; + consume(key: string): PluginBlobEntry | undefined; + delete(key: string): boolean; + entries(): PluginBlobEntry[]; + clear(): void; +}; + const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu; const MAX_NAMESPACE_BYTES = 128; const MAX_KEY_BYTES = 512; @@ -150,6 +159,33 @@ export function createPluginBlobStore>( pluginId: string, options: OpenKeyedStoreOptions, ): PluginBlobStore { + const syncStore = createPluginBlobSyncStore(pluginId, options); + return { + async register(key, metadata, blob, opts) { + syncStore.register(key, metadata, blob, opts); + }, + async lookup(key) { + return syncStore.lookup(key); + }, + async consume(key) { + return syncStore.consume(key); + }, + async delete(key) { + return syncStore.delete(key); + }, + async entries() { + return syncStore.entries(); + }, + async clear() { + syncStore.clear(); + }, + }; +} + +export function createPluginBlobSyncStore>( + pluginId: string, + options: OpenKeyedStoreOptions, +): PluginBlobSyncStore { if (pluginId.startsWith("core:")) { throw new Error("Plugin ids starting with 'core:' are reserved for core consumers."); } @@ -161,7 +197,7 @@ export function createPluginBlobStore>( const now = () => Date.now(); return { - async register(key, metadata, blob, opts) { + register(key, metadata, blob, opts) { const normalizedKey = validateKey(key); const metadataJson = assertJsonMetadata(metadata); const createdAt = now(); @@ -246,7 +282,7 @@ export function createPluginBlobStore>( } }); }, - async lookup(key) { + lookup(key) { const normalizedKey = validateKey(key); const database = openOpenClawStateDatabase(); const row = executeSqliteQueryTakeFirstSync( @@ -261,7 +297,7 @@ export function createPluginBlobStore>( ); return row ? rowToEntry(row) : undefined; }, - async consume(key) { + consume(key) { const normalizedKey = validateKey(key); const row = runOpenClawStateWriteTransaction((database) => { const db = getPluginBlobKysely(database.db); @@ -287,7 +323,7 @@ export function createPluginBlobStore>( }); return row ? rowToEntry(row) : undefined; }, - async delete(key) { + delete(key) { const normalizedKey = validateKey(key); const result = runOpenClawStateWriteTransaction((database) => executeSqliteQuerySync( @@ -301,7 +337,7 @@ export function createPluginBlobStore>( ); return Number(result.numAffectedRows ?? 0) > 0; }, - async entries() { + entries() { const database = openOpenClawStateDatabase(); const rows = executeSqliteQuerySync( database.db, @@ -316,7 +352,7 @@ export function createPluginBlobStore>( ).rows; return rows.map((row) => rowToEntry(row)); }, - async clear() { + clear() { runOpenClawStateWriteTransaction((database) => { executeSqliteQuerySync( database.db,