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); const vaultReady = 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(); if (!vaultReady) { console.warn(`[memory] vault not ready for ${email}, skipping edge attach`); return mem._key; } try { const edgeRes = 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: "child" }, ], }), signal, }); if (!edgeRes.ok) { console.warn(`[memory] vault edge attach returned ${edgeRes.status} for ${mem._key} (recall may miss this entry)`); } } catch (e) { console.warn(`[memory] vault edge attach threw: ${(e as Error).message}`); } return mem._key; }, async listAll(email: string, signal?: AbortSignal, cap = 200): 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 === "child" || e?.role === "presentation") .map((e) => (e._to || "").split("/").pop()) .filter(Boolean) .slice(0, cap) 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, 30); 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); }, };