Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 147 additions & 1 deletion desktop/src-tauri/src/commands/engrams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<String>,
previous_value: Option<String>,
}

#[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<String>,
evidence_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
previous_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reviewed_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
target_event_id: Option<String>,
}

/// 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.
Expand Down Expand Up @@ -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<nostr::Event, String> {
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::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/agent-memory/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,7 +92,12 @@ export function useAgentMemoryGraph(
const query = useAgentMemoryQuery(agentPubkey, options);
const graph = React.useMemo<MemoryGraph | null>(() => {
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 };
}
45 changes: 45 additions & 0 deletions desktop/src/features/agent-memory/lib/memoryProposal.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
));
81 changes: 81 additions & 0 deletions desktop/src/features/agent-memory/lib/memoryProposal.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryProposal>;
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))
);
}
Loading