diff --git a/src/lib/agentMemory.ts b/src/lib/agentMemory.ts new file mode 100644 index 0000000..2582d1c --- /dev/null +++ b/src/lib/agentMemory.ts @@ -0,0 +1,154 @@ +import { api } from "./api"; + +export interface Memory { + _key: string; + content: string; + tags: string[]; + created_at: string; +} + +const SOURCE_CTX = "CANVAS_AGENT_MEMORY"; + +function authHeaders(): Record { + return { + Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, + "Content-Type": "application/json", + }; +} + +function newRequestId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; +} + +function vaultKeyFor(email: string): string { + const slug = email.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 50); + return `memory_vault_${slug}`; +} + +async function ensureVault(vaultKey: string, email: string, signal?: AbortSignal): Promise { + try { + const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${vaultKey}`, { headers: authHeaders(), signal }); + if (head.ok) return true; + } catch { /* fall through */ } + const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + request_id: newRequestId(), + ops: [ + { + op: "create", + coll: "flow", + data: { + _key: vaultKey, + kind: "value", + status: "published", + name: vaultKey, + display_name: `Vault · ${email}`, + description: `Per-tenant memory vault for ${email}`, + source_context: "CANVAS_AGENT_VAULT_ROOT", + config: { vault: { owner_email: email } }, + }, + }, + ], + }), + signal, + }); + return res.ok || res.status === 409; +} + +export const agentMemory = { + /** + * Persist a memory observation. Each memory is a flow.kind=value doc linked to + * the user's vault root via a defines edge with role=presentation. + */ + async remember(email: string, content: string, tags: string[] = [], signal?: AbortSignal): Promise { + if (!content.trim()) return null; + const vaultKey = vaultKeyFor(email); + await ensureVault(vaultKey, email, signal); + const doc = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + kind: "value", + status: "published", + name: `memory_${Date.now()}`, + display_name: content.slice(0, 80), + description: content, + source_context: SOURCE_CTX, + config: { memory: { tags, owner_email: email } }, + }), + signal, + }); + if (!doc.ok) return null; + const mem = await doc.json(); + await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + request_id: newRequestId(), + ops: [ + { op: "create_edge", edge_coll: "defines", from: `flow/${vaultKey}`, to: `flow/${mem._key}`, role: "presentation" }, + ], + }), + signal, + }); + return mem._key; + }, + + async listAll(email: string, signal?: AbortSignal): Promise { + const vaultKey = vaultKeyFor(email); + const ok = await ensureVault(vaultKey, email, signal); + if (!ok) return []; + const edgesRes = await fetch(`${api.config.baseUrl}/api/ea2/edges/defines?from=flow/${vaultKey}&limit=500`, { headers: authHeaders(), signal }); + if (!edgesRes.ok) return []; + const edges = (await edgesRes.json())?.items || []; + const memKeys = (edges as any[]) + .filter((e) => e?.role === "presentation") + .map((e) => (e._to || "").split("/").pop()) + .filter(Boolean) as string[]; + const memos = await Promise.all( + memKeys.map(async (k) => { + try { + const r = await fetch(`${api.config.baseUrl}/api/ea2/flow/${k}`, { headers: authHeaders(), signal }); + if (!r.ok) return null; + const d = await r.json(); + if (d?.source_context !== SOURCE_CTX) return null; + return { + _key: d._key, + content: d.description || d.display_name || "", + tags: d.config?.memory?.tags || [], + created_at: d.created_at, + } as Memory; + } catch { return null; } + }) + ); + return memos.filter((m): m is Memory => !!m).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")); + }, + + /** + * Retrieve memories whose content overlaps with the query by word stems. + * Score = count of unique stem matches. Ties broken by recency. + */ + async recall(email: string, query: string, limit = 5, signal?: AbortSignal): Promise { + const all = await agentMemory.listAll(email, signal); + if (!query.trim() || all.length === 0) return all.slice(0, limit); + const stems = new Set( + query.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2) + ); + if (stems.size === 0) return all.slice(0, limit); + const scored = all.map((m) => { + const body = (m.content || "").toLowerCase(); + let score = 0; + for (const s of stems) if (body.includes(s)) score++; + const tagScore = m.tags.reduce((acc, t) => acc + (stems.has(t.toLowerCase()) ? 2 : 0), 0); + return { m, score: score + tagScore }; + }); + return scored + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score || (b.m.created_at || "").localeCompare(a.m.created_at || "")) + .slice(0, limit) + .map((x) => x.m); + }, +}; diff --git a/src/lib/agentTools.ts b/src/lib/agentTools.ts index bccf781..3045a65 100644 --- a/src/lib/agentTools.ts +++ b/src/lib/agentTools.ts @@ -2,6 +2,7 @@ import { api } from "./api"; import { wizardApi } from "./wizardApi"; import { chatApi } from "./chatApi"; import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration"; +import { agentMemory, type Memory } from "./agentMemory"; export interface ToolResult { ok: boolean; @@ -151,11 +152,41 @@ const TOOLS: ToolDef[] = [ return { ok: true, display: `Here's what's published:\n${lines}` }; }, }, + { + name: "remember", + description: "Stash a note in your per-tenant memory vault", + matcher: /^(remember|note|save)\s+(that\s+)?(.+)$/i, + async run(input, ctx) { + const m = input.match(/^(remember|note|save)\s+(that\s+)?(.+)$/i); + const content = m?.[3]?.trim() || ""; + if (!content) return { ok: false, display: "Tell me what to remember." }; + const key = await agentMemory.remember(ctx.userEmail, content, ["user-said"]); + return key + ? { ok: true, display: `Got it — I'll remember: "${content}".` } + : { ok: false, display: "Couldn't save that to your vault. Try again." }; + }, + }, + { + name: "recall", + description: "Pull related notes from your memory vault", + matcher: /^(recall|remind me|what do you know|what did i say)\s*(about|of|on)?\s*(.*)$/i, + async run(input, ctx) { + const m = input.match(/^(recall|remind me|what do you know|what did i say)\s*(about|of|on)?\s*(.*)$/i); + const query = m?.[3]?.trim() || ""; + const memories = await agentMemory.recall(ctx.userEmail, query, 5); + if (!memories.length) return { ok: true, display: query ? `Nothing in your vault about "${query}" yet.` : "Your vault is empty." }; + const lines = memories.map((mem) => `• ${mem.content}`).join("\n"); + return { ok: true, display: `From your vault${query ? ` about "${query}"` : ""}:\n${lines}` }; + }, + }, ]; export async function routeAgentInput(text: string, ctx: ToolContext): Promise { const trimmed = text.trim(); if (!trimmed) return { ok: false, display: "Tell me what to do." }; + // Best-effort: every turn becomes an episodic memory in the vault. We never + // block the response on it so a vault outage doesn't break the assistant. + agentMemory.remember(ctx.userEmail, trimmed, ["turn"]).catch(() => {}); for (const tool of TOOLS) { if (tool.matcher.test(trimmed)) { try { @@ -165,6 +196,18 @@ export async function routeAgentInput(text: string, ctx: ToolContext): Promise 0) { + const lines = hints.map((h) => `• ${h.content}`).join("\n"); + return { + ok: true, + display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`, + }; + } + } catch { /* ignore */ } return { ok: false, display: