Sloth Brief:本地 AI 夜間整理羣組聊天,早上提供兩行摘要
Sloth Brief: a local AI that reads your group chats while you sleep and gives you two lines in the morning
Sloth Brief 是本機夜間智能體,會摘要自上次執行以來活躍的羣組聊天,早上再把兩行摘要和需用戶處理的訊息送至 Telegram 的 Saved Messages。
This is a submission for the Slow and Steady challenge: build something with open-source AI that rewards laziness. Happy (belated) International Sloth Day. 🦥
Sloth Brief is an overnight agent for people who are in too many group chats.
While you sleep, it reads every group chat that got busy since yesterday, using an open-weight model running on your own machine. When you wake up, there's one message waiting for you:
☀️ Good morning. 214 messages in 6 chats while you slept.
Family settled on Sunday lunch at Grandma's at 11. The work group
is still arguing about the launch date; nothing was decided.
Needs you (2):
→ Linh asked if you can bring the speaker on Sunday (Family)
→ Minh needs your review on the pricing doc by Thursday (Work)
Two lines of context, plus the messages that actually need you. Everything else, you can safely ignore.
Your messages never leave your machine for summarizing, it never marks anything as read, and it costs nothing to run every night, forever.
The problem: the morning scroll
I'm in a family group, two friend groups, a work group, a building residents' group, and a group for a trip that happened in 2023 and still won't die.
Every morning starts the same way: I open my phone, see a few hundred unread messages, and scroll. Most of it is memes, reactions, and conversations that were resolved hours ago. But somewhere in there is the one message where someone asked me something directly. If I skip the scroll, I miss it. If I do the scroll, I lose twenty minutes before I've even gotten out of bed.
This is exactly the kind of work a sloth would refuse to do. So I made my computer do it instead, at 3 a.m., when nobody needs it for anything else.
What I built
Sloth Brief does three things, once a night:
- Collects messages from your group chats since the last run.
- Summarizes each busy chat with a local model and finds anything addressed to you.
- Delivers one short message to your "Saved Messages," so it's the first thing you see in the morning.
The design goal was simple: the brief should take less time to read than it takes to unlock your phone.
Why "slow" is a feature here
This is where the theme and the architecture meet.
Most AI tools have to be fast, because someone is staring at a loading spinner. Sloth Brief doesn't. It runs at 3 a.m. and has until you wake up, which is hours.
That changes which models make sense. I don't need a huge model on a powerful GPU, or a paid API that answers in two seconds. A small open-weight model running on an ordinary laptop CPU is more than fast enough, because "fast enough" means "done before breakfast."
The sloth approach isn't just a theme. It's what makes local inference practical.
How it works
3:00 a.m. (cron)
│
▼
┌──────────────────────────────────┐
│ 1. Collect │ Telegram (your own account, read-only)
│ messages since last run │ or exported chats
└────────────────┬─────────────────┘
▼
┌──────────────────────────────────┐
│ 2. Filter │ muted chats, quiet chats, bots
└────────────────┬─────────────────┘
▼
┌──────────────────────────────────┐
│ 3. Summarize each chat (map) │ local LLM via Ollama
│ gist, decisions, asks for me │ JSON-schema output
└────────────────┬─────────────────┘
▼
┌──────────────────────────────────┐
│ 4. Verify "needs you" items │ plain code: did they really ask *you*?
└────────────────┬─────────────────┘
▼
┌──────────────────────────────────┐
│ 5. Write the brief (reduce) │ local LLM → two lines
└────────────────┬─────────────────┘
▼
☀️ Message to yourself
Step 1: Collect messages (without marking them as read)
I use Telegram, which has an official API that lets you read your own chats with your own account. Telethon, an open-source Python client, makes that easy:
from datetime import datetime, timezone
from telethon import TelegramClient
client = TelegramClient("sloth", API_ID, API_HASH) # your own app credentials
async def collect(since: datetime) -> dict[str, list[dict]]:
chats = {}
async for dialog in client.iter_dialogs():
if not dialog.is_group:
continue
messages = []
async for m in client.iter_messages(dialog): # newest first
if m.date < since:
break
if not m.text:
continue
sender = await m.get_sender()
messages.append({
"id": m.id,
"from": getattr(sender, "first_name", None) or "Someone",
"text": m.text,
"reply_to": m.reply_to_msg_id,
"is_me": m.out,
})
if messages:
chats[dialog.name] = list(reversed(messages)) # oldest first
return chats
Two details matter here:
- Reading history this way doesn't send read receipts. Your friends won't see you "read" their messages at 3 a.m., and the chats stay unread in your app.
- It only reads. Sloth Brief never posts in a group. The only message it sends is to your own "Saved Messages."
If you use WhatsApp or another app, most of them can export a chat to a text file. Sloth Brief has a simple importer for those exports too. It's more manual, but still no cloud required.
Step 2: Ignore what you'd ignore anyway
A plain config file decides what's worth reading:
me:
names: ["An", "Anh", "@an_dev"] # how people refer to you
mute:
- "Trip 2023" # the chat that won't die
min_messages: 10 # quiet chats aren't worth a summary
skip_bots: true
Any chat under the threshold just gets counted ("plus 3 quiet chats"), not summarized. Being lazy about what to read is half the job.
Step 3: Summarize each chat with a local model
Each busy chat is summarized separately. The model gets the messages as a numbered transcript and must answer in a fixed JSON structure, using Ollama's structured output:
import ollama
from pydantic import BaseModel
class Ask(BaseModel):
message_id: int
who: str
what: str # short, in plain words
class ChatSummary(BaseModel):
gist: str # one sentence: what happened
decisions: list[str] # anything agreed on
asks_for_me: list[Ask]
SYSTEM = """You summarize a group chat for a busy person named {name}.
- gist: one plain sentence about what mattered. Ignore memes, greetings and reactions.
- decisions: only things the group actually agreed on.
- asks_for_me: only messages that ask {name} directly or reply to {name}.
Use the exact message_id from the transcript. If there are none, return an empty list.
Never invent messages, people, times, or decisions."""
def to_transcript(messages: list[dict]) -> str:
return "\n".join(f"[{m['id']}] {m['from']}: {m['text']}" for m in messages)
def summarize_chat(messages: list[dict], my_name: str) -> ChatSummary:
resp = ollama.chat(
model="qwen3:8b",
messages=[
{"role": "system", "content": SYSTEM.format(name=my_name)},
{"role": "user", "content": to_transcript(messages)},
],
format=ChatSummary.model_json_schema(),
options={"temperature": 0.2},
)
return ChatSummary.model_validate_json(resp.message.content)
Very long chats are split into chunks of a few hundred messages, summarized chunk by chunk, and merged. That's slower, but again, it's 3 a.m. Nobody's waiting.
Step 4: Verify every "needs you" item
The "Needs you" list is the most important part of the brief, and the worst place for a mistake. A model that invents "Minh needs your review by Thursday" could send me chasing a task that doesn't exist. A model that misses a real ask is just as bad.
So every ask the model returns is checked with plain code against the real messages:
def verify_asks(summary: ChatSummary, messages: list[dict], my_names: list[str]) -> list[Ask]:
by_id = {m["id"]: m for m in messages}
my_ids = {m["id"] for m in messages if m["is_me"]}
names = [n.lower() for n in my_names]
verified = []
for ask in summary.asks_for_me:
m = by_id.get(ask.message_id)
if m is None or m["is_me"]:
continue # message doesn't exist, or it's mine
mentions_me = any(n in m["text"].lower() for n in names)
replies_to_me = m["reply_to"] in my_ids
if mentions_me or replies_to_me:
verified.append(ask)
return verified
An ask only makes it into the brief if the message exists and either mentions me or replies to one of my messages. The model does the hard part, understanding that "can you bring the speaker?" is a request. The code makes sure it's pointing at a real message.
There's also a safety net in the other direction: any message that mentions my name but wasn't flagged by the model gets added as "You were mentioned in Work (1 message)." The model can be lazy, but it can't make me miss something.
Step 5: Write the brief
The last step takes all the chat summaries and squeezes them into two lines:
class Brief(BaseModel):
line_1: str
line_2: str
def write_brief(summaries: dict[str, ChatSummary]) -> Brief:
gists = "\n".join(f"{chat}: {s.gist} Decisions: {s.decisions}" for chat, s in summaries.items())
resp = ollama.chat(
model="qwen3:8b",
messages=[
{"role": "system", "content": (
"Write a morning brief in exactly two short, plain sentences. "
"Lead with decisions and plans. Skip anything trivial. No emojis."
)},
{"role": "user", "content": gists},
],
format=Brief.model_json_schema(),
options={"temperature": 0.3},
)
return Brief.model_validate_json(resp.message.content)
The "Needs you" list isn't written by the model at all. It's assembled directly from the verified asks in Step 4, so it can't be paraphrased into something nobody said.
Then the whole thing is sent to your own Saved Messages:
await client.send_message("me", brief_text)
Step 6: Schedule it and forget it
One cron line, and Sloth Brief runs every night:
0 3 * * * cd ~/sloth-brief && python sloth.py run >> sloth.log 2>&1
It needs a machine that's awake at 3 a.m.: a laptop on its charger, an old PC, or a small single-board computer in a corner. If the machine was asleep, Sloth Brief simply runs when it wakes and covers everything since the last run.
Why open innovation matters for this project
1. These aren't just my messages
This is the most important reason, and it's easy to overlook.
A group chat isn't only my data. It's my mom's messages, my friends' jokes, my coworkers' complaints about the launch date. None of them agreed to have their messages sent to an AI company's servers so that I could skip my morning scroll.
With a local open-weight model, the summarizing happens entirely on my machine. My friends' messages go from the chat app to my laptop, and nowhere else. That isn't a privacy setting I have to trust; it's just how the system works.
2. Slow and steady means small models are enough
Because the job runs overnight, I don't need speed, and that's exactly where open models shine. A small model on a CPU might take a while to work through a few hundred messages, and that's fine. A paid API would make every night faster at something that doesn't need to be fast, and charge me for it.
3. Set it up once, never pay again
A morning summary is useful every single day, which is exactly the kind of tool that becomes expensive with per-request pricing. Running an open model locally, the cost is a bit of electricity overnight. There's no subscription to cancel and no bill that grows with how chatty my friends are.
4. I control how it behaves
Every part of the agent's behavior is something I can see and change:
- What counts as "needs you." The verification rules are plain Python.
- What gets ignored. Muted chats and thresholds are a config file.
- The tone. The prompts are in the repo. If I want the brief shorter, funnier, or in another language, I edit a line.
- The model. Swapping to a newer open model is a one-line change, with no migration and no new account.
With a closed assistant, I'd get whatever summary style the provider decided on, and whatever their policy says about my messages this year.
5. Most of it works offline
Collecting messages needs the internet, of course. But everything after that, summarizing, verifying, and writing the brief, runs with no connection at all. Nothing about the AI part depends on a service being up at 3 a.m.
Where closed would have been easier (to be fair)
Large hosted models are better at sarcasm, inside jokes, and messages that mix several languages. A small local model sometimes reports a joke as a decision, or misses that "lol sure" means "no."
For this job, that's an acceptable trade. The brief is a starting point, not a replacement for the chats, and the verification step means it can't invent requests. Getting the gist slightly wrong now and then is a small price for never sending my friends' private messages to someone else's server.
Limitations
- It needs a machine that's on overnight. Or at least one that wakes up before you do.
- Telegram is the smoothest path. Other apps rely on manual chat exports, which is less lazy than I'd like.
- Sarcasm and mixed languages are hard for small models. Treat the brief as a summary, not as the source of truth.
- It only reads text. Voice messages and images are counted, not summarized (yet).
What's next
- [ ] Transcribe voice messages overnight with a local speech model
- [ ] Describe shared images with a small local vision model ("Linh sent 4 photos from the beach")
- [ ] A weekly digest for chats too slow to deserve a daily one
- [ ] "Snooze until": hide a chat from the brief until a date, then bring it back
Try it
pip install telethon ollama pydantic pyyaml
ollama pull qwen3:8b
cp config.example.yaml config.yaml # add your names and muted chats
python sloth.py login # one-time Telegram login
python sloth.py run # test it once, then add the cron line
Then go to sleep. That's the whole point. 🦥
Built with: Ollama, Qwen3 (open weights), Telethon, Pydantic, Python.
來源:DEV Community · dev.to