I run a personal AI system called Supra on a box at home. It is a Telegram bot sitting in front of about thirty skills, a heartbeat scheduler firing roughly forty cron jobs, and a knowledge layer that writes into an Obsidian vault. The systemd user unit has been up since 26 July with zero restarts and about 238 MiB resident. I talk to it most days.
This week I sat down and read its logs properly for the first time. The part of the system I was proudest of — the layer that decides to message me unprompted, without me asking anything — has not sent a single message in the trailing thirty days. It woke up 1,795 times and said no 1,784 of them.
That is the post. The rest is how it got there.
The shape of it
TypeScript on Node, ESM. 118 non-test files, 34,470 lines, plus 19 test files and 4,726 lines. Twenty-two commits. No README — there is no markdown at the repo root at all, which I only noticed while writing this. It is documented in commit messages and inline comments and nowhere else. package.json still calls it "Personal AI Operating System — your second brain", which is the kind of thing you write in the first hour of a project.
Telegram is the interface and it is not close. The channel file registers 17 bot.command() handlers and 4 callback-query handlers. Of 250 logged tool executions between 17 April and 27 July, 214 came from Telegram and 36 from the web dashboard. One of the commands is /test, which spawns vitest as a child process, parses the summary, and returns pass/fail counts, duration and up to ten failing files with a three-minute timeout. I wrote it so I could check a build from my phone.
Skill dispatch is deliberately not a router. SkillRegistry.getToolDefinitions() flattens every registered skill's tools into one namespaced list — {skillName}_{toolName} — and hands the whole array to the model; findTool() resolves an incoming call by splitting on the first underscore. 24 skills register unconditionally at boot, five more only when their credentials are present in env. Anthropic tool use is the router. That has held up better than any intent classifier I would have written.
The only thing in front of the model is a regex pre-classifier that can short-circuit it entirely:
{ pattern: /^(ok|okay|k|cool|bet|word|nice)\s*[.!]?$/i, reply: "👍" }
That returns skipLLM: true and costs zero API calls. Default model is Haiku, MAX_ITERATIONS 10, MAX_TOKENS 4096; Sonnet is reserved for code generation and mining. Every commit in the repo carries a Co-Authored-By trailer naming a Claude model, so the system was built with roughly the class of tooling it embodies.
The actually interesting problem
Everything above is a request/response assistant, and request/response assistants are solved. I built the proactive layer because Supra felt manual next to what other people were describing their personal AIs doing. I asked, it answered, and it never had an opinion about my day unless I opened the app.
An assistant that initiates is a different design problem. Deciding what to say is easy. Deciding whether you are allowed to say it at all is the whole thing, because the failure mode is not a wrong answer — it is a person who mutes you. The mechanism I shipped:
- A dispatcher cron on '*/15 * * * *' collects signals from wearables, markets, prediction markets, calendar and todos. Every collector is individually try/caught with an inline /* graceful */ comment, so a dead integration degrades the signal set rather than killing the tick.
- Urgency is computed in code before the model sees anything, from hardcoded thresholds — recovery under 34 is high and under 50 is medium, sleep under 6h high and under 7h medium, SPY at ±2% high and ±1% medium, three or more overdue todos high. Those are config constants, not readings.
- A judge — one Haiku call, max_tokens 400 — returns strict JSON {send, topic, urgency, draft, reasoning} over exactly seven topics: fitness, finance, kalshi, calendar, health, focus, general.
- Per-topic cooldowns in seconds: fitness 3h, finance 90m, kalshi 1h, calendar 30m, health 2h, focus 4h, general 3h. urgency 'high' bypasses the cooldown check entirely.
- Quiet hours 23:00–07:00 America/New_York, computed with Intl.DateTimeFormat rather than a date library. Quiet-hours skips are the one skip reason deliberately not logged.
- A feedback sweep on '17 * * * *' — offset from the hour with the comment "avoid :00 pileup", because everything else in the heartbeat fires on :00.
The feedback loop is passive by design. registerUserInteraction() fires from the Telegram handler immediately after the typing indicator, non-blocking, with a .catch() that only warns. If I reply within ENGAGEMENT_WINDOW_MS (10 minutes) the dispatch counts as engaged; after IGNORE_TTL_MS (6 hours) the sweep marks it ignored. An engagedOne flag stops a single reply crediting several dispatches — the comment reads "prevents one message from counting for many". HISTORY_MAX is 20 per topic.
Later I upgraded the judge from aggregate stats to few-shot. It now also sees the three most recent engaged drafts and three most recent ignored ones, cross-topic, labelled in the prompt:
ENGAGED (Pranav replied — write more like these): … IGNORED (Pranav did not reply — avoid this shape):
And the whole philosophy is one line in JUDGE_SYSTEM:
The bar is HIGH. Only send when the signal is either genuinely useful, time-sensitive, or a pattern Pranav cares about. Ignoring a signal is always safer than sending noise.
The layer ran in production for a while before it was ever committed. The checkpoint commit says so out loud: "Checkpoint commit for the proactive layer that has been running in prod unversioned."
The judge has never said yes
Thirty days of dispatcher log, non-quiet-hours only — since quiet-hours skips aren't logged, the true tick count is higher: 1,795 ticks, 1,784 skipped=judge_no, 11 skipped=no_signals, zero sends. Trailing seven days: 342, 331, 11, zero.
The reasoning field is a near-verbatim loop, tick after tick:
No engagement history exists yet — this is a cold start. All current signals are routine… / No engagement history exists yet — all topics at 0/0.
I read live Redis while writing this. All seven topics return total=0, engaged=0, ignored=0. getFewShotExamples returns zero engaged and zero ignored. The pending dispatch list has length 0.
The bug is circular and it is mine. JUDGE_SYSTEM tells the model the bar is HIGH, that ignoring is always safer, and then:
If feedback history shows a topic has <20% engagement over its last 5+ dispatches, DO NOT send for that topic unless urgency='high'.
With an empty history, Haiku reads 0/0 not as "no data, try one" but as "no calibration, therefore abstain". Because it never sends, logDispatch() is never called. Because logDispatch is never called, the feedback history lists stay empty. Because those are empty, getTopicStats() returns 0/0 forever.
I built the feedback loop to dampen an over-eager judge. The judge started maximally conservative, so there is nothing to dampen and no bootstrap. There is no exploration term anywhere in the code — no seed dispatch per topic, no rule treating a zero-history topic as licence to send once. Every mechanism designed to teach the judge what to send has never received a single training example.
Two caveats I can't resolve. journalctl retention gave me thirty verified days; whether it sent anything in the first weeks after launch in April, before the logs rotated, I don't know. And because it has never sent inside the window I can see, I have no real dispatch to show as an example of the voice.
The persona that never evolved
Same story one layer down. The persona has a fixed spine — five hardcoded VOICE_ANCHORS that never evolve:
Disagree when you disagree. Pushback > sycophancy. / No filler preamble ("Sure!", "Great question!", "Here's what I found:"). Open with the answer.
On top of that, a nightly evolver at 06:00 ET reads my recent messages and rewrites four fields: style, current focus, preferences, avoid list. One commit changed those outputs from descriptions to imperatives with a justification I still agree with — "The LLM can act on directives; it mostly ignores descriptions" — moved the cadence from weekly to daily because "Pranav's focus shifts daily, weekly was too slow", and added the anchors so there would be "personality stability from day one, not week three".
Here is the actual log:
[heartbeat:persona-evolver] skipped — only 3 user messages (need ≥15)
[heartbeat:persona-evolver] skipped — only 0 user messages (need ≥15)
The persona key is null. Version undefined. No dynamic persona has ever been written, which means the formatter has only ever emitted the anchors block.
Two mismatches, both mine. Volume: the evolver sets SAMPLE_SIZE = 200 and lranges the last 200 entries, but the conversation store trims that same list on every single write with CONV_MAX_LENGTH = 50 — covering user and assistant turns, so roughly 25 user messages is the physical ceiling. The evolver asks for four times more than the store can hold. Lifetime: every save also calls expire with CONV_TTL_SECONDS = 86400, so the key vanishes 24 hours after the last message, and any day with a usage gap finds nothing at all. Measured live, llen on the conversation key returns 0.
The conversation store is correct. It was built as a 24-hour working-memory scratchpad and it is a good one. The evolver was written as though it were a long-term corpus.
The compounding matters more than the bug. The dispatcher's loadRecentUserMessages() reads that same expiring key, so the judge's "Recent user messages to you" block is usually "(none)" as well. The judge is starved of engagement history, few-shot exemplars and recent context simultaneously. That is why the deadlock above is so stable — it isn't marginal, it's structural. The anchors I added so Supra would have a personality from day one turn out to be the only persona that has ever shipped.
There is a smaller one alongside it. Unlike the dispatcher's per-collector try/catch, the evolver wraps its entire body in a single outer catch, so a transient Upstash REST failure takes out the whole run:
[heartbeat:persona-evolver] failed: TypeError: fetch failed
at async Object.runPersonaEvolver (src/heartbeat/jobs/persona-evolver.ts:53:17)
Line 53 is the Redis lrange. The scheduler then logs "Completed: persona-evolver" right after, so a failed run and a successful one are indistinguishable in the completion log.
The self-improvement loop mined a test fixture
This is the best failure in the repo. Supra has a trace-miner: it reads the production tool-execution log, splits it into sessions on a 10-minute gap, mines 2- and 3-grams of skill:tool sequences, and proposes composite skills. Thresholds are MIN_PATTERN_OCCURRENCES = 3, MIN_DISTINCT_SESSIONS = 2, MAX_PROPOSALS = 5, over a seven-day window. Approved proposals go to Sonnet, which writes a real skill file.
The single most frequent entry in that production log is homelab__loop_tool — 100 occurrences, 50 on 20 April and 50 on 23 April, all under userId 'user1'. No such tool exists anywhere in src/skills/.
trace-log.ts writes to a hardcoded absolute path with no env override and no test guard. orchestrator.test.ts contains a test named 'returns fallback message when MAX_ITERATIONS exhausted' that defines a tool called loop_tool and mocks the Anthropic client to return tool_use every time and text never — so the orchestrator genuinely executes the tool ten times per run. './trace-log.js' was not in that file's vi.mock list. Ten real JSONL writes per run, five runs a day, fifty events a day, straight into production.
Three days after the last one, the miner did exactly what I built it to do. At 2026-04-26T18:38:49.351Z it proposed, and Sonnet generated, a skill whose description opens "Runs loop diagnostics" and whose body builds a Loop Diagnostics section against an endpoint that has never existed outside a test fixture.
The fix was one line — adding vi.mock('./trace-log.js', …) — and it landed ten minutes after the final polluting write. But I never purged the 100 events, which is why the miner still produced the phantom skill three days later. The wrong lesson is "mock harder". The right one is that an observability path a test can write to is a training path a test can poison, and thresholds of three occurrences across two sessions are low enough that a hundred fake events outrank every genuine pattern.
What saved it was the gate. Generated files land in a sandboxed src/skills/proposed/ directory under a header reading "AUTO-GENERATED SKILL — REVIEW BEFORE REGISTERING", and activation requires me to hand-edit an import and a registry.register() call. The Sonnet code-gen call is itself gated behind a Telegram Approve/Reject inline button. Exactly one generated skill exists on disk. It was never activated. Ten tools across eight skills carry requiresConfirmation: true for the same reason.
What does work
The unglamorous parts are fine, which is its own kind of finding.
- 231 of 250 tool executions succeeded. The 19 failures split cleanly: environmental (10× homelab get_status 'Connection refused', 2× 'Proxmox API error 501') and type coercion at the tool boundary — 'params.tags.split is not a function', 'The "path" argument must be of type string. Received undefined', "Cannot read properties of undefined (reading 'toLocaleString')". The model handed my handlers parameter shapes they trusted without validating. zod is a declared dependency and is not used at that boundary.
- trace-log.ts redacts before it writes: keys matching /(password|token|secret|api[_-]?key|authorization|cookie|bearer)/i become '[REDACTED]', strings over 80 chars truncate, the params digest caps at 200, and logTrace is wrapped so it can never throw. That is the only reason the failures above are countable.
- The EADDRINUSE crash-loop that used to be the service's most common failure became a boot step: index.ts shells lsof -ti on the port before binding, SIGTERMs any PID that isn't itself, waits 500ms, all inside a try/catch commented /* no process on port — good */. A manual runbook turned into startup code, and NRestarts is 0.
- vault-sync runs every 30 minutes and suppresses its own Telegram notification when nothing changed — "No changes (N projects) — skipping Telegram". Restraint got implemented at the job level, where it works, rather than only in the judge, where it doesn't.
- live-state.ts injects a "what's true right now" block into every chat turn, but only when a signal is out of band, returning an empty string otherwise. Pure Redis cache reads, no network fetches. Ordinary days cost zero extra tokens.
The fitness surface is the largest single skill — one file with 21 tool definitions, backed by separate Whoop, Strava, Garmin and Apple Health integration modules plus OAuth storage and refresh. Most of that work was protocol plumbing rather than intelligence; one commit reads "oauth: Whoop refresh without redirect_uri + scope=offline per RFC 6749". It is the clearest example in the system of an always-on data surface, which is exactly why it feeds several of the seven judge topics.
The knowledge layer writes into an Obsidian vault across 14 typed categories mapped to subfolders. Scanning the vault graph today: 379 nodes, 546 edges, 202 orphans, 11 unresolved links, 36 folders. More than half the vault has no inbound or outbound link at all. Auto-generated notes accumulate far faster than anything links them — the same shape of problem as the judge, from the other end. Generation is cheap; judgment is not.
On cost: the tracked spend log holds 45,568 calls and $68.74 between 8 April and 27 July, of which the conversational orchestrator is only $9.05 across 385 Haiku calls. The overwhelming majority is an autonomous loop elsewhere in the system, not the chat interface. The dispatcher and evolver call the SDK directly and never route through the token tracker, so the proactive layer's own spend across those 1,795 ticks isn't in that file at all — I can't put a clean number on it.
What I'm changing
The judge needs an exploration term. A zero-history topic should be licence to send once, not a reason to abstain — the current prompt treats "I have no evidence this is welcome" and "I have evidence this is unwelcome" as the same state, and they are not. The cheap version is a forced seed dispatch per topic before the <20% engagement rule is allowed to apply at all.
The evolver needs its own durable store: an append-only list of user messages with no TTL and a cap that is actually ≥ SAMPLE_SIZE, separate from the 24-hour conversation key. The judge's recent-context block should read from the same place.
And I want quiet-hours skips logged after all. Leaving them out was a deliberate noise-reduction choice at the time, and it means the number I most want — how many times a day this thing considers speaking to me and decides not to — is the one number I cannot get out of the logs.
