Long-term memory

BusyBro remembers durable facts about you across every surface (personal memory), and learns shared, non-personal project knowledge for the whole team (global memory) — how both work, the privacy model, and how to benefit.

BusyBro remembers what matters about you — across every conversation and every surface. Tell it something once in the dashboard and it's there next week on Telegram. This page covers how it's built, how it works end to end, and how to get the most out of it.

How to benefit (start here)

You don't have to do anything special — BusyBro learns as you chat. But a few habits make its memory sharp and useful.

Teach it naturally

Just talk. After each exchange, BusyBro quietly distills the durable bits — who you are, how you like things, decisions you've made — and keeps them. Over time it needs less context from you because it already knows it.

  • "I work mostly on the iOS app." → remembered as a fact.
  • "Keep answers short, code first." → remembered as a preference.
  • "My main bmc machine is Serebanos-MacBook-Air." → remembered as a fact.

Be explicit when it matters

If something is important, say so and it's saved immediately:

  • "Remember that our staging API is at staging.example.com."
  • "Note that I always want HAR exports, not screenshots."

Ask what it knows any time: "what do you remember about me?"

Corrections overwrite

If BusyBro remembers something out of date, just correct it — the new version replaces the old one (near-duplicate memories merge, they don't pile up):

You: Actually I moved off the iOS app, I'm on the dashboard now. BusyBro: Got it — updated.

Cross-surface recall

Memory is tied to your account, not to one chat. So:

  • Teach it a fact in the dashboard chat → recalled by @busybrobot on Telegram.
  • Correct it on Telegram → the dashboard reflects the correction.

(Telegram memory requires a linked account — that's what ties a chat to your dashboard user.)

Privacy & forgetting

You're always in control:

  • "Forget that I prefer short answers." — removes the closest matching memory.
  • /forget on Telegram (or "forget everything about me") — wipes all of your long-term memories, everywhere.
  • BusyBro never stores secrets (tokens, passwords, credentials) — those are skipped on purpose.
  • Your personal memories are yours alone: row-level security means no other user can ever read them. (Separately, BusyBro also keeps a small shared, non-personal team-knowledge store — see Global (team) memory — which by design can never carry anything tied to a person.)

What makes a good memory

Durable and reusable beats specific and fleeting. "Prefers Postgres examples over ORM" is a great memory; "wants the count of entries from last Tuesday" is not — that's a one-off, and BusyBro won't keep it.

How it works (end to end)

Every message goes through a recall → answer → learn loop. Recall happens before the answer; learning happens after, in the background, so it never slows your reply.

Your messageEmbed query(gte-small)Recall top-K(vector search)AnswerExtractor distillsdurable memoriesinject into promptafter reply (background)
  1. Recall. Your message is turned into a 384-dimension embedding, and the most relevant memories are pulled by vector similarity (weighted by importance and recency). They're injected into the prompt as a short "what you remember about this user" note.
  2. Answer. BusyBro replies, using those memories to be more personal and to skip context you've already given it.
  3. Learn. After the reply is sent, a cheap background pass reads the exchange and distills any new durable memories — saving them (or merging into an existing one) for next time.

The same loop runs on every surface — the dashboard chat, @busybrobot on Telegram, and (soon) MCP — all reading and writing the same per-user store, which is why recall works across them.

How it's implemented

Storage — busybro_memories

One table holds every memory, unlimited in number:

ColumnPurpose
iduuid primary key
user_idowner (FK to auth.users, cascade delete)
surfacewhere it was learned — web / telegram / mcp / bmc
kindfact · preference · correction · episode
contentthe distilled memory — one durable statement
embeddingvector(384) — the gte-small embedding of content
importance1..5, biases recall ranking
recall_count / last_recalled_atpopularity + recency signals
source_thread, created_at, updated_atprovenance + timestamps

Storage is unlimited (no row cap) — recall always returns a small top-K, so memory can grow forever without ballooning the prompt.

Embeddings — gte-small in the Edge runtime

Embeddings are produced by Supabase.ai.Session('gte-small') — a 384-dimension model that runs natively inside the Supabase Edge runtime, with zero external API keys. Text is embedded the same way at write time (when saving a memory) and at query time (when recalling), so they live in the same vector space. gte-small outputs L2-normalized vectors, so cosine is the right similarity metric.

Indexing & recall — pgvector + HNSW

The embedding column is indexed with pgvector's HNSW index using cosine distance (vector_cosine_ops) — approximate-nearest-neighbour search that stays fast at unlimited scale. Recall calls a match_busybro_memories SQL function that returns the top-K for one user, ranked by a weighted score:

score = cosine_distance
        − importance × 0.02         (more important → ranked higher)
        − recency_boost (≤ 0.05)    (recently used → ranked higher, decays over ~30 days)

Semantic similarity dominates; importance and recency are gentle nudges. Recalled memories also get their recall_count and last_recalled_at bumped, so frequently useful memories surface more easily over time.

The self-teach extractor

After each completed exchange, a background pass (a cheap, fast model call) reads the user message and BusyBro's reply and returns a small JSON array of durable memories — each a single third-person statement with a kind and an importance. It is deliberately selective: one-off task details, transcript summaries, small talk, and anything resembling a secret are dropped. Returning "nothing durable" is the common case.

This runs after the reply is delivered (via the runtime's background-work hook), so the self-teaching adds zero latency to your answer.

Dedupe & corrections — the 0.9 merge

When a new memory is about to be saved, its embedding is compared to your nearest existing memory. If they're ≥ 0.9 cosine similarity, the existing row is updated in place (content, kind, importance, embedding) instead of inserting a new one. This is what makes corrections work: restating a fact with a tweak overwrites the old version rather than leaving two contradictory memories behind.

Prompt-cache discipline

BusyBro's system prompt is split into a large cached prefix (instructions + project docs, identical every call) and a small uncached per-call block (your name, current screen, recent turns). The recalled-memories note is injected into the uncached block — after the cache breakpoint — so personalising every message never busts the prompt cache. Memory adds recall quality without adding cache cost.

Ownership & safety

busybro_memories (your personal store) is protected by row-level security: a user can only ever read or change rows where user_id is their own. The Edge Functions that write memories run with elevated privileges but gate every read and write on the caller's verified user id explicitly — a personal memory can never cross between users. A memory is context, never permission: it can make BusyBro more helpful, but it can never grant an action your role doesn't already allow.

The separate Global (team) memory store has a different owner-class — it holds only shared, non-personal project facts and is built so that nothing tied to a person can ever land in it. The two stores are merged only in the prompt text, never in SQL.

The explicit memory tools

Alongside the automatic recall + self-teach, BusyBro has explicit tools it reaches for on a direct request:

  • remember — save one durable statement ("remember that…").
  • recall_memory — search your memories ("what do you know about me?").
  • forget_memory — delete the closest match, or all of your memories.

On Telegram, /forget wipes your long-term memory in one command (distinct from /reset, which only clears the current conversation's recent turns).

Global (team) memory

Everything above is personal memory — what BusyBro learns about you, owner-scoped, never shared. Alongside it, BusyBro keeps a second, much smaller store: global (team) memory — shared, non-personal knowledge about the project, distilled from everyone's chats and recalled for any teammate.

This layer is live.

Personal vs global — the line

Personal (busybro_memories)Global (busybro_global_memories)
What it holdsAnything tied to a who — "I work on the iOS app", "prefers short answers", "my main bmc host is …", contact info, one person's setup or behavior.Stable, generalizable third-person, subject-less project facts — "the PAC pool is ports 9000–9999", "block rules live in settings_*.data.blockRules", "the team prefers HAR exports over screenshots".
ScopeOwner-scoped. Yours alone, forever. Never copied to global.No owner. Recalled for any user — even anonymous brain calls.
Kindsfact · preference · correction · episodefact · howto · reference · glossary (no preference/episode — those are inherently personal).

The rule in one line: if it names or describes a person, it's personal and stays personal. Global holds only what's true about the project, with no person attached.

Why a personal fact can't leak into the team store

The boundary is architectural, not a promise in the prompt — three independent layers, each sufficient on its own:

  1. Physical separation (schema). The global table has no user_id column at all. Personal recall reads only busybro_memories; global recall reads only busybro_global_memories. The two are merged in the prompt text, never in SQL — there is no join, view, or RPC by which one user's private rows could surface in another user's recall. The global recall function returns only { kind, content, importance, confidence } and takes no user id, so even a misclassified row carries no identity. (Who contributed a fact lives in a separate side-table that is only read for dedup, corroboration, stats, and erasure — never on the recall path.)
  2. A deterministic filter at write time. A strict extractor proposes only shareable, subject-less project statements — but that LLM is advisory. The real gate is a deterministic PII/person-reference filter (regex for first/second-person references, names, emails, phones, IPs, hostnames and device names, secrets/tokens, and file paths) that rejects any candidate still carrying an identifier. Classifiers can be fooled by crafted input; the regex plus the no-user_id schema are the enforcement.
  3. Quarantine before recall. A candidate that passes (1) and (2) lands as pending and is not recallable. It becomes recallable only once promoted — by an admin in the review queue, and/or by corroboration (the same fact independently extracted from N distinct contributors, default N = 2). So the worst case of any misclassification is a pending row a human can reject — never a recalled fact. Rejected rows are kept for audit and dedup, never recalled.
Candidatefrom a chatGateLLM + PII scrubQuarantinependingPromoteadmin / N≥2Recallrejected →audit only, never recalled

The leak-proof property holds even if the extractor misclassifies — a wrong guess is, at worst, a pending row an operator rejects.

How a fact joins the team store

After a clean answer, the same background pass that distills your personal memories also runs a second, much stricter extractor that proposes only subject-less project facts (returning nothing is the overwhelmingly common case — global is a curated corpus, not a log). Each proposal is scrubbed by the deterministic filter, then deduped against the global store only, at a tighter similarity threshold than personal memory: a near-duplicate merges (bumping the fact's confidence and its distinct-contributor count) rather than inserting a new row, so corroboration accumulates for free. New facts insert a fresh pending row. An explicit "remember this for the team" routes to a pending proposal too — no single chat writes team truth directly.

Quality grows over time: confidence rises with independent corroboration and decays with age or contradiction, so un-reconfirmed old facts rank lower and contradictory pairs get flagged for review.

Origin — who authored a fact

Every row in the team store carries an origin — a coarse author class (not an identity, and not a user_id; the leak-proof no-user_id property is unchanged):

originMeaning
busybroBusyBro's own background self-train extractor learned it (the default).
claude-codeThe boss / Claude Code authored it (e.g. mirrored a standing rule, or proposed it over MCP).
userA teammate authored it (a manual promote, or a "remember this for the team" through BusyBro).

Because facts are differentiable by author, recall can be scoped: ask BusyBro to "use only Claude's memories", "exclude Claude's", or "answer using only BusyBro's knowledge", and it filters the team-recall by origin for that turn (a plain question never filters — scoping only kicks in on an explicit only / exclude phrase about an author). The origin shows as a badge on every row in Settings → BusyBro, with an origin filter in the Manage tab; over MCP, list_global_memories takes an origin filter (one value or a list) and every row carries its origin.

Managing it — Settings → BusyBro

The dashboard's Settings → BusyBro page is the control surface (capability-gated to the users section — users:view to look, users:edit to change anything). It has three tabs:

  • Stats — a read-only dashboard of the whole system: personal vs global counts (global split into approved / pending / rejected), a by-kind breakdown, growth over time, the top-recalled facts, extractor activity (including the merge rate — the corroboration-health signal), and the live calibration readouts (corroboration N, dedup/confidence thresholds, recall top-K) so the strict-vs-loose balance is observable. A collapsible how-it-works panel renders the recall → gate → quarantine → promote loop and states the privacy invariant in plain language.
  • Review queue — the promotion surface: every pending candidate, with Approve / Reject / Edit content / Set importance, plus bulk actions and a "single-source only" filter to triage the borderline corroboration cases. It shows a candidate's contributor count but never contributor identities — the re-identification guard is enforced in the UI itself. Rejecting keeps the row (for audit/dedup), never recalls it.
  • Manage — browse approved/rejected global facts (search, filter, edit, re-open, delete), an admin per-user drill-down for support and GDPR, and the toggles below.

The toggles let an operator tune the system live (each Realtime-persisted, one source of truth with the Stats readouts):

  • Self-train on/off — master switches for the personal extractor and, separately, the global-candidate extractor.
  • Per-surface recall — whether team knowledge is injected on web / Telegram / MCP (and for anonymous brain calls), independently.
  • Auto-promote N — the distinct-contributor threshold for auto-approval (default 2); set it higher to require more corroboration, or off to require manual approval for everything.
  • Dedup / confidence thresholds — the merge-similarity and recall-confidence floors.

Erasure (GDPR)

Erasing a user unwinds their contribution to the team store too. Forgetting all of a user's memory (/forget all, or deleting the account) wipes their personal busybro_memories and drops their rows in the contributions side-table. A global fact that loses its last contributor is deleted; a fact that still has other contributors stays — at that point it is genuinely aggregate, non-personal knowledge with no person attached. A lighter "erase contributions only" mode drops a user's team contributions without touching their personal store.