DEV Community

Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

slacktag-oss Part 2: Three Features That Make Memory Actually Useful

A follow-up to Building a Slack Bot That Actually Remembers. We shipped the foundation — a Slack bot with persistent semantic memory via Mem0. Now we're going deeper: three new features that turn memory from a novelty into something your team will rely on daily.


What we shipped in Part 1

Quick recap: slacktag-oss is an open-source Slack bot backed by Mem0 for persistent semantic memory. Instead of a rolling message window, it extracts facts from every conversation and surfaces them when relevant — even weeks later. Channel memory, thread memory, DM memory, all correctly scoped.

In Part 1 we covered the core architecture. This post is about what we added next, and why each one required Mem0 specifically — these aren't features you could bolt onto a stateless bot.


Feature 1: !summarize

The simplest addition, but surprisingly useful.

!memory already existed — it dumps a raw numbered list of every fact stored in the current scope. That's fine for debugging, but nobody wants to read a raw fact dump in a Slack channel. !summarize takes the same data and asks the LLM to synthesize it into a readable paragraph.

def _summarize_memories(memories: list, scope_label: str) -> str:
    if not memories:
        return "Nothing stored yet for this scope."
    raw = _format_memories_for_display(memories)
    prompt = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=(
            f"Here are all the facts stored in memory for {scope_label}:\n\n{raw}\n\n"
            "Write a concise, readable summary (3–6 sentences) of what has been discussed "
            "and what key facts are worth remembering."
        )),
    ]
    return llm.invoke(prompt).content
Enter fullscreen mode Exit fullscreen mode

The only new thing here is the prompt. The heavy lifting — fact extraction, deduplication, persistent storage — was already being done by Mem0 on every message. !summarize is just a better read path on top of mem0.get_all().

This is a recurring pattern: Mem0 works in the background accumulating structured knowledge, and you add new commands as different views onto that knowledge. No extra storage. No extra pipelines.


Feature 2: Reaction-to-memory (📌 / 🗑️)

This one changes the dynamic from "the bot decides what to remember" to "humans curate what's worth remembering."

React 📌 (pushpin) to any message → it's explicitly saved as a memory fact for that channel.

React 🗑️ (wastebasket) to any message → the closest matching memory entry is found and deleted.

┌─────────────────────────────────────┐
│  User reacts 📌 to a message        │
│                                     │
│  reaction_added event fires         │
│       │                             │
│       ▼                             │
│  conversations_history API call     │
│  (fetch original message text)      │
│       │                             │
│       ▼                             │
│  mem0.add([{role: user,             │
│             content: message_text}],│
│            user_id=channel_scope)   │
│       │                             │
│       ▼                             │
│  Bot confirms in channel            │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The save path is straightforward — mem0.add() with the raw message text. Mem0 handles entity extraction and deduplication the same way it does for regular conversations.

The delete path is more interesting. Mem0 doesn't have a "delete by text content" API — it deletes by memory ID. So we do a semantic search first, take the top result, and delete by its ID:

def handle_reaction(channel_id: str, user_id: str, text: str, action: str) -> str:
    scope = channel_memory.scope_id(channel_id)
    if action == "save":
        channel_memory.add([{"role": "user", "content": text}], scope)
        return f"📌 Saved to memory: _{text[:120]}_"
    elif action == "remove":
        matches = channel_memory.search(text, scope)
        if not matches:
            return "Nothing matching that message found in memory."
        memory_id = matches[0].get("id")
        if memory_id:
            channel_memory.client.delete(memory_id)
            return f"🗑️ Removed from memory: _{matches[0].get('memory', text)[:120]}_"
Enter fullscreen mode Exit fullscreen mode

Why this matters: Auto-extraction is imperfect. The bot might miss a critical decision buried in a long message, or over-index on something throwaway. Reaction triggers give your team a lightweight control surface — no commands to learn, no special syntax. Just use emoji the way Slack teams already do.

Wiring it up in slack-bolt is clean:

@app.event("reaction_added")
def on_reaction_added(event, client, say):
    route_reaction(event, client, say)
Enter fullscreen mode Exit fullscreen mode

The client injected by slack-bolt is used to fetch the original message via conversations_history. One thing to remember: you need reaction_added in your Slack app's bot event subscriptions, and reactions:read in your OAuth scopes.


Feature 3: !catchmeup — personalized channel digests

This is the feature that most directly shows what becomes possible when you have two memory scopes to cross-reference.

The problem: !summarize gives everyone the same summary of a channel. But a frontend engineer and a backend engineer don't need the same catch-up. The frontend engineer wants to know about design decisions and API contract changes. The backend engineer wants to know about schema migrations and performance issues.

!catchmeup solves this by combining two Mem0 calls:

  1. channel_memory.get_all(channel_scope) — everything discussed in this channel
  2. dm_memory.get_all(dm_scope) — everything known about the person asking (their role, their past questions, their areas of ownership)
def handle_catchmeup(channel_id: str, user_id: str) -> str:
    channel_scope = channel_memory.scope_id(channel_id)
    channel_facts = channel_memory.get_all(channel_scope)
    if not channel_facts:
        return "No channel history in memory yet — nothing to catch you up on."

    user_scope = dm_memory.scope_id(user_id)
    user_facts = dm_memory.get_all(user_scope)

    channel_context = _format_memories_for_display(channel_facts)
    user_context = (
        _format_memories_for_display(user_facts)
        if user_facts
        else "No personal context available — give a generic summary."
    )

    prompt = [
        SystemMessage(content="You are a helpful assistant summarizing a Slack channel for a returning teammate."),
        HumanMessage(content=(
            f"Here is what has been discussed in this channel:\n\n{channel_context}\n\n"
            f"Here is what I know about the person asking:\n\n{user_context}\n\n"
            "Write a personalized catch-up digest (4–8 bullet points) highlighting what's most "
            "relevant and important for this specific person. Lead with a one-sentence intro. "
            "Skip anything they already clearly know or wouldn't care about."
        )),
    ]
    return llm.invoke(prompt).content
Enter fullscreen mode Exit fullscreen mode

Here's what this looks like in practice. Say Alice (frontend) and Bob (backend) both type !catchmeup after a week away from #platform:

Alice gets:

Here's what happened in #platform while you were away — focus on the API contract changes and the new design system tokens.

  • The REST API now returns created_at as ISO 8601 instead of Unix timestamp — frontend date parsing will need updating
  • Design tokens for the new component library were merged; migration guide is in Notion
  • ...

Bob gets:

Here's what happened in #platform while you were away — mostly schema and performance work relevant to your services.

  • The users table added a composite index on (tenant_id, created_at) — query patterns for your billing service should improve
  • Rate limiter config moved to Redis; the old in-memory approach was causing inconsistency across pods
  • ...

Same channel. Same Mem0 data. Different people, different summaries.

This only works because DM memory has been accumulating context about each user from their private conversations with the bot. The longer someone uses the bot in DMs, the more personalized their !catchmeup becomes. The memory compounds.


The pattern across all three features

Looking at these three features together, there's a consistent pattern:

Feature Mem0 operation What it adds
!summarize get_all() → LLM synthesis Better read path on existing data
📌 reaction add() with explicit content Human-curated write path
🗑️ reaction search()delete(id) Human-curated delete path
!catchmeup get_all() × 2 scopes → LLM Cross-scope personalization

Mem0 is doing the hard work in all four cases: storing, indexing, retrieving. The application code is just deciding when to call which operation and how to frame the result for the LLM.

This is the real argument for using a managed memory layer instead of rolling your own vector store: you get add, search, get_all, and delete as primitives, and you can build surprisingly rich behavior by composing them.


What's next

A few directions we're thinking about:

Graph memory — Mem0 supports a graph mode that tracks relationships between entities. You could model "Alice owns the auth service", "auth service depends on user-db", and surface that graph when someone asks about a deployment.

Per-channel LLM config — store a channel's preferred model in Mem0 itself (alongside conversation memory). #architecture uses a powerful reasoning model; #random uses a fast cheap one.

!whoknows <topic> — search across all user DM scopes to find who has the most relevant memories about a given topic. Instant expert routing.


Links

Top comments (0)