Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion code_puppy/plugins/puppy_kennel/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ def _cmd_search(query: str) -> bool:
if not query.strip():
emit_warning("Usage: /kennel search <your query>")
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}")
Expand Down
35 changes: 35 additions & 0 deletions code_puppy/plugins/puppy_kennel/kennel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
115 changes: 115 additions & 0 deletions code_puppy/plugins/puppy_kennel/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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},
]
134 changes: 134 additions & 0 deletions tests/plugins/test_puppy_kennel_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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