STATUS.md current-focus and counts refreshed; DECISIONS.md gains the no-unredacted-console gate (#418) and automated-review triage (#454) entries; ARCHITECTURE.md security layers and audit status extended. Co-Authored-By: Claude <bot@wafflenet.io>
59 KiB
Architecture Overview
Last updated: 2026-07-03 · Version: 1.0.11
Synapse is an Obsidian plugin that layers AI-powered features over a vault: note elaboration (with image analysis), audio transcription, video transcription, image OCR, note enrichment, summarization, note tidying, semantic organization, recursive deep-dive note generation, title proposals, and in-place wikilink discovery (REM). Two coordination layers tie them together — a Fire Synapse pipeline that runs the features in a fixed order over a folder or note, and an intake watcher that auto-processes notes dropped into an inbox. It runs on both desktop and mobile (video and media-clipping features are desktop-only).
The codebase has 17 modules under src/ (audio, commands, deep-dive, elaboration, enrichment, image, intake, organize, pipeline, rem, shared, summarize, tidy, title, transcription, video, views) plus top-level glue: main.ts, settings.ts, settings-tab.ts, a small pure onboarding.ts (first-run welcome, #89), brand-icons.ts (Synapse SVG icons), changelog.ts/changelog-modal.ts (in-app "What's new", #375), and properties-fold.ts (auto-fold note Properties, #381).
Note
: This plugin was previously named "Auto Notes" and was rebranded to "Synapse" in March 2026. The data folder was renamed from
.auto-notes/to.synapse/, with automatic one-time migration on load.
How the pieces fit (plain language)
- Feature modules do the actual work (transcribe, enrich, summarize…). Each one is self-contained in
src/<feature>/and exposes its public surface through a singleindex.tsbarrel. shared/is the foundation everything stands on — the AI client, validation, checkpoints, callouts, and URL detection. It depends on no feature module.commands/is a developer-level master switch for every command, sitting above user settings. It also depends on nothing else insrc/.pipeline/runs features in order;intake/decides what to feed the pipeline. Neither imports a feature module directly —main.tsinjects the features into them. This is what keeps the dependency graph acyclic.views/holds the two sidebars: the unified proposal view (review/accept every proposal type) and the Synapse actions view (registry-driven, touch-friendly buttons so mobile users reach any command without the palette).- Mobile safety: the plugin ships
isDesktopOnly: false. Anything that needs Node.js (yt-dlp, ffmpeg, the filesystem) is funneled through one guarded loader (shared/node-loader.ts) that refuses to run off-desktop, so the bundle loads cleanly on mobile and desktop-only features degrade gracefully.
System Diagram
graph TB
subgraph Obsidian["Obsidian"]
Main["main.ts<br/>SynapsePlugin"]
Settings["Settings + Tab"]
Sidebar["Unified Proposal View<br/>(sidebar)"]
Actions["Synapse Actions View<br/>(registry-driven sidebar)"]
CkptMgr["CheckpointManager<br/>(shared, singleton)"]
subgraph Coord["Coordination Layers"]
Pipe["Pipeline<br/>(Fire Synapse runner)"]
Intake["Intake<br/>(folder watcher)"]
end
subgraph Features["Feature Modules"]
Elab["Elaboration<br/>(+ ImageAnalyzer)"]
Audio["Audio"]
Video["Video"]
Image["Image OCR"]
Trans["Transcription<br/>(UI only)"]
Enrich["Enrichment"]
Summ["Summarize"]
Tidy["Tidy"]
Org["Organize"]
DD["Deep Dive"]
Title["Title"]
Rem["REM<br/>(wikilinks)"]
end
Commands["Commands<br/>(registry · base layer)"]
Shared["Shared Layer<br/>AIClient · Notifications · Validation<br/>File Utils · Frontmatter · Callouts · URL Detection"]
end
subgraph External["External Services"]
AI["AI Providers<br/>OpenAI · Anthropic · Gemini · Ollama"]
TransAPI["Transcription APIs<br/>Whisper · Deepgram · Gemini"]
Tools["CLI Tools<br/>yt-dlp · ffmpeg"]
end
Main --> Settings
Main --> Sidebar
Main --> Actions
Main --> CkptMgr
Main --> Commands
Main --> Coord
Main --> Features
Commands -.->|derives buttons| Actions
Pipe -.->|injected modules| Features
Intake -.->|injected fireOnFile| Pipe
Features --> Shared
Features --> Commands
Features --> CkptMgr
Shared --> AI
Audio --> TransAPI
Video --> Tools
Video --> Audio
Video --> Shared
Image --> AI
Elab -.->|image analysis| Image
Trans --> Audio
Trans --> Video
Trans --> Image
Summ -.->|transcribeUrl injection| Video
DD -.->|auto-organize| Org
style Trans fill:#f9f,stroke:#333
style Sidebar fill:#bbf,stroke:#333
style Actions fill:#bbf,stroke:#333
style CkptMgr fill:#ffd,stroke:#333
style Commands fill:#fde,stroke:#333
style Shared fill:#def,stroke:#333
style Image fill:#e8f5e9,stroke:#333
Both shared/ and commands/ are base layers: every feature may depend on them, but they depend on no feature module. pipeline/ and intake/ reach the features only through dependencies injected by main.ts, never by importing them — that is what keeps the whole graph acyclic.
Module Map
src/
├── main.ts # Plugin entry, module orchestration, callback wiring, dependency injection
├── settings.ts # Type definitions, defaults, model options (type-only imports of ProposalKind + ExclusionRule)
├── settings-tab.ts # Obsidian settings UI (video section gated behind Platform.isDesktop)
├── onboarding.ts # Pure first-run welcome logic (#89): planFirstRun, needsApiKey
├── brand-icons.ts # registerSynapseIcons(): S-Signal mark + per-feature glyphs (before any ribbon/setIcon use)
├── changelog.ts # parseChangelog/renderChangelog of the build-inlined CHANGELOG.md (#375)
├── changelog-modal.ts # ChangelogModal — in-app "What's new" (#375)
├── properties-fold.ts # registerPropertiesAutoFold(): auto-fold note Properties panel on open (#381)
│
├── commands/ # Command registry (base layer; imports nothing in src/)
│ ├── registry.ts # COMMAND_REGISTRY source of truth + flow/status/context gates
│ ├── registrar.ts # Single wiring point to plugin.addCommand
│ ├── audit.ts # Registry <-> handler drift detection
│ └── index.ts # Barrel export (listPaletteActions for the actions sidebar)
│
├── pipeline/ # Fire Synapse: ordered multi-phase runner
│ ├── synapse-runner.ts # Sequential phase executor (fire / fireOnFile)
│ ├── types.ts # SYNAPSE_PIPELINE phase list + scan-fn contract
│ └── index.ts # Barrel export
│
├── intake/ # Inbox watcher: auto-process dropped notes (#111)
│ ├── intake-dispatcher.ts# Route a note (article / media / general)
│ ├── types.ts # Route + IntakeDeps types, processed-flag constants
│ └── index.ts # IntakeModule (debounce, idempotency, fireOnFile)
│
├── rem/ # In-place [[wikilink]] discovery
│ ├── mention-scanner.ts # Literal title/alias matches (down-weighted by titleMatchWeight, #380)
│ ├── semantic-matcher.ts # Always-on AI semantic matches (#380)
│ ├── rem-applier.ts # Insert wikilinks into note body
│ ├── rem-store.ts # Proposal persistence
│ └── index.ts # RemModule orchestrator
│
├── elaboration/ # Stub note detection + AI content proposals (image-aware)
│ ├── detector.ts # PlaceholderDetector (short notes, TODOs, empty sections)
│ ├── proposer.ts # ProposalGenerator (context gathering + AI generation)
│ ├── image-analyzer.ts # Multi-modal image analysis for proposal enrichment
│ ├── proposal-store.ts # JSON file persistence
│ └── index.ts # ElaborationModule orchestrator
│
├── audio/ # Audio transcription
│ ├── transcriber.ts # Whisper / Deepgram / Gemini / local routing; manual multipart w/ sanitized headers
│ ├── post-processor.ts # AI transcript cleanup
│ ├── note-scanner.ts # Find audio embeds in note content
│ └── index.ts # AudioModule orchestrator
│ # NOTE: type-only `import type { AudioExtractor }` from video/ (no runtime cycle)
│
├── video/ # Video download + transcription
│ ├── audio-extractor.ts # yt-dlp + ffmpeg via execFile
│ ├── note-scanner.ts # Find video URLs in note content (uses shared/url-detector)
│ └── index.ts # VideoModule orchestrator (delegates to Audio)
│ # NOTE: url-detector.ts moved to shared/ (decycling, 2026-06-08)
│
├── image/ # Image OCR via multi-modal AI (vision models)
│ ├── extractor.ts # ImageExtractor (base64 + ContentBlock[] -> AI vision)
│ ├── note-scanner.ts # Find image embeds (![[*.png]]) in note content
│ ├── types.ts # ImageEmbed, OCRResult
│ └── index.ts # ImageModule orchestrator (batch + checkpoint)
│
├── transcription/ # Unified transcription UI (issue #20)
│ ├── unified-modal.ts # File picker + URL input with duration + time-range
│ ├── note-media-modal.ts # Selection modal for media in current note
│ ├── time-range-slider.ts# Dual-handle range slider (pure DOM component)
│ ├── time-range-toast.ts # Confirmation toast with embedded slider
│ ├── duration-detector.ts# Media duration via ffprobe (local) / yt-dlp (URL)
│ └── index.ts # Barrel export
│
├── enrichment/ # Tags, links, refs, frontmatter
│ ├── metadata-classifier.ts # AI tag classification against vocabulary
│ ├── topic-extractor.ts # AI topic extraction -> link candidates
│ ├── link-resolver.ts # Graph-based link resolution + merge
│ ├── vault-analyzer.ts # Cached vault tag index + link graph
│ ├── weight-calculator.ts # Proximity weight scoring (pure function)
│ ├── prompt-builder.ts # External links + frontmatter suggestions
│ ├── enrichment-applier.ts # Apply/undo enrichments with markers
│ ├── enrichment-store.ts # JSON file persistence
│ └── index.ts # EnrichmentModule orchestrator
│
├── summarize/ # URL + transcription summarization
│ ├── summarizer.ts # AI summarization (bullets/paragraph/key-points)
│ ├── content-fetcher.ts # HTTP fetch + HTML-to-text + JSON-LD extraction
│ ├── note-scanner.ts # Find summarizable targets in notes
│ └── index.ts # SummarizeModule orchestrator
│
├── tidy/ # Spelling + formatting correction
│ ├── tidy-store.ts # Snapshot storage for undo
│ └── index.ts # TidyModule orchestrator
│
├── organize/ # AI-powered directory structuring
│ ├── content-analyzer.ts # AI topic extraction for organization
│ ├── directory-matcher.ts# Match topics to directories
│ ├── organize-store.ts # Proposal + snapshot persistence
│ └── index.ts # OrganizeModule orchestrator
│
├── deep-dive/ # Recursive topic exploration
│ ├── topic-analyzer.ts # AI topic extraction from note content
│ ├── note-generator.ts # AI content generation for topics
│ ├── quality-scorer.ts # Local heuristic quality scoring
│ ├── syllabus-navigator.ts # Traversal ordering, syllabus index, navigation
│ ├── deep-dive-store.ts # Proposal + run persistence
│ └── index.ts # DeepDiveModule orchestrator
│
├── title/ # Note title suggestions
│ ├── title-module.ts # Title checking, proposal lifecycle, collision resolution (iterate/merge, #408)
│ ├── title-suggester.ts # AI title generation + mismatch detection
│ ├── title-proposal-store.ts # JSON persistence
│ ├── content-key.ts # titleContentKey() — input-keyed dedup so a rejected title isn't re-proposed (#408)
│ ├── settings-section.ts # Settings UI: enabled toggle + duplicate-handling dropdown
│ ├── types.ts # TitleProposal, trigger/status types, TitleDuplicateStrategy
│ ├── title-detector.ts # Re-exports isUntitled from ../shared (canonical home is shared/)
│ └── index.ts # Re-exports module, types, isUntitled
│
├── shared/ # Cross-cutting utilities (base layer)
│ ├── ai-client.ts # Multi-provider AI (OpenAI, Anthropic, Gemini, Ollama); per-instance LRU response cache + in-flight coalescing (#397); re-exports redactSecrets
│ ├── redact.ts # redactSecrets() (strings) + redactError() (raw caught errors) — single source of truth for API-key/token redaction
│ ├── settings-migrations.ts # Version-stamped migration runner (#93): migrateSettings/readSettingsVersion/CURRENT_SETTINGS_VERSION
│ ├── hash-utils.ts # hashString/contentKey — dependency-free input-keyed hashing for idempotency (#395)
│ ├── untrusted-content.ts# wrapUntrusted() — structural prompt-injection fence for fetched external content (#398)
│ ├── review-action.ts # reviewAction() — centralized "Review" completion-toast gate (#366)
│ ├── node-loader.ts # loadNodeModules/assertDesktop/DesktopOnlyError/shellEnv — the ONE guarded Node-builtin site
│ ├── exclusions.ts # Centralized path-exclusion model + glob matcher (#307); tag-exclusion helper
│ ├── credential-validator.ts # validateCredentials() — one minimal authenticated probe per provider (#335)
│ ├── provider-metadata.ts# Per-provider get-key URL, placeholder, probe spec (#335)
│ ├── credential-field.ts # Settings decorator: "Get an API key →" link + Test button + status chip (#335)
│ ├── encoding.ts # arrayBufferToBase64 / base64EncodedLength (shared by audio/image/elaboration)
│ ├── url-detector.ts # YouTube/TikTok/Instagram URL parsing (moved here 2026-06-08)
│ ├── checkpoint-manager.ts # Checkpoint/resume for long-running operations
│ ├── checkpoint-types.ts # Checkpoint type definitions
│ ├── id-utils.ts # ID generation and validation
│ ├── notifications.ts # Centralized notifications: progress, cancellation, action buttons, dispose() teardown
│ │ # every error sink routes through redactSecrets; equal-message throttle for one-shot toasts (#396)
│ ├── fire-and-forget.ts # fireAndForget() — rejection handling for un-awaited promises; sinks route through redactError
│ ├── update-checker.ts # UpdateChecker/isNewerVersion — once/24h GitHub Releases poll, sticky notice (#365)
│ ├── title-detector.ts # isUntitled/isGenericTitle predicates — canonical home; title/ re-exports isUntitled
│ ├── validation.ts # URL, path, AI response sanitization
│ ├── file-utils.ts # Vault file operations
│ ├── frontmatter-utils.ts# YAML frontmatter parsing/serialization
│ ├── callouts.ts # Callout type registry + builder
│ ├── diagram-generator.ts# Mermaid diagram generation
│ ├── slider-helper.ts # Settings UI helper for range sliders
│ ├── folder-picker-modal.ts # Modal for folder selection
│ ├── api-utils.ts # Retry logic + error handling helpers
│ ├── json-utils.ts # Safe JSON parse + record/string-array guards + readJsonFile
│ ├── content-fetcher.ts # HTTP fetch + HTML-to-text + JSON-LD recipe extraction
│ ├── tweet-fetcher.ts # Tweet/X content fetch (untrusted-wrapped by callers)
│ ├── reddit-fetcher.ts # Reddit post fetch + canonical-URL extraction
│ ├── url-classifier.ts # Classify a URL (article / media / unknown) for intake routing
│ ├── content-schemas.ts # Content-type detection registry (recipe / receipt / lyrics)
│ ├── collapsible-section.ts # Settings UI: collapsible accordion section
│ ├── feature-chip-select.ts # Settings UI: multi-feature chip selector (exclusions)
│ ├── settings-section.ts # Settings UI: shared per-section context + collapse persistence
│ ├── markdown.d.ts # Ambient `declare module '*.md'` so esbuild can inline CHANGELOG.md (#375)
│ └── index.ts # Barrel export
│
└── views/ # UI components
├── unified-proposal-view.ts # Single sidebar for all proposal types + checkpoints
├── synapse-actions-view.ts # Registry-driven action buttons sidebar (mobile-friendly)
└── types.ts # UnifiedItem, UnifiedViewCallbacks
Dependency Graph
The graph is acyclic. shared/ and commands/ form the base layer (bottom); feature modules depend down onto them; the coordination layers (pipeline/, intake/) sit on top and receive features only by injection from main.ts.
graph TD
Main["main.ts"]
subgraph Coordination["Coordination (top — features injected, never imported)"]
Pipe["pipeline/"]
Intake["intake/"]
end
subgraph FeatureLayer["Feature Modules"]
Elab["elaboration/"]
Audio["audio/"]
Video["video/"]
Image["image/"]
Trans["transcription/"]
Enrich["enrichment/"]
Summ["summarize/"]
Tidy["tidy/"]
Org["organize/"]
DD["deep-dive/"]
Title["title/"]
Rem["rem/"]
Views["views/"]
end
subgraph BaseLayer["Base Layer (depends on no feature module)"]
Shared["shared/"]
Commands["commands/"]
end
Main --> Pipe
Main --> Intake
Main --> FeatureLayer
Main --> BaseLayer
Pipe --> Commands
Pipe -. injected modules .-> FeatureLayer
Intake --> Shared
Intake -. injected fireOnFile .-> Pipe
Video --> Audio
Audio -. type only .-> Video
Video --> Shared
Elab -. ImageAnalyzer .-> Image
Trans --> Audio
Trans --> Video
Trans --> Image
Summ -. transcribeUrl injected .-> Video
Summ --> Audio
DD --> Org
Elab --> Shared
Audio --> Shared
Image --> Shared
Enrich --> Shared
Summ --> Shared
Tidy --> Shared
Org --> Shared
DD --> Shared
Title --> Shared
Rem --> Shared
Views -. types only .-> FeatureLayer
Elab --> Commands
Audio --> Commands
Video --> Commands
Image --> Commands
Enrich --> Commands
Summ --> Commands
Tidy --> Commands
Org --> Commands
DD --> Commands
Rem --> Commands
style Shared fill:#def,stroke:#333
style Commands fill:#fde,stroke:#333
style Pipe fill:#ffd,stroke:#333
style Intake fill:#ffd,stroke:#333
Key constraints:
- Acyclic graph.
sharedandcommandsare base layers depending on no feature module. The formershared ⇄ videocycle was removed on 2026-06-08 by movingurl-detector.tsintoshared— the edge is now one-directionalvideo → shared. settings→sharedis one sanctioned edge.settings.tsimports the runtime valueCURRENT_SETTINGS_VERSIONfromshared/settings-migrations.ts(#93), which depends only onshared/exclusions.tsand never on../settings— sosettings → settings-migrations → exclusionsstays acyclic. (settings.tsalso type-only-importsProposalKind,ExclusionRule,TitleDuplicateStrategy, all erased at compile time.)commandsimports nothing insrc/.pipelineimportscommands(for flow gating) but never the feature modules —main.tsinjects them viaPipelineModuleMap.intakeimports onlyobsidian+shared. All cross-module work routes through an injectedIntakeDeps(notablyfireOnFile).- Video depends on Audio — reuses the transcription pipeline (runtime edge
video → audio). - Audio has a type-only back-edge to Video —
audio/index.tsdoesimport type { AudioExtractor } from '../video'for time-range clipping. The type is erased at compile time, so there is no runtime cycle; it is a structural exception flagged for a future cleanup (moveAudioExtractortoshared/or pass a structural interface). - Transcription is UI-only — delegates all work to Audio, Video, and Image via callbacks.
- Elaboration uses ImageAnalyzer — analyzes embedded images during proposal generation (dotted line to Image).
- Summarize receives
video.transcribeUrlvia constructor injection only (no staticsummarize → videoimport); URL-platform helpers resolve fromshared; also callsaudio.findAudioEmbeds. - Shared utilities are imported via the
../sharedbarrel — never through a sibling feature module or an internalshared/file. Canonical homes (url-detector,redact,encoding) live inshared/and re-export elsewhere only for back-compat. - Deep Dive reuses Organize for
auto-organizenesting mode. - Views imports types only from feature modules (including REM).
- CheckpointManager is a singleton — created in
main.ts, injected into modules with resumable scans (elaboration, enrichment, audio, video, image, summarize, organize, deep-dive, rem).tidy,title,transcription, andintakedo not use it.
Fire Synapse Pipeline
"Fire Synapse" runs the AI features over a folder (or a single note) in a fixed, deliberate order. The pipeline/ module owns a SynapseRunner that executes each phase sequentially, isolating failures so one bad phase doesn't abort the run.
This diagram expands subgraph c (Fire Synapse) of the master command-pipeline overview in
README.md→ How it all fits together, which is the canonical birds-eye view.
graph LR
Elab["1 · Elaboration"] --> Summ["2 · Summarize"] --> Enrich["3 · Enrichment"] --> Rem["4 · REM"] --> Tidy["5 · Tidy"] --> Org["6 · Organize"]
style Org fill:#e8f5e9,stroke:#333
- Order matters. Content-generating phases run first; organize runs last because it is the content-aware mover — it should only relocate a note after all its content exists.
- Gating. A phase runs only if its feature is
enabledand the command registry lists it in thefire-synapseflow. - Decoupling. The runner never imports the feature modules.
main.tsbuilds aPipelineModuleMap(phase key → scan function) and injects it, so the runner stays independent of concrete features. - Two entry points.
fire(folder?)scans a whole folder;fireOnFile(file)scopes every phase to a single note (this is what the intake watcher calls).
Intake: Auto-Processing Inbox (Issue #111)
The intake/ module turns a watched folder into a hands-off inbox. Drop a note — or a note containing an article/media URL — and Synapse processes it automatically.
This diagram expands subgraph d (Intake) of the master command-pipeline overview in
README.md→ How it all fits together, which is the canonical birds-eye view.
graph TB
Event["Vault create/modify in intake folder"] --> Guard["Cheap guards:<br/>is .md? enabled? in folder? not in-flight?"]
Guard --> Debounce["Per-path debounce<br/>(wait for note to settle)"]
Debounce --> Idem{"Already has<br/>synapse-processed flag?"}
Idem -->|Yes| Skip["Skip (idempotent)"]
Idem -->|No| Bare{"Body is essentially<br/>one bare URL?"}
Bare -->|"no — general / mixed / text"| Fire["deps.fireOnFile(note)<br/>(full pipeline on one note)"]
Bare -->|yes| Classify{"Classify URL"}
Classify -->|article| Article["Fetch article content<br/>+ deps.fireOnFile(note)"]
Classify -->|"video / audio"| Stub["Transcribe (stub — no-op today, #112)"]
Classify -->|unknown| Fire
Article --> Stamp
Stub --> Stamp
Fire --> Stamp["Stamp synapse-processed<br/>(before any move)"]
Stamp --> Move["Organize moved it?<br/>else optional fallback move"]
Move --> Log["If it left the inbox:<br/>write dated capture-log breadcrumb (#224)"]
- Settle-then-process. A per-path debounce waits until the note stops changing, so notes aren't reprocessed mid-edit.
- Idempotency. A
synapse-processedfrontmatter flag is stamped before any relocation, so the move's rename echo can't trigger reprocessing. - Leaf of the graph. Intake imports only
obsidian+shared; the pipeline call arrives via an injectedIntakeDeps.fireOnFile. - Stubbed branch. The media-transcription route (#112) currently no-ops with a notice.
Plugin Lifecycle
sequenceDiagram
participant O as Obsidian
participant M as main.ts
participant Reg as CommandRegistrar
participant Mod as Feature Modules
participant Coord as Pipeline + Intake
participant CK as CheckpointManager
O->>M: onload()
M->>M: loadSettings() (version-stamped migrateSettings replay → deep-merge → stamp settingsVersion, #93; v1 excludeFolders→exclusions #307, v2 drop rem.semanticMatching)
M->>M: registerSynapseIcons() (brand S-Signal mark + feature glyphs, before any ribbon/setIcon use)
M->>M: migrateDataFolder() (.auto-notes -> .synapse)
M->>M: addSettingTab()
M->>M: NotificationManager() (status bar on desktop only)
M->>CK: new CheckpointManager(app)
M->>Reg: new CommandRegistrar(plugin)
M->>Mod: Initialize feature modules<br/>(Audio before Video; Video desktop-only; inject CheckpointManager, registrar, shouldAutoAccept getter)
M->>M: new UpdateChecker(...) (#365)
M->>M: registerView(UnifiedProposalView) + registerView(SynapseActionsView)
M->>M: registerPropertiesAutoFold(plugin, () => settings) (#381)
M->>M: Wire view refresh + onOpenProposalView + cross-module callbacks<br/>(enrichment, title, organize)
M->>Mod: Conditional module.onload()<br/>(registers commands via registrar if enabled)
M->>M: addRibbonIcon x3 (synapse; synapse-transcribe [desktop]; synapse-actions)
M->>Reg: registrar.register(main commands)
M->>CK: startupTimeout = checkForIncompleteCheckpoints() (delayed 3s)
M->>M: updateCheckTimeout = updateChecker.maybeCheck() (delayed 5s, self-gated once/day, #365)
M->>Coord: Build SynapseRunner (inject PipelineModuleMap)<br/>+ IntakeModule (inject IntakeDeps.fireOnFile)
M->>Reg: auditCommands(attempted) — warn on drift
M->>M: runFirstRunOnboarding() (#89, fresh installs only)
O->>M: onunload()
M->>M: clearTimeout(startupTimeout); clearTimeout(updateCheckTimeout)
M->>Mod: module?.onunload() for every module (video may be null)
M->>M: notifications.dispose() (tear down ellipsis timers + hide notices)
Desktop Gating Model
Synapse ships isDesktopOnly: false in manifest.json, so the single bundle must load on Obsidian mobile — which has no os, path, fs, or child_process. esbuild marks those Node builtins as external, so any top-level require('fs') would throw on mobile at module-load time, before any platform check could run. The plugin closes that hole structurally:
graph TB
Caller["Audio / Video / Duration detector<br/>(needs ffmpeg / yt-dlp / fs)"]
Caller --> Guard["assertDesktop()<br/>throws DesktopOnlyError off-desktop"]
Guard --> Load["loadNodeModules()<br/>lazy require os/path/fs/execFile (inside the fn body)"]
Load --> Env["shellEnv()<br/>allowlisted PATH/HOME/TMPDIR/proxy for subprocesses"]
Env --> Tools["execFile yt-dlp / ffmpeg / ffprobe"]
style Guard fill:#fde,stroke:#333
style Load fill:#def,stroke:#333
- One sanctioned site.
shared/node-loader.tsis the only place Node builtins are required, and it's the only file allowed to disable theno-var-requireslint rule. Every desktop-only path routes through it. - Explicit assertion, not truthiness. Code paths call
assertDesktop()(which throws a descriptiveDesktopOnlyError) rather than relying on athis.extractorbeing defined. - Construction-time gating too.
main.tsonly buildsVideoModuleand the sharedAudioExtractorwhenPlatform.isDesktop; themicribbon icon and the settings-tab video section are desktop-only. - Graceful degradation. On mobile, media clipping/duration detection fall back to full-file processing or surface a clear "not available on mobile" message. All non-Node features (elaboration, enrichment, summarize, tidy, organize, deep-dive, title, REM, audio API transcription, image OCR) run on both platforms.
Path Exclusions (#307)
A single settings.exclusions list controls which folders each feature may touch, replacing the old per-module excludeFolders fields. The model and matcher live in shared/exclusions.ts.
interface ExclusionRule { pattern: string; features: 'all' | FeatureId[] }
// FeatureId is a closed 12-member union with an ALL_FEATURE_IDS exhaustiveness guard.
- First-match-wins.
findMatchingRule(path, featureId, settings)walksexclusionsin order and returns the first rule that applies to the feature and matches the path;isPathExcluded(...)is the boolean wrapper. - Glob forms.
dir/**(folder + all descendants),dir/*(direct children), a bare token (recursive prefix), or an exactdir/file.mdpath. Patterns are normalized and anchored;.is escaped so.synapse/**can't over-match. - Defaults. Fresh installs get
[{ pattern: '.synapse/**', features: 'all' }, { pattern: 'templates/**', features: 'all' }]. - One-time migration. The
excludeFolders → exclusionsfold now runs as migration v1 of the version-stamped settings framework (#93, below), still guarded so a user who deliberately cleared exclusions to[]keeps[]. - Tags stay per-module.
excludeTagsremains per feature but routes through a sharedmatchesExcludeTaghelper (handles inline + frontmatter tags, case-insensitively).
Settings Migrations (#93)
Settings evolve through a version-stamped, append-only migration chain in shared/settings-migrations.ts, replacing the old scattered presence-guarded one-offs.
- One stamp, ordered replay. A persisted
settingsVersionrecords the schema version ofdata.json. On load,migrateSettings(raw, from)clones the raw object once and applies every migration whoseto > fromin ascending order, before the deep-merge overDEFAULT_SETTINGS.main.loadSettings()then stampssettingsVersion = CURRENT_SETTINGS_VERSIONand saves once on upgrade. - Pure & tested. Each step is a pure
Record<string, unknown> → Record<string, unknown>function; a drift-guard test assertsCURRENT_SETTINGS_VERSIONequals the highest migrationto. - Current chain (v2):
v1folds legacyexcludeFoldersintoexclusions(#307);v2drops the inertrem.semanticMatchingflag left by the always-on REM change (#380). - Layering. Lives in
shared/, imports onlyshared/exclusions.ts, never../settings— so the sanctionedsettings → settings-migrations → exclusionsedge stays acyclic.
Synapse Actions Sidebar (#289)
A second sidebar (SynapseActionsView, synapse-actions view, opened by the layout-grid ribbon icon) gives mobile users — where the command palette is hardest to reach — a touch-friendly button for every enabled command.
- Registry-derived. Buttons come from
listPaletteActions(registrar.getRegistered()); no command behavior is re-declared. Each button dispatches the already-registered command via Obsidian'sexecuteCommandById, honoring the same editor/check gating as the palette. - Context-aware. Every registry entry carries a
context(note|vault|global). Per-note buttons disable when no note is active; fornotecommands the opener re-activates the note's markdown leaf first, so opening the panel (which can steal editor focus) never makes a button silently no-op.
Checkpoint/Resume System
Long-running operations (vault scans, batch transcriptions) can be interrupted by plugin reload or Obsidian restart. The checkpoint system preserves progress:
graph TB
Start["Module starts operation"] --> Create["CheckpointManager.create()<br/>Record all work items"]
Create --> Work["Process items one at a time"]
Work --> Complete["completeItem(id)<br/>Move to completedItems"]
Complete --> More{More items?}
More -->|Yes| Work
More -->|No| Finish["complete()<br/>Fire deferred tasks"]
Work -->|"Interrupted!"| Persist["Checkpoint saved to<br/>.synapse/checkpoints/{id}.json"]
Persist --> Reload["Plugin reloads"]
Reload --> Detect["checkForIncompleteCheckpoints()<br/>Notify user"]
Detect --> Resume["resume(id)<br/>Return remaining items"]
Resume --> Work
- Checkpoints are stored as JSON in
.synapse/checkpoints/ - Each module implements
resumeFromCheckpoint(checkpoint)to continue work - Users can also discard checkpoints (completed items are kept, remaining items abandoned)
- The unified sidebar shows a banner for any incomplete checkpoints
Transcription Architecture (Issue #20)
The transcription system uses a UI layer + backend modules pattern:
graph TB
subgraph UI["Transcription UI (src/transcription/)"]
UM["UnifiedTranscriptionModal<br/>File picker + URL input"]
NM["NoteMediaModal<br/>Scan note -> select media"]
end
subgraph Backend["Backend Modules"]
Audio["AudioModule<br/>transcribeFileToActiveNote()<br/>transcribeAndInsert()"]
Video["VideoModule<br/>transcribeUrlToActiveNote()<br/>transcribeAndInsert()"]
Image["ImageModule<br/>extractFromFile()<br/>extractAndInsert()"]
end
subgraph Providers["Transcription/OCR Providers"]
Whisper["Whisper API"]
Deepgram["Deepgram"]
YtDlp["yt-dlp + ffmpeg"]
Vision["Vision Models<br/>(GPT-4o, Claude, etc.)"]
end
Ribbon["Ribbon Icon (mic)"] --> UM
Cmd1["transcribe-media command"] --> UM
Cmd2["transcribe-note-media command"] --> NM
UM -->|onTranscribeFile| Audio
UM -->|onTranscribeUrl| Video
NM -->|onTranscribeAudio| Audio
NM -->|onTranscribeVideo| Video
NM -->|onExtractImages| Image
Audio --> Whisper
Audio --> Deepgram
Video --> YtDlp
Video -->|delegates| Audio
Image --> Vision
The transcription module replaced 4 modal files across audio/ and video/ with 2 unified modals. The NoteMediaModal also handles image OCR extraction. All callbacks are wired in main.ts.
Time-Range Clipping
When a user selects an audio file or enters a video URL, the modal auto-detects media duration via ffprobe (local) or yt-dlp (URLs). If duration >= 10 seconds, a dual-handle TimeRangeSlider appears allowing sub-range selection. Clipping uses ffmpeg on the extracted audio (desktop only; mobile falls back to full-file transcription).
File/URL selected --> detectDuration() --> duration >= 10s?
|-- Yes --> Show TimeRangeSlider (dual handles, live timestamps)
| User adjusts range --> TimeRange { start, end }
| Transcribe button --> clip audio via ffmpeg --> transcribe clip
|-- No --> Full file transcription (no slider shown)
Supported Platforms
URL detection (shared/url-detector.ts) recognizes:
- YouTube:
youtube.com/watch,youtu.be,youtube.com/shorts,music.youtube.com - TikTok:
tiktok.com/@user/video/id,tiktok.com/t/...,vm.tiktok.com,vt.tiktok.com - Instagram:
instagram.com/reel/{id},instagram.com/p/{id}(Reels)
Cross-Module Communication
All inter-module communication flows through main.ts via nullable callback assignments. No event bus, no pub-sub.
This diagram is the wiring-level detail behind the per-note cascade — subgraph a of the master command-pipeline overview in
README.md→ How it all fits together, which is the canonical birds-eye view. The setting names and defaults on the edges below match that diagram.
graph LR
subgraph Triggers["Enrichment + Title Triggers"]
Elab["Elaboration<br/>onProposalAccepted"]
Audio["Audio<br/>onTranscriptionComplete"]
Video["Video<br/>onTranscriptionComplete"]
Img["Image<br/>onExtractionComplete"]
Summ["Summarize<br/>onSummaryComplete"]
DDa["Deep Dive<br/>onNoteAccepted"]
end
Enrich["Enrichment.enrich()<br/>gated by enrichment.autoEnrich (default ON)"]
TitleChk["Title.checkTitle()<br/>gated by title.checkAfterOperations (default ON)"]
Elab -->|"'elaboration'"| Enrich
Audio -->|"'transcription'"| Enrich
Video -->|"'transcription'"| Enrich
Img -->|"'transcription'"| Enrich
Summ -->|"'summarization'"| Enrich
DDa -->|"'deep-dive' · deepDive.autoEnrichOnAccept (default ON)"| Enrich
Elab --> TitleChk
Audio --> TitleChk
Video --> TitleChk
Img --> TitleChk
Summ --> TitleChk
DDa --> TitleChk
DD2["Deep Dive<br/>onOrganizeRequested"] -->|"deepDive.autoOrganizeOnAccept (default OFF)"| Org["Organize.organizeNote()"]
Summ2["Summarize<br/>onOrganizeRequested"] -->|"summarize.autoOrganizeOnSummarize (default OFF)"| Org
ElabR["Elaboration"] & EnrichR["Enrichment"] & OrgR["Organize"] & DDR["Deep Dive"] & TitleR["Title"] -->|onViewRefreshNeeded| Refresh["main.refreshUnifiedView()"]
Deep Dive caveat. When global enrichment is on (
enrichment.autoEnrichON) butdeepDive.autoEnrichOnAcceptis OFF, accepting a deep-dive note wires neither the enrich nor the title callback — so the title check is effectively gated behinddeepDive.autoEnrichOnAccepttoo. The standalone title-only fallback (each trigger →Title.checkTitle()with no enrich) is wired only whenenrichment.autoEnrichis OFF.
Proposal System Architecture
Six modules generate proposals that appear in the unified sidebar. Each has a different review workflow:
| Module | Proposal Type | Review UX | Accept Behavior |
|---|---|---|---|
| Elaboration | Content additions (image-aware) | Editable textarea | Blockquote original, append additions in callout |
| Enrichment | Tags, links, refs, frontmatter | Per-item checkboxes | Cherry-pick items, apply with markers |
| Organize | New directory suggestion | Directory path + AI reasoning | Create directory, move file |
| Deep Dive | Generated child note | Read-only content preview | Create note at proposed path |
| Title | Rename suggestion (distinct state on collision) | Current vs proposed title + reasoning | Rename file; on filename collision resolve via iterate/merge (#408) |
| REM | [[wikilink]] insertions |
Per-match checkboxes | Rewrites note body (snapshot kept for undo) |
Proposal States
Generated --> Pending --+--> Accepted
+--> Rejected
+--> Partially Accepted (enrichment, REM)
Auto-Accept (Issue #228)
Each proposal kind has an autoAccept.{kind} setting (all default false). When on, a freshly generated proposal is accepted in full as generated, skipping the sidebar. REM is the cautionary case: its accept rewrites note prose (inserting wikilinks), whereas the others only add a separate section — so enabling REM auto-accept is a more consequential choice.
Tidy, Summarize, and Image do NOT use proposals — they apply changes immediately (tidy has undo via snapshots; image OCR inserts callouts inline).
Idempotency & the Review gate
- Dedup by content key (#395). Stores record each proposal's
contentKey(hashed from inputs: note path + content hash + detection/AI settings). Re-scanning an unchanged note skips re-proposing the same item, so "scan twice" no longer duplicates proposals; the per-notemaxProposalsPerNotecap is now enforced. Editing the note changes the hash and allows a fresh proposal. - Centralized Review toast (#366). A completion toast shows its "Review" button only when something was generated, auto-accept is off for that kind, and the run is not an automatic post-op side effect — decided in one shared
reviewAction()gate so all six flows behave consistently.
Deep Dive: Cascade Rejection
Rejecting a parent automatically rejects all descendants:
Root Note
+-- Topic A (rejected)
| +-- Subtopic A1 (auto-rejected)
| +-- Subtopic A2 (auto-rejected)
+-- Topic B (pending)
+-- Subtopic B1 (pending)
Deep Dive: Recursive Generation
graph TB
Start["User triggers deep dive"] --> Depth["DepthSelectorModal<br/>Choose max depth (1-5)"]
Depth --> Phase1["Phase 1: Extract Topics<br/>TopicAnalyzer.extractTopics()"]
Phase1 --> Filter["Filter: skip existing vault notes"]
Filter --> Phase2["Phase 2: User Confirmation<br/>'Found N new topics. Generate?'"]
Phase2 --> BFS["Phase 3: BFS Generation Loop"]
BFS --> Gen["NoteGenerator.generateContent()"]
Gen --> Extract["TopicAnalyzer.extractTopics()<br/>(if depth+1 < maxDepth)"]
Extract --> Score["scoreQuality()<br/>(local heuristic, no AI)"]
Score --> Decision{Score >= threshold<br/>AND depth < max?}
Decision -->|Yes| Queue["Queue children"]
Queue --> BFS
Decision -->|No| Stop["Stop branch"]
BFS --> Phase4["Phase 4: Present in Sidebar"]
Quality Scoring (Local, No AI)
Score = topicCount x 0.3 min(1.0, childTopics / 3)
+ wordCount x 0.2 min(1.0, words / 200)
+ generic x 0.2 penalty for "Introduction", "Overview", etc.
+ overlap x 0.2 penalty for child topics matching ancestors
+ depthDecay x 0.1 linear decay toward max depth
Below qualityThreshold (default 0.4) -> stop recursion for this branch
Enrichment Architecture
graph TB
Note["Note Content"] --> MC["MetadataClassifier.classify()<br/>AI -> vocabulary-validated tags"]
Note --> TE["TopicExtractor.extractTopics()<br/>AI -> 5-15 key concepts"]
Note --> LR["LinkResolver.findInternalLinks()<br/>Graph hops + shared tags + proximity"]
Note --> PB1["PromptBuilder.suggestExternalLinks()<br/>AI -> relevant URLs"]
Note --> PB2["PromptBuilder.suggestFrontmatter()<br/>AI -> validated metadata keys"]
TE --> Matched["Matched topics<br/>-> [[internal link]] candidates"]
TE --> Unmatched["Unmatched topics<br/>-> accumulated for cross-note resolution"]
LR --> Merge["LinkResolver.mergeTopicCandidates()<br/>Topic relevance dominates"]
Matched --> Merge
MC & Merge & PB1 & PB2 --> Proposal["EnrichmentProposal<br/>-> Unified Sidebar -> User Review"]
Vault-Wide Scan (4-Phase)
| Phase | Action | Cost |
|---|---|---|
| 1. Scan | Collect eligible files, warm caches | Cheap |
| 2. Confirm | User approval before AI calls | Free (gates cost) |
| 3. Generate | Per-file enrichment, accumulate topics | Expensive (AI calls) |
| 4. Resolve | Topics with 2+ references -> new-note suggestions | Cheap |
Summarize: Content-Aware Templates
The summarize module detects content type and applies specialized templates. Supports recipe pages (via JSON-LD schema data) and receipt images (via OCR keyword scoring):
Note with URL --> content-fetcher.ts
|-- Fetch HTML
|-- Extract JSON-LD structured data (Recipe, Article, etc.)
|-- Extract plain text
|
v
summarizer.ts
|-- Detect content type from JSON-LD or heuristics
|-- Select template (recipe: ingredients + steps; default: bullets/paragraph/key-points)
|-- AI summarization with template-specific prompt
|-- Output: structured summary with amalgamated ingredients, step images, etc.
Storage Layer
All module data is stored as individual JSON files under .synapse/:
.synapse/
+-- proposals/ # Elaboration
| +-- {id}.json # Proposal with detection reasons + AI content
+-- enrichments/ # Enrichment
| +-- {id}.json # Tags, links, refs, frontmatter suggestions
+-- tidy-snapshots/ # Tidy
| +-- {path-as-filename}.json # Original content for undo (one per file)
+-- organize/
| +-- proposals/{id}.json # New-directory proposals
| +-- snapshots/{id}.json # Move snapshots for undo
| +-- summaries/{name}.md # Mermaid move diagrams
+-- deep-dive/
| +-- {id}.json # Individual note proposals
| +-- runs/{id}.json # Run metadata (stats, depth breakdown)
+-- title-proposals/ # Title
| +-- {id}.json # Title rename proposals
+-- rem/ # REM
| +-- {id}.json # Wikilink proposals (+ pre-apply body snapshot for undo)
+-- checkpoints/ # Checkpoint/resume
| +-- {id}.json # Operation state (completed + remaining items)
+-- temp/ # Temporary video/audio (auto-cleaned)
Design principles:
- One file per proposal/snapshot (no corruption cascade)
- Human-inspectable JSON (debuggable)
- Survives plugin reloads and Obsidian restarts
.synapse/excluded from all module scans by default- Legacy
.auto-notes/folder auto-migrated on first load
AI Integration Pattern
graph TB
AIC["AIClient<br/>(shared/ai-client.ts)"]
AIC -->|"'openai'"| OAI["POST api.openai.com/v1/chat/completions<br/>Auth: Bearer {ai.apiKey}"]
AIC -->|"'anthropic'"| ANT["POST api.anthropic.com/v1/messages<br/>Auth: x-api-key<br/>Models: opus/sonnet/haiku resolved to full IDs in ai-client.ts"]
AIC -->|"'gemini'"| GEM["POST generativelanguage.googleapis.com/v1beta/models/{model}:generateContent<br/>Auth: x-goog-api-key · system -> system_instruction"]
AIC -->|"'ollama'"| OLL["POST {ollamaEndpoint}/api/chat<br/>HTTPS required (HTTP localhost only)"]
AIC --> Safe["safeRequest()<br/>Obsidian requestUrl · 2min timeout<br/>Error extraction · Secret redaction (shared/redact.ts)"]
Caching & Coalescing (#397)
AIClient.chat() wraps the raw provider dispatch with idempotency support, keyed by a deterministic contentKey([messages, provider, model, temperature, maxTokens]) (inputs only):
- In-flight coalescing — a concurrent identical request joins the live dispatch promise (in-flight
Map, entry cleared in.finally()) instead of issuing a second network call. - Response cache — a per-instance, bounded LRU of request key → successful response. Participates when
temperature === 0(deterministic) or the user opts in viaai.cacheResponses; populated only on success (errors are never cached). - Bypass — a
bypassCacheoption skips both the cache read and coalescing so a "regenerate" action always re-dispatches.
Multi-Modal Vision Support
AIClient.chat() accepts ChatMessage[] where content can be string | ContentBlock[]:
ContentBlock = TextContentBlock { type: 'text', text: string }
| ImageContentBlock { type: 'image', data: base64, mediaType: string }
Provider-specific format conversion:
- OpenAI:
image_urlwithdata:URI - Anthropic:
imagesource withbase64type - Gemini:
inline_datawithmime_type+ base64data(system role routed tosystem_instruction) - Ollama: separate
imagesarray on the message
Used by: image/extractor.ts (OCR), elaboration/image-analyzer.ts (image analysis for proposals)
Audio transcription uses provider-specific APIs over Obsidian requestUrl (not AIClient, not native fetch):
- Whisper: OpenAI
/v1/audio/transcriptions— manualmultipart/form-dataviabuildMultipartBody()(sanitized headers;requestUrlhas noFormData) - Deepgram:
/v1/listen— rawArrayBufferbody - Gemini:
…:generateContent— inline base64 audio (≤ 15 MB); instruction insystem_instruction(prompt-injection hardening)
Callout Types
All AI-generated content uses Obsidian callouts from a shared registry:
| Key | Type String | Usage |
|---|---|---|
| summary | synapse-summary |
Inline URL/transcription summaries |
| transcription | synapse-transcription |
Audio/video transcriptions |
| enrichment | synapse-enrichment |
Enrichment sections |
| elaboration | synapse-elaboration |
Elaboration proposals |
| deepDive | synapse-deep-dive |
Deep dive content |
| nav | synapse-nav |
Deep dive navigation blocks |
| ocr | synapse-ocr |
Image OCR extraction results |
Settings Hierarchy
SynapseSettings
+-- settingsVersion -> Persisted schema version (#93); drives the migration runner, stamped to CURRENT_SETTINGS_VERSION on save
+-- ai -> Provider, API key, model, temperature, max tokens,
| cacheResponses (#397, opt-in; caching automatic at temperature 0)
+-- elaboration -> Detection thresholds, scan behavior, proposal storage
| +-- detection -> Word threshold, TODO markers, empty sections, exclude tags
| +-- proposal -> Max per note, preserve frontmatter, include context
+-- audio -> Transcription provider, API keys, post-processing, auto-format lyrics (#234)
| +-- postProcessing -> Filler removal, structure, key points, custom prompt
+-- video -> yt-dlp/ffmpeg paths, download folder, embed setting
| +-- frameExtraction -> (Not implemented) interval, vision model, max frames
+-- image -> Enabled, vision model override, language hint, max image size MB (auto-downscale)
+-- enrichment -> Auto-enrich, max tags/links, vocabulary, proximity weights, exclude tags
| +-- tagVocabulary -> TagVocabularyEntry[] (category, tags, description)
| +-- weights -> Same/sibling/cousin/distant folder, decay, minimum
+-- summarize -> Style (bullets/paragraph/key-points), max length, templates,
| exclude tags, auto-organize on summarize,
| include note content + combine summaries (#367, both default on)
+-- tidy -> Snapshot folder path
+-- organize -> Proposal/snapshot folder paths, confidence threshold, exclude tags
+-- deepDive -> Max depth, quality threshold, max notes, output folder,
| nesting mode, auto-enrich/organize on accept, exclude tags
+-- title -> Enabled, proposal folder path, check after operations,
| duplicateHandling (#408, 'iterate' | 'merge'; default resolution for filename collisions)
+-- rem -> Enabled, title-match weight (#380, no UI), semantic confidence
| threshold, max links per note, proposal folder path
+-- intake -> Enabled, watched folder, mark-processed, move-when-done,
| settle seconds, capture log + capture-log folder
+-- ui -> collapsedSections (settings accordion state),
| autoFoldProperties (#381, default off)
+-- autoAccept -> Per-kind booleans (elaboration, enrichment, organize,
| deep-dive, title, rem) — all default false (#228)
+-- onboarding -> hasSeenWelcome (first-run welcome gate, #89)
+-- updates -> enableUpdateNotifications, lastUpdateCheck,
| dismissedUpdateVersion (in-app update check, #365)
+-- exclusions -> ExclusionRule[] — centralized per-path exclusion (#307);
replaces all former per-module excludeFolders fields
Path exclusion is centralized (#307): the per-module excludeFolders fields were removed in favor of one exclusions list (see "Path Exclusions" above). Tag exclusion (excludeTags) stays per-module. Modules access settings via the getSettings() closure -- always reads latest values, no event subscriptions needed.
Security Layers
| Layer | Protection | Location |
|---|---|---|
| Input validation | sanitizeUrl(), sanitizePath() |
shared/validation.ts |
| Output sanitization | sanitizeAIResponse() strips scripts, event handlers, dangerous URIs |
shared/validation.ts |
| Subprocess security | execFile with argument arrays (no shell); narrowed allowlist env via shellEnv() |
video/audio-extractor.ts, transcription/duration-detector.ts, shared/node-loader.ts |
| Desktop gating | assertDesktop()/loadNodeModules() — Node builtins resolve only on desktop, behind one guarded site; keeps isDesktopOnly: false mobile-safe |
shared/node-loader.ts |
| Temp-path hardening | Vault-derived basenames sanitized before composing temp paths (2026-06-08) | transcription/duration-detector.ts |
| Multipart header hardening | Vault-/settings-derived field + file names sanitized (sanitizeMultipartHeaderValue: strip CR/LF, replace "/\) before Content-Disposition lines — blocks multipart/header injection (2026-06-11) |
audio/transcriber.ts |
| API key protection | redactSecrets() (strings) + redactError() (raw caught errors — Error .stack/.message) — single source of truth scrubbing keys from error messages/console on every error path; covers OpenAI/Anthropic sk-, key-, Deepgram dg-, Bearer/Token, anthropic-, and Gemini AIza. Password-masked inputs; keys live only in gitignored data.json |
shared/redact.ts (used by ai-client.ts, credential-validator.ts, all of notifications.ts; redactError at audio/index, rem/semantic-matcher, elaboration/image-analyzer + proposer, image/preprocess, shared/fire-and-forget, the clipboard-copy catches in notifications.ts + video/settings-section.ts, main.ts lifecycle paths, and update-checker.ts; redactSecrets also at the credential Test chip credential-field.ts and the update-checker.ts fetch-fail log) |
| Redaction enforcement (lint) | Custom type-aware ESLint rule synapse/no-unredacted-console (#418): every value reaching a console.* sink must be statically string-like — i.e. already rendered through redactError/redactSecrets, which return string. Flags String()/JSON.stringify() of non-strings and direct .message/.stack access as bypasses; fails closed if type info is unavailable; scoped to shipped src (tests/mocks excluded). Runs in CI via npm run lint |
scripts/eslint-rules/no-unredacted-console.mjs, eslint.config.mjs |
| Prompt-injection defense | wrapUntrusted() fences fetched untrusted content (article/tweet/Reddit, image analysis) in labeled delimiters + data-not-instructions frame + anti-breakout scrub — structural, not lexical (#398); Gemini audio instruction in system_instruction |
shared/untrusted-content.ts (used by elaboration/proposer.ts), audio/transcriber.ts |
| Frontmatter safety | Key validation regex + forbidden keys blocklist | enrichment/enrichment-applier.ts |
| Network security | Ollama HTTPS required (HTTP for localhost only), 2min timeouts | shared/ai-client.ts |
| Credential validation | Live key probe is one minimal authenticated GET; result is ephemeral (never persisted) and routed through redactSecrets so an echoed key can't reach the status chip |
shared/credential-validator.ts, shared/provider-metadata.ts |
| Idempotent updates | %% synapse-enrichment-start/end %% markers |
enrichment/enrichment-applier.ts |
| Prototype pollution | deepMerge skips __proto__, constructor, prototype keys |
main.ts |
| Lifecycle hygiene | NotificationManager.dispose() tears down in-flight ellipsis timers + hides notices on unload (no orphaned setInterval) |
shared/notifications.ts, main.ts:onunload() |
Audit status: The full audit (2026-06-08) found no critical or high vulnerabilities. The 2026-06-11 re-audit added two defense-in-depth hardenings — canonical secret redaction (now covering Gemini AIza keys everywhere) and multipart header-injection hardening. The 2026-06-20 audit pass re-verified architecture, security, and Obsidian-guideline compliance as clean; its one fix was a lifecycle leak (in-flight notification ellipsis timers now torn down on unload). The 2026-06-25 audit pass (v1.0.6) brought the per-operation error console.error under redactSecrets, so the single redaction source now guards every error sink in notifications.ts. The 2026-06-29 audit pass (v1.0.7) added redactError() so raw caught errors logged directly to the console get the same scrub (five direct error sinks routed through it); the idempotency bundle also landed a structural prompt-injection fence (wrapUntrusted, #398) for fetched external content. The 2026-07-02 audit pass (v1.0.10) completed the redactError rollout — the remaining direct error-log call sites (main.ts lifecycle paths, the update checker, the credential Test chip, the image-downscale fallback, and the two clipboard-copy catches) now route through the scrub — and rerouted the title module's back-compat re-export of isUntitled through the ../shared barrel instead of the internal shared/title-detector file (barrel-import rule; the canonical home has been shared/ since #387). The 2026-07-03 audit pass (v1.0.11) re-verified architecture and security as clean and made the redaction contract regression-proof: the custom synapse/no-unredacted-console lint rule (#418) now fails CI on any console sink that isn't statically string-like. Separately, the v1.0.11 store automated-review findings were triaged into a reviewer-facing docs/automated-review-notes.md (#454) — its one code fix replaced summarize's deprecated querySelectorAll usage with Obsidian's typed containerEl.findAll(). data.json (live API keys) is gitignored and never committed.
Known posture / not-yet-enforced:
ensureWithinVault()exists inshared/validation.tsbut is not yet wired into write paths — there is no active vault-boundary enforcement on writes today.sanitizeUrlpermits arbitrary hosts (an SSRF surface on user-supplied URLs). This is an accepted risk: URLs are author-supplied within the user's own vault.
Getting Started for Contributors
- Clone into Obsidian vault's plugin directory
npm installthennpm run dev(watch mode)- Module pattern: each feature in
src/<module>/withindex.tsexporting the module class - Follow the FeatureModule contract:
constructor(plugin, getSettings, notifications, …),onload(),onunload().main.tsinjects the rest — the sharedCheckpointManager(for resumable scans), theCommandRegistrar, and a() => settings.autoAccept[kind]getter where applicable — never reach for globals. - Types go in
<module>/types.ts, tests co-located as<name>.test.ts - All shared utilities imported from
../shared(barrel export) — never from a sibling feature module or an internalshared/file. Where you only need a type from a higher layer, useimport type(erased at compile time, no runtime cycle). - Need a Node builtin (
fs/path/os/child_process)? Go throughloadNodeModules()/assertDesktop()only — neverrequireat module top level (keeps the bundle mobile-loadable). - Gate vault paths with
isPathExcluded(path, featureId, settings)and tags with the sharedmatchesExcludeTaghelper. - Build check:
npm run build(type-checks + bundles); tests:npm test; lint:npm run lint(Obsidian store-review rules + the redaction console-sink gate, #418) - Git: create a feature branch, push, open PR. See
.claude/skills/git-workflow/SKILL.mdfor full protocol.