feat(agent): per-tenant Karpathy-style memory vault
User explicit ask: 'maybe with a miniature hindsight or a Karpathy
vault behind it as its memory system for each tenant.'
- src/lib/agentMemory.ts: EA2-backed vault per user. Vault root is
flow.kind=value at flow/memory_vault_<email_slug>; each memory is a
flow.kind=value doc linked via defines edge with role=presentation.
- agentMemory.remember/listAll/recall — recall is a simple keyword
overlap score with recency tiebreak.
- agentTools: new 'remember' and 'recall' tools.
matchers: 'remember that ...', 'note ...', 'save ...' /
'recall ...', 'remind me ...', 'what do you know about ...'
- routeAgentInput: every user turn is best-effort persisted as an
episodic memory (fire-and-forget). When no command matches, the
router does a vault recall and surfaces related notes before
showing the help message.
Vault key: deterministic per-user. Owner_email stored on the vault
root + on each memory's config.memory. No raw IDs visible to the user.
This commit is contained in:
@@ -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<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);
|
||||||
|
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<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 === "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<Memory[]> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { api } from "./api";
|
|||||||
import { wizardApi } from "./wizardApi";
|
import { wizardApi } from "./wizardApi";
|
||||||
import { chatApi } from "./chatApi";
|
import { chatApi } from "./chatApi";
|
||||||
import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration";
|
import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration";
|
||||||
|
import { agentMemory, type Memory } from "./agentMemory";
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -151,11 +152,41 @@ const TOOLS: ToolDef[] = [
|
|||||||
return { ok: true, display: `Here's what's published:\n${lines}` };
|
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<ToolResult> {
|
export async function routeAgentInput(text: string, ctx: ToolContext): Promise<ToolResult> {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return { ok: false, display: "Tell me what to do." };
|
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) {
|
for (const tool of TOOLS) {
|
||||||
if (tool.matcher.test(trimmed)) {
|
if (tool.matcher.test(trimmed)) {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +196,18 @@ export async function routeAgentInput(text: string, ctx: ToolContext): Promise<T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If nothing matched, try to recall related context from the vault before
|
||||||
|
// falling back to the help message.
|
||||||
|
try {
|
||||||
|
const hints: Memory[] = await agentMemory.recall(ctx.userEmail, trimmed, 3);
|
||||||
|
if (hints.length > 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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
display:
|
display:
|
||||||
|
|||||||
Reference in New Issue
Block a user