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_proposeMCP tool call): setssourceexplicitly (e.g."agent:payments/coder") and omitsuser. The controller skips its team-membership check whensourceis 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): omitssource, setsuserthe same way/team//budgetdo. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as/budget) and derivessource = "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 forsearch/list/export-- each element is oneMemoryEntry.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.
Semantic search¶
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.