feat(ask): long-term memory
Adds src/lib/agent/memory.ts (remember/forget/listMemories, dedupe by normalised text, formatMemoriesForPrompt) and wires it in: a "## What you remember" system-prompt section, the remember/forget/ list_memories tools (src/lib/agent/tools-actions.ts, a per-request factory since these need the actor unlike the static read-only tool set), a REST view for the UI (/api/ask/memories), and a Memory drawer in chat-view.tsx (Mine / Shared with everyone, add, delete). Adjusted src/lib/agent/read-only.test.ts: its blanket "no mutation calls under src/lib/agent" contract now allowlists the specific write seams the plan designates (memory.ts here; tools-actions.ts, gateway.ts and images.ts to follow) instead of forbidding every file, since memories/requests are specified to bypass the gateway and write directly. Live browser check skipped: ask_log doesn't exist yet in the target PocketBase (pending the user's --apply of Task 1's schema). Covered by mocked unit tests instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7c5df11b84
commit
dc25fb13cd
14 changed files with 763 additions and 11 deletions
37
src/app/api/ask/memories/[id]/route.test.ts
Normal file
37
src/app/api/ask/memories/[id]/route.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const resolveActorMock = vi.fn();
|
||||
vi.mock("@/lib/agent/context", () => ({
|
||||
resolveActor: (...args: unknown[]) => resolveActorMock(...args),
|
||||
}));
|
||||
|
||||
const forgetMock = vi.fn();
|
||||
vi.mock("@/lib/agent/memory", () => ({
|
||||
forget: (...args: unknown[]) => forgetMock(...args),
|
||||
}));
|
||||
|
||||
import { DELETE } from "./route";
|
||||
|
||||
const params = Promise.resolve({ id: "m1" });
|
||||
|
||||
describe("DELETE /api/ask/memories/[id]", () => {
|
||||
beforeEach(() => {
|
||||
resolveActorMock.mockReset();
|
||||
forgetMock.mockReset();
|
||||
resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" });
|
||||
});
|
||||
|
||||
it("forgets a memory", async () => {
|
||||
forgetMock.mockResolvedValue({ ok: true });
|
||||
const res = await DELETE(new Request("http://x", { method: "DELETE" }), { params });
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(forgetMock).toHaveBeenCalledWith({ actor: { id: "u1", name: "Dashboard" } }, { id: "m1" });
|
||||
});
|
||||
|
||||
it("400s with the reason when forget refuses", async () => {
|
||||
forgetMock.mockResolvedValue({ ok: false, error: "Only the owner can forget a personal memory." });
|
||||
const res = await DELETE(new Request("http://x", { method: "DELETE" }), { params });
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toContain("owner");
|
||||
});
|
||||
});
|
||||
19
src/app/api/ask/memories/[id]/route.ts
Normal file
19
src/app/api/ask/memories/[id]/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** DELETE /api/ask/memories/[id] — forget one (kept as status "undone"). */
|
||||
import { resolveActor, type AgentContext } from "@/lib/agent/context";
|
||||
import { forget } from "@/lib/agent/memory";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const actor = await resolveActor(request);
|
||||
if (actor instanceof Response) return actor;
|
||||
const { id } = await params;
|
||||
|
||||
const ctx: AgentContext = { actor };
|
||||
const result = await forget(ctx, { id });
|
||||
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
58
src/app/api/ask/memories/route.test.ts
Normal file
58
src/app/api/ask/memories/route.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const resolveActorMock = vi.fn();
|
||||
vi.mock("@/lib/agent/context", () => ({
|
||||
resolveActor: (...args: unknown[]) => resolveActorMock(...args),
|
||||
}));
|
||||
|
||||
const listMemoriesMock = vi.fn();
|
||||
const rememberMock = vi.fn();
|
||||
vi.mock("@/lib/agent/memory", () => ({
|
||||
listMemories: (...args: unknown[]) => listMemoriesMock(...args),
|
||||
remember: (...args: unknown[]) => rememberMock(...args),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
|
||||
function req(method: string, body?: unknown): Request {
|
||||
return new Request("http://x/api/ask/memories", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("/api/ask/memories", () => {
|
||||
beforeEach(() => {
|
||||
resolveActorMock.mockReset();
|
||||
listMemoriesMock.mockReset();
|
||||
rememberMock.mockReset();
|
||||
resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" });
|
||||
});
|
||||
|
||||
it("GET requires an actor", async () => {
|
||||
resolveActorMock.mockResolvedValue(Response.json({ error: "Pick who is using Ask" }, { status: 400 }));
|
||||
const res = await GET(req("GET"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET lists memories for the actor", async () => {
|
||||
listMemoriesMock.mockResolvedValue([{ id: "m1", text: "hi" }]);
|
||||
const res = await GET(req("GET"));
|
||||
expect(await res.json()).toEqual({ memories: [{ id: "m1", text: "hi" }] });
|
||||
expect(listMemoriesMock).toHaveBeenCalledWith({ actor: { id: "u1", name: "Dashboard" } });
|
||||
});
|
||||
|
||||
it("POST adds a memory manually", async () => {
|
||||
rememberMock.mockResolvedValue({ id: "m2", deduped: false });
|
||||
const res = await POST(req("POST", { text: "hello", shared: true }));
|
||||
expect(await res.json()).toEqual({ id: "m2", deduped: false });
|
||||
expect(rememberMock.mock.calls[0][1]).toMatchObject({ text: "hello", shared: true });
|
||||
});
|
||||
|
||||
it("POST rejects empty text", async () => {
|
||||
const res = await POST(req("POST", { text: " " }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(rememberMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
41
src/app/api/ask/memories/route.ts
Normal file
41
src/app/api/ask/memories/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* GET /api/ask/memories — the actor's active memories plus active shared
|
||||
* ones, for the chat UI's "Memory" drawer (a plain REST view of what
|
||||
* src/lib/agent/memory.ts already stores — the drawer doesn't need to
|
||||
* go through the model to read or add a memory by hand).
|
||||
* POST /api/ask/memories — add one manually: { text, site?, shared? }.
|
||||
*/
|
||||
import { resolveActor, type AgentContext } from "@/lib/agent/context";
|
||||
import { remember, listMemories } from "@/lib/agent/memory";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const actor = await resolveActor(request);
|
||||
if (actor instanceof Response) return actor;
|
||||
const ctx: AgentContext = { actor };
|
||||
const memories = await listMemories(ctx);
|
||||
return Response.json({ memories });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const actor = await resolveActor(request);
|
||||
if (actor instanceof Response) return actor;
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Body must be JSON" }, { status: 400 });
|
||||
}
|
||||
const text = typeof body.text === "string" ? body.text.trim() : "";
|
||||
if (!text) return Response.json({ error: "text is required" }, { status: 400 });
|
||||
|
||||
const ctx: AgentContext = { actor };
|
||||
const result = await remember(ctx, {
|
||||
text,
|
||||
site: typeof body.site === "string" ? body.site : undefined,
|
||||
shared: body.shared === true,
|
||||
});
|
||||
return Response.json(result);
|
||||
}
|
||||
|
|
@ -71,6 +71,15 @@ vi.mock("@/lib/chat/threads", () => ({
|
|||
appendMessage: (...args: unknown[]) => appendMessageMock(...args),
|
||||
}));
|
||||
|
||||
const listMemoriesMock = vi.fn<(...args: unknown[]) => Promise<{ text: string }[]>>(
|
||||
async () => [],
|
||||
);
|
||||
vi.mock("@/lib/agent/memory", () => ({
|
||||
listMemories: (...args: unknown[]) => listMemoriesMock(...args),
|
||||
formatMemoriesForPrompt: (memories: { text: string }[]) =>
|
||||
memories.map((m) => `- ${m.text}`).join("\n"),
|
||||
}));
|
||||
|
||||
describe("POST /api/chat", () => {
|
||||
it("streams chart specs to the browser and only ids/titles to the model", async () => {
|
||||
const res = await POST(
|
||||
|
|
@ -113,6 +122,18 @@ describe("POST /api/chat — threaded", () => {
|
|||
resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" });
|
||||
loadThreadEntriesMock.mockResolvedValue([]);
|
||||
appendMessageMock.mockResolvedValue("m1");
|
||||
listMemoriesMock.mockClear();
|
||||
listMemoriesMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("embeds the actor's memories in the system prompt and offers the remember tool", async () => {
|
||||
createThreadMock.mockResolvedValue({ id: "t9", title: "Report" });
|
||||
listMemoriesMock.mockResolvedValue([{ text: "The Hills works 45-hour weeks" }]);
|
||||
await POST(threadedRequest({ message: "How many hours does The Hills work?" }));
|
||||
|
||||
const last = seen[seen.length - 1];
|
||||
expect(last.system).toContain("The Hills works 45-hour weeks");
|
||||
expect(last.tools.map((t) => t.name)).toContain("remember");
|
||||
});
|
||||
|
||||
it("rejects a request with no x-ask-user (via resolveActor)", async () => {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@
|
|||
* - Legacy: `{ history }` with no `threadId` — kept working for one
|
||||
* release so a tab open across a deploy doesn't break. Not persisted.
|
||||
*/
|
||||
import { tools } from "@/lib/agent/tools";
|
||||
import { tools, type AgentTool } from "@/lib/agent/tools";
|
||||
import { actionTools } from "@/lib/agent/tools-actions";
|
||||
import { listMemories, formatMemoriesForPrompt } from "@/lib/agent/memory";
|
||||
import { extractCharts } from "@/lib/reports/chart-spec";
|
||||
import type { ChartSpec } from "@/lib/reports/chart-spec";
|
||||
import { resolveActor } from "@/lib/agent/context";
|
||||
import type { AgentContext } from "@/lib/agent/context";
|
||||
import {
|
||||
autoTitle,
|
||||
createThread,
|
||||
|
|
@ -41,8 +44,6 @@ import {
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const toolByName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
/**
|
||||
* Coerce whatever the browser posted into well-formed ChatEntry values.
|
||||
*
|
||||
|
|
@ -103,10 +104,15 @@ function streamAnswer(args: {
|
|||
threadId?: string;
|
||||
ownerId?: string;
|
||||
threadTitle?: string;
|
||||
system?: string;
|
||||
extraTools?: AgentTool[];
|
||||
}) {
|
||||
const { history, threadId, ownerId, threadTitle } = args;
|
||||
const provider = getProvider();
|
||||
const toolDefs = toChatToolDefs(tools);
|
||||
const allTools = [...tools, ...(args.extraTools ?? [])];
|
||||
const toolByName = new Map(allTools.map((t) => [t.name, t]));
|
||||
const toolDefs = toChatToolDefs(allTools);
|
||||
const system = args.system ?? buildSystemPrompt(new Date());
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
|
|
@ -129,7 +135,7 @@ function streamAnswer(args: {
|
|||
provider,
|
||||
history,
|
||||
tools: toolDefs,
|
||||
system: buildSystemPrompt(new Date()),
|
||||
system,
|
||||
onTextDelta: (delta) => {
|
||||
lastText += delta;
|
||||
send("delta", { text: delta });
|
||||
|
|
@ -264,5 +270,23 @@ export async function POST(request: Request) {
|
|||
...(body.attachmentIds ? { attachmentIds: body.attachmentIds } : {}),
|
||||
});
|
||||
|
||||
return streamAnswer({ history, threadId, ownerId: actor.id, threadTitle });
|
||||
const ctx: AgentContext = { actor, threadId };
|
||||
// Memories and action tools both need the actor — the legacy path above
|
||||
// has none, so it gets neither, matching its pre-threads behaviour.
|
||||
let memoriesBlock = "";
|
||||
try {
|
||||
memoriesBlock = formatMemoriesForPrompt(await listMemories(ctx));
|
||||
} catch {
|
||||
// ask_log may not exist yet (schema not applied) — answer without
|
||||
// memories rather than failing the whole turn.
|
||||
}
|
||||
|
||||
return streamAnswer({
|
||||
history,
|
||||
threadId,
|
||||
ownerId: actor.id,
|
||||
threadTitle,
|
||||
system: buildSystemPrompt(new Date(), memoriesBlock),
|
||||
extraTools: actionTools(ctx),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import * as React from "react";
|
||||
import { Suspense } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowUp, Camera, Database, Menu, Paperclip, Plus, Sparkles } from "lucide-react";
|
||||
import { ArrowUp, Brain, Camera, Database, Menu, Paperclip, Plus, Sparkles, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnswerMarkdown } from "./answer-markdown";
|
||||
import { UploadCard, type UploadState } from "./upload-card";
|
||||
|
|
@ -39,6 +39,14 @@ interface ThreadSummary {
|
|||
last_message_at?: string;
|
||||
}
|
||||
|
||||
interface UiMemory {
|
||||
id: string;
|
||||
text: string;
|
||||
shared?: boolean;
|
||||
site?: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
const ACTOR_STORAGE_KEY = "mega-ask-user";
|
||||
|
||||
function loadStoredActor(): Actor | null {
|
||||
|
|
@ -180,6 +188,74 @@ function ChatViewInner() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// ---- Memory drawer -----------------------------------------------------
|
||||
const [memoryOpen, setMemoryOpen] = React.useState(false);
|
||||
const [memories, setMemories] = React.useState<UiMemory[]>([]);
|
||||
const [memoryText, setMemoryText] = React.useState("");
|
||||
const [memoryShared, setMemoryShared] = React.useState(false);
|
||||
|
||||
const refreshMemories = React.useCallback(async (forActor: Actor) => {
|
||||
try {
|
||||
const res = await fetch("/api/ask/memories", { headers: { "x-ask-user": forActor.id } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setMemories(data.memories ?? []);
|
||||
} catch {
|
||||
/* the drawer just stays as-is */
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!memoryOpen || !actor) return;
|
||||
// Inlined (rather than calling refreshMemories) so the setState below
|
||||
// runs inside the fetch's async continuation, not synchronously in
|
||||
// the effect body — see the identical note on the threads effect.
|
||||
let cancelled = false;
|
||||
fetch("/api/ask/memories", { headers: { "x-ask-user": actor.id } })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setMemories(data.memories ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* the drawer just stays as-is */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [memoryOpen, actor]);
|
||||
|
||||
const addMemory = React.useCallback(async () => {
|
||||
const text = memoryText.trim();
|
||||
if (!text || !actor) return;
|
||||
setMemoryText("");
|
||||
try {
|
||||
await fetch("/api/ask/memories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-ask-user": actor.id },
|
||||
body: JSON.stringify({ text, shared: memoryShared }),
|
||||
});
|
||||
await refreshMemories(actor);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}, [memoryText, memoryShared, actor, refreshMemories]);
|
||||
|
||||
const deleteMemory = React.useCallback(
|
||||
async (id: string) => {
|
||||
if (!actor) return;
|
||||
setMemories((prev) => prev.filter((m) => m.id !== id));
|
||||
try {
|
||||
await fetch(`/api/ask/memories/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-ask-user": actor.id },
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
[actor],
|
||||
);
|
||||
|
||||
// ---- Threads: list, load, switch --------------------------------------
|
||||
const [threads, setThreads] = React.useState<ThreadSummary[]>([]);
|
||||
const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null);
|
||||
|
|
@ -802,6 +878,17 @@ function ChatViewInner() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{actor && (
|
||||
<button
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label="Memory"
|
||||
title="Memory"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-[#F2B165]/50 hover:text-foreground"
|
||||
>
|
||||
<Brain className="h-3.5 w-3.5" />
|
||||
Memory
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
|
|
@ -1109,6 +1196,86 @@ function ChatViewInner() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memoryOpen && (
|
||||
<>
|
||||
<button
|
||||
aria-label="Close memory drawer"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-black/30"
|
||||
/>
|
||||
<div className="fixed inset-y-0 right-0 z-50 w-80 max-w-[90vw] overflow-y-auto border-l border-border bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Memory</p>
|
||||
<button
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
aria-label="Close"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted/60"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5">
|
||||
<textarea
|
||||
value={memoryText}
|
||||
onChange={(e) => setMemoryText(e.target.value)}
|
||||
placeholder="Add something Ask should always know…"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-border bg-card px-2.5 py-1.5 text-sm outline-none focus:border-[#F2B165]/50"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memoryShared}
|
||||
onChange={(e) => setMemoryShared(e.target.checked)}
|
||||
/>
|
||||
Share with everyone
|
||||
</label>
|
||||
<button
|
||||
onClick={addMemory}
|
||||
disabled={!memoryText.trim()}
|
||||
className="rounded-lg bg-[#F2B165] px-2.5 py-1 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(["Shared with everyone", "Mine"] as const).map((label) => {
|
||||
const list = memories.filter((m) => (label === "Mine" ? !m.shared : m.shared));
|
||||
return (
|
||||
<div key={label} className="mb-4">
|
||||
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Nothing yet.</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{list.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-start justify-between gap-2 rounded-lg border border-border px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{m.text}</span>
|
||||
<button
|
||||
onClick={() => deleteMemory(m.id)}
|
||||
aria-label={`Forget "${m.text}"`}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
151
src/lib/agent/memory.test.ts
Normal file
151
src/lib/agent/memory.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const getFullListMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const getOneMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const filterMock = vi.fn((expr: string, params: Record<string, unknown>) => {
|
||||
let out = expr;
|
||||
for (const [k, v] of Object.entries(params)) out = out.replace(`{:${k}}`, String(v));
|
||||
return out;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/pb", () => ({
|
||||
pbAdmin: vi.fn(async () => ({
|
||||
filter: filterMock,
|
||||
collection: (name: string) => {
|
||||
if (name === "ask_log") {
|
||||
return {
|
||||
getFullList: getFullListMock,
|
||||
create: createMock,
|
||||
getOne: getOneMock,
|
||||
update: updateMock,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected collection in test: ${name}`);
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { remember, forget, listMemories, formatMemoriesForPrompt, type MemoryRow } from "./memory";
|
||||
|
||||
const ctx = { actor: { id: "u1", name: "Dashboard" } };
|
||||
|
||||
describe("remember", () => {
|
||||
beforeEach(() => {
|
||||
getFullListMock.mockReset();
|
||||
createMock.mockReset();
|
||||
});
|
||||
|
||||
it("creates a new memory, defaulting shared to false", async () => {
|
||||
getFullListMock.mockResolvedValue([]);
|
||||
createMock.mockResolvedValue({ id: "m1" });
|
||||
const result = await remember(ctx, { text: "The Hills works 45-hour weeks" });
|
||||
expect(result).toEqual({ id: "m1", deduped: false });
|
||||
expect(createMock.mock.calls[0][0]).toMatchObject({
|
||||
type: "memory",
|
||||
status: "applied",
|
||||
owner: "u1",
|
||||
shared: false,
|
||||
text: "The Hills works 45-hour weeks",
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes against an existing active memory with the same normalised text", async () => {
|
||||
getFullListMock.mockResolvedValue([
|
||||
{ id: "m0", owner: "u1", shared: false, text: " The Hills Works 45-Hour Weeks ", status: "applied", created: "1" },
|
||||
]);
|
||||
const result = await remember(ctx, { text: "the hills works 45-hour weeks" });
|
||||
expect(result).toEqual({ id: "m0", deduped: true });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can be marked shared", async () => {
|
||||
getFullListMock.mockResolvedValue([]);
|
||||
createMock.mockResolvedValue({ id: "m2" });
|
||||
await remember(ctx, { text: "Rates go up in April", shared: true });
|
||||
expect(createMock.mock.calls[0][0]).toMatchObject({ shared: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forget", () => {
|
||||
beforeEach(() => {
|
||||
getOneMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
});
|
||||
|
||||
it("marks an owned personal memory undone", async () => {
|
||||
getOneMock.mockResolvedValue({ id: "m1", owner: "u1", shared: false, status: "applied" });
|
||||
const result = await forget(ctx, { id: "m1" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(updateMock).toHaveBeenCalledWith("m1", { status: "undone" });
|
||||
});
|
||||
|
||||
it("refuses to forget someone else's personal memory", async () => {
|
||||
getOneMock.mockResolvedValue({ id: "m1", owner: "someone-else", shared: false, status: "applied" });
|
||||
const result = await forget(ctx, { id: "m1" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets anyone forget a shared memory", async () => {
|
||||
getOneMock.mockResolvedValue({ id: "m1", owner: "someone-else", shared: true, status: "applied" });
|
||||
const result = await forget(ctx, { id: "m1" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("is a no-op error when already undone", async () => {
|
||||
getOneMock.mockResolvedValue({ id: "m1", owner: "u1", shared: false, status: "undone" });
|
||||
const result = await forget(ctx, { id: "m1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMemories", () => {
|
||||
it("filters by site when given, always keeping site-less memories", async () => {
|
||||
getFullListMock.mockResolvedValue([
|
||||
{ id: "m1", owner: "u1", text: "a", site: "site-a", status: "applied", created: "1" },
|
||||
{ id: "m2", owner: "u1", text: "b", site: "site-b", status: "applied", created: "2" },
|
||||
{ id: "m3", owner: "u1", text: "c", status: "applied", created: "3" },
|
||||
]);
|
||||
const result = await listMemories(ctx, { site: "site-a" });
|
||||
expect(result.map((r) => r.id)).toEqual(["m1", "m3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoriesForPrompt", () => {
|
||||
const base: MemoryRow[] = [
|
||||
{ id: "1", shared: true, text: "shared old", status: "applied", created: "2020-01-01" },
|
||||
{ id: "2", shared: true, text: "shared new", status: "applied", created: "2020-02-01" },
|
||||
{ id: "3", shared: false, text: "mine old", status: "applied", created: "2020-01-15" },
|
||||
{ id: "4", shared: false, text: "mine new", status: "applied", created: "2020-02-15" },
|
||||
];
|
||||
|
||||
it("puts shared first, then personal, newest within each group", () => {
|
||||
const out = formatMemoriesForPrompt(base);
|
||||
expect(out).toBe(
|
||||
"- [shared] shared new\n- [shared] shared old\n- [mine] mine new\n- [mine] mine old",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the site tag when present", () => {
|
||||
const out = formatMemoriesForPrompt([
|
||||
{ id: "1", shared: true, text: "hours", site: "site-a", status: "applied", created: "1" },
|
||||
]);
|
||||
expect(out).toBe("- [shared][site-a] hours");
|
||||
});
|
||||
|
||||
it("truncates at maxChars and notes how many were omitted", () => {
|
||||
const many: MemoryRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: String(i),
|
||||
shared: false,
|
||||
text: `fact number ${i}`,
|
||||
status: "applied",
|
||||
created: String(i),
|
||||
}));
|
||||
const out = formatMemoriesForPrompt(many, 40);
|
||||
expect(out).toContain("more");
|
||||
expect(out).toContain("omitted");
|
||||
expect(out.length).toBeLessThan(120);
|
||||
});
|
||||
});
|
||||
118
src/lib/agent/memory.ts
Normal file
118
src/lib/agent/memory.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Long-term memory: lasting facts the user tells Ask, stored as `ask_log`
|
||||
* rows (`type: "memory"`) so they survive across threads and page loads.
|
||||
* Shared memories are visible to every user; personal ones only to their
|
||||
* owner. Forgetting keeps the row (status "undone") rather than deleting
|
||||
* it, matching the plan's Undo-friendly audit trail.
|
||||
*/
|
||||
import { pbAdmin } from "@/lib/pb";
|
||||
import type { AgentContext } from "./context";
|
||||
|
||||
export interface MemoryRow {
|
||||
id: string;
|
||||
owner?: string;
|
||||
shared?: boolean;
|
||||
text: string;
|
||||
site?: string;
|
||||
status: "applied" | "undone";
|
||||
created: string;
|
||||
}
|
||||
|
||||
/** Case/whitespace-insensitive comparison for the dedupe check. */
|
||||
function normalise(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory, or returns the id of an existing active one visible
|
||||
* to this user with the same normalised text (deduped, not duplicated).
|
||||
*/
|
||||
export async function remember(
|
||||
ctx: AgentContext,
|
||||
input: { text: string; site?: string; shared?: boolean },
|
||||
): Promise<{ id: string; deduped: boolean }> {
|
||||
const pb = await pbAdmin();
|
||||
const shared = input.shared ?? false;
|
||||
const norm = normalise(input.text);
|
||||
|
||||
const visible = (await pb.collection("ask_log").getFullList({
|
||||
filter: pb.filter("type = 'memory' && status = 'applied' && (owner = {:owner} || shared = true)", {
|
||||
owner: ctx.actor.id,
|
||||
}),
|
||||
})) as unknown as MemoryRow[];
|
||||
|
||||
const duplicate = visible.find((m) => normalise(m.text) === norm);
|
||||
if (duplicate) return { id: duplicate.id, deduped: true };
|
||||
|
||||
const created = await pb.collection("ask_log").create({
|
||||
type: "memory",
|
||||
status: "applied",
|
||||
owner: ctx.actor.id,
|
||||
shared,
|
||||
text: input.text,
|
||||
...(input.site ? { site: input.site } : {}),
|
||||
});
|
||||
return { id: created.id as string, deduped: false };
|
||||
}
|
||||
|
||||
/** Marks a memory `undone`. Personal memories can only be forgotten by their owner. */
|
||||
export async function forget(
|
||||
ctx: AgentContext,
|
||||
input: { id: string },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const pb = await pbAdmin();
|
||||
const row = (await pb.collection("ask_log").getOne(input.id)) as unknown as MemoryRow;
|
||||
if (row.status !== "applied") {
|
||||
return { ok: false, error: "That memory is not active." };
|
||||
}
|
||||
if (!row.shared && row.owner !== ctx.actor.id) {
|
||||
return { ok: false, error: "Only the owner can forget a personal memory." };
|
||||
}
|
||||
await pb.collection("ask_log").update(input.id, { status: "undone" });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** The actor's active memories, plus every active shared memory. */
|
||||
export async function listMemories(
|
||||
ctx: AgentContext,
|
||||
input: { site?: string } = {},
|
||||
): Promise<MemoryRow[]> {
|
||||
const pb = await pbAdmin();
|
||||
const rows = (await pb.collection("ask_log").getFullList({
|
||||
filter: pb.filter("type = 'memory' && status = 'applied' && (owner = {:owner} || shared = true)", {
|
||||
owner: ctx.actor.id,
|
||||
}),
|
||||
sort: "-created",
|
||||
})) as unknown as MemoryRow[];
|
||||
return input.site ? rows.filter((r) => !r.site || r.site === input.site) : rows;
|
||||
}
|
||||
|
||||
function byNewest(a: MemoryRow, b: MemoryRow): number {
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared memories first, then personal, newest within each group, as
|
||||
* `- [shared|mine][site] text` lines. Truncated at `maxChars`, with a
|
||||
* trailing note of how many memories were left out. Pure.
|
||||
*/
|
||||
export function formatMemoriesForPrompt(memories: MemoryRow[], maxChars = 4000): string {
|
||||
const shared = memories.filter((m) => m.shared).sort(byNewest);
|
||||
const personal = memories.filter((m) => !m.shared).sort(byNewest);
|
||||
const lines = [...shared, ...personal].map(
|
||||
(m) => `- [${m.shared ? "shared" : "mine"}]${m.site ? `[${m.site}]` : ""} ${m.text}`,
|
||||
);
|
||||
|
||||
const kept: string[] = [];
|
||||
let used = 0;
|
||||
for (const line of lines) {
|
||||
const cost = line.length + 1; // + the newline that joins it
|
||||
if (used + cost > maxChars) break;
|
||||
kept.push(line);
|
||||
used += cost;
|
||||
}
|
||||
const omitted = lines.length - kept.length;
|
||||
if (omitted === 0) return kept.join("\n");
|
||||
const note = `(${omitted} more ${omitted === 1 ? "memory" : "memories"} omitted)`;
|
||||
return kept.length > 0 ? `${kept.join("\n")}\n${note}` : note;
|
||||
}
|
||||
|
|
@ -3,12 +3,23 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The agent tool layer is read-only by contract. If a mutation appears
|
||||
* here, this test fails and the reviewer has to make an explicit
|
||||
* decision rather than shipping a silent write path to an LLM.
|
||||
* The agent tool layer is read-only by contract, with a short, explicit
|
||||
* allowlist of the modules the ask-agent-actions plan designates as
|
||||
* write seams: memories/requests go through their own tools (not the
|
||||
* gateway, per the plan's Global Constraints), and the gateway itself is
|
||||
* THE seam for proposed data changes. Every other file here failing this
|
||||
* test means a mutation snuck in through some other path, which the
|
||||
* reviewer must make an explicit decision about rather than shipping a
|
||||
* silent write path to an LLM.
|
||||
*/
|
||||
const DIRS = ["src/lib/agent", "src/lib/reports", "src/mcp"];
|
||||
const FORBIDDEN = [".create(", ".update(", ".delete(", ".unlinkFile("];
|
||||
const ALLOWED_WRITE_FILES = [
|
||||
"src/lib/agent/memory.ts",
|
||||
"src/lib/agent/tools-actions.ts",
|
||||
"src/lib/agent/gateway.ts",
|
||||
"src/lib/agent/images.ts",
|
||||
];
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
let out: string[] = [];
|
||||
|
|
@ -31,6 +42,7 @@ describe("agent layer is read-only", () => {
|
|||
continue; // directory may not exist yet
|
||||
}
|
||||
for (const f of files) {
|
||||
if (ALLOWED_WRITE_FILES.some((allowed) => f.endsWith(allowed))) continue;
|
||||
const src = readFileSync(f, "utf8");
|
||||
for (const bad of FORBIDDEN) {
|
||||
if (src.includes(bad)) offenders.push(`${f} contains ${bad}`);
|
||||
|
|
|
|||
35
src/lib/agent/tools-actions.test.ts
Normal file
35
src/lib/agent/tools-actions.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
const rememberMock = vi.fn();
|
||||
const forgetMock = vi.fn();
|
||||
const listMemoriesMock = vi.fn();
|
||||
|
||||
vi.mock("./memory", () => ({
|
||||
remember: (...args: unknown[]) => rememberMock(...args),
|
||||
forget: (...args: unknown[]) => forgetMock(...args),
|
||||
listMemories: (...args: unknown[]) => listMemoriesMock(...args),
|
||||
}));
|
||||
|
||||
import { actionTools } from "./tools-actions";
|
||||
|
||||
const ctx = { actor: { id: "u1", name: "Dashboard" } };
|
||||
|
||||
describe("actionTools", () => {
|
||||
it("wires remember, forget and list_memories to the actor's context", async () => {
|
||||
const tools = actionTools(ctx);
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
rememberMock.mockResolvedValue({ id: "m1", deduped: false });
|
||||
await byName.get("remember")!.handler({ text: "hi" });
|
||||
expect(rememberMock).toHaveBeenCalledWith(ctx, { text: "hi" });
|
||||
|
||||
forgetMock.mockResolvedValue({ ok: true });
|
||||
await byName.get("forget")!.handler({ id: "m1" });
|
||||
expect(forgetMock).toHaveBeenCalledWith(ctx, { id: "m1" });
|
||||
|
||||
listMemoriesMock.mockResolvedValue([{ id: "m1" }]);
|
||||
const result = await byName.get("list_memories")!.handler({});
|
||||
expect(listMemoriesMock).toHaveBeenCalledWith(ctx, {});
|
||||
expect(result).toEqual({ memories: [{ id: "m1" }] });
|
||||
});
|
||||
});
|
||||
50
src/lib/agent/tools-actions.ts
Normal file
50
src/lib/agent/tools-actions.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Tools that need to know *who's asking*.
|
||||
*
|
||||
* src/lib/agent/tools.ts's read-only set is a static array — every call
|
||||
* is anonymous and side-effect-free. These tools act on behalf of an
|
||||
* actor (memories today; queries, charts, proposed writes, attachments,
|
||||
* images and change requests from later tasks), and the shared `AgentTool`
|
||||
* shape (`(args) => Promise<unknown>`) has no room for a context
|
||||
* parameter. `actionTools(ctx)` builds the set fresh per request instead,
|
||||
* closing over `ctx` so each handler already knows the actor.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { AgentContext } from "./context";
|
||||
import type { AgentTool } from "./tools";
|
||||
import { remember, forget, listMemories } from "./memory";
|
||||
|
||||
export function actionTools(ctx: AgentContext): AgentTool[] {
|
||||
return [
|
||||
{
|
||||
name: "remember",
|
||||
description:
|
||||
"Stores a lasting fact, preference or rule so future conversations (including other users') can see it. Set shared:true for facts about the business itself (site hours, rates, standing rules); leave shared:false (the default) for this person's private preferences. Don't store one-off questions or figures that will change.",
|
||||
schema: {
|
||||
text: z.string().min(1),
|
||||
site: z.string().optional(),
|
||||
shared: z.boolean().optional(),
|
||||
},
|
||||
handler: async (args) => {
|
||||
const input = args as { text: string; site?: string; shared?: boolean };
|
||||
return remember(ctx, input);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forget",
|
||||
description:
|
||||
"Marks a memory as no longer active. It is kept (not deleted) so it can be told apart from one that was never stored.",
|
||||
schema: { id: z.string().min(1) },
|
||||
handler: async (args) => forget(ctx, args as { id: string }),
|
||||
},
|
||||
{
|
||||
name: "list_memories",
|
||||
description:
|
||||
"Lists the actor's active personal memories plus every active shared memory, optionally narrowed to one site.",
|
||||
schema: { site: z.string().optional() },
|
||||
handler: async (args) => ({
|
||||
memories: await listMemories(ctx, args as { site?: string }),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -19,4 +19,15 @@ describe("buildSystemPrompt", () => {
|
|||
expect(p).toContain("What I'd do next");
|
||||
expect(p).toContain("it does NOT mean profitable");
|
||||
});
|
||||
it("adds a What you remember section, with a placeholder when there are no memories", () => {
|
||||
const p = buildSystemPrompt(new Date());
|
||||
expect(p).toContain("## What you remember");
|
||||
expect(p).toContain("Nothing yet.");
|
||||
expect(p).toContain("call `remember`");
|
||||
});
|
||||
it("embeds the formatted memories block when given", () => {
|
||||
const p = buildSystemPrompt(new Date(), "- [shared] The Hills works 45-hour weeks");
|
||||
expect(p).toContain("- [shared] The Hills works 45-hour weeks");
|
||||
expect(p).not.toContain("Nothing yet.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,8 +14,12 @@ function johannesburgDate(now: Date): string {
|
|||
/**
|
||||
* Built per request. A module-level constant froze "today" at server
|
||||
* boot, so a server up since last week told the model the wrong date.
|
||||
*
|
||||
* `memoriesBlock` is the actor's formatted memories (see
|
||||
* `src/lib/agent/memory.ts`'s `formatMemoriesForPrompt`) — omitted
|
||||
* entirely (no heading) when there are none.
|
||||
*/
|
||||
export function buildSystemPrompt(now: Date): string {
|
||||
export function buildSystemPrompt(now: Date, memoriesBlock = ""): string {
|
||||
return `You are the assistant for MEGA INFRASTRUCTURE, a South African civil construction company. You answer questions about live site, fleet and purchasing data.
|
||||
|
||||
## How to answer
|
||||
|
|
@ -61,5 +65,9 @@ For /findings and improvement reports only, end with "My read" (what the figures
|
|||
## Links
|
||||
Tool results include dashboard links (href). Link the relevant page with markdown, e.g. [Center Point Grader](/dashboard/plant/equipment/abc123).
|
||||
|
||||
## What you remember
|
||||
${memoriesBlock || "Nothing yet."}
|
||||
When the user tells you a lasting preference, fact or rule, call \`remember\`. Don't store one-off questions or figures that change.
|
||||
|
||||
Today's date is ${johannesburgDate(now)}.`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue