ADR-0023: AI-editorialized journal — mechanical day rollups + cached LLM digests
- Status: Accepted
- Date: 2026-07-11
- Relates to: 📝 ADR-0011 (contact facts — the sibling extraction feature this mirrors: single egress, exclude list, model-versioned cache), 📝 ADR-0010 (single egress, exclude list), 📝 ADR-0002 (FK-less derived cache precedent), 📝 ADR-0003 (dual-source archive), SECURITY.md (egress posture)
Context
msgbrowse should answer "what happened on this day?" the way contact facts answers "who is this person?" — an editorialized layer over the raw transcript. We want a day-by-day journal: a factual, always-available rollup of each day's activity, plus an optional short prose digest the LLM writes about the day.
The design splits cleanly into two layers with very different costs and trust properties:
- A mechanical day journal — a deterministic per-day rollup (message count,
conversation count, per-source counts, top senders) derived entirely from the
local
messagestable. No LLM, no network, always available. - An LLM digest — one prose paragraph per day, written by the configured chat model. This is the only part that leaves the machine.
Constraints from the rest of the system:
- One egress, opt-in cost. LLM calls go only to
llm.base_urland must be a deliberate step, never a side effect of import or serving (📝 ADR-0010, SECURITY.md). The digest is exactly the egress posture thefactsfeature already established. - Re-ingest churn.
ReplaceConversationMessagesdeletes and re-inserts a conversation's rows on every import (new rowids, stable content), exactly as it does for embeddings (📝 ADR-0002) and facts (📝 ADR-0011). - Day boundaries are a correctness hazard.
messages.tsis a wall-clock string'YYYY-MM-DD HH:MM:SS'andmessages.ts_unixis that string parsed as UTC (internal/signal/message.go: local time with no zone, parsed as UTC purely for a stable ordering key). A day bucket must be derived in the same frame or messages land in the wrong day. - Privacy.
journal.exclude_conversationsnames threads that must never be sent to any LLM.
Decision
A real msgbrowse journal command (replacing the current errNotImplemented
stub) builds both layers, modeled directly on the facts command's incremental,
single-egress, model-versioned design.
-
Two layers, independently gated. The mechanical day journal is always built from
messagesregardless ofjournal.digest_enabled, with no LLM and no network. The LLM digest is built only whenjournal.digest_enabledis true (the default) and is the only new egress — onellm.Chatcall per day tollm.base_url, mirroring facts. -
Day bucketing is UTC. A day is
substr(ts,1,10), equivalentlydate(ts_unix,'unixepoch'). Becausets_unixis the wall-clock string parsed as UTC, this is a pure slice with no timezone conversion. Using'localtime'would double-shift the already-UTC value and misfile messages across midnight — this is the single highest-consequence correctness constraint of the feature. -
Schema v11: two day-keyed, FK-less tables.
journal_days(PRIMARY KEY day) caches the mechanical rollup;journal_digests(PRIMARY KEY day, plusmodel,prompt_version,body,updated_at) caches the digest. Neither has a foreign key tomessages: re-ingest deletes and re-inserts message rowids, so a CASCADE would wipe every derived journal row on each import — the same reasoning that keeps embeddings (v3) and contact_facts (v4) FK-less. Both are day-keyed, so the migration'sforeign_key_checkpasses trivially. -
Digest cache keyed by (day, model, prompt_version).
prompt_version = sha256hex(lower(trim(effective DigestPrompt)))— the exact hashing recipe asfactHashininternal/store/facts.go. Editingjournal.digest_promptor switchingllm.chat_modelchanges the key, so the cached digest no longer matches and that day becomes eligible again on the next run. A day whose cached(model, prompt_version)still matches is skipped with zero LLM calls. -
One digest per day. The digest is a single cross-thread daily summary, matching
config.DefaultDigestPrompt("summarizing one day"). Per-conversation digests are an explicit future extension, not in scope — the schema keys ondayalone. -
Honors the exclude list before assembly.
journal.exclude_conversationsis applied during day enumeration and transcript assembly, before any content is gathered, so excluded threads never reach the transcript, let alone the endpoint — the same boundaryFactConversationsenforces for facts. The sole egress isllm.Chattollm.base_url. -
CLI semantics and an honest
--dry-run. A default run is incremental: it digests every day whose cached digest is absent or stale by(model, prompt_version), bounded byjournal.max_days_per_runfor cron use and reporting the count of days left.--backfillapplies the same eligibility across all history (unbounded),--regeneratewipes all digests then rebuilds, and--since YYYY-MM-DDsets a day floor.--dry-runmakes zeroChatcalls: it enumerates the days lacking a current digest and estimates input tokens with a locallen(runes)/4heuristic. It deliberately prints no dollar figure — the codebase has no tokenizer and no price table, and the OpenAI-compatible response'susageobject is not decoded today, so a fabricated cost would be dishonest. A real cost estimate is future work.
Consequences
- The mechanical journal is free, deterministic, and always present: it needs no
LLM, never egresses, and is a rebuildable cache, so a stale or missing
journal_daysrow is repaired by a cheap re-derive frommessages. - The digest inherits facts' privacy and cost posture exactly — one auditable egress, gated behind an explicit command, defaulting to a local endpoint, with the exclude list applied before any content is assembled (📝 ADR-0010, SECURITY.md).
- Cache invalidation is automatic and cheap: a prompt edit or model switch
re-derives digests without a
--reset-style wipe, because the key carries both identifiers;--regenerateremains for a forced rebuild. - The UTC bucketing rule is load-bearing and must be preserved everywhere a day
is derived (SQL and Go). A single
'localtime'slip silently misfiles messages around midnight; it is called out in the schema comment and the spec. --dry-runis honest about what it cannot know: it reports day count and a coarse token estimate, not money. A precise estimate is deferred and would need a price-config knob plus decoding the provider'susageobject.- Deferred (stated non-goals): per-conversation digests, image-caption and audio-transcript enrichment (SECURITY.md Slice 6), and a real per-run cost/price estimate. The schema (day-keyed rows, not a blob) leaves room to layer these on later.