KMS note summaries (AI auto-summary + drift lifecycle)
A one-paragraph AI précis stored inside each note's own file, wrapped in a comment block so any Markdown editor round-trips it untouched. Omniscio notices when a note has drifted away from its summary, shows that state on the note itself, and can quietly refresh stale summaries on a timer.
What it is
Omniscio can generate a short AI summary for any note in a KMS vault, 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 records that intent.
Single-click behavior depends on the current state:
stale— the most common action. A click invokeskms:generate-summary { force: false }and the chip flips to the optimisticgeneratingstate 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:
- Regenerate now —
kms:generate-summary { force: false }. Disabled when the chip iscost-cappedorgenerating. On a locked note (user_edited) this returnsuser-edited-lockedand the toast surfaces a hint pointing the user at the Force regenerate item below. - Force regenerate (override lock) — only shown when the chip is
user_edited. Confirms first ("Forced regen will overwrite your hand-edited summary. Continue?"), then invokeskms: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'sforce:trueflag stands in for at the contract level. - Mark / Unmark as user-edited — toggles
kms:set-summary-user-edited. The label flips on the current state. No confirm — the action is reversible. - 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 reportsstate: '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 emitsv=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 av≠1marker.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 () 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, stampssummary_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 storedsummary_hash(and the note isn't locked), Omniscio flips it tostale. 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 internalsuppressSummaryDriftDetectionflag) 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 storedsummary_hashand lands oncurrentif they match,staleif 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
driftPercentcarried 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:
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 silentlylocksit. No spend, no network — this is just hash bookkeeping.Periodic auto-regeneration — opt-in, off by default. A background worker wakes on a timer, finds notes that are
staleand 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.
- Auto-regenerate stale summaries (
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 returnsuser-edited-lockedand does nothing. Withforce: 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 returnsnot-found, neveruser-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≠1marker — 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: truelocks (user_edited);flagged: falseunlocks and recomputescurrent/stalefrom 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 speaksnot-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 >= capblocks; 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-summaryreturnscost-cap-hit, andkms:get-summary-statusreportscost-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_editedand 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 "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/stripSummaryMarkerand the canonical-hash + drift primitives. The forward-compatv≠1fence 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 ofkms: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:
checkKmsSummaryCap,resolveKmsSummaryCap,getKmsSpendToday, the two cost-source constants, andDEFAULT_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 bootStartupTaskin /src/main/startup/registry.ts gated onnothariAutoRegenEnabled, a settings reconcile in /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. Locked by /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
writeNoteBody: flipscurrent → staleon content change, locks on marker-text change, idempotent, with thesuppressSummaryDriftDetectionopt-out for the service's own writes. - IPC handlers — /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. - IPC wiring — channels in /src/shared/ipc-channels/index.ts (
KMS_GENERATE_SUMMARYetc.), Zod input schemas in /src/shared/ipc-schemas.ts, response types in /src/shared/ipc-types.ts, contract pinned in /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; search-index entries (
kms-auto-regen-enabled/-interval/-cooldown) in /src/renderer/src/features/settings/settings-search-index.ts. - In-editor chip — /src/renderer/src/features/kms/SummaryStatusChip.tsx: the six-state chip, the seed fetch + push reconciliation (with noteId filter), the optimistic
generatingtransition, and the chip menu wiring. Mounted from the status bar's leftmost slot in /src/renderer/src/features/kms/status-bar/StatusBar.tsx; fed the activenoteIdby /src/renderer/src/features/kms/KmsView.tsx. The chip menu reuses the existing /src/renderer/src/features/kms/context-menu/EditorContextMenu.tsx portal primitive (the editor right-click menu) with a chip-specific action list. Thekms-summary-status-chipanchor is declared in the co-located /src/renderer/src/features/kms/kms-rich-text-vault-editor.ui-anchors.ts (required by the UI-snapshot lint) and assembled intoui-anchor-registry.generated.tsbynpm run ui-anchors:reindex— never hand-editui-anchor-registry.tsor the generated file. Tests: /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 indexidx_nothari_notes_drift_staleon(summary_drift_state)filtered tostale AND summary_user_edited = 0for the worker's hot path. Read viagetNoteSummaryStatein /src/main/db/queries-kms/notes.ts.
Related
- kms.md — the KMS vault editor this feature lives inside (vault setup, the
nothariEnabledmaster toggle, the safe-write contract, the file watcher). - The integration is declared in /src/shared/integration-registry.ts; the
kms:summary-updatedpush is forwarded to inbox clients via /src/main/services/web/web-access-push-filter.ts with a regression assertion in /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.
Last verified 2026-09-28