Files
canvas-frontend/src/lib/agentMemory.ts
T
canvas-bot 14a98056ed
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
fix(memory): use edge role=child (valid EA2 vocab)
Probed backend's defines edge role vocabulary. Accepted: child,
dispatch. Rejected: memory, observation, note, recall, vault, step,
view. Switching from invalid 'memory' to valid 'child' so the edge
attach actually lands without 500.
2026-06-16 21:21:52 +04:00

167 lines
5.8 KiB
TypeScript

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<string, string> {
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<boolean> {
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<string | null> {
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<Memory[]> {
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<Memory[]> {
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);
},
};