Skip to content

Team memory command (kapelle.controller.command, command: "memory")

Backlog task 35 (design doc §14 "Team memory"). Reuses the same channel services/gateway/docs/controller-commands.md documents for /team .../ /budget -- plain core NATS request/reply, kapelle.controller.command, one queue-group subscriber on the controller side (kapelle_controller.commands.ControllerCommandServer) -- rather than a new gRPC surface, so both the worker's memory_* MCP tools (backlog task 26) and the gateway's /memory surface commands (not yet wired) share one implementation (kapelle_controller.memory.store.MemoryStore).

See docs/skills.md for the sibling command: "skills" channel (backlog task 498cfd9c, design doc §15 "Skills") -- team skills reuse this exact same wire pattern (worker/gateway dual caller, source-based authorization skip), just for SkillStore instead of MemoryStore. See docs/mcp.md for the same pattern again, for command: "mcp" / McpServerStore (backlog task d6b93b0c, design doc §15 "MCP servers").

Two kinds of caller

  • Worker-originated (an agent's memory_search/memory_get/ memory_propose MCP tool call): sets source explicitly (e.g. "agent:payments/coder") and omits user. The controller skips its team-membership check when source is already set -- the MCP server's own per-agent bearer-token auth (kapelle_worker.mcp.context. resolve_mcp_context) already established which team this call is scoped to, before it ever reaches this channel.
  • Person-originated (a /memory ... surface command, kapelle_gateway.core.GatewayCore._handle_controller_command): omits source, sets user the same way /team//budget do. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as /budget) and derives source = "person:<person_id>" itself.

Request

{
  "command": "memory",
  "team": "payments",
  "args": ["search", "flaky webhook test", ""],
  "source": "agent:payments/coder"
}

args[0] is the operation; the rest are its positional arguments (already split, never a raw command line to re-parse -- a worker builds these directly from its MCP tool's own structured parameters, a gateway command handler splits the human-typed args before adding user).

args Operation
["search", query, kind_or_empty] MemoryStore.search -- keyword search, blended with embedding-similarity ranking when KAPELLE_EMBED_ALIAS is configured (see below), ranked, curated before proposed. kind empty string means "any kind".
["get", entry_id] MemoryStore.get
["list"] MemoryStore.list_all -- every entry for the team, curated first then proposed, newest first within each group. Not a search -- no query, so nothing to rank by relevance.
["propose", kind, title, body, tags_csv] MemoryStore.propose -- always creates a proposed entry, source's caller never mind which kind. tags_csv is a comma-joined string, "" for no tags.
["add", kind, title, body, tags_csv] Same as propose, then immediately accepts the result -- a person directly asserting a fact doesn't need review of their own addition.
["accept", entry_id] MemoryStore.accept -- idempotent.
["edit", entry_id, kind, title, body, tags_csv] MemoryStore.supersede -- creates a new curated entry linked via supersedes; the old entry is untouched, not deleted.
["forget", entry_id] MemoryStore.forget -- hard delete.
["export"] MemoryStore.export -- every curated entry for the team, oldest first. The controller only ever returns this data; kapelle_gateway.core.GatewayCore._handle_memory_export (kapelle_gateway.memory_export.MemoryExportService) is what turns it into a docs/team-memory.md PR on the team's linked repo -- it separately calls ["team", "show", team] for repo_url (services/gateway/docs/controller-commands.md's own note on that command's entries field) and falls back to plain-text entries (no PR) if GitHub isn't configured on this gateway, the team has no repo linked, or opening the PR fails for any reason.

/memory export's PR uses one stable branch (kapelle/team-memory), reused across every export rather than a fresh one each time -- an export updates that branch's docs/team-memory.md in place (a Contents API PUT against its own current sha) and opens a PR from it only if none is already open; a merged or closed PR just means the next export opens a new one from the same branch. When the rendered content hasn't changed since the branch's last export, nothing is pushed and no new PR opens -- the reply says so instead of implying a fresh push:

Team memory unchanged, PR #14 still open: https://github.com/acme/payments/pull/14

Response

{"ok": true, "text": "3 entries found", "entries": ["[curated] gotcha: ...\n...", "..."]}

or, for single-entry operations:

{"ok": true, "text": "Entry 12 accepted."}

or, on failure:

{"ok": false, "error": "unknown memory kind 'not-a-real-kind'; expected one of (...)"}
  • text: always present on success, a one-line human-readable summary.
  • entries: present only for search/list/export -- each element is one MemoryEntry.render()'d block ("[status] kind: title\nbody"), ready to show a model or a person as-is. Absent (not an empty list) for every other operation.
  • error: chat-ready, same convention as /team//budget's errors.

search is keyword-only (Postgres full-text search) unless KAPELLE_EMBED_ALIAS names a LiteLLM alias backed by an embedding model -- the dev environment's embed alias (qwen3-embedding-0.6B, 1024 dims, deploy/compose/litellm/config.yaml) is one example. With it set, MemoryStore.search blends keyword relevance with pgvector cosine-similarity ranking (kapelle_controller.memory.store's own module docstring has the exact blending rule) -- a query can then find a related entry even when it shares no words with it at all, not just ones that happen to match literally. Every propose/add/edit embeds the new entry's title+body best-effort at write time; existing rows from before the alias was configured need a one-off backfill (scripts/memory_backfill_embeddings.py, dry-run by default). Unset (or the alias erroring), search silently falls back to keyword-only -- this is never a hard dependency, by design.

Safety

Every propose/add/edit body is scanned (kapelle_controller.memory.scan.scan_entry_body) for credential-looking patterns and links to hosts outside a small allowlist before it's stored -- a rejection comes back as a normal ok: false reply, error naming what tripped it, never a stored-then-flagged entry.