From b1885adfd0a96784b53fa6e72d1aac288c9b12c2 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 6 Aug 2026 16:03:29 -0400 Subject: [PATCH] feat(memory): add owner-reviewed proposals Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/engrams.rs | 148 +++++++++++++++- desktop/src-tauri/src/lib.rs | 1 + desktop/src/features/agent-memory/hooks.ts | 8 +- .../agent-memory/lib/memoryProposal.test.mjs | 45 +++++ .../agent-memory/lib/memoryProposal.ts | 81 +++++++++ .../agent-memory/ui/MemorySection.tsx | 160 +++++++++++++++++- desktop/src/shared/api/tauriEngrams.ts | 16 ++ 7 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 desktop/src/features/agent-memory/lib/memoryProposal.test.mjs create mode 100644 desktop/src/features/agent-memory/lib/memoryProposal.ts diff --git a/desktop/src-tauri/src/commands/engrams.rs b/desktop/src-tauri/src/commands/engrams.rs index 74de129492..48f50489de 100644 --- a/desktop/src-tauri/src/commands/engrams.rs +++ b/desktop/src-tauri/src/commands/engrams.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use std::time::SystemTime; use nostr::PublicKey; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; use buzz_core_pkg::engram::{self, extract_refs, select_head, validate_and_decrypt, Body}; @@ -31,6 +31,40 @@ use buzz_core_pkg::kind::KIND_AGENT_ENGRAM; use crate::commands::identity_archive::{extract_oa_owner, fetch_kind0}; use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay}; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewMemoryProposalInput { + agent_pubkey: String, + proposal_slug: String, + proposal_event_id: String, + proposal_body: String, + decision: String, + edited_content: Option, + previous_value: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct MemoryProposalDocument { + schema: u8, + status: String, + kind: String, + scope: String, + target_slug: String, + content: String, + reason: String, + source_event_ids: Vec, + evidence_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + previous_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reviewed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_event_id: Option, +} + /// Hard cap on engrams returned per (agent, owner) pair. Matches the CLI /// `mem ls` reference. If the relay returns this many we set /// `truncated = true` so the UI can warn that the list may be incomplete. @@ -274,6 +308,118 @@ pub async fn get_agent_memory( }) } +/// Review a proposed memory and publish the decision as a new encrypted +/// engram version. Approval also publishes the edited target value. Undo +/// restores the captured previous value (or writes a tombstone when the +/// target did not previously exist). Writes are deliberately limited to +/// locally managed agents because signing requires the agent's secret key. +#[tauri::command] +pub async fn review_agent_memory_proposal( + input: ReviewMemoryProposalInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let mut proposal: MemoryProposalDocument = serde_json::from_str(&input.proposal_body) + .map_err(|e| format!("invalid proposal body: {e}"))?; + if proposal.schema != 1 + || !input.proposal_slug.starts_with("mem/proposals/") + || proposal.target_slug.starts_with("mem/proposals/") + || !proposal.target_slug.starts_with("mem/") + || input.proposal_event_id.len() != 64 + { + return Err("invalid memory proposal".into()); + } + match input.decision.as_str() { + "approve" if proposal.status == "proposed" => {} + "reject" if proposal.status == "proposed" => {} + "undo" if proposal.status == "approved" => {} + _ => return Err("proposal is not in a reviewable state".into()), + } + + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&input.agent_pubkey)) + .ok_or_else(|| "memory proposals can only be reviewed for a locally managed agent".to_string())?; + let agent_keys = nostr::Keys::parse(record.private_key_nsec.trim()) + .map_err(|_| "managed agent signing key is unavailable".to_string())?; + if !agent_keys.public_key().to_hex().eq_ignore_ascii_case(&input.agent_pubkey) { + return Err("managed agent signing key does not match the proposal agent".into()); + } + let (owner_pubkey, now) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + (keys.public_key(), nostr::Timestamp::now().as_secs()) + }; + + let publish_body = |body: Body| -> Result { + buzz_core_pkg::engram::build_event(&agent_keys, &owner_pubkey, &body, now) + .map_err(|e| e.to_string()) + }; + + if input.decision == "approve" { + let value = input.edited_content.unwrap_or_else(|| proposal.content.clone()); + if value.trim().is_empty() { + return Err("approved memory cannot be empty".into()); + } + proposal.content = value.clone(); + proposal.previous_value = input.previous_value; + let event = publish_body(Body::Memory { + slug: proposal.target_slug.clone(), + value: Some(value), + })?; + crate::relay::submit_signed_event_with_keys( + &event, + &state, + &agent_keys, + record.auth_tag.as_deref(), + ) + .await?; + proposal.target_event_id = Some(event.id.to_hex()); + proposal.status = "approved".into(); + } else if input.decision == "undo" { + let previous = input.previous_value.or(proposal.previous_value.clone()); + let event = publish_body(Body::Memory { + slug: proposal.target_slug.clone(), + value: previous, + })?; + crate::relay::submit_signed_event_with_keys( + &event, + &state, + &agent_keys, + record.auth_tag.as_deref(), + ) + .await?; + proposal.target_event_id = Some(event.id.to_hex()); + proposal.status = "undone".into(); + } else { + proposal.status = "rejected".into(); + } + proposal.reviewed_at = Some(now); + + // Use the next second so a review is guaranteed to supersede the exact + // proposal version the owner saw, even on relays using timestamp-first + // replaceable-event head selection. + let proposal_json = serde_json::to_string(&proposal).map_err(|e| e.to_string())?; + let proposal_event = buzz_core_pkg::engram::build_event( + &agent_keys, + &owner_pubkey, + &Body::Memory { + slug: input.proposal_slug, + value: Some(proposal_json), + }, + now.saturating_add(1), + ) + .map_err(|e| e.to_string())?; + crate::relay::submit_signed_event_with_keys( + &proposal_event, + &state, + &agent_keys, + record.auth_tag.as_deref(), + ) + .await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4f935631b6..5702e64690 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -890,6 +890,7 @@ pub fn run() { fetch_join_policy, set_prevent_sleep_active, get_agent_memory, + review_agent_memory_proposal, relay_reconnect_hook, relay_reconnect_hook_configured, observer_archive_default_enabled, diff --git a/desktop/src/features/agent-memory/hooks.ts b/desktop/src/features/agent-memory/hooks.ts index fb6d4edcd5..241201fff6 100644 --- a/desktop/src/features/agent-memory/hooks.ts +++ b/desktop/src/features/agent-memory/hooks.ts @@ -7,6 +7,7 @@ import { type AgentMemoryListing, } from "@/shared/api/tauriEngrams"; import { buildMemoryGraph, type MemoryGraph } from "./lib/buildMemoryGraph"; +import { MEMORY_PROPOSAL_PREFIX } from "./lib/memoryProposal"; export const agentMemoryQueryKey = (agentPubkey: string) => ["agent-memory", agentPubkey.toLowerCase()] as const; @@ -91,7 +92,12 @@ export function useAgentMemoryGraph( const query = useAgentMemoryQuery(agentPubkey, options); const graph = React.useMemo(() => { if (!query.data) return null; - return buildMemoryGraph(query.data); + return buildMemoryGraph({ + ...query.data, + memories: query.data.memories.filter( + (entry) => !entry.slug.startsWith(MEMORY_PROPOSAL_PREFIX), + ), + }); }, [query.data]); return { query, graph }; } diff --git a/desktop/src/features/agent-memory/lib/memoryProposal.test.mjs b/desktop/src/features/agent-memory/lib/memoryProposal.test.mjs new file mode 100644 index 0000000000..6a1da16e04 --- /dev/null +++ b/desktop/src/features/agent-memory/lib/memoryProposal.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseMemoryProposal } from "./memoryProposal.ts"; + +const id = "a".repeat(64); +const valid = { + schema: 1, + status: "proposed", + kind: "preference", + scope: "agent", + targetSlug: "mem/preferences/tone", + content: "Use plain language.", + reason: "The owner requested it twice.", + sourceEventIds: [id], + evidenceIds: [id], + confidence: 0.9, +}; + +test("parses a complete proposal", () => + assert.equal( + parseMemoryProposal("mem/proposals/tone", JSON.stringify(valid)) + ?.targetSlug, + "mem/preferences/tone", + )); +test("ignores ordinary memories", () => + assert.equal( + parseMemoryProposal("mem/preferences/tone", JSON.stringify(valid)), + null, + )); +test("rejects proposals without evidence-shaped identifiers", () => + assert.equal( + parseMemoryProposal( + "mem/proposals/tone", + JSON.stringify({ ...valid, evidenceIds: ["claim"] }), + ), + null, + )); +test("rejects recursive proposal targets", () => + assert.equal( + parseMemoryProposal( + "mem/proposals/tone", + JSON.stringify({ ...valid, targetSlug: "mem/proposals/other" }), + ), + null, + )); diff --git a/desktop/src/features/agent-memory/lib/memoryProposal.ts b/desktop/src/features/agent-memory/lib/memoryProposal.ts new file mode 100644 index 0000000000..f38efd279a --- /dev/null +++ b/desktop/src/features/agent-memory/lib/memoryProposal.ts @@ -0,0 +1,81 @@ +export const MEMORY_PROPOSAL_PREFIX = "mem/proposals/"; + +export const MEMORY_PROPOSAL_KINDS = [ + "fact", + "preference", + "policy", + "procedure", + "delegation-role", +] as const; +export type MemoryProposalKind = (typeof MEMORY_PROPOSAL_KINDS)[number]; + +export const MEMORY_PROPOSAL_SCOPES = ["agent", "owner"] as const; +export type MemoryProposalScope = (typeof MEMORY_PROPOSAL_SCOPES)[number]; + +export type MemoryProposal = { + schema: 1; + status: "proposed" | "approved" | "rejected" | "undone"; + kind: MemoryProposalKind; + scope: MemoryProposalScope; + targetSlug: string; + content: string; + reason: string; + sourceEventIds: string[]; + evidenceIds: string[]; + confidence?: number; + previousValue?: string | null; + reviewedAt?: number; + targetEventId?: string; +}; + +const MEMORY_SLUG = + /^mem\/[a-z0-9][a-z0-9_-]{0,63}(\/[a-z0-9][a-z0-9_-]{0,63})*$/; +const EVENT_ID = /^[0-9a-f]{64}$/i; + +export function parseMemoryProposal( + slug: string, + body: string, +): MemoryProposal | null { + if (!slug.startsWith(MEMORY_PROPOSAL_PREFIX)) return null; + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Partial; + if ( + candidate.schema !== 1 || + !["proposed", "approved", "rejected", "undone"].includes( + candidate.status ?? "", + ) || + !MEMORY_PROPOSAL_KINDS.includes(candidate.kind as MemoryProposalKind) || + !MEMORY_PROPOSAL_SCOPES.includes(candidate.scope as MemoryProposalScope) || + typeof candidate.targetSlug !== "string" || + !MEMORY_SLUG.test(candidate.targetSlug) || + candidate.targetSlug.startsWith(MEMORY_PROPOSAL_PREFIX) || + typeof candidate.content !== "string" || + candidate.content.trim().length === 0 || + typeof candidate.reason !== "string" || + candidate.reason.trim().length === 0 || + !isEventIds(candidate.sourceEventIds) || + !isEventIds(candidate.evidenceIds) + ) + return null; + if ( + candidate.confidence !== undefined && + (typeof candidate.confidence !== "number" || + candidate.confidence < 0 || + candidate.confidence > 1) + ) + return null; + return candidate as MemoryProposal; +} + +function isEventIds(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every((id) => typeof id === "string" && EVENT_ID.test(id)) + ); +} diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index cfc5ed7bd9..5501a3b151 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -1,13 +1,22 @@ import * as React from "react"; import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; import { useAgentMemoryGraph } from "@/features/agent-memory/hooks"; import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph"; -import type { EngramEntry } from "@/shared/api/tauriEngrams"; +import { + parseMemoryProposal, + type MemoryProposal, +} from "@/features/agent-memory/lib/memoryProposal"; +import { + type EngramEntry, + reviewMemoryProposal, +} from "@/shared/api/tauriEngrams"; import { cn } from "@/shared/lib/cn"; import { Button, type ButtonProps } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { Textarea } from "@/shared/ui/textarea"; const MEMORY_LIST_PREVIEW_LIMIT = 3; @@ -126,6 +135,12 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { query.refetch()} /> ) : null} + query.refetch()} + /> ) : null} @@ -133,6 +148,149 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) { ); } +function MemoryProposalList({ + agentPubkey, + entries, + memories, + onReviewed, +}: { + agentPubkey: string; + entries: EngramEntry[]; + memories: EngramEntry[]; + onReviewed: () => Promise; +}) { + const proposals = entries.flatMap((entry) => { + const proposal = parseMemoryProposal(entry.slug, entry.body); + return proposal ? [{ entry, proposal }] : []; + }); + if (proposals.length === 0) return null; + return ( +
+

+ Memory review +

+ {proposals.map(({ entry, proposal }) => ( + memory.slug === proposal.targetSlug) + ?.body ?? null + } + proposal={proposal} + onReviewed={onReviewed} + /> + ))} +
+ ); +} + +function MemoryProposalCard({ + agentPubkey, + entry, + previousValue, + proposal, + onReviewed, +}: { + agentPubkey: string; + entry: EngramEntry; + previousValue: string | null; + proposal: MemoryProposal; + onReviewed: () => Promise; +}) { + const [content, setContent] = React.useState(proposal.content); + const [busy, setBusy] = React.useState(false); + const decide = async (decision: "approve" | "reject" | "undo") => { + setBusy(true); + try { + await reviewMemoryProposal({ + agentPubkey, + proposalSlug: entry.slug, + proposalEventId: entry.eventId, + proposalBody: entry.body, + decision, + editedContent: decision === "approve" ? content : undefined, + previousValue: proposal.previousValue ?? previousValue, + }); + toast.success( + decision === "approve" + ? "Memory approved" + : decision === "reject" + ? "Memory rejected" + : "Memory change undone", + ); + await onReviewed(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Memory review failed", + ); + } finally { + setBusy(false); + } + }; + return ( +
+
+
+ {proposal.kind} + · + {proposal.scope} + · + {proposal.status} +
+

+ +

+

{proposal.reason}

+

+ {proposal.evidenceIds.length} evidence item + {proposal.evidenceIds.length === 1 ? "" : "s"} +

+
+ {proposal.status === "proposed" ? ( + <> +