Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications64
  4. Projects & Tasks95
  5. Automation & Scheduling81
  6. Knowledge & Memory26
  7. AI Features61
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization59
  12. Account & Billing28
  13. Troubleshooting85
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Briefings (AI briefing of what happened) (part 2)

How a briefing is actually assembled: the sources it draws on, the pass that turns them into prose, and the schedule it runs on.

What it is

The continuation of part 1, covering the engine behind a briefing rather than the reader-facing recap.

Where to find it

The briefing runs on its own schedule; the code that assembles it is under the daily-digest service named in the frontmatter of this page.

How it behaves

How it works

Gathering runs off the main thread (2026-07-13). Building a digest scans a whole day of sessions, messages, and channel activity in the database. Those scans used to run synchronously on Omniscio's main window thread — and when Windows had pushed the database out of RAM under heavy disk load, they could freeze the entire window for minutes at a time, once per 15-minute retry. The scans now run in a dedicated short-lived database helper process (amc-db-host-digest, spawned per digest and always terminated afterward), so a slow disk can stall the digest — never your window. If the helper can't start, gathering falls back to the old in-process path; a mid-scan failure just fails that cycle and retries at the next 15-minute tick. Escape hatch: the AMC_DISABLE_DIGEST_OFFTHREAD=1 environment variable forces the legacy path. (The same fix repaired a silent day-one bug: the "recent project history" context the AI uses for feature ideas had never actually loaded — digests generated after this change include it for the first time.) Behavior lock: digest-gather-offthread-contract.md.

The prompt stays bounded on busy days (2026-07-16). A single very active day can involve hundreds of sessions. The digest used to list every one of them in the prompt it sends to Haiku — and once the "recent project history" fix above started actually loading, that block grew with your project count too. On the busiest days the request grew large enough that the model couldn't finish inside the call's time budget, so it timed out, and the automatic catch-up retried the same oversized request every cycle — leaving those days permanently blank (this is what stranded 2026-07-12/13/14). Two caps fix it at the root: the prompt enumerates at most the 120 most-recent sessions (the stats row still counts the true total, and a "showing the 120 most recent of N" note tells the model it saw a subset) and at most the 10 most-active projects in the history block — mirroring the per-channel message caps every other source already had. The generation call also now uses the digest's own ~50-second budget instead of silently inheriting the provider's shorter 30-second default. Together these keep even a heavy day's request well inside the time budget, so catch-up can fill it. Caps are MAX_SESSIONS_IN_DIGEST / MAX_HISTORY_PROJECTS in /src/main/services/daily-digest/digest-prompt-builder.ts, locked by /tests/unit/services/daily-digest-prompt-bounds.test.ts.

The service is /src/main/services/daily-digest-service.ts — runs a 15-minute createPeriodicTask loop, checks settings.dailyDigestGenerationHour against local time, and if a digest for yesterday is missing it pulls source data via the enabled integrations (each source has its own helper: session-summary, gmail-summary, calendar-summary, …), concatenates them, and sends the whole thing through llmProviderService.chat() with provider: 'openrouter' + responseFormat: 'json_object'. The router uses the embedded OpenRouter key from default-credentials.local.json for the cheap path (so the digest works without any user-configured Anthropic account), falling back to the user's Anthropic apikey only if the cheap provider 5xxs or returns empty content. Generation skips only when there is no usable credential of either kind (no embedded OpenRouter key AND no user apikey); that genuine can't-generate case — plus an OpenRouter-only generation failure with no apikey to fall back on — raises ONE de-duplicated inbox alert ("Couldn't generate your daily digest") so the otherwise-silent stop surfaces instead of digests just disappearing. Every generation failure is routed exactly once (routeDigestFailure, 2026-09-08) into one of three surfaces, and the same verdict sets the log level so the two can't disagree: a credential/billing failure (rejected key, exhausted monthly allowance, no key left to fall back to, dry credit balance) raises that actionable card; a transient failure (a timeout/abort, or a 429/5xx) touches nothing and self-heals on the next 15-minute tick; only a genuinely unexplained failure feeds the F113 "your digest keeps failing" give-up streak. Before this, only an HTTP 401 counted as credential, so the other three classes fed the streak and its card told the user the digest was failing "for a non-credential reason" when it was entirely a credential problem — and transient blips, counted in a streak that only clears on a success, accumulated into the same false alarm. Invariants I7–I9 in daily-digest-credential-contract.md. The follow-up Q&A path uses the same routing without responseFormat (returns prose). On first run it loops from today - 7 forward so you get a backfill. Results land in SQLite via /src/main/db/queries-daily-digest.ts with unread / archived / snoozed state and a per-digest conversation thread. The IPC surface lives in /src/main/ipc/daily-digest-handlers.ts (list / get / mark-read / archive / snooze / follow-up / generate / unread-count / toggle-alert-dismissal / dismiss-all-alerts / list-suppressions / remove-suppression); the push events DAILY_DIGEST_GENERATED, DAILY_DIGEST_UNREAD_CHANGED, and DAILY_DIGEST_UPDATED keep the sidebar count and the open viewer live. Because that GENERATED push is fire-and-forget (the push bus has no replay), the briefing — and the weekly summary — also reconcile on return: a window focus / document-visible event refetches both, so a digest generated while Omniscio sat backgrounded overnight self-heals into the inbox the moment you come back to the window, with no manual refresh/reload/restart needed (useBriefingReconcileOnReturn, invariant briefing-reconcile-contract.md). There is no delete channel — archive flips the is_archived flag on the digest row, so digests are never hard-deleted from the database.

What the Alerts box contains (and what it deliberately excludes). The digest's Alerts section is reserved for non-obvious, cross-cutting problems you can't already see elsewhere — a broken build, a failing or wedged CI/cloud pipeline, an app crash (Sentry), or a systemic issue spanning many sessions. It does not re-list individual sessions that are errored / stalled / waiting / in recovery — those already appear in your inbox and the sidebar, so repeating them is noise (and a single session's transient status is usually stale by the time the morning digest is read). This is enforced two ways: the generation prompt tells the model to keep per-session attention in the action items section, not alerts; and a deterministic shared filter (filterInboxSessionAlerts in /src/shared/types/digest-data.ts) drops any alert whose text echoes an internal session marker (needs_you, wait_timeout, response_aborted, recovery_failed). The filter runs at generation, at render, AND in the share export, so even digests generated before this rule clean up the moment you open them — no regeneration needed. Genuinely useful infra/crash alerts carry none of those tokens and are untouched. Invariant: digest-alert-inbox-dedup-contract.md.

Sidebar bucketing (Active / Snoozed / Archived) — The active/snoozed/archived split is computed by a single pure helper, bucketDigests in /src/renderer/src/features/daily-digest/digest-bucket.ts, called from DigestSidebarContent.tsx on every render with (digests, Date.now()). Active is anything with isArchived === false, snoozedUntil === null (or in the past), and digestDate within the last 7 days; the same 7-day cutoff folds older un-archived rows into Archived. Snooze re-bucketing is therefore passive — the row moves to Active on the first render after Date.now() > snoozedUntil, no timer or push event needed. The two flags live in daily_digests.is_archived INTEGER NOT NULL DEFAULT 0 and daily_digests.snoozed_until TEXT NULL (added in migration v107 — see /src/main/db/incremental-migrations.ts and /tests/unit/db/digest-sidebar-archived-migration.test.ts). Mutators are setDigestArchived(id, archived) and setDigestSnoozedUntil(id, iso | null) in queries-daily-digest.ts; the five archive trigger paths (middle-click, X button, right-click, E / Ctrl+W, and the open briefing's header button) all funnel into useDailyDigestStore.archiveDigest. The right-click context menu lives in DigestContextMenu.tsx; the row component (with its mobile-only X button + mousedown-based middle-click handler — auxclick doesn't fire reliably inside scrollable ancestors) is DigestSidebarRow.tsx. Snoozing reuses the generalized SnoozePalette.tsx modal (also used for sessions and emails) via a kind: 'digest' | 'session' | 'email' discriminator. The same setDigestSnoozedUntil mutator also drives the unified Inbox row — selectUnreadCount and digestInboxSelectItems in /src/renderer/src/stores/daily-digest-store.ts both filter out active snoozes so the inbox rows and the amber count badge mirror the sidebar's Snoozed bucketing without a separate code path. Individual briefing rows (2026-07-17): the inbox surfaces ONE row per unread briefing — each titled with its own date (formatDigestDate) and dismissed on its own — instead of a single collapsed "N new briefings" line; digestInboxSelectItems emits those per-briefing rows newest-first (plus a neutral-gray row for the briefing you're actively reading), and the backend digestToInboxItems projection mirrors it for the CLI /inbox view. The active-row highlight tracks the specific briefing via the digest store's selectedDigestId (threaded through the render surfaces as activeInboxDigestId) so only the briefing you're on highlights, never all of them. Right-clicking a digest in the Inbox opens the same DigestContextMenu; pressing S while the digest is the active inbox item dispatches the shared snooze-entity CustomEvent that the global SnoozePalette listener picks up.

Customization splits cleanly across the gather phase (which runs once per day) and the render phase (which runs every time you open a digest). Project exclusions are gather-phase: the service consults a getEffectiveProjectIds helper that subtracts the user's excluded-project list from the current real-project set before any session-summary work begins, so excluded projects never enter the prompt and never accrue Haiku tokens. Section visibility toggles are render-phase: the digest is generated with all sections present, and /src/renderer/src/features/daily-digest/DigestViewer.tsx consults the section-visibility settings at display time to filter out hidden sections. This keeps "toggle on" instant with no regeneration needed, and a previously-generated digest immediately reflects the new visibility settings. Alert dismissals are persisted per-digest in SQLite (a JSON dismissed-IDs array on the digest row) and applied at render time by /src/renderer/src/features/daily-digest/AlertsSection.tsx, which also exposes the per-row × button and the section-header "Dismiss all" chip. The customization UI lives in /src/renderer/src/features/settings/DailyDigestCustomizationCard.tsx, embedded in the existing settings page at /src/renderer/src/features/settings/DailyDigestSettings.tsx. The sidebar entry is /src/renderer/src/features/daily-digest/DigestSidebarContent.tsx. Follow-up Q&A also uses Haiku so costs stay low even when you ask ten questions about the same digest.

Permanent alert suppressions (Phase 2) are a third customization track that hybridizes the gather and render phases. Each suppression is a row in digest_alert_suppressions (created by the toggle-alert-dismissal IPC when called with suppress: true) keyed by the {alertText, alertType} of the alert that triggered it. The generation prompt builder injects a SUPPRESSED ALERT TOPICS block listing every active suppression so Haiku is asked, in-prompt, not to re-surface those topics. Belt-and-braces: a post-generation safety filter (isAlertSuppressed + extractKeyTokens exported from /src/main/services/daily-digest-service.ts) walks every alert in the model's response and drops any whose text shares ≥ 1 distinctive token with an active suppression — that catches the case where Haiku rewords the same topic. The suppressed-alert list and per-row × / overflow menu / hotkey wiring all share one identity check ({type, text}), exported as isAlertDismissed from AlertsSection.tsx so the dispatcher and the renderer always agree on what "the topmost undismissed alert" means.

Keyboard hotkeys (Phase 2) are wired through the standard keybindings registry — three actions in src/shared/keybindings.ts (digestDismissAlert, digestSuppressAlert, digestDismissAllAlerts) all in the daily-digest category. The dispatcher in /src/renderer/src/hooks/useKeyboardShortcuts.ts gates them: each keystroke is matched against a category-scoped lookup that only fires when activeProjectId === DAILY_DIGEST_PROJECT_ID, so X / Shift+X never collide with the same letters used in Gmail or diff-review. Handlers are registered from DigestViewer.tsx via the useShortcutAction hook (one call per action ID); each handler computes the topmost undismissed alert via the shared identity check and calls the corresponding store action (dismissAlert / dismissAlert({suppress: true}) / dismissAllAlerts). The Alerts section's hint chip uses the reactive useShortcutHint() hook (subscribed to the keybindings store) to render the live binding labels, so changing a binding in settings updates the chip text without a remount or page refresh.

Deterministic commit tally — DigestStats carries two fields, commitsTotal and commitProjectCount, that are injected into the parsed stats object after the LLM JSON is parsed — the model never authors these numbers, so they are deterministic and never hallucinated. gatherContext calls gatherDayCommits, which uses the same gatherCommitActivity() helper in src/main/services/commit-activity.ts over every project with ≥1 session on the local calendar day (date(started_at, 'localtime')). It reads all local branches rather than just HEAD, because Omniscio's worktree-per-session model lands commits on per-session branches the main checkout can't reach. Since 2026-10-01 it counts only authored commits — git rev-list --branches --since --until --min-parents=1 --max-parents=1 --format=…, then keeps a commit only when a person wrote it, it has exactly one parent (no merge, no parentless root), and it changed something (emptiness by tree oid, not --numstat, which measured ~297 s for a week against ~0.9 s for the tree compare). The parent filter and the machine-author test are the shared declarations in scripts/lib/git-authored-history.mjs — the same ones behind the Weekly/Daily Contributor Report, so the two cannot disagree about those two questions. They are not the same number: the report additionally keeps only commits that touch code or tests, collapses rebased copies, and resolves its roster before the machine filter. Virtual-project sentinels are excluded via NOT GLOB '__*'. DigestViewer's StatsBar renders "N commits across M projects today" (hidden when zero).

Deterministic activity tally (2026-08-02) — alongside the commit tally, DigestStats carries three day-scoped activity counts — inboxArchived, userAnswers, and automatedActions — injected into the parsed stats object after the LLM JSON is parsed, so (like the commit tally) the model never authors them and they can't be hallucinated, and they add no prompt cost. gatherDayActivity in /src/main/services/daily-digest/digest-context-gatherer.ts counts them app-wide over the digest's local day through the same off-thread reader: answered = your genuine replies to agents — an operator row that is not an auto-response, and is not agent-to-agent (metadata.peerMessage), not injected over the CLI server by another session or a cron (metadata.cliSourceSessionId / cliSourceCronJobId), and not the session's first operator row (an opening prompt is not a reply, whoever typed it). The "written by you" half is the SHARED predicate in /src/main/db/human-operator-turn-sql.ts — the same one behind the Statistics panel's Your Messages card, so the two surfaces can never disagree about who "you" is; the day-windowed query that wraps it lives in /src/main/services/daily-digest/digest-activity-sql.ts, which the SQL test imports rather than hand-copying. "Earlier operator row" is ordered by (timestamp, rowid), not timestamp alone — on a bare <, two turns written in the same millisecond would each find no predecessor and BOTH be discarded as openers. source='operator' alone is the channel, not the author — peer turns and CLI injections are written there with is_auto_response=0 deliberately, so the receiving agent treats them as a real new turn to answer; before 2026-09-09 the bare channel test made this chip report machinery as the user (measured on the dev box: 2,426 "replies by you" for 2026-09-08, of which 1,443 were peer chatter and 252 machine-spawned openers — 73% machinery, corrected to 636). The Statistics panel's human bucket carried the same flaw and was fixed in the same change — its per-session denormalized message_count_* columns are re-derived by migration 20260909100102, which clears every message_counts_synced_at stamp so the read falls back to a live scan while the background sweeper refills them; archived = inbox items you dismissed (inbox_alert_items.archived_by='user' — a new nullable actor column that tells a user dismissal apart from an automatic self-clear, since both stamp the same archived_at; every automatic archiver defaults to 'auto' and only the two user-facing IPC archive handlers pass 'user', locked by inbox-alert-contract.md invariant I28); automated = automation-rule runs that fired, succeeded, and did something (condition_matched=1 AND action_result='success' AND action_type != 'pass' — the same "effective automation" filter as readChannelActions in queries-stats/summary.ts) plus away-mode auto-replies to an agent (is_auto_response=1 AND metadata.autoRule). StatsBar renders the three as an "activity" chip row (answered / archived / automated), each hidden when absent or zero so old digests and quiet days stay clean. Directly beneath that chip row, a human-vs-automated summary line sums the human side (answered + archived) against automation and states the % automated (e.g. "Human vs. automated: 1,093 by you · 1,931 by automation (64% automated)") — shown only when BOTH the human side and automation had activity that day, so it always frames a real comparison; a one-sided day is left to the chips alone. SQL coverage: /tests/integration/daily-digest-sql.test.ts; actor-tag coverage: /tests/unit/services/inbox-archive.test.ts.

Day-scoped usage ledger (2026-10-03) — the token and cost figures above are now read from a per-day ledger, session_daily_usage, instead of from whole session rows. sessions.input_tokens / output_tokens are cumulative counters with no day baseline, so the old read — sum each session over a started_at window — answered the day's question wrongly in both directions at once: it dropped every session that started earlier (which on a real day is the handful of long-running agents doing most of the work) and credited every session that did start in the window its entire lifetime. Measured on the owner's install for 2026-10-02, the briefing published 87.5 M tokens for a day whose transcripts carry 3.83 B, and $45.02 for a day that cost about $197. The ledger row is written on every turn, in the same statement as the session's cumulative counter, so the day's delta and the lifetime total can never disagree; it is keyed on the local day the work happened, so a session counts on each day it worked however long ago it started. It carries no history before it shipped — the per-day split was never recorded, so a backfill would be a guess — and an earlier day reads zero rather than a reconstructed lifetime total. Invariant: daily-digest-figures-contract.md a-day-figure-is-the-days-own-work. Coverage: /tests/integration/session-daily-usage-ledger.test.ts.

Related

Last verified 2026-10-04