From ef59b0f39aa8b4d20b9a5305285c1424e20b4015 Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Tue, 28 Jul 2026 20:31:29 +0530 Subject: [PATCH] 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. --- coworker/agent.py | 111 +++- coworker/cli.py | 7 +- coworker/memory/__init__.py | 16 +- coworker/memory/base.py | 72 ++- coworker/memory/settings.py | 75 +++ coworker/memory/sqlite_store.py | 45 +- coworker/memory/tools.py | 101 +++- coworker/server/app.py | 28 +- coworker/server/manager.py | 95 +++- coworker/tui/app.py | 6 + surfaces/gui/src/App.tsx | 38 +- surfaces/gui/src/api.ts | 67 +++ surfaces/gui/src/components/MemorySection.tsx | 279 ++++++++++ surfaces/gui/src/components/SettingsView.tsx | 8 +- .../gui/src/components/Transcript.test.tsx | 58 ++ surfaces/gui/src/components/Transcript.tsx | 39 +- surfaces/gui/src/types.ts | 9 +- tests/test_memory.py | 513 +++++++++++++++++- tests/test_memory_api.py | 197 +++++++ 19 files changed, 1717 insertions(+), 47 deletions(-) create mode 100644 coworker/memory/settings.py create mode 100644 surfaces/gui/src/components/MemorySection.tsx create mode 100644 tests/test_memory_api.py diff --git a/coworker/agent.py b/coworker/agent.py index d24f5c59..b8d8f069 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -23,7 +23,13 @@ from .connectors import ( ) from .engine import Approver, TurnEngine from .environment import environment_context -from .memory import MemoryStore, Scope, format_memories, memory_tools +from .memory import ( + MemoryStore, + Scope, + format_user_rules, + memory_tools, + render_memory_block, +) from .permissions import Mode, PermissionEngine from .project import load_agents_md from .roots import RootDir, normalize_roots, render_context @@ -57,21 +63,50 @@ in which files, how you'll verify) — don't describe edits as if you were makin the plan is approved, this same session switches to execution and you implement it; if rejected, revise the plan using the feedback.""" -# When-to-remember rules, injected only when a memory store is wired. Without these, -# models either never call `remember` or save noise the repo already records. +# When-to-remember rules (MEMORY-SPEC §4.2), injected only when a memory store is wired. +# Without these, models either never call `remember` or save noise the repo already +# records. The conservative bias is deliberate: a wrong memory feels broken and creepy at +# once; a missing one merely means the user repeats themselves. _MEMORY_GUIDANCE = """\ Memory: - You have persistent memory across sessions. Use `remember` for durable facts: the user's \ corrections and stated preferences (include the why), and project context you couldn't \ -rederive from the code. Don't save what the repo already records (code structure, git \ -history, AGENTS.md) or details that only matter to the current task. Use absolute dates, \ -never "yesterday". +rederive from the code. Scope by what the fact is about: facts about the user -> "global"; \ +facts about the current work -> "workspace". Always pass a one-line summary (15 words max) \ +alongside the full content. +- Save conservatively — a wrong memory costs more than a missing one. Save only clearly \ +durable facts ("from now on", "always", "in all my chats"). Ambiguous one-off phrasing \ +("I prefer simple talking"): apply it now, don't save it. But when the user explicitly \ +asks you to remember something, always save it. +- Sensitive topics (health, finances, relationships, beliefs): never save silently. Ask \ +first — "Want me to remember this for next time?" — and save only on a yes. +- When you save, say so in one short plain sentence in your visible reply ("I'll remember \ +that you prefer short replies."). And the first time a remembered fact shapes your \ +behavior in a session, note it in one quiet line ("Keeping this short since you prefer \ +simple replies.") — first use only, not every message. +- Don't save what the repo already records (code structure, git history, AGENTS.md) or \ +details that only matter to the current task. Use absolute dates, never "yesterday". - Before saving, check the known-memories list: if an entry already covers it, revise that \ entry with `memory_update` instead of adding a near-duplicate; retire wrong or obsolete \ entries with `memory_forget`. - Memories reflect when they were written. If one names a file, flag, or URL, verify it \ still exists before relying on it.""" +# Injected INSTEAD of the memory guidance when the user turned memory off (§4.3). +# Off means "stop LEARNING", not "forget what you know": already-saved memories stay +# injected and usable; only the write tools are gone. Without this notice the model +# bluffs — asked to "remember" with no remember tool, it narrated a fake save through +# its todo list ("I'll remember that your favorite color is blue"), observed live +# 2026-07-28. Honesty needs the model to KNOW saving is off, not just lack the tools. +_MEMORY_OFF_NOTICE = """\ +Saving new memories is turned off in this user's Settings. What you already know about \ +them (the known-memories list, if any) is still true and you should keep using it — but \ +you have no way to save, change, or delete anything, and nothing new from this \ +conversation will carry over to future ones. If the user asks you to remember something \ +new, state both halves plainly: you'll keep it in mind for the rest of this conversation, \ +but it won't be saved once the conversation ends — they can turn saving back on in \ +Settings ▸ Memory. Never imply you saved, noted, or will remember anything new.""" + # UX-015 (§33): the GUI interleaves these status lines with humanized tool rows inside a # collapsed "turn" — they're what the user reads while the agent works. Universal (appended # for every persona); models that ignore it degrade gracefully to a turn with no narration. @@ -118,6 +153,21 @@ def build_engine( max_iterations: Optional[int] = None, model_settings: Optional[dict[str, Any]] = None, memory_store: Optional[MemoryStore] = None, + # MEMORY-SPEC §5.1: called with the MemoryItem right after `remember` persists it — + # the manager uses this to push the memory_saved event that powers the save toast. + on_memory_saved: Optional[Any] = None, + # MEMORY-SPEC §6: the user's standing rules (Settings textarea). Injected verbatim + # above auto memories; independent of the memory on/off switch. No tool writes it. + # A CALLABLE is read per turn (the server passes one so a Settings edit reaches + # conversations already open); a plain string is a fixed value for CLI/tests. + user_rules: Optional[Any] = None, + # True when the user turned memory OFF in Settings (vs. memory simply not wired): + # injects the honesty notice so the model says so instead of faking a save. + memory_off: bool = False, + # LIVE saving switch, consulted per write so turning memory off applies to + # conversations already running (the registry is fixed at build, so the tool stays + # and refuses). Same pattern as the skills menu's live filter. + memory_saving_enabled: Optional[Any] = None, messages: Optional[list[dict[str, Any]]] = None, extra_tools: Optional[list[Any]] = None, secrets: Optional[SecretStore] = None, @@ -247,15 +297,46 @@ def build_engine( if conventions: instructions = f"{instructions}\n\n{conventions}" + # The user's own standing instructions, read once here: like the memories below, + # they're session-stable knowledge. Edits apply to NEW conversations (the Settings + # copy says exactly that), never mid-conversation. + rules_block = format_user_rules( + (user_rules() if callable(user_rules) else user_rules) or "" + ) + if rules_block: + instructions = f"{instructions}\n\n{rules_block}" + + # The live saving switch. The callable (server) beats the build-time flag (CLI/tests): + # the setting can flip EITHER WAY mid-conversation, so nothing about it may be baked + # into the fixed registry or the static instructions (owner-hit 2026-07-28, both + # directions: off kept saving, then on kept claiming it was off). + def _saving_enabled() -> bool: + if memory_saving_enabled is not None: + return bool(memory_saving_enabled()) + return not memory_off + if memory_store is not None: + # Always the full toolset: the registry is fixed at build, so a session born + # while saving was off must still be able to save the moment it's turned on. + # Enforcement is the tools' own live check, not their absence. registry.register_all( - memory_tools(memory_store, workspace=str(ws) if ws else None) + memory_tools( + memory_store, + workspace=str(ws) if ws else None, + on_saved=on_memory_saved, + saving_enabled=_saving_enabled, + ) ) instructions = f"{instructions}\n\n{_MEMORY_GUIDANCE}" + # What the coworker KNOWS is fixed at session start (MEMORY-SPEC §7.1): a + # conversation's knowledge must not shift underfoot — a fact it referenced ten + # turns ago cannot silently vanish — and the system prompt is the cached prefix, + # so the facts are processed once instead of re-sent every turn. Deletions reach + # NEW conversations; the UI says so rather than pretending otherwise. remembered = memory_store.list(scope=Scope.GLOBAL) if ws is not None: remembered += memory_store.list(scope=Scope.WORKSPACE, workspace=str(ws)) - block = format_memories(remembered) + block = render_memory_block(remembered) if block: instructions = f"{instructions}\n\n{block}" @@ -285,9 +366,12 @@ def build_engine( registry.register(propose_plan_tool()) # Per-turn ephemeral context, appended to the latest user message since mid-thread system - # messages aren't reliable across providers. Two producers: the plan-mode reminder (mode can - # flip mid-session, so it's checked each turn, not baked into the instructions) and the live - # directory list (orphan Cowork can gain folders mid-session; Cowork/MyHelper only). + # messages aren't reliable across providers. Three producers: the plan-mode reminder (mode can + # flip mid-session, so it's checked each turn, not baked into the instructions), the live + # directory list (orphan Cowork can gain folders mid-session; Cowork/MyHelper only), and the + # memory-SAVING notice (same reason as plan mode — the switch flips either way mid-chat). + # Note what is NOT here: the memories and the user's rules. Those are knowledge, fixed at + # session start (§7.1). roots_context = ( (lambda: render_context(root_list)) if root_list and agent.family == "knowledge" @@ -300,6 +384,11 @@ def build_engine( parts.append(_PLAN_MODE_CONTEXT) elif permissions.mode is Mode.DISCUSS: parts.append(_DISCUSS_MODE_CONTEXT) + # Only the SAVING switch is per-turn (§4.3): it governs an action, not + # knowledge, so it must bite the moment the user flips it. What the coworker + # knows stays fixed for the session — see the instructions built above. + if memory_store is not None and not _saving_enabled(): + parts.append(_MEMORY_OFF_NOTICE) if roots_context is not None: ctx = roots_context() if ctx: diff --git a/coworker/cli.py b/coworker/cli.py index ce6dba1e..2fa17f33 100644 --- a/coworker/cli.py +++ b/coworker/cli.py @@ -10,7 +10,7 @@ from typing import Optional from .config import load_config from .conversations import ConversationStore -from .memory import SQLiteMemoryStore +from .memory import MemorySettingsStore, SQLiteMemoryStore from .permissions import Mode from .secrets import state_dir @@ -39,6 +39,9 @@ def main(argv: Optional[list[str]] = None) -> None: workspace = Path(args.cwd).expanduser().resolve() # Unified global store shared with the GUI/server (one place for all conversations). data_dir = state_dir() + # Same on/off switch and user rules the GUI manages (MEMORY-SPEC §4.3/§6). The + # store is always wired: off means "stop learning", so saved facts stay usable. + memory_settings = MemorySettingsStore(data_dir / "memory-settings.json") memory_store = SQLiteMemoryStore(data_dir / "coworker.db") session_store = ConversationStore(data_dir) session_store.touch_workspace(os.path.realpath(str(workspace))) @@ -59,6 +62,8 @@ def main(argv: Optional[list[str]] = None) -> None: model=model, mode=Mode(mode), memory_store=memory_store, + memory_off=not memory_settings.enabled, + user_rules=memory_settings.user_rules, session_store=session_store, session_id=session_id, resume_messages=resume_messages, diff --git a/coworker/memory/__init__.py b/coworker/memory/__init__.py index 6aa9e8e6..72895fe5 100644 --- a/coworker/memory/__init__.py +++ b/coworker/memory/__init__.py @@ -1,12 +1,26 @@ -from .base import MemoryItem, MemoryStore, Scope, format_memories +from .base import ( + INDEX_THRESHOLD_CHARS, + MemoryItem, + MemoryStore, + Scope, + format_memories, + format_memory_index, + render_memory_block, +) +from .settings import MemorySettingsStore, format_user_rules from .sqlite_store import SQLiteMemoryStore from .tools import memory_tools __all__ = [ + "INDEX_THRESHOLD_CHARS", "MemoryItem", "MemoryStore", + "MemorySettingsStore", "Scope", "format_memories", + "format_memory_index", + "format_user_rules", + "render_memory_block", "SQLiteMemoryStore", "memory_tools", ] diff --git a/coworker/memory/base.py b/coworker/memory/base.py index 27d99158..f6c12e09 100644 --- a/coworker/memory/base.py +++ b/coworker/memory/base.py @@ -25,6 +25,7 @@ class MemoryItem: scope: Scope content: str key: Optional[str] = None + summary: Optional[str] = None workspace: Optional[str] = None session_id: Optional[str] = None created_at: Optional[str] = None @@ -38,6 +39,7 @@ class MemoryStore(ABC): *, scope: Scope = Scope.WORKSPACE, key: Optional[str] = None, + summary: Optional[str] = None, workspace: Optional[str] = None, session_id: Optional[str] = None, ) -> MemoryItem: ... @@ -55,16 +57,80 @@ class MemoryStore(ABC): ) -> list[MemoryItem]: ... @abstractmethod - def update(self, item_id: int, content: str) -> Optional[MemoryItem]: ... + def update( + self, item_id: int, content: str, *, summary: Optional[str] = None + ) -> Optional[MemoryItem]: ... @abstractmethod def delete(self, item_id: int) -> bool: ... + @abstractmethod + def delete_all(self, *, scope: Optional[Scope] = None) -> int: ... + + +# MEMORY-SPEC §7: below this rendered size, every memory is injected in full; above it, +# the block flips to index mode (newest few in full, one-line summaries for the rest, +# bodies fetched on demand via memory_read). ~2k tokens: a typical memory is 20-40 +# tokens, so this only trips past ~50-100 memories — and the weakest supported setup +# (a local model with an 8k context) binds the ceiling. +INDEX_THRESHOLD_CHARS = 8_000 +# In index mode the newest N stay in full: recent facts are disproportionately relevant, +# which softens the two-step recall cost where it matters most. +INDEX_FULL_NEWEST = 10 + +_INDEX_NOTE = ( + "(Some memories above show only a one-line summary. Call memory_read with the " + "[#id]s before acting on anything a summary hints at.)" +) + + +def _index_line(item: MemoryItem) -> str: + """One-line rendering: the saved summary, or a truncated first line for rows + written before summaries existed (no data migration).""" + text = (item.summary or "").strip() + if not text: + text = item.content.strip().splitlines()[0] if item.content.strip() else "" + if len(text) > 80: + text = text[:77] + "..." + return f"- [#{item.id}] {text}" + def format_memories(items: list[MemoryItem]) -> str: - """Render memories for injection into the system prompt. Ids are shown so the agent - can revise a memory (`memory_update`) or retire it (`memory_forget`).""" + """Render memories in full for injection into the system prompt. Ids are shown so + the agent can revise a memory (`memory_update`) or retire it (`memory_forget`).""" if not items: return "" lines = [f"- [#{item.id}] {item.content}" for item in items] return "Known memories (from earlier sessions):\n" + "\n".join(lines) + + +def format_memory_index( + items: list[MemoryItem], *, full_newest: int = INDEX_FULL_NEWEST +) -> str: + """Index rendering: newest `full_newest` in full, one-line summaries for the rest, + plus the fetch-before-acting note for memory_read.""" + if not items: + return "" + newest = {item.id for item in sorted(items, key=lambda i: i.id)[-full_newest:]} + lines = [ + f"- [#{item.id}] {item.content}" if item.id in newest else _index_line(item) + for item in items + ] + return ( + "Known memories (from earlier sessions):\n" + + "\n".join(lines) + + f"\n{_INDEX_NOTE}" + ) + + +def render_memory_block( + items: list[MemoryItem], *, threshold_chars: int = INDEX_THRESHOLD_CHARS +) -> str: + """The injected memories block. Full mode while it's affordable; automatically and + invisibly flips to index mode when the full rendering exceeds the threshold + (MEMORY-SPEC §7). Evaluated once per engine build — a session is always in exactly + one mode for its whole life.""" + full = format_memories(items) + if len(full) <= threshold_chars: + return full + return format_memory_index(items) diff --git a/coworker/memory/settings.py b/coworker/memory/settings.py new file mode 100644 index 00000000..22441b8a --- /dev/null +++ b/coworker/memory/settings.py @@ -0,0 +1,75 @@ +"""Memory settings — the on/off switch and the user's standing rules. + +Settings-level state, deliberately outside the memory table (MEMORY-SPEC §2, §4.3, §6): + +- ``enabled``: off means engines are built with no memory tools, no memories block, and + no memory guidance. Existing memories are kept but inert. Read at build time; running + sessions finish under the mode they started with. +- ``user_rules``: one text blob the user typed into Settings. Injected verbatim above + auto memories; on conflict the rule wins. **The agent never writes, edits, or deletes + this** — no tool touches it; the only writer is the Settings UI via the manager. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Optional + +# User Rules is a bounded settings field, not a document store: big enough for any +# real rule list, small enough that a paste-accident (or a hostile client) can't +# bloat every future system prompt. +MAX_USER_RULES_CHARS = 20_000 + + +class MemorySettingsStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self._lock = threading.Lock() + + def _load(self) -> dict: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + def _save(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + @property + def enabled(self) -> bool: + return bool(self._load().get("enabled", True)) # on by default (spec §5.4) + + @property + def user_rules(self) -> str: + rules = self._load().get("user_rules", "") + return rules if isinstance(rules, str) else "" + + def set( + self, *, enabled: Optional[bool] = None, user_rules: Optional[str] = None + ) -> dict: + with self._lock: + data = self._load() + if enabled is not None: + data["enabled"] = bool(enabled) + if user_rules is not None: + data["user_rules"] = str(user_rules)[:MAX_USER_RULES_CHARS] + self._save(data) + return {"enabled": self.enabled, "user_rules": self.user_rules} + + def snapshot(self) -> dict: + return {"enabled": self.enabled, "user_rules": self.user_rules} + + +def format_user_rules(rules: str) -> str: + """The system-prompt block for user rules. Empty rules -> empty string.""" + text = (rules or "").strip() + if not text: + return "" + return ( + "User rules (written by the user in Settings; always follow these — on any " + f"conflict they outrank learned memories):\n{text}" + ) diff --git a/coworker/memory/sqlite_store.py b/coworker/memory/sqlite_store.py index 2229016c..8d57f902 100644 --- a/coworker/memory/sqlite_store.py +++ b/coworker/memory/sqlite_store.py @@ -26,11 +26,20 @@ class SQLiteMemoryStore(MemoryStore): scope TEXT NOT NULL, key TEXT, content TEXT NOT NULL, + summary TEXT, workspace TEXT, session_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) + # Databases created before the summary column existed: rows without one fall + # back to a truncated first line of content at render time (no data migration). + cols = { + row["name"] + for row in self._conn.execute("PRAGMA table_info(memories)").fetchall() + } + if "summary" not in cols: + self._conn.execute("ALTER TABLE memories ADD COLUMN summary TEXT") self._conn.commit() def add( @@ -39,15 +48,16 @@ class SQLiteMemoryStore(MemoryStore): *, scope: Scope = Scope.WORKSPACE, key: Optional[str] = None, + summary: Optional[str] = None, workspace: Optional[str] = None, session_id: Optional[str] = None, ) -> MemoryItem: scope = Scope(scope) with self._lock: cursor = self._conn.execute( - "INSERT INTO memories (scope, key, content, workspace, session_id) " - "VALUES (?, ?, ?, ?, ?)", - (scope.value, key, content, workspace, session_id), + "INSERT INTO memories (scope, key, content, summary, workspace, session_id) " + "VALUES (?, ?, ?, ?, ?, ?)", + (scope.value, key, content, summary, workspace, session_id), ) self._conn.commit() item = self.get(cursor.lastrowid) @@ -84,11 +94,19 @@ class SQLiteMemoryStore(MemoryStore): rows = self._conn.execute(query, params).fetchall() return [_row_to_item(row) for row in rows] - def update(self, item_id: int, content: str) -> Optional[MemoryItem]: + def update( + self, item_id: int, content: str, *, summary: Optional[str] = None + ) -> Optional[MemoryItem]: with self._lock: - self._conn.execute( - "UPDATE memories SET content = ? WHERE id = ?", (content, item_id) - ) + if summary is not None: + self._conn.execute( + "UPDATE memories SET content = ?, summary = ? WHERE id = ?", + (content, summary, item_id), + ) + else: + self._conn.execute( + "UPDATE memories SET content = ? WHERE id = ?", (content, item_id) + ) self._conn.commit() return self.get(item_id) @@ -98,6 +116,18 @@ class SQLiteMemoryStore(MemoryStore): self._conn.commit() return cursor.rowcount > 0 + def delete_all(self, *, scope: Optional[Scope] = None) -> int: + """Delete every memory (optionally one scope). Returns the number removed.""" + with self._lock: + if scope is not None: + cursor = self._conn.execute( + "DELETE FROM memories WHERE scope = ?", (Scope(scope).value,) + ) + else: + cursor = self._conn.execute("DELETE FROM memories") + self._conn.commit() + return cursor.rowcount + def close(self) -> None: self._conn.close() @@ -108,6 +138,7 @@ def _row_to_item(row: sqlite3.Row) -> MemoryItem: scope=Scope(row["scope"]), content=row["content"], key=row["key"], + summary=row["summary"], workspace=row["workspace"], session_id=row["session_id"], created_at=row["created_at"], diff --git a/coworker/memory/tools.py b/coworker/memory/tools.py index 9d82ee1e..5997f2ef 100644 --- a/coworker/memory/tools.py +++ b/coworker/memory/tools.py @@ -1,51 +1,128 @@ -"""Memory tools — the agent's explicit write paths into memory. +"""Memory tools — the agent's explicit paths into memory. `remember` saves a new fact; `memory_update` / `memory_forget` revise or retire one by the [#id] shown in the known-memories block, so corrections replace stale facts instead -of piling up next to them. +of piling up next to them. `memory_read` fetches full bodies by id — the retrieval half +of index mode (MEMORY-SPEC §7); registered always, harmless in full mode. + +`on_saved` is the save-notice hook (spec §5.1): the manager passes a callback that pushes +a memory_saved event to the session's surface so it can render "I'll remember that — … +[Undo]" inline in the transcript. It fires for `memory_update` too — the +update-don't-duplicate rule means many saves arrive as edits to an existing memory, and +those were invisible (owner-hit 2026-07-28) — carrying the previous text so Undo can put +it back. Failures in the callback never fail the write. """ from __future__ import annotations -from typing import Optional +from typing import Callable, Optional import aisuite as ai -from .base import MemoryStore, Scope +from .base import MemoryItem, MemoryStore, Scope _SCOPES = {s.value for s in Scope} _META = dict(category="memory", risk_level="low", capabilities=["remember"]) -def memory_tools(store: MemoryStore, *, workspace: Optional[str]) -> list: - def remember(content: str, scope: str = "workspace") -> dict: +def memory_tools( + store: MemoryStore, + *, + workspace: Optional[str], + on_saved: Optional[Callable[[MemoryItem, Optional[str]], None]] = None, + saving_enabled: Optional[Callable[[], bool]] = None, +) -> list: + """The agent's memory tools. + + `saving_enabled` is a LIVE callable checked on each write, so the Settings switch + applies to conversations already running — in BOTH directions (owner-hit + 2026-07-28: off kept saving, then on kept refusing). The registry is fixed at + build, so the write tools are always registered and refuse when saving is off; + `memory_read` never gates (off = stop learning, not amnesia). + """ + + def _saving_off() -> bool: + return saving_enabled is not None and not saving_enabled() + + _OFF_ERROR = ( + "Saving memories is turned off in the user's Settings (they can turn it back " + "on in Settings ▸ Memory). Nothing was saved — tell the user plainly instead " + "of implying you remembered it." + ) + + def _announce(item: MemoryItem, previous: Optional[str]) -> None: + """Surface the write to the user (§5.1). Best-effort: the notice is never worth + failing a write that already succeeded.""" + if on_saved is None: + return + try: + on_saved(item, previous) + except Exception: + pass + def remember(content: str, summary: str = "", scope: str = "workspace") -> dict: """Save a durable memory (a fact or preference) to recall in future sessions. Check the known-memories list first: if one already covers this, use memory_update instead of saving a near-duplicate. Args: - content (str): The thing to remember. - scope (str): "workspace" (this project) or "global" (everywhere). + content (str): The thing to remember, with the why. + summary (str): One-line gist (15 words max) shown in compact listings. + scope (str): "global" (facts about the user — applies everywhere) or + "workspace" (facts about this project only). """ + if _saving_off(): + return {"saved": False, "error": _OFF_ERROR} chosen = Scope(scope) if scope in _SCOPES else Scope.WORKSPACE + if chosen is Scope.SESSION: # dead scope (spec §3): never save to it + chosen = Scope.WORKSPACE item = store.add( content, scope=chosen, + summary=summary.strip() or None, workspace=workspace if chosen is Scope.WORKSPACE else None, ) + _announce(item, None) return {"id": item.id, "scope": item.scope.value, "saved": True} - def memory_update(memory_id: int, content: str) -> dict: + def memory_read(memory_ids: list[int]) -> dict: + """Read the full content of memories by id (use when the known-memories list + shows only a one-line summary and you need the details before acting). + + Args: + memory_ids (list[int]): The [#id]s to fetch. + """ + found, missing = [], [] + for mid in memory_ids: + item = store.get(int(mid)) + if item is None: + missing.append(int(mid)) + else: + found.append( + {"id": item.id, "scope": item.scope.value, "content": item.content} + ) + result: dict = {"memories": found} + if missing: + result["missing"] = missing + return result + + def memory_update(memory_id: int, content: str, summary: str = "") -> dict: """Rewrite an existing memory with corrected or refined content. Args: memory_id (int): The memory's id, from the [#id] in the known-memories list. content (str): The full corrected memory text (replaces the old text). + summary (str): Corrected one-line gist (15 words max). """ - item = store.update(memory_id, content) + if _saving_off(): + return {"updated": False, "error": _OFF_ERROR} + # Captured BEFORE the write so the user's Undo can restore the old wording. + existing = store.get(memory_id) + previous = existing.content if existing is not None else None + item = store.update(memory_id, content, summary=summary.strip() or None) if item is None: return {"updated": False, "error": f"no memory with id {memory_id}"} + _announce(item, previous) return {"updated": True, "id": item.id} def memory_forget(memory_id: int) -> dict: @@ -54,11 +131,13 @@ def memory_tools(store: MemoryStore, *, workspace: Optional[str]) -> list: Args: memory_id (int): The memory's id, from the [#id] in the known-memories list. """ + if _saving_off(): + return {"deleted": False, "error": _OFF_ERROR} if store.delete(memory_id): return {"deleted": True, "id": memory_id} return {"deleted": False, "error": f"no memory with id {memory_id}"} return [ ai.tool(fn, metadata=ai.ToolMetadata(**_META)) - for fn in (remember, memory_update, memory_forget) + for fn in (remember, memory_read, memory_update, memory_forget) ] diff --git a/coworker/server/app.py b/coworker/server/app.py index 3b054945..f50fb507 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -644,10 +644,36 @@ def create_app(manager: SessionManager) -> FastAPI: @app.post("/v1/memory") def add_memory(body: dict) -> dict[str, Any]: + body = body or {} return manager.add_memory( - body.get("content", ""), body.get("scope", "workspace") + str(body.get("content", "")), str(body.get("scope", "workspace")) ) + # Declared before the /{item_id} routes so "settings" can never be parsed as an id. + @app.get("/v1/memory/settings") + def memory_settings() -> dict[str, Any]: + return manager.get_memory_settings() + + @app.put("/v1/memory/settings") + def memory_settings_put(body: dict) -> dict[str, Any]: + body = body or {} + return manager.set_memory_settings( + enabled=bool(body["enabled"]) if "enabled" in body else None, + user_rules=str(body["user_rules"]) if "user_rules" in body else None, + ) + + @app.patch("/v1/memory/{item_id}") + def memory_patch(item_id: int, body: dict) -> dict[str, Any]: + return manager.update_memory(item_id, str((body or {}).get("content", ""))) + + @app.delete("/v1/memory/{item_id}") + def memory_delete(item_id: int) -> dict[str, Any]: + return manager.delete_memory(item_id) + + @app.delete("/v1/memory") + def memory_delete_all() -> dict[str, Any]: + return manager.delete_all_memory() + @app.post("/v1/chat/completions") def chat_completions(body: dict) -> dict[str, Any]: model = body.get("model", manager.model) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 9d10716d..1d042ca0 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -69,7 +69,7 @@ from ..mcp import ( put_global_server, read_global, ) -from ..memory import MemoryStore, Scope, SQLiteMemoryStore +from ..memory import MemorySettingsStore, MemoryStore, Scope, SQLiteMemoryStore from ..permissions import Mode from ..agents import list_agents as _list_agents from ..providers import ( @@ -131,6 +131,9 @@ class SessionManager: base.mkdir(parents=True, exist_ok=True) self.memory_store: MemoryStore = SQLiteMemoryStore(base / "coworker.db") + # MEMORY-SPEC §4.3/§6: the on/off switch + the user's standing rules. Settings- + # level, outside the memory table; read at engine build time. + self.memory_settings = MemorySettingsStore(base / "memory-settings.json") self.audit_store = AuditStore(base / "coworker.db") self.session_store = ConversationStore(base) self.session_store.canonicalize_workspaces() # collapse /tmp vs /private/tmp etc. @@ -422,7 +425,18 @@ class SessionManager: model=model, mode=mode, provider=self.provider, + # Memory off (§4.3) = stop LEARNING, not amnesia: saved facts still inject + # and stay usable, only the write tools go. Read at build time; running + # sessions finish under the mode they started with. memory_store=self.memory_store, + memory_off=not self.memory_settings.enabled, + # LIVE, not a snapshot: turning saving off mid-conversation must take + # effect at once (owner-hit 2026-07-28 — a running session kept saving). + memory_saving_enabled=lambda: self.memory_settings.enabled, + # Callable, not a snapshot: editing your instructions in Settings applies + # to conversations already open (same reason as the saving switch). + user_rules=lambda: self.memory_settings.user_rules, + on_memory_saved=self._memory_saved_notifier(session_id), messages=messages, extra_tools=extra_tools, secrets=self.secrets, @@ -2581,6 +2595,12 @@ class SessionManager: approver=self._scheduled_approver(task, session_id), provider=self.provider, memory_store=self.memory_store, + memory_off=not self.memory_settings.enabled, + memory_saving_enabled=lambda: self.memory_settings.enabled, + # Callable, not a snapshot: editing your instructions in Settings applies + # to conversations already open (same reason as the saving switch). + user_rules=lambda: self.memory_settings.user_rules, + on_memory_saved=self._memory_saved_notifier(session_id), secrets=self.secrets, # No scheduling tools inside a scheduled run: the executing agent's job is to DO the # task, and instructions that mention timing ("every day at 5:32pm…") otherwise tempt @@ -3648,20 +3668,91 @@ class SessionManager: loader = SkillLoader([state_dir() / "skills"]) return loader.catalog() + def _memory_saved_notifier(self, session_id: str): + """MEMORY-SPEC §5.1: push the memory_saved event that powers the GUI's save + toast ("I'll remember that — … [Undo]"). Best-effort by design: `remember` may + run with no socket attached (background runs) or off the loop thread — a lost + toast never fails the save.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + def notify(item, previous=None) -> None: + if loop is None or not loop.is_running(): + return + payload = { + "type": "memory_saved", + "data": { + "id": item.id, + "scope": item.scope.value, + "summary": item.summary or "", + "content": item.content, + # Set when this was an EDIT of an existing memory: the surface says + # "I've updated what I remember" and Undo restores this text. + "previous": previous or "", + }, + } + try: + asyncio.run_coroutine_threadsafe( + self.broadcast_session(session_id, payload), loop + ) + except RuntimeError: + pass + + return notify + def list_memory(self) -> list[dict[str, Any]]: return [ - {"id": m.id, "scope": m.scope.value, "content": m.content} + { + "id": m.id, + "scope": m.scope.value, + "content": m.content, + "summary": m.summary or "", + "created_at": m.created_at or "", + } for m in self.memory_store.list() ] def add_memory( self, content: str, scope: str = "workspace", workspace: Optional[str] = None ) -> dict[str, Any]: + content = (content or "").strip() + if not content: + return {"ok": False, "error": "content required"} chosen = Scope(scope) if scope in _SCOPES else Scope.WORKSPACE ws = self.resolve_workspace(workspace) if chosen is Scope.WORKSPACE else None item = self.memory_store.add(content, scope=chosen, workspace=ws) return {"id": item.id, "scope": item.scope.value, "content": item.content} + def update_memory(self, item_id: int, content: str) -> dict[str, Any]: + """Edit-in-place from the memory screen (§5.3). The user rewrote the fact, so + the stale one-line summary is cleared rather than left contradicting it.""" + content = (content or "").strip() + if not content: + return {"ok": False, "error": "content required"} + item = self.memory_store.update(item_id, content, summary="") + if item is None: + return {"ok": False, "error": f"no memory with id {item_id}"} + return {"ok": True, "id": item.id, "content": item.content} + + def delete_memory(self, item_id: int) -> dict[str, Any]: + """Row delete on the memory screen — and the toast's Undo (§5.1).""" + if self.memory_store.delete(item_id): + return {"ok": True, "id": item_id} + return {"ok": False, "error": f"no memory with id {item_id}"} + + def delete_all_memory(self) -> dict[str, Any]: + return {"ok": True, "deleted": self.memory_store.delete_all()} + + def get_memory_settings(self) -> dict[str, Any]: + return self.memory_settings.snapshot() + + def set_memory_settings( + self, enabled: Optional[bool] = None, user_rules: Optional[str] = None + ) -> dict[str, Any]: + return self.memory_settings.set(enabled=enabled, user_rules=user_rules) + def _parse_inbox_json(s: str) -> dict[str, Any]: """Parse a structured Inbox resolution (directory/plan carry their reply as a JSON string).""" diff --git a/coworker/tui/app.py b/coworker/tui/app.py index c33bf301..c218a4bf 100644 --- a/coworker/tui/app.py +++ b/coworker/tui/app.py @@ -86,6 +86,8 @@ class CoworkerApp(App): mode: Mode = Mode.INTERACTIVE, provider: Optional[ProviderClient] = None, memory_store: Optional[MemoryStore] = None, + memory_off: bool = False, + user_rules: str = "", session_store: Optional[ConversationStore] = None, session_id: Optional[str] = None, resume_messages: Optional[list[dict]] = None, @@ -96,6 +98,8 @@ class CoworkerApp(App): self.mode = mode self._provider = provider self._memory_store = memory_store + self._memory_off = memory_off + self._user_rules = user_rules self._session_store = session_store self._session_id = session_id self._resume_messages = resume_messages @@ -116,6 +120,8 @@ class CoworkerApp(App): approver=self._approve, provider=self._provider, memory_store=self._memory_store, + memory_off=self._memory_off, + user_rules=self._user_rules, messages=self._resume_messages, ) self._write( diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 603d1e8e..1f50216b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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(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. diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index dcb54ac7..93021221 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -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 { + 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 { + const res = await fetch(`${httpBase()}/v1/memory/settings`); + return res.json(); +} + +export async function setMemorySettings( + patch: Partial, +): Promise { + 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; diff --git a/surfaces/gui/src/components/MemorySection.tsx b/surfaces/gui/src/components/MemorySection.tsx new file mode 100644 index 00000000..328c225f --- /dev/null +++ b/surfaces/gui/src/components/MemorySection.tsx @@ -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(null); + const [entries, setEntries] = useState(null); + // State-change copy (§5.3): shown under the toggle / list after an action. + const [toggleMsg, setToggleMsg] = useState(null); + const [listMsg, setListMsg] = useState(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
Loading…
; + + return ( +
+ + + {/* On/off — one switch, no other setup (§5.4). */} +
+
+ +
+
Remember new things about me
+
+ 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. +
+
+
+ {toggleMsg && ( +
+ {toggleMsg} +
+ )} +
+ + {/* 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. */} +
+
+
What I've learned about you
+ {entries.length > 0 && ( + + )} +
+
+ 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. +
+ {listMsg && ( +
+ {listMsg} +
+ )} + {entries.length === 0 ? ( + !listMsg && ( +
+ Nothing yet. When you mention a lasting preference in chat — or say "remember + that…" — it will show up here. +
+ ) + ) : ( +
+ {entries.map((m) => ( + + ))} +
+ )} +
+ + {/* Your instructions (§6): user-authored, toggle-independent — so it sits apart + from the auto-memory pair above. The agent never edits these. */} + +
+ ); +} + +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 ( +
+
Your instructions
+
+ Your coworkers follow these in every conversation. +
+