From 043416878f9528d765fc396f1757f9dc062e0416 Mon Sep 17 00:00:00 2001 From: Krusty Date: Mon, 2 Mar 2026 00:46:19 -0800 Subject: [PATCH 1/2] feat(mission-control): add openclaw bidirectional memory sync with conflict policy --- API.md | 13 ++- convex/http.ts | 10 ++ convex/memories.ts | 185 ++++++++++++++++++++++++++++++-- convex/missionControlApi.ts | 203 ++++++++++++++++++++++++++++++++++-- convex/schema.ts | 9 +- src/pages/Memory.tsx | 9 +- 6 files changed, 410 insertions(+), 19 deletions(-) diff --git a/API.md b/API.md index bb0ea01..51517a4 100644 --- a/API.md +++ b/API.md @@ -197,10 +197,14 @@ New endpoints for Agent Mission Control with scoped API keys. - API key (`X-API-Key: pa_xxx...`) for `/api/v1/*` endpoints ### API Keys -- `GET /api/v1/auth/keys` — list keys (JWT only) +- `GET /api/v1/auth/keys` — list keys + recent rotation events (JWT only) - `POST /api/v1/auth/keys` — create key (JWT only) - body: `{ "label": "CI Agent", "scopes": ["tasks:read","memory:write"] }` -- `DELETE /api/v1/auth/keys/:keyId` — revoke key (JWT only) +- `POST /api/v1/auth/keys/:keyId/rotate` — zero-downtime rotation (JWT only) + - creates a new key, keeps old key active for grace period + - body: `{ "gracePeriodHours": 24, "label": "CI Agent v2" }` +- `POST /api/v1/auth/keys/:keyId/finalize-rotation` — revoke old key after cutover (JWT only) +- `DELETE /api/v1/auth/keys/:keyId` — revoke key immediately (JWT only) ### Agent Registration / Profiles - `GET /api/v1/agents` — list agent profiles (`agents:read`) @@ -216,6 +220,8 @@ New endpoints for Agent Mission Control with scoped API keys. ### Memory - `GET /api/v1/memory?agentSlug=[&key=]` (`memory:read`) - `POST /api/v1/memory` (`memory:write`) +- `GET /api/v1/memory/sync?since=&limit=` (`memory:read`) — pull Convex memory changes for OpenClaw +- `POST /api/v1/memory/sync` (`memory:write`) — push OpenClaw memory entries into Convex with conflict policy (`lww` or `preserve_both`) - body: `{ "agentSlug": "platform", "key": "runbook", "value": "...", "listId": "...optional..." }` ### Mission Runs (P0-6 hardening) @@ -229,6 +235,9 @@ New endpoints for Agent Mission Control with scoped API keys. - `POST /api/v1/runs/:runId/artifacts` (`runs:write`) - body: `{ "type": "screenshot|log|diff|file|url", "ref": "...", "label": "...optional..." }` - `POST /api/v1/runs/monitor` (`runs:control`) — applies heartbeat timeout state updates for all owner runs +- `GET /api/v1/runs/retention` (JWT only) — retention config + recent deletion logs +- `PUT /api/v1/runs/retention` (JWT only) — set artifact retention days (default 30) +- `POST /api/v1/runs/retention` (JWT only) — run retention job (`dryRun` defaults to `true`) ### Run Dashboard - `GET /api/v1/dashboard/runs?[windowMs=86400000]` (`dashboard:read`) diff --git a/convex/http.ts b/convex/http.ts index d6311ff..16b44aa 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -46,6 +46,7 @@ import { runsHandler, runsDashboardHandler, schedulesHandler, + runRetentionHandler, } from "./missionControlApi"; // Rate limit configuration @@ -461,8 +462,14 @@ http.route({ path: "/api/v1/auth/keys", method: "GET", handler: apiKeysHandler } http.route({ path: "/api/v1/auth/keys", method: "POST", handler: apiKeysHandler }); http.route({ path: "/api/v1/auth/keys", method: "OPTIONS", handler: v1AuthCors }); http.route({ pathPrefix: "/api/v1/auth/keys/", method: "DELETE", handler: apiKeyByIdHandler }); +http.route({ pathPrefix: "/api/v1/auth/keys/", method: "POST", handler: apiKeyByIdHandler }); http.route({ pathPrefix: "/api/v1/auth/keys/", method: "OPTIONS", handler: v1AuthCors }); +http.route({ path: "/api/v1/runs/retention", method: "GET", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "PUT", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "POST", handler: runRetentionHandler }); +http.route({ path: "/api/v1/runs/retention", method: "OPTIONS", handler: v1AuthCors }); + http.route({ path: "/api/v1/agents", method: "GET", handler: agentsHandler }); http.route({ path: "/api/v1/agents", method: "POST", handler: agentsHandler }); http.route({ path: "/api/v1/agents", method: "OPTIONS", handler: v1AuthCors }); @@ -477,7 +484,10 @@ http.route({ path: "/api/v1/activity", method: "OPTIONS", handler: v1AuthCors }) http.route({ path: "/api/v1/memory", method: "GET", handler: memoryHandler }); http.route({ path: "/api/v1/memory", method: "POST", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "GET", handler: memoryHandler }); +http.route({ pathPrefix: "/api/v1/memory/", method: "POST", handler: memoryHandler }); http.route({ path: "/api/v1/memory", method: "OPTIONS", handler: v1AuthCors }); +http.route({ pathPrefix: "/api/v1/memory/", method: "OPTIONS", handler: v1AuthCors }); http.route({ path: "/api/v1/schedules", method: "GET", handler: schedulesHandler }); http.route({ pathPrefix: "/api/v1/schedules/", method: "POST", handler: schedulesHandler }); diff --git a/convex/memories.ts b/convex/memories.ts index dfe1b8a..ea0edfd 100644 --- a/convex/memories.ts +++ b/convex/memories.ts @@ -2,32 +2,162 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; const memorySource = v.union(v.literal("manual"), v.literal("openclaw"), v.literal("clawboot"), v.literal("import"), v.literal("api")); +const conflictPolicy = v.union(v.literal("lww"), v.literal("preserve_both")); + +function normalizeTags(tags?: string[]) { + const cleaned = tags?.map((t) => t.trim().toLowerCase()).filter(Boolean) ?? []; + return cleaned.length ? Array.from(new Set(cleaned)) : undefined; +} + +function computeSearchText(title: string, content: string, tags?: string[]) { + const tagText = tags?.length ? `\n${tags.join(" ")}` : ""; + return `${title}\n${content}${tagText}`; +} export const createMemory = mutation({ - args: { ownerDid: v.string(), authorDid: v.string(), title: v.string(), content: v.string(), tags: v.optional(v.array(v.string())), source: v.optional(memorySource), sourceRef: v.optional(v.string()) }, + args: { + ownerDid: v.string(), + authorDid: v.string(), + title: v.string(), + content: v.string(), + tags: v.optional(v.array(v.string())), + source: v.optional(memorySource), + sourceRef: v.optional(v.string()), + externalId: v.optional(v.string()), + externalUpdatedAt: v.optional(v.number()), + }, handler: async (ctx, args) => { const now = Date.now(); const title = args.title.trim(); const content = args.content.trim(); if (!title || !content) throw new Error("title and content are required"); - const tags = args.tags?.map((t) => t.trim().toLowerCase()).filter(Boolean); + const tags = normalizeTags(args.tags); return await ctx.db.insert("memories", { ownerDid: args.ownerDid, authorDid: args.authorDid, title, content, - searchText: `${title}\n${content}`, - tags: tags?.length ? Array.from(new Set(tags)) : undefined, + searchText: computeSearchText(title, content, tags), + tags, source: args.source, sourceRef: args.sourceRef, + externalId: args.externalId, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: args.source === "openclaw" ? now : undefined, + syncStatus: args.source === "openclaw" ? "synced" : undefined, + conflictNote: undefined, createdAt: now, updatedAt: now, }); } }); +export const upsertOpenClawMemory = mutation({ + args: { + ownerDid: v.string(), + authorDid: v.string(), + externalId: v.string(), + title: v.string(), + content: v.string(), + tags: v.optional(v.array(v.string())), + sourceRef: v.optional(v.string()), + externalUpdatedAt: v.number(), + policy: v.optional(conflictPolicy), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const title = args.title.trim(); + const content = args.content.trim(); + if (!title || !content) throw new Error("title and content are required"); + + const tags = normalizeTags(args.tags); + const policy = args.policy ?? "lww"; + const existing = await ctx.db + .query("memories") + .withIndex("by_owner_external", (q) => q.eq("ownerDid", args.ownerDid).eq("externalId", args.externalId)) + .first(); + + if (!existing) { + const id = await ctx.db.insert("memories", { + ownerDid: args.ownerDid, + authorDid: args.authorDid, + title, + content, + searchText: computeSearchText(title, content, tags), + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalId: args.externalId, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: now, + syncStatus: "synced", + conflictNote: undefined, + createdAt: now, + updatedAt: now, + }); + return { id, status: "created" as const }; + } + + const localIsNewer = (existing.updatedAt ?? 0) > args.externalUpdatedAt; + const contentChanged = existing.title !== title || existing.content !== content; + + if (localIsNewer && contentChanged) { + if (policy === "preserve_both") { + const conflictId = await ctx.db.insert("memories", { + ownerDid: args.ownerDid, + authorDid: args.authorDid, + title: `${title} (remote conflicted copy)`, + content, + searchText: computeSearchText(`${title} (remote conflicted copy)`, content, tags), + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalId: `${args.externalId}:conflict:${now}`, + externalUpdatedAt: args.externalUpdatedAt, + lastSyncedAt: now, + syncStatus: "conflict", + conflictNote: "Remote update older than local edit; preserved as conflicted copy.", + createdAt: now, + updatedAt: now, + }); + + await ctx.db.patch(existing._id, { + syncStatus: "conflict", + conflictNote: "Remote update older than local edit; local version kept.", + lastSyncedAt: now, + }); + + return { id: existing._id, status: "conflict_preserved" as const, conflictId }; + } + + await ctx.db.patch(existing._id, { + syncStatus: "conflict", + conflictNote: "Skipped stale remote update (LWW kept newer local version).", + lastSyncedAt: now, + }); + return { id: existing._id, status: "conflict_skipped" as const }; + } + + await ctx.db.patch(existing._id, { + title, + content, + tags, + source: "openclaw", + sourceRef: args.sourceRef, + externalUpdatedAt: args.externalUpdatedAt, + searchText: computeSearchText(title, content, tags), + syncStatus: "synced", + conflictNote: undefined, + lastSyncedAt: now, + updatedAt: Math.max(now, args.externalUpdatedAt), + }); + + return { id: existing._id, status: "updated" as const }; + }, +}); + export const listMemories = query({ - args: { ownerDid: v.string(), query: v.optional(v.string()), tag: v.optional(v.string()), source: v.optional(memorySource), limit: v.optional(v.number()) }, + args: { ownerDid: v.string(), query: v.optional(v.string()), tag: v.optional(v.string()), source: v.optional(memorySource), limit: v.optional(v.number()), syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))) }, handler: async (ctx, args) => { const limit = Math.min(Math.max(args.limit ?? 50, 1), 100); const queryText = args.query?.trim(); @@ -43,8 +173,49 @@ export const listMemories = query({ } const tag = args.tag?.trim().toLowerCase(); - const memories = rows.filter((m) => (tag ? (m.tags ?? []).includes(tag) : true)).slice(0, limit); + const memories = rows + .filter((m) => (tag ? (m.tags ?? []).includes(tag) : true)) + .filter((m) => (args.syncStatus ? m.syncStatus === args.syncStatus : true)) + .slice(0, limit); const availableTags = Array.from(new Set(memories.flatMap((m) => m.tags ?? []))).sort((a, b) => a.localeCompare(b)); - return { memories, availableTags }; + const conflictCount = rows.filter((m) => m.syncStatus === "conflict").length; + return { memories, availableTags, conflictCount }; } }); + +export const listMemoryChangesSince = query({ + args: { ownerDid: v.string(), since: v.optional(v.number()), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 100, 1), 250); + const rows = await ctx.db + .query("memories") + .withIndex("by_owner_time", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(400); + + const since = args.since ?? 0; + const changes = rows + .filter((row) => row.updatedAt > since) + .slice(0, limit) + .map((row) => ({ + id: row._id, + ownerDid: row.ownerDid, + authorDid: row.authorDid, + externalId: row.externalId, + title: row.title, + content: row.content, + tags: row.tags, + source: row.source, + sourceRef: row.sourceRef, + updatedAt: row.updatedAt, + externalUpdatedAt: row.externalUpdatedAt, + syncStatus: row.syncStatus, + conflictNote: row.conflictNote, + })); + + return { + changes, + cursor: changes.length ? changes[0].updatedAt : since, + }; + }, +}); \ No newline at end of file diff --git a/convex/missionControlApi.ts b/convex/missionControlApi.ts index 7541492..ccc3ca0 100644 --- a/convex/missionControlApi.ts +++ b/convex/missionControlApi.ts @@ -74,6 +74,9 @@ async function authenticate(ctx: ActionCtx, request: Request): Promise { return new Response(null, { status: 204, @@ -128,7 +144,11 @@ export const apiKeysHandler = httpAction(async (ctx, request) => { const userDid = await getUserDidFromJwt(ctx, request); if (request.method === "GET") { - const keys = await ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as any[]; + const [keys, rotationEvents] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as Promise, + ctx.runQuery((api as any).missionControlCore.listApiKeyRotationEvents, { ownerDid: userDid, limit: 20 }) as Promise, + ]); + return jsonResponse(request, { apiKeys: keys.map((k) => ({ _id: k._id, @@ -136,11 +156,15 @@ export const apiKeysHandler = httpAction(async (ctx, request) => { keyPrefix: k.keyPrefix, scopes: k.scopes, agentProfileId: k.agentProfileId, + rotatedFromKeyId: k.rotatedFromKeyId, + rotatedToKeyId: k.rotatedToKeyId, + rotationGraceEndsAt: k.rotationGraceEndsAt, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt, revokedAt: k.revokedAt, expiresAt: k.expiresAt, })), + rotationEvents, }); } @@ -184,17 +208,122 @@ export const apiKeyByIdHandler = httpAction(async (ctx, request) => { try { const userDid = await getUserDidFromJwt(ctx, request); const path = new URL(request.url).pathname; - const id = path.split("/").pop(); - if (!id) return errorResponse(request, "key id required", 400); + const { keyId, action } = parseApiKeyPath(path); + if (!keyId || !action) return errorResponse(request, "key id required", 400); - if (request.method === "DELETE") { + if (request.method === "DELETE" && action === "delete") { await ctx.runMutation((api as any).missionControlCore.revokeApiKey, { - keyId: id, + keyId, ownerDid: userDid, }); return jsonResponse(request, { success: true }); } + if (request.method === "POST" && action === "rotate") { + const body = await request.json().catch(() => ({})) as { + label?: string; + gracePeriodHours?: number; + expiresAt?: number; + }; + + const existing = await ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as any[]; + const oldKey = existing.find((k) => k._id === keyId); + if (!oldKey) return errorResponse(request, "API key not found", 404); + if (oldKey.revokedAt) return errorResponse(request, "Cannot rotate revoked API key", 400); + + const gracePeriodHours = Math.min(Math.max(Math.floor(body.gracePeriodHours ?? 24), 1), 168); + const graceEndsAt = Date.now() + gracePeriodHours * 60 * 60 * 1000; + const rawKey = `pa_${randomToken(8)}_${randomToken(24)}`; + const keyPrefix = rawKey.slice(0, 12); + const keyHash = await sha256Hex(rawKey); + + const result = await ctx.runMutation((api as any).missionControlCore.createRotatedApiKey, { + ownerDid: userDid, + rotatedByDid: userDid, + oldKeyId: keyId, + label: body.label ?? `${oldKey.label} (rotated)`, + keyPrefix, + keyHash, + scopes: oldKey.scopes, + agentProfileId: oldKey.agentProfileId, + expiresAt: body.expiresAt, + graceEndsAt, + }) as any; + + return jsonResponse(request, { + success: true, + rotationEventId: result.rotationEventId, + oldKeyId: keyId, + oldKeyGraceEndsAt: graceEndsAt, + newKeyId: result.newKeyId, + apiKey: rawKey, + keyPrefix, + zeroDowntime: true, + next: "Use the new key in production, then POST /api/v1/auth/keys/:id/finalize-rotation to revoke the old key.", + }, 201); + } + + if (request.method === "POST" && action === "finalize") { + const result = await ctx.runMutation((api as any).missionControlCore.finalizeApiKeyRotation, { + ownerDid: userDid, + oldKeyId: keyId, + }) as any; + return jsonResponse(request, { success: true, ...result }); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const runRetentionHandler = httpAction(async (ctx, request) => { + try { + const userDid = await getUserDidFromJwt(ctx, request); + + if (request.method === "GET") { + const [settings, logs] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.getMissionControlSettings, { ownerDid: userDid }), + ctx.runQuery((api as any).missionControlCore.listArtifactDeletionLogs, { ownerDid: userDid, limit: 25 }), + ]); + + return jsonResponse(request, { settings, deletionLogs: logs }); + } + + if (request.method === "PUT") { + const body = await request.json() as { artifactRetentionDays?: number }; + if (!body.artifactRetentionDays || !Number.isFinite(body.artifactRetentionDays)) { + return errorResponse(request, "artifactRetentionDays is required", 400); + } + + const result = await ctx.runMutation((api as any).missionControlCore.upsertMissionControlSettings, { + ownerDid: userDid, + updatedByDid: userDid, + artifactRetentionDays: body.artifactRetentionDays, + }); + + return jsonResponse(request, { success: true, ...result }); + } + + if (request.method === "POST") { + const body = await request.json().catch(() => ({})) as { + retentionDays?: number; + dryRun?: boolean; + maxRuns?: number; + }; + + const result = await ctx.runMutation((api as any).missionControlCore.applyArtifactRetention, { + ownerDid: userDid, + actorDid: userDid, + retentionDays: body.retentionDays, + dryRun: body.dryRun ?? true, + maxRuns: body.maxRuns, + }); + + return jsonResponse(request, result, 200); + } + return errorResponse(request, "Method not allowed", 405); } catch (error) { if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); @@ -305,12 +434,30 @@ export const activityHandler = httpAction(async (ctx, request) => { export const memoryHandler = httpAction(async (ctx, request) => { try { const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + const isSyncRoute = url.pathname.endsWith("/sync"); if (request.method === "GET") { const missing = requireScopes(authCtx, ["memory:read"]); if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); - const url = new URL(request.url); + if (isSyncRoute) { + const since = parseOptionalNumber(url.searchParams.get("since")); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const result = await ctx.runQuery((api as any).memories.listMemoryChangesSince, { + ownerDid: authCtx.userDid, + since, + limit, + }); + return jsonResponse(request, { + ...result, + sync: { + mode: "bidirectional", + policy: "lww", + }, + }); + } + const agentSlug = url.searchParams.get("agentSlug"); if (!agentSlug) return errorResponse(request, "agentSlug query param required", 400); const key = url.searchParams.get("key") ?? undefined; @@ -326,6 +473,50 @@ export const memoryHandler = httpAction(async (ctx, request) => { const missing = requireScopes(authCtx, ["memory:write"]); if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + if (isSyncRoute) { + const body = await request.json() as { + policy?: "lww" | "preserve_both"; + entries: Array<{ + externalId: string; + title: string; + content: string; + tags?: string[]; + sourceRef?: string; + updatedAt: number; + authorDid?: string; + }>; + }; + + if (!Array.isArray(body.entries)) return errorResponse(request, "entries array is required", 400); + + const results: Array<{ externalId: string; status: string; id: string; conflictId?: string }> = []; + for (const entry of body.entries) { + if (!entry?.externalId || !entry?.title || typeof entry?.content !== "string" || !Number.isFinite(entry?.updatedAt)) { + return errorResponse(request, "Each entry requires externalId, title, content, and updatedAt", 400); + } + const res = await ctx.runMutation((api as any).memories.upsertOpenClawMemory, { + ownerDid: authCtx.userDid, + authorDid: entry.authorDid ?? authCtx.userDid, + externalId: entry.externalId, + title: entry.title, + content: entry.content, + tags: entry.tags, + sourceRef: entry.sourceRef, + externalUpdatedAt: entry.updatedAt, + policy: body.policy ?? "lww", + }) as { id: string; status: string; conflictId?: string }; + results.push({ externalId: entry.externalId, status: res.status, id: res.id, conflictId: res.conflictId }); + } + + const conflicts = results.filter((r) => r.status.startsWith("conflict")).length; + return jsonResponse(request, { + applied: results.length, + conflicts, + policy: body.policy ?? "lww", + results, + }, 200); + } + const body = await request.json() as { agentSlug: string; key: string; value: string; listId?: Id<"lists"> }; if (!body.agentSlug || !body.key || typeof body.value !== "string") { return errorResponse(request, "agentSlug, key, and value are required", 400); diff --git a/convex/schema.ts b/convex/schema.ts index 1f64e1f..27fbcc2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -455,6 +455,11 @@ export default defineSchema({ v.literal("api") )), sourceRef: v.optional(v.string()), + externalId: v.optional(v.string()), + externalUpdatedAt: v.optional(v.number()), + lastSyncedAt: v.optional(v.number()), + syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))), + conflictNote: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) @@ -462,9 +467,11 @@ export default defineSchema({ .index("by_owner_time", ["ownerDid", "updatedAt"]) .index("by_owner_source", ["ownerDid", "source"]) .index("by_owner_author", ["ownerDid", "authorDid"]) + .index("by_owner_external", ["ownerDid", "externalId"]) + .index("by_owner_sync_status", ["ownerDid", "syncStatus"]) .searchIndex("search_content", { searchField: "searchText", - filterFields: ["ownerDid", "source", "authorDid"], + filterFields: ["ownerDid", "source", "authorDid", "syncStatus"], }), // Mission Control schedule entries (Phase 4 schedule/calendar) diff --git a/src/pages/Memory.tsx b/src/pages/Memory.tsx index ac1fb67..50356e2 100644 --- a/src/pages/Memory.tsx +++ b/src/pages/Memory.tsx @@ -10,24 +10,27 @@ export function Memory() { const [q, setQ] = useState(""); const [tag, setTag] = useState(""); const [source, setSource] = useState(""); + const [syncStatus, setSyncStatus] = useState(""); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const createMemory = useMutation(anyApi.memories.createMemory); - const data = useQuery(anyApi.memories.listMemories, did ? { ownerDid: did, query: q || undefined, tag: tag || undefined, source: source || undefined } : "skip"); + const data = useQuery(anyApi.memories.listMemories, did ? { ownerDid: did, query: q || undefined, tag: tag || undefined, source: source || undefined, syncStatus: syncStatus || undefined } : "skip"); return

Memory

-
+ {(data?.conflictCount ?? 0) > 0 &&
OpenClaw sync has {data?.conflictCount} conflict{data?.conflictCount === 1 ? "" : "s"}.
} +
setQ(e.target.value)} /> setTag(e.target.value)} /> +
setTitle(e.target.value)} />