diff --git a/code_puppy/plugins/puppy_kennel/commands.py b/code_puppy/plugins/puppy_kennel/commands.py index 57ee9fc4b..06c4eeee7 100644 --- a/code_puppy/plugins/puppy_kennel/commands.py +++ b/code_puppy/plugins/puppy_kennel/commands.py @@ -138,7 +138,13 @@ def _cmd_search(query: str) -> bool: if not query.strip(): emit_warning("Usage: /kennel search ") return True - wings = default_recall_scope("code-puppy", detect_cwd()) + try: + from code_puppy.agents.agent_manager import get_current_agent_name + + agent_name = get_current_agent_name() + except Exception: # noqa: BLE001 — best-effort; never crash the command + agent_name = "code-puppy" + wings = default_recall_scope(agent_name, detect_cwd()) hits = kennel.search_drawers_multi(query, wing_names=wings, limit=5) if not hits: emit_warning(f"No hits for '{query}' in scope {wings}") diff --git a/code_puppy/plugins/puppy_kennel/kennel.py b/code_puppy/plugins/puppy_kennel/kennel.py index 834d3fd46..0ff0e8680 100644 --- a/code_puppy/plugins/puppy_kennel/kennel.py +++ b/code_puppy/plugins/puppy_kennel/kennel.py @@ -350,6 +350,41 @@ def _sanitize_fts_query(query: str) -> str: return " OR ".join(tokens) +def delete_drawer(drawer_id: int) -> tuple[bool, str | None]: + """Delete a drawer by ID. Returns (found, content_preview). + + The FTS ``drawers_ad`` trigger keeps the full-text index in sync + automatically — no manual index cleanup needed. + """ + with _connect() as conn: + row = conn.execute( + "SELECT content FROM drawers WHERE id = ?", (drawer_id,) + ).fetchone() + if not row: + return False, None + preview = row["content"][:200] + conn.execute("DELETE FROM drawers WHERE id = ?", (drawer_id,)) + return True, preview + + +def update_drawer(drawer_id: int, new_content: str) -> bool: + """Replace the content of a drawer. Returns True if the row was found. + + The FTS ``drawers_au`` trigger removes the old index entry and inserts + the new one — no manual reindex needed. + """ + if not new_content or not new_content.strip(): + return False + if len(new_content) > MAX_DRAWER_CHARS: + new_content = new_content[:MAX_DRAWER_CHARS] + "\n...[truncated]" + with _connect() as conn: + cur = conn.execute( + "UPDATE drawers SET content = ?, ts = ? WHERE id = ?", + (new_content, _now_iso(), drawer_id), + ) + return cur.rowcount > 0 + + def list_wings() -> list[str]: with _connect() as conn: rows = conn.execute("SELECT name FROM wings ORDER BY name").fetchall() diff --git a/code_puppy/plugins/puppy_kennel/tools.py b/code_puppy/plugins/puppy_kennel/tools.py index 50c640761..c76f2bf23 100644 --- a/code_puppy/plugins/puppy_kennel/tools.py +++ b/code_puppy/plugins/puppy_kennel/tools.py @@ -95,6 +95,24 @@ class KennelStatsOutput(BaseModel): error: str | None = None +class KennelForgetOutput(BaseModel): + """Output for ``kennel_forget``.""" + + drawer_id: int + found: bool + deleted_content_preview: str | None = None + error: str | None = None + + +class KennelUpdateOutput(BaseModel): + """Output for ``kennel_update``.""" + + drawer_id: int + found: bool + bytes_stored: int = 0 + error: str | None = None + + def _drawer_to_model(d: Any) -> KennelDrawer: meta = d.metadata or {} return KennelDrawer( @@ -419,6 +437,101 @@ async def kennel_stats(context: RunContext) -> KennelStatsOutput: ) +def register_kennel_forget(agent: Any) -> None: + """Register the ``kennel_forget`` tool — permanent drawer deletion by ID.""" + + @agent.tool + async def kennel_forget( + context: RunContext, + drawer_id: int, + ) -> KennelForgetOutput: + """Permanently delete a drawer from the Puppy Kennel by its ID. + + **Workflow:** call ``kennel_recall`` or ``kennel_recent`` first to + find the drawer ID and confirm you are targeting the correct memory. + Deletion is irreversible. + + The response includes a preview of the deleted content so you can + verify the right drawer was removed. + + Args: + drawer_id: Numeric ID of the drawer to delete (from kennel_recall/kennel_recent). + """ + if not is_enabled(): + return KennelForgetOutput( + drawer_id=drawer_id, found=False, error=DISABLED_TOOL_ERROR + ) + try: + found, preview = kennel.delete_drawer(drawer_id) + if not found: + return KennelForgetOutput( + drawer_id=drawer_id, + found=False, + error=f"No drawer with id={drawer_id} found.", + ) + return KennelForgetOutput( + drawer_id=drawer_id, + found=True, + deleted_content_preview=preview, + ) + except Exception as exc: # noqa: BLE001 — tool must never raise. + return KennelForgetOutput( + drawer_id=drawer_id, + found=False, + error=f"kennel_forget failed: {exc!r}", + ) + + +def register_kennel_update(agent: Any) -> None: + """Register the ``kennel_update`` tool — replace a drawer's content by ID.""" + + @agent.tool + async def kennel_update( + context: RunContext, + drawer_id: int, + new_content: str, + ) -> KennelUpdateOutput: + """Replace the content of an existing drawer in the Puppy Kennel. + + **Workflow:** call ``kennel_recall`` or ``kennel_recent`` first to + find the drawer ID and confirm you are correcting the right memory. + The FTS index is updated automatically — no separate reindex needed. + + Args: + drawer_id: Numeric ID of the drawer to update (from kennel_recall/kennel_recent). + new_content: Replacement text. Must be non-empty. + """ + if not is_enabled(): + return KennelUpdateOutput( + drawer_id=drawer_id, found=False, error=DISABLED_TOOL_ERROR + ) + if not new_content or not new_content.strip(): + return KennelUpdateOutput( + drawer_id=drawer_id, + found=False, + error="Empty new_content — nothing to update.", + ) + try: + found = kennel.update_drawer(drawer_id, new_content) + if not found: + return KennelUpdateOutput( + drawer_id=drawer_id, + found=False, + error=f"No drawer with id={drawer_id} found.", + ) + return KennelUpdateOutput( + drawer_id=drawer_id, + found=True, + bytes_stored=len(new_content.encode("utf-8")), + ) + except Exception as exc: # noqa: BLE001 — tool must never raise. + return KennelUpdateOutput( + drawer_id=drawer_id, + found=False, + error=f"kennel_update failed: {exc!r}", + ) + + def _agent_name_from_context(context: RunContext) -> str: """Best-effort extraction of the calling agent's name from the run context. @@ -446,4 +559,6 @@ def register_tools_callback() -> list[dict[str, Any]]: {"name": "kennel_recent", "register_func": register_kennel_recent}, {"name": "kennel_list_wings", "register_func": register_kennel_list_wings}, {"name": "kennel_stats", "register_func": register_kennel_stats}, + {"name": "kennel_forget", "register_func": register_kennel_forget}, + {"name": "kennel_update", "register_func": register_kennel_update}, ] diff --git a/tests/plugins/test_puppy_kennel_tools.py b/tests/plugins/test_puppy_kennel_tools.py index c89bb3d3b..c59344717 100644 --- a/tests/plugins/test_puppy_kennel_tools.py +++ b/tests/plugins/test_puppy_kennel_tools.py @@ -325,6 +325,140 @@ def test_register_tools_callback_exposes_full_surface() -> None: "kennel_recent", "kennel_list_wings", "kennel_stats", + "kennel_forget", + "kennel_update", } for spec in specs: assert callable(spec["register_func"]) + + +# --------------------------------------------------------------------------- # +# kennel_forget +# --------------------------------------------------------------------------- # + + +def test_kennel_forget_deletes_existing_drawer(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import kennel, tools + + agent = _FakeAgent() + tools.register_kennel_remember(agent) + tools.register_kennel_forget(agent) + remember = agent.registered["kennel_remember"] + forget = agent.registered["kennel_forget"] + + rem = asyncio.run(remember(_ctx(), "ECDSA is better than RSA for JWT signing.")) + assert rem.drawer_id > 0 + + out = asyncio.run(forget(_ctx(), rem.drawer_id)) + assert out.error is None + assert out.found is True + assert "ECDSA" in (out.deleted_content_preview or "") + assert kennel.count_drawers() == 0 + + +def test_kennel_forget_missing_id_returns_error(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import tools + + agent = _FakeAgent() + tools.register_kennel_forget(agent) + forget = agent.registered["kennel_forget"] + + out = asyncio.run(forget(_ctx(), 99999)) + assert out.found is False + assert out.error is not None + assert "99999" in out.error + + +def test_kennel_forget_content_preview_truncated_at_200(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import tools + + agent = _FakeAgent() + tools.register_kennel_remember(agent) + tools.register_kennel_forget(agent) + remember = agent.registered["kennel_remember"] + forget = agent.registered["kennel_forget"] + + long_content = "x" * 500 + rem = asyncio.run(remember(_ctx(), long_content)) + out = asyncio.run(forget(_ctx(), rem.drawer_id)) + + assert out.found is True + assert len(out.deleted_content_preview or "") <= 200 + + +# --------------------------------------------------------------------------- # +# kennel_update +# --------------------------------------------------------------------------- # + + +def test_kennel_update_replaces_content(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import kennel, tools + + agent = _FakeAgent() + tools.register_kennel_remember(agent) + tools.register_kennel_update(agent) + remember = agent.registered["kennel_remember"] + update = agent.registered["kennel_update"] + + rem = asyncio.run(remember(_ctx(), "We use RSA-2048 for JWT signing.")) + assert rem.drawer_id > 0 + + out = asyncio.run( + update(_ctx(), rem.drawer_id, "We use ECDSA P-256 for JWT signing.") + ) + assert out.error is None + assert out.found is True + assert out.bytes_stored > 0 + + drawers = kennel.recent_drawers(rem.wing, limit=5) + assert any("ECDSA" in d.content for d in drawers) + assert not any("RSA-2048" in d.content for d in drawers) + + +def test_kennel_update_missing_id_returns_error(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import tools + + agent = _FakeAgent() + tools.register_kennel_update(agent) + update = agent.registered["kennel_update"] + + out = asyncio.run(update(_ctx(), 99999, "New content.")) + assert out.found is False + assert out.error is not None + assert "99999" in out.error + + +def test_kennel_update_empty_content_returns_error(kennel_root: Path) -> None: + from code_puppy.plugins.puppy_kennel import tools + + agent = _FakeAgent() + tools.register_kennel_remember(agent) + tools.register_kennel_update(agent) + remember = agent.registered["kennel_remember"] + update = agent.registered["kennel_update"] + + rem = asyncio.run(remember(_ctx(), "Something to update.")) + out = asyncio.run(update(_ctx(), rem.drawer_id, "")) + assert out.found is False + assert out.error is not None + + +def test_kennel_update_fts_index_reflects_new_content(kennel_root: Path) -> None: + """After update, BM25 search finds the new term and not the old one.""" + from code_puppy.plugins.puppy_kennel import kennel, tools + from code_puppy.plugins.puppy_kennel.wings import repo_wing + + agent = _FakeAgent() + tools.register_kennel_remember(agent) + tools.register_kennel_update(agent) + remember = agent.registered["kennel_remember"] + update = agent.registered["kennel_update"] + + rem = asyncio.run(remember(_ctx(), "quagga extinct subspecies zebra")) + asyncio.run(update(_ctx(), rem.drawer_id, "axolotl aquatic salamander neoteny")) + + wing = repo_wing() + old_hits = kennel.search_drawers("quagga", wing_name=wing, limit=5) + new_hits = kennel.search_drawers("axolotl", wing_name=wing, limit=5) + assert len(old_hits) == 0 + assert len(new_hits) == 1