---
title: KMS note summaries (AI auto-summary + drift lifecycle)
---

# KMS note summaries (AI auto-summary + drift lifecycle)

## What it is

Omniscio can generate a short AI summary for any note in a [KMS vault](kms.md), store it _inside the note file itself_ as an HTML-comment marker block, detect when the note has drifted away from its summary, and (opt-in) quietly regenerate stale summaries on a timer. The full slice is live: Settings controls, the background drift detection, the opt-in periodic worker, the four IPC operations, **and the per-note in-editor chip + right-click menu** that surfaces the lifecycle to the user.

A **note summary** is a one-paragraph, ≤300-character plain-text précis of a note's content, written by a small AI model (Claude Haiku by default). It is not stored in a database column or a sidecar file — it is embedded directly at the **top of the note's own Markdown body**, wrapped in a pair of HTML comments so it round-trips cleanly through any Markdown editor (Omniscio's editor, Obsidian, VS Code, plain Notepad) without the editor mangling it or the user tripping over it.

The point of the feature is that a vault of hundreds of notes becomes _skimmable_: every note carries a current one-liner of what it's about, and Omniscio keeps those one-liners honest — when you meaningfully edit a note, its summary is flagged "stale", and (if you opt in) a background worker rewrites it within minutes. If you hand-write your own summary into the marker, Omniscio notices and **locks** it so the AI never overwrites your words.

**Everything here is gated by the master `nothariEnabled` setting** (Settings → Features → **Enable KMS**). With KMS off, no summaries are generated, no drift is tracked, the worker never runs, and none of the IPC operations do anything. The AI-spend portion is _additionally_ gated: summary generation only happens when the user has opted into the periodic worker, clicks the in-editor chip's **Regenerate now** action, or runs the bulk-seed (a separate sub-PR), and every call is metered against a daily dollar cap.

## Where to find it

### User-visible surface (the summary chip)

When a note has a summary marker, the **KMS status bar** at the bottom of the editor pane carries a small chip on its left edge that reflects the current lifecycle state at a glance. The chip is hidden entirely when the active note has no marker (`none`) — a fresh, never-summarised note shows nothing rather than a "no summary" placeholder. Hovering the chip surfaces a one-line tooltip explaining the state (e.g. "Summary up to date", "Drift 42% · Regen pending", "Locked — you edited this summary"); see the table above for the per-state mapping.

The chip's status bar slot was chosen because no tag chip row exists in the editor today (verified 2026-05-19 — `TagChip` grep under `features/kms/` returns zero matches). When a tag row lands above the status bar in a later sub-PR, the chip moves up to sit next to the tags; the header comment in [/src/renderer/src/features/kms/SummaryStatusChip.tsx](/src/renderer/src/features/kms/SummaryStatusChip.tsx) records that intent.

**Single-click behavior** depends on the current state:

- **`stale`** — the most common action. A click invokes `kms:generate-summary { force: false }` and the chip flips to the optimistic `generating` state until the next push lands; if the call fails the chip re-fetches the real state and a toast surfaces the reason (cost cap, rate limit, missing API-key account, etc.).
- **`current` / `user_edited` / `cost-capped`** — a click opens the chip menu rather than firing any destructive default. There is no "single-click force regenerate" path because forcing always crosses a confirm dialog.
- **`generating`** — a click is a no-op (an IPC is already in flight; a second one would either duplicate spend or be rejected by the per-account mutex).

**Right-click** always opens the chip menu, in every state, so the menu items are reachable even when the chip isn't actionable on a left click.

The chip menu lists, in order:

1. **Regenerate now** — `kms:generate-summary { force: false }`. Disabled when the chip is `cost-capped` or `generating`. On a locked note (`user_edited`) this returns `user-edited-locked` and the toast surfaces a hint pointing the user at the **Force regenerate** item below.
2. **Force regenerate (override lock)** — only shown when the chip is `user_edited`. Confirms first ("Forced regen will overwrite your hand-edited summary. Continue?"), then invokes `kms:generate-summary { force: true }`. This is the only single action that crosses the lock; the explicit confirm is the human-intent gate that the backend's `force:true` flag stands in for at the contract level.
3. **Mark / Unmark as user-edited** — toggles `kms:set-summary-user-edited`. The label flips on the current state. No confirm — the action is reversible.
4. **Clear summary marker** — destructive (red label). Confirms first, then invokes `kms:clear-summary`, which strips the marker from the note body and resets the lifecycle columns; the chip then hides itself because the next push reports `state: 'none'`.

All chip actions reconcile via the same `kms:summary-updated` push the IPC handlers emit. The renderer ignores pushes whose `noteId` doesn't match the active note, so a burst of pushes during a bulk-seed run (a separate sub-PR) cannot stomp the active chip's state. The optimistic `generating` value is always replaced by whatever the next push reports — there is no path where the chip stays stuck in "generating" once the backend has resolved the call.

## How it behaves

### What the marker block looks like

A summarised note's body begins with exactly this shape:

```
<!-- kms:summary v=1 hash=3f9a…<64 hex chars total>…b1 -->
A one-paragraph paraphrase of the note, 300 characters or fewer, no line breaks, no Markdown, no "Summary:" prefix.
<!-- /kms:summary -->

# The rest of the note's real content starts here…
```

Anatomy:

- **`v=1`** — marker format version. The current writer only ever emits `v=1`. If Omniscio reads a marker with a _different_ version number (a note touched by a newer app build), it treats the note as **locked** (`user_edited`) and refuses to regenerate or overwrite it — a forward-compatibility fence so an older build can't clobber a newer format. An explicit "Clear summary" is the only thing that removes a `v≠1` marker.
- **`hash=<64 hex>`** — a SHA-256 "canonical hash" of the note's body _with the marker block stripped out_. This is the drift fingerprint (see below). It is **not** a hash of the raw bytes — the body is first canonicalised: CRLF→LF, runs of spaces/tabs collapsed, trailing whitespace trimmed, 3+ blank lines collapsed to 2, and every image (`![alt](url)`) replaced by a token derived from the URL only. The effect: reformatting, re-wrapping, or editing image alt-text does **not** count as drift; changing actual prose or swapping an image URL does.
- **The middle line** — the AI summary itself. Always one paragraph, ≤300 characters, paraphrased (never quotes the body), no headings/lists/Markdown, no leading/trailing whitespace, no "Summary:" / "This note is about" preamble. An empty or unintelligible note yields a single descriptive word (e.g. `Empty.`).
- The marker sits at the very top; the user's original content follows after one blank line. Stripping the marker (the "Clear" action) removes the block _and_ that separator newline, leaving the original body untouched.

Because the summary lives in the file, it travels with the note: sync the vault folder to another machine or open it in another editor and the summary is just there, as a comment, invisible in rendered Markdown.

### Drift lifecycle — the four states

Every note row carries four bookkeeping columns (added by migration v200 on the `nothari_notes` table): `summary_hash`, `summary_generated_at`, `summary_drift_state`, and `summary_user_edited`. Together they resolve to one of four user-facing states (plus two transient ones the status probe can report):

| State                       | Meaning                                                                                                                                                                                                        | Chip cue                                                                                                                              |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **`none`**                  | No marker in the note, or the note was never summarised.                                                                                                                                                       | No chip shown.                                                                                                                        |
| **`current`**               | A marker is present and the note's canonical hash still matches the hash stored in the marker — the summary is up to date.                                                                                     | Green dot, label "Up to date", tooltip "Summary up to date".                                                                          |
| **`stale`**                 | The note's content has changed since the summary was written (canonical hash no longer matches). The summary is now out of date.                                                                               | Amber pulsing dot, label "Stale", tooltip "Drift N% · Regen pending" (or just "Regen pending" when no percent is available).          |
| **`user_edited`**           | The summary is **locked**. Either the user hand-edited the text inside the marker, or explicitly marked the note user-edited, or the marker is a future format version. Omniscio will never auto-overwrite it. | Muted grey dot, label "Locked", tooltip "Locked — you edited this summary".                                                           |
| `generating` _(transient)_  | A regeneration is in flight.                                                                                                                                                                                   | Inline spinner, label "Generating", tooltip "Generating…". Renderer-side optimistic — cleared by the next `kms:summary-updated` push. |
| `cost-capped` _(transient)_ | The daily AI budget for this account is exhausted, so a wanted regeneration was refused.                                                                                                                       | Red dot, label "Budget reached", tooltip "Daily AI budget reached. Raise in Settings → AI Coach."                            |

How a note moves between states:

- **→ `current`**: a successful generate writes a fresh marker, stores the new hash, stamps `summary_generated_at`, and clears the lock.
- **`current` → `stale`**: handled automatically on save. When the editor writes a note body whose canonical hash diverges from the stored `summary_hash` (and the note isn't locked), Omniscio flips it to `stale`. This is idempotent — an already-stale note doesn't re-flip on subsequent saves — and the summary service's _own_ writes are exempt (they pass an internal `suppressSummaryDriftDetection` flag) so a regeneration doesn't trip drift on itself.
- **anything → `user_edited` (locked)**: also automatic on save — if the text _inside_ the marker block changes, Omniscio reads that as "the human is curating this summary themselves" and locks it. If both a body edit and a marker edit land in one save, the marker-edit wins (lock takes precedence over stale). The lock can also be set explicitly (see "Manual control" below).
- **`user_edited` → `current`/`stale` (unlock)**: explicitly unlocking recomputes the state — it compares the live canonical hash against the stored `summary_hash` and lands on `current` if they match, `stale` if they don't (no stored hash → `current`).

> **Phase-6 drift is binary.** Omniscio stores only the canonical _hash_ alongside the marker, not the original canonical text, so it can answer "did this change?" (hash compare) but not "by how much?". The `driftPercent` carried by some internal types is therefore 0 or 1 today, and the status probe deliberately omits it rather than fabricating a precise-looking number. A later sub-PR can persist the canonical text to make drift a continuous percentage.

### Automatic background behavior (the live user surface today)

Two things happen without any per-note UI:

1. **Drift detection on save** — always on (when KMS is enabled). Editing a note's content silently flips its summary to `stale`; editing the summary text itself silently `locks` it. No spend, no network — this is just hash bookkeeping.

2. **Periodic auto-regeneration** — **opt-in, off by default.** A background worker wakes on a timer, finds notes that are `stale` and not locked and outside their per-note cooldown, and regenerates their summaries (which costs AI spend). It is controlled entirely from **Settings → Features**:
   - **Auto-regenerate stale summaries** (`kms-auto-regen-enabled`) — the master switch for the worker. Default **off**: until you turn it on you pay nothing and the timer never even queries the database.
   - **Scan interval** (`kms-auto-regen-interval`) — how often the worker wakes, in minutes. Default **5 minutes**. Range 1 minute – 24 hours.
   - **Per-note cooldown** (`kms-auto-regen-cooldown`) — minimum time between two regenerations of the _same_ note, in minutes. Default **15 minutes**. Range 1 minute – 24 hours. This stops a note that keeps drifting from being rewritten every single scan.

   (Internally these are stored as milliseconds — `nothariAutoRegenIntervalMs` / `nothariAutoRegenPerNoteCooldownMs`, defaults 300 000 / 900 000, Zod-bounded `[60 000, 86 400 000]`. The minutes ↔ ms conversion is the UI's job; the two minute inputs are disabled until the toggle is on.)

   Worker behavior you can't tune (deliberately not knobs): at most **2** regenerations run concurrently (a higher number would thunder the per-account AI mutex; lower would crawl after a bulk import), and at most **50** stale notes are picked up per scan (a huge backlog is chipped away one scan at a time rather than pinning the app). The first scan is deferred one full interval after app launch so a boot-time backlog doesn't fire during startup. The worker only ever _reads_ drift state and asks the summary service to regenerate — it never writes the drift columns itself, keeping the producer (save-time detection) and consumer (worker) cleanly separated.

### Manual control (IPC surface)

Four IPC operations back the chip and any other consumer (CLI, mobile when it lands). Documented here so the contract is unambiguous independent of the chip's rendering.

| Channel (string)              | Input                 | Result                                                                                                                                                         |
| ----------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kms:generate-summary`        | `{ noteId, force? }`  | `{ ok:true, hash, summary, costUsd }` · or `{ ok:false, error: 'cost-cap-hit' \| 'rate-limit' \| 'no-apikey-account' \| 'not-found' \| 'user-edited-locked' }` |
| `kms:get-summary-status`      | `{ noteId }`          | `{ state: 'none'\|'current'\|'stale'\|'user_edited'\|'generating'\|'cost-capped', lastGeneratedAt? }`                                                          |
| `kms:clear-summary`           | `{ noteId }`          | `{ ok:true }` · or `{ ok:false, error:'not-found' }`                                                                                                           |
| `kms:set-summary-user-edited` | `{ noteId, flagged }` | `{ ok:true }` · or `{ ok:false, error:'not-found' }`                                                                                                           |

Behavioral contract:

- **Generate** with `force: false` (the polite path) regenerates only if needed and **respects the lock** — calling it on a locked note returns `user-edited-locked` and does nothing. With `force: true` (a deliberate human "regenerate this now" from the chip) it **bypasses both** the "already current" short-circuit _and_ the lock: a forced regen on a locked note is read as explicit intent to replace the user's own text, so it overwrites and clears the lock. A missing note always returns `not-found`, never `user-edited-locked`.
- **Get-status** is the chip's paint-on-mount probe. It is the _only_ operation that can report `cost-capped`, because that fact lives in the drift columns the periodic worker keys off — reading the same source guarantees the chip and the worker agree on a note's state.
- **Clear** strips the marker block from the note body (any version, including a future `v≠1` marker — Clear is explicit user intent so the forward-compat fence doesn't apply) and resets the four columns to pristine. It is non-destructive to the rest of the note. It always reports success if the note exists, because the user-visible promise ("the summary is gone from my note") _is_ kept even if the secondary column reset hiccups.
- **Set-user-edited** toggles the lock. `flagged: true` locks (`user_edited`); `flagged: false` unlocks and recomputes `current`/`stale` from the hash. Unlike Generate's best-effort column writeback, this is the user's explicit intent, so a failure surfaces as an error envelope rather than a false success.

Every state change — generate, clear, lock/unlock — emits a `kms:summary-updated` push (`{ noteId, state, hash?, driftPercent? }`). The chip and the Settings spend display reconcile on it; the push is forwarded to mobile/web inbox clients too (wired now to avoid a second plumbing edit when the mobile note UI lands). The IPC handlers themselves never emit the push — the summary service does, so each change fires exactly once.

> **Vocabulary note for implementers.** The service layer speaks `note-not-found` / `user-edited`; the IPC contract speaks `not-found` / `user-edited-locked`. The rename happens _only_ at the handler boundary so the two vocabularies never leak into each other (the periodic worker and other non-IPC callers keep the stable service words).

### Cost cap behavior

Summary generation calls a paid AI model, so it is metered:

- **Per-account, per-day dollar cap.** Default **$2.00**, configurable (`nothariSummaryCostCapUsd`, range $0.50–$50.00, in the same Settings → Features area). The "day" boundary is **local midnight**, matching Omniscio's other daily caps so they don't disagree across the UTC line.
- The cap counts **both** ordinary per-note summary spend and bulk-seed spend (cost-log sources `haiku-nothari-summary` + `haiku-nothari-bulk`) so neither path can hide spend from the other.
- The check is **pre-flight and strict** (`spent >= cap` blocks; no estimate padding) and runs _inside a per-account lock_ that also wraps the AI call, the cost-ledger write, and the marker writeback. Serialising per account means a burst of concurrent generations can't all pass the cap check before any of them has recorded its cost — call N sees the spend of calls 1…N-1.
- It **fails open**: if the spend query errors (transient DB issue), the call is allowed rather than the feature being silently starved — over-charging by at most one call is preferred to a confusing dead feature; the post-call ledger insert remains the source of truth.
- When the cap is hit: `kms:generate-summary` returns `cost-cap-hit`, and `kms:get-summary-status` reports `cost-capped` (the red-dot state). Auto-regen simply stops making progress until either spend rolls over at local midnight or the user raises the cap; nothing is lost — the stale notes are picked up on a later scan.

The model is Claude Haiku by default; an advanced setting (`kmsSummaryModel`) can point it at another model without changing any of the above.

### Privacy + safety contract

- **No spend without opt-in.** A fresh install never generates a summary: the periodic worker is off by default and there is no other automatic generation path. Drift detection (hash bookkeeping) is free and local.
- **The user's words are never overwritten silently.** A hand-edited or explicitly-locked summary is `user_edited` and is skipped by both the worker and the polite (`force:false`) generate path. Only a deliberate forced regenerate (an explicit human action) replaces locked text, and even then it's a conscious chip click, never the background timer.
- **Daily dollar ceiling, fail-safe both ways.** Spend is capped per account per local-midnight day, the cap-check is serialised so a concurrent burst can't bust it, and a DB hiccup fails open (one possible over-charge) rather than dead (silent starvation).
- **The summary lives in the user's own file** under their configured vault root — Omniscio writes it through the same `resolveInsideVault()` path-validated writer as every other KMS edit (see [kms.md](kms.md) "Privacy + safety contract"). Clearing a summary is non-destructive to the rest of the note.
- **Forward-compatible.** An older Omniscio build that meets a newer marker format locks the note instead of clobbering it.

## For agents

### How it works

- **Marker codec** — `src/main/services/kms/summary/summary-marker.ts`: `encodeSummaryMarker` / `decodeSummaryMarker` / `stripSummaryMarker` and the canonical-hash + drift primitives. The forward-compat `v≠1` fence and the image/whitespace canonicalisation live here.
- **Service** — `src/main/services/kms/summary/summary-service.ts`: `generateSummary(noteId, { force?, source? })`, `regenerateIfStale`, `clearSummary`, `setSummaryUserEdited`, `getSummaryStatus`. Owns the per-account cost-cap mutex and is the sole emitter of `kms:summary-updated`.
- **Prompt** — `src/main/services/kms/summary/summary-prompt.ts`: the model-agnostic system/user prompt encoding the four output guardrails (one paragraph, ≤300 chars, paraphrase-only, no whitespace).
- **Cost cap** — [/src/main/services/kms/cost-cap-ledger.ts](/src/main/services/kms/cost-cap-ledger.ts): `checkKmsSummaryCap`, `resolveKmsSummaryCap`, `getKmsSpendToday`, the two cost-source constants, and `DEFAULT_KMS_SUMMARY_COST_CAP_USD = 2`.
- **Periodic worker** — `src/main/services/kms/summary/summary-regen-worker.ts`: `startSummaryRegenWorker` / `stopSummaryRegenWorker`, the feature-flag gate, the interval/cooldown resolvers, the concurrency-2 / 50-per-tick caps. **Ignited** by a boot `StartupTask` in [/src/main/startup/registry.ts](/src/main/startup/registry.ts) gated on `nothariAutoRegenEnabled`, a settings reconcile in [/src/main/services/settings-apply.ts](/src/main/services/settings-apply.ts) (start on false→true, stop on true→false, RESTART on an interval change), and a stop in [/src/main/app/shutdown.ts](/src/main/app/shutdown.ts). Locked by [/tests/unit/services/settings-apply.test.ts](/tests/unit/services/settings-apply.test.ts) describe "KMS summary auto-regen toggle".
- **Save-time drift detection** — [/src/main/services/kms/note-crud-service.ts](/src/main/services/kms/note-crud-service.ts) `writeNoteBody`: flips `current → stale` on content change, locks on marker-text change, idempotent, with the `suppressSummaryDriftDetection` opt-out for the service's own writes.
- **IPC handlers** — [/src/main/ipc/kms/summary.ts](/src/main/ipc/kms/summary.ts): the four `wrapHandler`-wrapped handlers, the service→IPC error rename, and the handler-policy sticky-lock check. Registered from [/src/main/ipc/kms-handlers.ts](/src/main/ipc/kms-handlers.ts).
- **IPC wiring** — channels in [/src/shared/ipc-channels/index.ts](/src/shared/ipc-channels/index.ts) (`KMS_GENERATE_SUMMARY` etc.), Zod input schemas in [/src/shared/ipc-schemas.ts](/src/shared/ipc-schemas.ts), response types in [/src/shared/ipc-types.ts](/src/shared/ipc-types.ts), contract pinned in [/tests/integration/ipc-contract.test.ts](/tests/integration/ipc-contract.test.ts).
- **Settings UI** — the toggle + two minute inputs + the cost-cap input live in [/src/renderer/src/features/settings/sections/features/KmsFeatureSettings.tsx](/src/renderer/src/features/settings/sections/features/KmsFeatureSettings.tsx); search-index entries (`kms-auto-regen-enabled` / `-interval` / `-cooldown`) in [/src/renderer/src/features/settings/settings-search-index.ts](/src/renderer/src/features/settings/settings-search-index.ts).
- **In-editor chip** — [/src/renderer/src/features/kms/SummaryStatusChip.tsx](/src/renderer/src/features/kms/SummaryStatusChip.tsx): the six-state chip, the seed fetch + push reconciliation (with noteId filter), the optimistic `generating` transition, and the chip menu wiring. Mounted from the status bar's leftmost slot in [/src/renderer/src/features/kms/status-bar/StatusBar.tsx](/src/renderer/src/features/kms/status-bar/StatusBar.tsx); fed the active `noteId` by [/src/renderer/src/features/kms/KmsView.tsx](/src/renderer/src/features/kms/KmsView.tsx). The chip menu reuses the existing [/src/renderer/src/features/kms/context-menu/EditorContextMenu.tsx](/src/renderer/src/features/kms/context-menu/EditorContextMenu.tsx) portal primitive (the editor right-click menu) with a chip-specific action list. The `kms-summary-status-chip` anchor is declared in the co-located [/src/renderer/src/features/kms/kms-rich-text-vault-editor.ui-anchors.ts](/src/renderer/src/features/kms/kms-rich-text-vault-editor.ui-anchors.ts) (required by the UI-snapshot lint) and assembled into `ui-anchor-registry.generated.ts` by `npm run ui-anchors:reindex` — never hand-edit `ui-anchor-registry.ts` or the generated file. Tests: [/tests/unit/features/kms/SummaryStatusChip.test.tsx](/tests/unit/features/kms/SummaryStatusChip.test.tsx).
- **Drift columns** — migration v200 on `nothari_notes`: `summary_hash`, `summary_generated_at`, `summary_drift_state`, `summary_user_edited`, plus the partial index `idx_nothari_notes_drift_stale` on `(summary_drift_state)` filtered to `stale AND summary_user_edited = 0` for the worker's hot path. Read via `getNoteSummaryState` in [/src/main/db/queries-kms/notes.ts](/src/main/db/queries-kms/notes.ts).

## Related

- [kms.md](kms.md) — the KMS vault editor this feature lives inside (vault setup, the `nothariEnabled` master toggle, the safe-write contract, the file watcher).
- The integration is declared in [/src/shared/integration-registry.ts](/src/shared/integration-registry.ts); the `kms:summary-updated` push is forwarded to inbox clients via [/src/main/services/web/web-access-push-filter.ts](/src/main/services/web/web-access-push-filter.ts) with a regression assertion in [/tests/unit/services/web-access-subscriptions.test.ts](/tests/unit/services/web-access-subscriptions.test.ts).
- Phase 5 + Phase 6 closeout: [/docs/plans/2026-05-19-nothari-phase-5-and-6-closeout.md](../plans/2026-05-19-nothari-phase-5-and-6-closeout.md).
