Memory V1: remembered facts, your instructions, one screen

Coworkers remember durable things you tell them and use them in future sessions.
One Settings screen lists everything remembered - edit, delete, or stop new saves; standing instructions ride along.
Knowledge is session-stable, the save switch is per-message; sqlite gains a summary column via in-place migration.
This commit is contained in:
Devika Verma
2026-07-28 20:32:14 +05:30
parent 3766805d10
commit ef59b0f39a
19 changed files with 1717 additions and 47 deletions
+36 -2
View File
@@ -8,7 +8,10 @@ import {
getSessionMessages,
getSessions,
announceAutomationsChanged,
announceMemoryChanged,
connectEvents,
deleteMemory,
updateMemory,
getSettings,
getPersonas,
getInbox,
@@ -189,10 +192,10 @@ export function App() {
const [scheduledOpenId, setScheduledOpenId] = useState<string | null>(null);
const [gateCreate, setGateCreate] = useState(false);
// Which Settings section the full-page Settings surface opens on (§ Settings-as-page).
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "personas">(
const [settingsTab, setSettingsTab] = useState<"appearance" | "models" | "voice" | "memory" | "personas">(
"appearance",
);
const openSettings = (tab: "appearance" | "models" | "voice" | "personas" = "appearance") => {
const openSettings = (tab: "appearance" | "models" | "voice" | "memory" | "personas" = "appearance") => {
setSettingsTab(tab);
setSurface("settings");
};
@@ -689,6 +692,24 @@ export function App() {
if (d.model) setModel(d.model);
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
break;
case "memory_saved":
// §5.1 save notice — inline in the transcript, where the user is already
// looking and where it keeps until they act (a corner toast disappeared
// before it could be read or undone — owner-hit 2026-07-28). Summary is the
// friendly one-liner; content is the fallback when the model skipped it.
setItems((p) => [
...p,
{
kind: "memory",
id: Number(d.id),
text: String(d.summary || d.content || ""),
// Present when an existing memory was edited rather than added — the
// notice says so, and Undo restores this text instead of deleting.
...(d.previous ? { previous: String(d.previous) } : {}),
},
]);
announceMemoryChanged(); // Settings ▸ Memory, if open, is now stale
break;
case "interrupted":
flushPartialStream();
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
@@ -926,6 +947,18 @@ export function App() {
return () => window.clearTimeout(t);
}, [runToast]);
// MEMORY-SPEC §5.1: undo a write the transcript just announced. A new memory is
// deleted; an EDIT is rolled back to its previous text (deleting there would throw
// away whatever the memory already held). The notice confirms in place either way.
const undoMemorySave = async (id: number, previous?: string) => {
if (previous) await updateMemory(id, previous).catch(() => {});
else await deleteMemory(id).catch(() => {});
announceMemoryChanged();
setItems((p) =>
p.map((it) => (it.kind === "memory" && it.id === id ? { ...it, undone: true } : it)),
);
};
const openSessionFromInbox = (sid: string, ws: string, ag: string) => selectSession(sid, ws, ag);
const selectSession = async (id: string, ws: string, ag: string) => {
setSurface("session"); // selecting a conversation always returns to the conversation view
@@ -1470,6 +1503,7 @@ export function App() {
onApprove={approve}
running={running}
onRetry={retry}
onUndoMemory={(id, previous) => void undoMemorySave(id, previous)}
// §33 ref #3: sub-threshold streamed text renders INSIDE the live turn
// group (header when collapsed, quiet line when expanded) — never as a
// floating paragraph.
+67
View File
@@ -1256,6 +1256,73 @@ export async function setOnboarded(value: boolean): Promise<{ ok: boolean; onboa
return res.json();
}
// -- Memory (MEMORY-SPEC §5.3/§6: the memory screen, user rules, toast Undo) ----
export interface MemoryEntry {
id: number;
scope: string;
content: string;
summary: string;
created_at: string;
}
export interface MemorySettings {
enabled: boolean;
user_rules: string;
}
// Fired whenever memory changes from OUTSIDE the memory screen — today the agent
// saving or editing one mid-conversation. The screen only loads its list on mount, so
// without this it sits there stale and the user reads "Nothing yet" seconds after a
// save actually landed (owner-hit 2026-07-28).
export const MEMORY_CHANGED = "coworker:memory-changed";
export function announceMemoryChanged() {
window.dispatchEvent(new CustomEvent(MEMORY_CHANGED));
}
export async function getMemory(): Promise<MemoryEntry[]> {
const res = await fetch(`${httpBase()}/v1/memory`);
return (await res.json()).memory ?? [];
}
export async function updateMemory(
id: number,
content: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/memory/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
return res.json();
}
export async function deleteMemory(id: number): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/memory/${id}`, { method: "DELETE" });
return res.json();
}
export async function deleteAllMemory(): Promise<{ ok: boolean; deleted: number }> {
const res = await fetch(`${httpBase()}/v1/memory`, { method: "DELETE" });
return res.json();
}
export async function getMemorySettings(): Promise<MemorySettings> {
const res = await fetch(`${httpBase()}/v1/memory/settings`);
return res.json();
}
export async function setMemorySettings(
patch: Partial<MemorySettings>,
): Promise<MemorySettings> {
const res = await fetch(`${httpBase()}/v1/memory/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return res.json();
}
// -- model providers (OpenAI, Ollama, …) --------------------------------------
export interface ProviderField {
key: string;
@@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import {
deleteAllMemory,
deleteMemory,
getMemory,
getMemorySettings,
setMemorySettings,
updateMemory,
MEMORY_CHANGED,
type MemoryEntry,
type MemorySettings,
} from "../api";
import { Icon } from "./Icon";
import { PanelHead } from "./IntegrationsView";
import { Toggle } from "./Toggle";
// MEMORY-SPEC §5.3: the one memory screen. A plain-language list of remembered facts
// (edit/delete per row), the on/off toggle, delete-all, and the User Rules textarea —
// no scope vocabulary, no markdown, no files. Everything else memory does happens in
// chat (toast §5.1, attribution §5.2).
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
const FIELD_HELP = "text-[12px] text-muted mt-1.5 leading-relaxed";
const BTN_ACCENT =
"text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40";
export function MemorySection() {
const [settings, setSettings] = useState<MemorySettings | null>(null);
const [entries, setEntries] = useState<MemoryEntry[] | null>(null);
// State-change copy (§5.3): shown under the toggle / list after an action.
const [toggleMsg, setToggleMsg] = useState<string | null>(null);
const [listMsg, setListMsg] = useState<string | null>(null);
const refresh = () => {
getMemorySettings().then(setSettings).catch(() => setSettings(null));
getMemory().then(setEntries).catch(() => setEntries([]));
};
useEffect(refresh, []);
// Stay current while the screen is open: a save/edit landing in a conversation, or
// the window regaining focus after one did. Without this the list is a snapshot from
// whenever the page mounted — it showed "Nothing yet" seconds after a real save
// (owner-hit 2026-07-28), which reads as "it didn't work".
useEffect(() => {
window.addEventListener(MEMORY_CHANGED, refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener(MEMORY_CHANGED, refresh);
window.removeEventListener("focus", refresh);
};
}, []);
const toggleEnabled = async () => {
if (!settings) return;
const next = await setMemorySettings({ enabled: !settings.enabled });
setSettings(next);
setToggleMsg(
next.enabled
? "Details you share from future conversations will be remembered, so I can be more helpful over time."
: "I'll stop remembering new things about you. What I already know is kept and still used — delete anything below you'd rather I forget.",
);
};
const wipeAll = async () => {
if (
!window.confirm(
"Delete everything that's been remembered about you?\n\n" +
"This can't be undone. Conversations you already have open still know what " +
"they knew — new conversations start with a clean slate.",
)
)
return;
await deleteAllMemory();
setListMsg(
"Everything I remembered has been deleted. New conversations start fresh; ones " +
"you already have open still know what they knew when they started.",
);
refresh();
};
if (!settings || entries === null)
return <div className="text-[13px] text-muted">Loading</div>;
return (
<section>
<PanelHead
title="Memory"
sub="Your coworkers can remember useful things about you between conversations. Everything they know is listed here."
/>
{/* On/off — one switch, no other setup (§5.4). */}
<div className={CARD + " p-4 mb-4"} data-testid="memory-toggle-card">
<div className="flex items-center gap-3">
<Toggle checked={settings.enabled} onChange={toggleEnabled} title="Remember new things about you" />
<div className="min-w-0 flex-1">
<div className={FIELD_LABEL}>Remember new things about me</div>
<div className="text-[12px] text-muted mt-0.5">
Lasting preferences you mention in chat get saved and used in future conversations
you'll see a small note each time, with one-tap Undo. Turning this off stops new
saves; anything already below is still used until you delete it.
</div>
</div>
</div>
{toggleMsg && (
<div className="text-[12.5px] text-muted mt-3 pt-3 border-t border-line" data-testid="memory-toggle-msg">
{toggleMsg}
</div>
)}
</div>
{/* What I've learned (§5.3): directly under the toggle that governs it the off
message ("what I already know is kept, delete it below") points here. */}
<div className={CARD + " p-4 mb-4"} data-testid="memory-list-card">
<div className="flex items-center gap-2">
<div className={FIELD_LABEL + " flex-1"}>What I've learned about you</div>
{entries.length > 0 && (
<button
className="text-[12px] text-danger/80 hover:text-danger"
data-testid="memory-delete-all"
onClick={wipeAll}
>
Forget everything
</button>
)}
</div>
<div className={FIELD_HELP}>
Saved automatically from your conversations. Fix anything that's wrong or delete it.
Edits and deletions apply to new conversations; ones you already have open keep what
they knew when they started.
</div>
{listMsg && (
<div className="text-[12.5px] text-muted mt-2.5" data-testid="memory-list-msg">
{listMsg}
</div>
)}
{entries.length === 0 ? (
!listMsg && (
<div className="text-[12px] text-muted mt-3" data-testid="memory-empty">
Nothing yet. When you mention a lasting preference in chat or say "remember
that" it will show up here.
</div>
)
) : (
<div className="mt-3 divide-y divide-line">
{entries.map((m) => (
<MemoryRow key={m.id} entry={m} onChanged={refresh} />
))}
</div>
)}
</div>
{/* Your instructions (§6): user-authored, toggle-independent so it sits apart
from the auto-memory pair above. The agent never edits these. */}
<UserRulesCard settings={settings} onSaved={setSettings} />
</section>
);
}
function UserRulesCard({
settings,
onSaved,
}: {
settings: MemorySettings;
onSaved: (s: MemorySettings) => void;
}) {
const [draft, setDraft] = useState(settings.user_rules);
const [savedMsg, setSavedMsg] = useState(false);
const save = async () => {
const next = await setMemorySettings({ user_rules: draft });
onSaved(next);
setSavedMsg(true);
window.setTimeout(() => setSavedMsg(false), 3000);
};
return (
<div className={CARD + " p-4"} data-testid="user-rules-card">
<div className={FIELD_LABEL}>Your instructions</div>
<div className={FIELD_HELP}>
Your coworkers follow these in every conversation.
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={4}
placeholder={
"I use a screen reader — no tables, describe any image\n" +
"Use DD-MM-YYYY for dates"
}
data-testid="user-rules-input"
className="w-full mt-2.5 px-3 py-2.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent resize-y leading-relaxed"
/>
<div className="flex items-center gap-3 mt-2">
<button
className={BTN_ACCENT}
onClick={save}
disabled={draft === settings.user_rules}
data-testid="user-rules-save"
>
Save
</button>
{savedMsg && (
<span className="text-[12.5px] text-muted">
Saved applies to new conversations. Ones you already have open keep the
instructions they started with.
</span>
)}
</div>
</div>
);
}
function MemoryRow({ entry, onChanged }: { entry: MemoryEntry; onChanged: () => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(entry.content);
const save = async () => {
const text = draft.trim();
if (text && text !== entry.content) await updateMemory(entry.id, text);
setEditing(false);
onChanged();
};
const remove = async () => {
await deleteMemory(entry.id);
onChanged();
};
if (editing)
return (
<div className="py-2.5" data-testid={`memory-edit-${entry.id}`}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
autoFocus
className="w-full px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent resize-y leading-relaxed"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void save();
}
if (e.key === "Escape") setEditing(false);
}}
/>
<div className="flex items-center gap-2.5 mt-1.5">
<button className={BTN_ACCENT} onClick={() => void save()}>
Save
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={() => setEditing(false)}>
cancel
</button>
</div>
</div>
);
return (
<div className="py-2.5 flex items-start gap-2.5 group" data-testid={`memory-row-${entry.id}`}>
<div className="min-w-0 flex-1 text-[13px] leading-relaxed">{entry.content}</div>
<button
className="text-faint hover:text-ink shrink-0 mt-0.5"
title="Fix this"
data-testid={`memory-edit-btn-${entry.id}`}
onClick={() => {
setDraft(entry.content);
setEditing(true);
}}
>
<Icon name="pencil" size={14} />
</button>
<button
className="text-faint hover:text-danger shrink-0 mt-0.5"
title="Delete this memory"
data-testid={`memory-delete-${entry.id}`}
onClick={() => void remove()}
>
<Icon name="trash" size={14} />
</button>
</div>
);
}
+6 -2
View File
@@ -36,6 +36,7 @@ import { useThemePref } from "../theme";
import { Icon } from "./Icon";
import { PanelHead } from "./IntegrationsView";
import { ModelsTab } from "./ManageTabs";
import { MemorySection } from "./MemorySection";
import { GalleryModal } from "./GalleryModal";
import { PersonasTab } from "./PersonasTab";
import { showPersonas } from "../flags";
@@ -47,7 +48,7 @@ import { showPersonas } from "../flags";
// Models + Personas host the existing tab components inside the page shell (field re-skin to follow).
// "appearance" is the General tab's stable key — callers deep-link with it, so the
// rename (UX-021) changed only the label. "files" folded into General as a card.
type SetTab = "appearance" | "models" | "voice" | "personas";
type SetTab = "appearance" | "models" | "voice" | "memory" | "personas";
const CARD = "rounded-xl2 border border-line bg-panel";
const FIELD_LABEL = "text-[12.5px] font-medium text-ink";
@@ -58,10 +59,11 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri
const BTN_BORDERED =
"text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "sparkle" }[] = [
const SET_TABS: { key: SetTab; label: string; icon: "sliders" | "code" | "mic" | "archive" | "sparkle" }[] = [
{ key: "appearance", label: "General", icon: "sliders" },
{ key: "models", label: "Models", icon: "code" },
{ key: "voice", label: "Voice input", icon: "mic" },
{ key: "memory", label: "Memory", icon: "archive" },
{ key: "personas", label: "Personas", icon: "sparkle" },
];
@@ -122,6 +124,8 @@ export function SettingsView({
</section>
) : tab === "voice" ? (
<VoiceInputSection />
) : tab === "memory" ? (
<MemorySection />
) : (
<PersonasSection onOpenPersona={onOpenPersona} />
)}
@@ -180,6 +180,64 @@ describe("bubble hover affordances (FB-005)", () => {
});
});
// MEMORY-SPEC §5.1 — the save notice lives IN the conversation (a corner toast vanished
// before it could be read or undone, owner-hit 2026-07-28) and stays until acted on.
describe("memory save notice", () => {
it("announces the save inline and offers Undo", () => {
const onUndo = vi.fn();
render(
<Transcript
items={[{ kind: "memory", id: 7, text: "prefers short replies" }]}
onApprove={vi.fn()}
onUndoMemory={onUndo}
/>,
);
const notice = screen.getByTestId("memory-notice");
expect(notice.textContent).toContain("I'll remember that");
expect(notice.textContent).toContain("prefers short replies");
fireEvent.click(screen.getByTestId("memory-notice-undo"));
// No `previous` on a brand-new save — undo deletes it outright.
expect(onUndo).toHaveBeenCalledWith(7, undefined);
});
it("says an existing memory was UPDATED and undoes by restoring its old text", () => {
const onUndo = vi.fn();
render(
<Transcript
items={[
{
kind: "memory",
id: 4,
text: "diabetic, lactose-free, likes ice cream",
previous: "diabetic, lactose-free",
},
]}
onApprove={vi.fn()}
onUndoMemory={onUndo}
/>,
);
expect(screen.getByTestId("memory-notice").textContent).toContain(
"I've updated what I remember",
);
fireEvent.click(screen.getByTestId("memory-notice-undo"));
// Undo restores the previous wording rather than deleting the whole memory.
expect(onUndo).toHaveBeenCalledWith(4, "diabetic, lactose-free");
});
it("confirms in place once undone, with no Undo left to click", () => {
render(
<Transcript
items={[{ kind: "memory", id: 7, text: "prefers short replies", undone: true }]}
onApprove={vi.fn()}
onUndoMemory={vi.fn()}
/>,
);
expect(screen.getByTestId("memory-notice-undone").textContent).toContain("forgotten");
expect(screen.queryByTestId("memory-notice-undo")).toBeNull();
});
});
describe("humanizeTool", () => {
it("prefers run_shell's model-written description and keeps the command as the object", () => {
const line = humanizeTool("run_shell", { command: "git log --since=yesterday", description: "List yesterday's merges" });
+38 -1
View File
@@ -311,6 +311,9 @@ interface Props {
// Re-run the failed turn (no new user message). Offered only on a retriable notice that
// is the transcript tail of an idle session — anywhere else the error is history.
onRetry?: () => void;
// MEMORY-SPEC §5.1: undo a just-announced write. `previous` (set when the write was
// an edit) is the text to restore; without it the memory is deleted.
onUndoMemory?: (id: number, previous?: string) => void;
}
// The transcript index whose notice gets the Retry button: the tail error notice, looking
@@ -326,7 +329,7 @@ export function retryAnchor(items: Item[]): number {
return -1;
}
export function Transcript({ items, running, streamingText, onRetry }: Props) {
export function Transcript({ items, running, streamingText, onRetry, onUndoMemory }: Props) {
// §33 grouping: a turn = the maximal run of assistant/tool/resolved-approval items between
// breakers (user, connector, notices, plan/dir requests…). Trailing assistant texts are the
// ANSWER and render as bubbles after the group; interior assistant texts are narration and
@@ -455,6 +458,40 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
)}
</div>
);
// §5.1 save notice: quiet, inline, and it STAYS — the user reads it in place
// and can undo whenever they get to it.
case "memory":
return (
<div
className="notice flex items-center gap-2 text-left"
data-testid="memory-notice"
key={bi}
>
{item.undone ? (
<span data-testid="memory-notice-undone">
{item.previous ? "Okay — put back the way it was." : "Okay — forgotten."}
</span>
) : (
<>
<span className="min-w-0">
<span className="font-medium">
{item.previous ? "I've updated what I remember" : "I'll remember that"}
</span>
{item.text ? <span className="text-muted"> {item.text}</span> : null}
</span>
{onUndoMemory && (
<button
className="btn ml-auto shrink-0"
data-testid="memory-notice-undo"
onClick={() => onUndoMemory(item.id, item.previous)}
>
Undo
</button>
)}
</>
)}
</div>
);
default:
return null;
}
+8 -1
View File
@@ -18,6 +18,7 @@ export type EventType =
| "input_rejected"
| "interrupted"
| "model_changed"
| "memory_saved"
| "turn_done";
export interface WsEvent {
@@ -117,4 +118,10 @@ export type Item =
multi?: boolean;
resolved?: string;
}
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean }
// MEMORY-SPEC §5.1: the save notice, inline in the conversation where the user is
// already looking (a corner toast vanished before it could be read or undone —
// owner-hit 2026-07-28). Stays put. `previous` is set when an existing memory was
// EDITED rather than a new one added (the update-don't-duplicate rule sends many
// saves that way) — Undo restores that text instead of deleting the memory.
| { kind: "memory"; id: number; text: string; previous?: string; undone?: boolean };