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

KMS (the Vault) — notes, editor, search and sharing (part 4)

Part 4 of the KMS page: images, media and the links that turn into something — how a picture is added, stored and displayed in a note, how attachments are kept from bloating the vault, and what happens when you paste a bare web address into a note.

What it is

This is part 4 of the KMS page. It covers everything that is not text: how images and attachments get into a note, how they are stored and shown, and how pasting a plain web address can turn into a titled link without you doing anything.

Where to find it

The KMS panel — images and attachments are added from inside a note, and the link behaviour happens wherever you paste a URL while editing.

How it behaves

Images & media (Phase 5)

Phase 5 turns KMS from a notes-only surface into a notes-plus-images vault. Every image you paste / drop / pick into the editor is dedup-stored under <vaultRoot>/assets/<sha256>.<ext>, rendered through the custom nothari-asset:// protocol so traversal-safe path resolution sits between the renderer and the filesystem, optionally analyzed by an LLM that emits a per-image title / description / OCR / tags / category, browsed through a dedicated Gallery right-pane panel, opened full-screen in a Lightbox, and surfaced in the Unified Search modal under the kind:image chip.

Inserting an image (Phase 5 Sub-PRs 5b – 5d)

Three input methods, all routing through the same kms:asset-import handler:

  • Paste — Ctrl+V while focused in the editor. The clipboard-image handler in the editor's paste plugin captures the Blob and, because the import is an async round-trip, drops a short-lived "Adding image…" loading placeholder at the caret SYNCHRONOUSLY (so a Ctrl+Z right after the paste undoes it — it used to insert nothing until the import finished, which is why an early undo did nothing and the image popped in afterward); when the bytes resolve, the real image swaps into that placeholder without adding a second undo step, so one Ctrl+Z always removes the paste. The node stores the portable vault-relative ref assets/<sha>.<ext> (NOT the absolute nothari-asset:// URL), so the vault stays self-contained + matches KMT-imported notes; the NodeView resolves it to the per-platform display URL via the active vault (resolveDisplayAssetUrl → getVaultAssetUrl). The loading placeholder never serializes to disk (contract touch-has-first-class-doors).
  • Drag & drop — drop one or more image files anywhere in the editor surface. Multi-drop inserts in source order. Non-image MIME types are silently rejected.
  • Picker — the editor toolbar's ⋯ More menu carries an Image row that opens an OS file picker scoped to image extensions. Same insertion path. (Pre-2026-05-22 this lived as an inline button in the toolbar's media group; the 2026-05-22 collapse moved it into the overflow.)

In all three cases the bytes are hashed (SHA-256), and if the resulting <sha256>.<ext> already exists under <vaultRoot>/assets/, the importer reuses the existing file rather than writing a duplicate — pasting the same screenshot into ten different notes leaves you with one file on disk and ten assets/<sha>.<ext> references. The importer also bumps a refcount row in the nothari_assets table so a future cleanup pass can find truly-orphaned bytes.

The note→image references that the Gallery's delete-gate reads live in nothari_asset_refs. These are reconciled synchronously when the note is saved (writeNoteBody / createNote call reconcileNoteAssetRefs), not only later by the file-watcher/indexer — so a just-pasted image is immediately protected from the Gallery's delete action rather than deletable in the ~debounce window before a reindex. The indexer reconcile stays the backstop for EXTERNAL edits (contract asset-refs-reconcile-synchronously).

Editing an image in place (resize · replace · copy · caption · details)

Each editor image is a VaultImage NodeView (/src/renderer/src/features/kms/editor/extensions/VaultImageNodeView.tsx) with a full set of in-place controls — inline caption/alt-text rows (revealed on selection, not hover) plus a right-click menu (opened by a long-press on touch, since a phone has no right-click — same menu, same items):

  • Resize — hover the image and drag any corner handle. WIDTH is persisted as an INVISIBLE, list-safe marker inside the image's markdown link title — never block <img>/<figure> HTML (that would collapse a numbered list on the next save; see kms-markdown-serialization-contract.md). Height stays automatic so the aspect ratio is locked. The inline image is static — pan/zoom lives in the Lightbox, never in the document — so a drag resizes and a double-click (or Enter on the focused image) opens the Lightbox without the two fighting. A plain single click does NOTHING, so a stray click while reading or editing a note can't pop the viewer; the right-click menu's Open in lightbox is the third door.
  • Replace… — right-click → Replace opens a file picker; the new image is imported and EVERY note that referenced the old one is rewritten in place (one backend pass via kms:replace-image).
  • Copy image — right-click → Copy image puts the actual picture on the clipboard (paste into Gmail / Docs / etc.), falling back to copying the link when the browser blocks image-clipboard writes. "Copy URL" still copies the nothari-asset:// link as text.
  • Caption + alt text — inline rows that reveal only when the image is SELECTED (clicked) — never on hover — and hide at rest, so a plain hover no longer pops "Add caption"/"Add alt text" clutter up under every image the mouse passes over (and the row reclaims the space). A written caption stays visible for reading; alt text (the screen-reader description, not on-screen content) stays selection-gated even when set. (These rows are the ONLY place to set caption/alt — "Edit details" edits different, per-image metadata — so selecting the image is how you reach them.) The caption round-trips through the inline link title (![alt](src "caption")), never block <figure> markdown. Caption + alt-text are per-PLACEMENT (node attributes), so the same image can carry different captions in different notes. (contract caption-rows-reveal-on-selection)
  • Edit details… — a dialog (/src/renderer/src/features/kms/ImageDetailsDialog.tsx) to hand-edit the AI-derived title / description / tags (the per-IMAGE metadata in nothari_image_analysis). Saving writes the row with model: 'manual' and PRESERVES the machine-extracted OCR text.
  • Remove from note — right-click → Remove deletes the image from the note body. The menu preventDefaults its mousedown so clicking it does NOT blur the editor — otherwise TipTap's chained .focus() (deferred to requestAnimationFrame) would leave the editor momentarily unfocused and a follow-up Ctrl+Z would land on <body> instead of undoing the delete. With focus kept on the editor, Ctrl+Z restores the image (contract touch-has-first-class-doors).

Images on the web / phone client

The browser can't resolve the nothari-asset:// protocol, so the web/mobile client renders KMS images through a same-origin, token-gated HTTP route GET /vault-asset/<vaultId>/<relativePath> (/src/main/services/web/web-access-http-routes.ts) that reuses the SAME serveVaultAsset security core as the desktop protocol handler — identical path-safety, symlink refusal, extension allow-list, and SVG hardening. The displayed src is chosen per-platform by getVaultAssetUrl (/src/renderer/src/lib/platform.ts); note bodies always store the canonical nothari-asset:// URL.

Image IMPORT on the web can't ride the WebSocket IPC bridge (a Uint8Array doesn't survive the JSON transport), so the mobile editor's Insert image action (in the ⋯ action sheet) POSTs the raw file to POST /vault-asset/<vaultId>?ext=<ext> → importImageBytes, then inserts the returned asset. The upload route is authenticated upstream (the same resolveAuthedToken gate as every web data route) and streams with a 50 MB cap. A failed upload (unsupported type, network / server error) surfaces an error toast instead of silently closing the action sheet — parity with desktop paste/drop (contract upload-never-fails-silently).

On a phone every image surface is reachable by touch (desktop's right-click + hover + Ctrl+Shift+G don't exist there): a plain tap does nothing (a stray tap while reading can't pop the viewer); press-and-hold opens its edit menu (Replace / Edit details / Copy / Remove / Open full-screen — the touch twin of the desktop right-click menu, via the shared useLongPress hook, cancelled on scroll), whose Open full-screen item is the touch door to the Lightbox; the editor's ⋯ action sheet → Image gallery row opens the vault Gallery; and caption + alt-text are tappable rows under the image. The image container carries .mobile-press-target so the iOS long-press callout can't pre-empt the hold. Locked by kms-image-handling-contract.md touch-has-first-class-doors.

Behavior + invariants for everything in this section are locked by the KMS image-handling contract.

Asset storage layout

<vaultRoot>/
├── *.md                          # notes
├── .trash/                       # soft-deleted notes
└── assets/                       # all image bytes, content-addressed
    ├── 3a7c1e...f29.png
    ├── 9b4d0a...c16.jpg
    └── e5f8b2...a01.webp

The assets/ directory is automatically created on the first import, ignored by the note watcher (the watcher's .trash/ rule extends to assets/ for the same ping-pong reason — the asset writer drops a file there, the watcher would see it as a new "note", reindex, double-write). Direct hand-editing of assets/ is technically supported but unusual — most users will never look at it. If you delete an asset file from disk while a note still references it, the renderer renders a broken-image placeholder rather than crashing; the next analyzer cycle drops the analysis row for that sha.

nothari-asset:// protocol (Phase 5 Sub-PR 5a)

The renderer references vault images via a custom Electron scheme:

nothari-asset://<vaultId>/<relativePathFromVaultRoot>

The protocol handler at /src/main/protocol-handlers/kms-asset.ts is the single security gate before any vault image touches the editor. It resolves the URL through resolveInsideVault() to prevent path traversal (literal .., URL-encoded ..%2f, absolute Windows / POSIX paths, NTFS alternate-data-stream : tricks all reject as 400 Bad Request), refuses to follow symlinks via lstat() (a symlinked .png inside the vault could point at ~/.ssh/id_rsa — 403 Forbidden), and serves only the allow-listed image MIME types: png, jpg, jpeg, gif, webp, svg, avif (every other extension — .exe, .html, .js, .lnk, .pdf, etc. — returns 415 Unsupported Media Type, and path.extname is checked on the FINAL extension only so a double-extension polyglot like foo.png.exe rejects on .exe).

Every successful response carries X-Content-Type-Options: nosniff so a polyglot image with HTML bytes appended can't be MIME-sniffed into a scriptable document, plus Cache-Control: private, max-age=600 so a panel re-render doesn't refetch every embedded image. SVG responses get extra hardening (mirrors the attachment.ts RT-F080 pattern): Content-Disposition: attachment (top-level navigation downloads instead of rendering) plus Content-Security-Policy: default-src 'none'; script-src 'none'; sandbox so even a bypass would render in a unique sandbox origin with no script execution. The scheme is registered as privileged with bypassCSP: false set EXPLICITLY in /src/main/protocol-handlers/index.ts so vault assets obey the same CSP as every other image source — the renderer's CSP img-src includes the literal nothari-asset: token (no wildcards).

Gallery panel (Phase 5 Sub-PR 5e)

The right-pane panel stack carries a Gallery entry, collapsed by default. Open it and you see a thumbnail grid of every asset under <vaultRoot>/assets/ for the active vault, sorted by most-recently-added first. Each tile shows the thumbnail plus a small badge marking whether the image has been analyzed (filled marker) vs not (hollow marker). Click any tile to open the full-resolution Lightbox.

Behavior:

  • Thumbnails use the same nothari-asset:// URL the editor uses — Electron caches them after first paint, so re-opening the panel after browsing is instant.
  • The toolbar carries a filename search, a sort dropdown (Newest / Oldest / Largest / Most-referenced), All/Used/Orphaned chips, AND AI tag + category filter dropdowns (2026-06-11). The tag/category dropdowns only render when at least one analyzed image carries that data; "Clear filters" resets them. The list feed (KMS_LIST_IMAGES) left-joins each on-disk asset with its tags/category from nothari_image_analysis (one vault-scoped query via listImageAnalysisLite), so the dropdowns filter in-grid without opening the Unified Search modal.
  • An "Analyze all" button at the top of the panel kicks off the bulk-analyze flow (see "AI image analysis" below). The button is gated on nothariEnabled === true, on the user having configured an API-key account, and on at least one image being un-analyzed.
  • Per-row hover reveals a delete button. Click → confirms via danger-variant useConfirmDialog. On accept, fires kms:asset-delete. If the asset is referenced by any note, the handler rejects with a list of referencing notes (rendered as a toast) — the user must clear the references first. If unreferenced, the file is moved to <vaultRoot>/.trash/assets/<timestamp>__<sha256>.<ext> (same trash semantics as note delete; recoverable by hand).
  • Opening the Gallery on mobile. Desktop opens this overlay via Ctrl+Shift+G or the command palette's "Open image gallery" — a phone has neither, so the editor's ⋯ action sheet carries a vault-gated Image gallery row (the touch entry point to the same overlay). Tapping a tile still opens the Lightbox.

Lightbox (Phase 5 Sub-PR 5e)

Opens on a double-click (or Enter) of an in-note image — a plain single click no longer opens it — on click of a Gallery-panel tile, OR on click of an image hit in the Unified Search modal. Shape:

  • Full-resolution image, fit-to-viewport with letterboxing for non-matching aspect ratios.
  • Header: image title (from AI analysis if available, else the basename); close button.
  • Side rail: AI metadata (when present) — description, OCR text, tags chip strip, category.
  • Footer: tech details — sha256 (copy button), dimensions, file size, mtime, file path.
  • Keyboard: Esc closes; Left / Right Arrow navigate between images when opened from the Gallery; Z toggles native-pixel-size view (no fit-to-viewport).

The lightbox is the same surface for both gallery browsing AND unified-search image hits — Sub-PR 5h didn't introduce a second viewer.

AI image analysis (Phase 5 Sub-PR 5f)

When triggered (per-image via Lightbox or bulk via Gallery's "Analyze all"), KMS sends each image to the configured AI model and stores the response in the nothari_image_analysis table (migration v198 + v199; see below). Per-image record:

  • title — short noun-phrase summary (~3–7 words).
  • description — 1–3 sentence prose summary of the visible content.
  • ocr_text — every legible character the model extracted, line-broken.
  • tags — a JSON array of single-word tags (e.g. ["screenshot", "diagram", "ui"]).
  • category — high-level bucket (e.g. "screenshot", "photo", "diagram").
  • model + analyzed_at + input_tokens + output_tokens + cost_usd — provenance / cost-tracking fields.

The table uses (vault_id, sha256) as a logical unique key — re-analyzing the same image bytes UPSERTs in place rather than writing a duplicate row. A backing nothari_images_fts FTS5 external-content virtual table mirrors title + description + ocr_text + tags_json + category for the Unified Search images query; an ai/ad/au trigger triad keeps the FTS index in lockstep with the analysis table.

Schema note (migration v198 → v199 fix-up): Sub-PR 5f's original v198 migration shipped with an FTS5 column-name mismatch — the FTS5 column was tags while the content table column was tags_json. Because the FTS table is declared content='nothari_image_analysis', FTS5 reads the content column by the FTS5 column name during snippet(), 'rebuild', and integrity-check operations — the mismatch crashed snippet() with no such column: T.tags. The bug was invisible in 5f because no read path called snippet() until 5h introduced the unified-image search. Migration v199 (Sub-PR 5h) drops + recreates the FTS5 virtual table and the ai/ad/au triggers with tags_json as the column name, then runs the 'rebuild' command to repopulate the index from the existing content rows. v199 is content-preserving — no analysis rows are lost, only the FTS index is rewritten.

The analyzer at /src/main/services/kms/image-analyzer.ts sends one image per Anthropic API call (no batching — keeps individual costs visible in the cost dashboard). Spend hits api_cost_log via trackApiCost(accountId, 'kms-image-analyzer', model, in, out). AI features fail-with-clear-error when the active account is OAuth/login: per the project rule, "AI features call the Messages API which does not accept OAuth tokens — the analyzer falls back to the first available API-key account, and fails closed with a toast if none exists." Bulk-analyze iterates with rate-limit awareness: it polls listAnalyzedShas(vaultId) between calls so an interrupted run can resume without re-billing analyzed shas.

Agent & CLI access to image metadata (2026-06-11)

The AI image metadata is reachable headlessly so spawned agents (and the omniscio-control skill) can read it — on two surfaces, deliberately asymmetric:

  • CLI control server (runs in main, so it CAN disk-walk): GET /kms/images (asset list + tags/category), GET /kms/images/:sha (full analysis row or analysis:null), GET /kms/images/search?q= (FTS5 over the AI metadata). Read-only, same nothariEnabled 403-gate as the other KMS routes. Surface contract: kms-cli-routes-contract.md.
  • Bundled MCP server (a bare process that can't load the disk-walking asset store): kms_list_images / kms_search_images / kms_image_details — metadata-only (no file size/path), cross-vault, gated on nothariEnabled + nothariAgentToolsEnabled, 100 KB payload cap. In agent-tools-read.ts.

Triggering a new (billable) analysis from the CLI is never a direct call — POST /kms/images/analyze ({ vaultId?, sha256? }, sha set = one image, omitted = all un-analyzed) only ENQUEUES an approval-gated kms.analyze_images row (the kms-image-analysis approval family, danger:'cost', default ON), and the dispatcher runs image-analysis-runner.ts runImageAnalysis only after the user approves. The route returns 202 (queued) with a cost-estimate preview, never 200 (applied-inline) — so no CLI caller can spend without an approval. Mirrors POST /sms/send.

Image IPC surface (Phase 5)

All routes gate on settings.nothariEnabled === true.

Import + reference

  • kms:asset-import (5b) — input { vaultId, sourcePath?, blobBase64?, filename }. Either provide an absolute filesystem source path OR the base-64 bytes inline (paste / drag-drop). Returns { sha256, relativePath }. SHA-256 dedup happens here.
  • kms:asset-list (5e) — input { vaultId }. Returns { assets: { sha256, relativePath, sizeBytes, mtime, hasAnalysis }[] }. The Gallery's data source.
  • kms:asset-details (5g) — input { vaultId, sha256 }. Returns the same per-row fields plus the AI analysis record (if any). The Lightbox's data source.

Mutate

  • kms:asset-replace (5g) — input { vaultId, oldSha256, newSourcePath | newBlobBase64, filename }. Replaces an asset in place: writes the new bytes, rewrites every note's assets/<oldSha>.<ext> reference to assets/<newSha>.<ext> (single transaction, parameterized rewrite — the relative ref is the canonical on-disk form extractAssetRefs matches), drops the old analysis row, moves the old asset to .trash/assets/.
  • kms:asset-delete (5e/5g) — input { vaultId, sha256 }. Refuses if the asset is referenced by any note; otherwise moves to .trash/assets/.
  • kms:asset-orphans (5g) — input { vaultId }. Returns { orphans: { sha256, relativePath, sizeBytes }[] } — assets in <vaultRoot>/assets/ that no note references. Surfaces a small "Clean up orphans" affordance on the Gallery footer.

Analyze

  • kms:image-analyze (5f) — input { vaultId, sha256, model? }. Returns the persisted nothari_image_analysis row. Used by the Lightbox's per-image Analyze button.
  • kms:image-analyze-bulk (5f) — input { vaultId, model?, sinceCursor? }. Walks assets/, skips already-analyzed shas (via listAnalyzedShas(vaultId)), emits a push event per completion. Used by the Gallery's "Analyze all" button.

Search

  • kms:unified-search (3h + 5h) — see "Unified Search modal" above. Phase 5 added the images section + kind:image chip.

Image file map

  • Protocol handler: /src/main/protocol-handlers/kms-asset.ts.
  • Asset store: /src/main/services/kms/asset-store.ts (sha256 + dedup + refcount + trash move).
  • Analyzer service: /src/main/services/kms/image-analyzer.ts.
  • DB queries: /src/main/db/queries-kms-images.ts (analysis CRUD + searchImagesUnified).
  • IPC handlers: /src/main/ipc/kms/images.ts (import / list / details / replace / delete / orphans / analyze) + /src/main/ipc/kms/search.ts (unified-search Phase 5h kind branching).
  • Renderer Gallery: /src/renderer/src/features/kms/views/ImageGallery.tsx (panel + grid + analyze-all button).
  • Renderer Lightbox: /src/renderer/src/features/kms/ImageLightbox.tsx.
  • TipTap VaultImage node: /src/renderer/src/features/kms/editor/extensions/VaultImage.ts (+ VaultImageNodeView.tsx).
  • Paste / drop handler: /src/renderer/src/features/kms/editor/extensions/ImagePasteDropHandler.ts.

URL unfurl (clean-URL paste)

Pasting a clean URL (the pasted text, after trim, IS the URL — no surrounding text) into a note unfurls it, ported from the standalone Nothari app:

  • YouTube (youtube.com/watch?v=…, youtu.be/…, /shorts/…) and Vimeo (vimeo.com/<id>[/<hash>]) → the link text is replaced with the video's title, so the note shows [Real Title](url). The title comes from each site's public oEmbed endpoint (no API key); when oEmbed fails or returns nothing, it falls back to reading the video page's og:title (the endpoint that still answers when Vimeo's abuse WAF 403-blocks /api/oembed.json from an IP — see tasks-v2.md "Paste a video link"). Resolved titles are cached for the process + in-flight-deduped, so a duplicate/rapid re-paste never re-hits the provider.
  • X / Twitter (…/status/<id>) → a rendered screenshot of the tweet is fetched from the authenticated tweet-shots service, stored in the vault's content-addressed asset store, and inserted as an inline VaultImage (sized ~25% of editor width, column-aware) at a schema-valid slot next to the pasted link — nested INSIDE the current bullet when you paste into a list (rather than being lifted out and dropped outside the bullets), or the next block in a plain paragraph; the plain link stays as a fallback.
  • Triggers only on a whole-string URL paste, and never inside a code block. While the fetch is in flight a render-only ProseMirror Decoration marks the link (never serialized — a slow fetch can't persist a placeholder).
  • Any failure (offline, deleted/private tweet, missing/rate-limited key, no open vault) degrades to the plain link with a soft "kept the plain link" toast — never a crash.

Why main-process: the renderer may not make outbound network calls, so the fetch runs in main behind two IPC channels — kms:fetch-video-title → { title } and kms:fetch-tweet-screenshot → { relativePath, sha256 } (the renderer builds the nothari-asset:// src). Titles are HTML-entity-decoded + whitespace-collapsed (NOT markdown-escaped — the serializer owns that). The oEmbed + tweet-shots hosts are fixed (only a numeric tweet id / percent-encoded url param is user-influenced). The og:title fallback is the one request whose host comes from the user's URL, so it is re-validated against a video-host allow-list BEFORE the fetch AND on the final (post-redirect) URL, and the body read is size-bounded — keeping the "no SSRF" surface intact.

Tweet-shots key: default-only (getTweetShotsApiKey, no per-user setting) — baked into the committed internal defaults (zero setup; private-repo Known Debt). The shipped key is free-tier, so heavy use can hit its credit/rate limit and fall back to plain links.

URL-unfurl file map

  • Detection: /src/renderer/src/features/kms/editor/extensions/url-patterns.ts.
  • Editor extension + renderer fetchers: /src/renderer/src/features/kms/editor/extensions/UrlPasteHandler.ts.
  • Main service: /src/main/services/kms/url-unfurl.ts; IPC handlers: /src/main/ipc/kms/url-unfurl.ts.
  • Invariants (egress-in-main, content-type/size guards, graceful fallback, default-only key): kms-url-unfurl-contract.md.

Related

The vault itself is the KMS page; the editor's own syntax is part 2. Turning a clip from the web into a note is on KMS web clipper.

Last verified 2026-09-23