No description
  • TypeScript 96.7%
  • JavaScript 3%
  • CSS 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-30 12:31:25 +00:00
.forgejo/workflows build: In the obsidian-connor plugin, add Knap as an npm dependency and make… 2026-09-15 08:16:58 -05:00
src build: Fix the plugin build break that has failed every publish since… 2026-09-30 07:30:07 -05:00
test Merge branch 'main' into build/ed8ce5b4 2026-09-29 16:06:47 +00:00
.gitignore test: a gate this repo can fail — node:test, src/lib, and CI 2026-09-04 00:19:06 -05:00
buildstamp.mjs build: In the obsidian-connor repo, make the build reproducible and verifiable… 2026-09-14 08:37:28 -05:00
esbuild.config.mjs build: In the obsidian-connor repo, make the build reproducible and verifiable… 2026-09-14 08:37:28 -05:00
esbuild.test.mjs build: In the obsidian-connor plugin, add Knap as an npm dependency and make… 2026-09-15 08:16:58 -05:00
manifest.json build: Bump the plugin version from 0.4.0 to 0.4.1 in every file step 1 named… 2026-09-29 16:34:10 -05:00
package-lock.json build: Bump the plugin version from 0.4.0 to 0.4.1 in every file step 1 named… 2026-09-29 16:34:10 -05:00
package.json build: Bump the plugin version from 0.4.0 to 0.4.1 in every file step 1 named… 2026-09-29 16:34:10 -05:00
README.md build: In the obsidian-connor plugin, add Knap as an npm dependency and make… 2026-09-15 08:16:58 -05:00
styles.css feat: a partner in the vault — context, navigation, folders, canvases, bases 2026-09-03 23:44:04 -05:00
tsconfig.json test: a gate this repo can fail — node:test, src/lib, and CI 2026-09-04 00:19:06 -05:00
verify-build.mjs build: In the obsidian-connor repo, make the build reproducible and verifiable… 2026-09-14 08:37:28 -05:00
versions.json build: Bump the plugin version from 0.4.0 to 0.4.1 in every file step 1 named… 2026-09-29 16:34:10 -05:00

Connor for Obsidian

Connects this vault to Connor, both directions:

  • Vault → Connor: notes you choose (right-click a folder → Enable Connor / Disable Connor, inherited by subfolders unless they carry their own toggle; the folder allowlist and connor: true frontmatter still work where no toggle governs) are mirrored into Connor's memory as this vault's own knowledge-base (obsidian/<vault-name> — grantable per user in his admin panel, egress-controlled, wipeable). Changes travel as a change feed: ordered create/modify/delete/rename events, numbered per vault, batched into POST /vault/feed. Edits re-mirror, deletes un-mirror, and a rename moves the mirror instead of re-embedding the note. The service remembers the last number it applied, so a batch replayed after a lost reply is skipped, and a jump in the numbering (events lost, say to a restart mid-batch) comes back as a gap that the plugin answers with a full resync. The vault stays the source of truth.
  • Connor → Vault: the plugin dials out to Connor's edge and holds a link open (GET /vault/link, a server-sent-events stream); each of his tool calls — read/write/modify/list/search over the open vault — comes down that line and is answered by a short POST /vault/link/reply. The link redials itself with backoff whenever it drops, and the status bar says linked: <vault> while it is up. Where he may write is a folder rule set you control: right-click a folder → Let Connor write here / Stop Connor writing here (inherits down, overridable per subfolder; the rules live on the service, whose gate runs before anything reaches the plugin, and the plugin refuses the same paths on its own lane). With no rule, only the vault's Connor/ folder is open. A write outside an open folder is refused, never relocated. His notes are stamped with connor: authored frontmatter. What he asks for while Obsidian is closed goes to a command queue on the service — he is told "queued", never "written" — and it applies, oldest first, the moment the link next opens; the panel's queue button (or the command Show Connor's queued commands) lists it and cancels anything not yet landed. (A loopback MCP server on 127.0.0.1:27125 still runs for a Connor service on the same machine; off-box it is unreachable by design and the link is the line.)
  • What he can DO in the vault (v0.4.0, the partner-in-crime pass): read a note, list, search, and see a note's neighbourhood (outgoing links, backlinks, tags); write a whole note, replace text in place, append under a heading, replace one section's body, set a single frontmatter property, add to today's daily note, or make a note from one of your templates; create/move/rename folders and notes through Obsidian's own file manager (so wikilinks follow), see the folder tree, remove an empty folder, move a note to the recoverable trash, and build a whole folder structure from an indented outline in one pass; read and edit canvases (add a text or note card, connect two cards) and bases (explain one in plain words, create one from a short description, list the notes it matches); and attach an actual file — a PDF, an image, an archive — written with Obsidian's binary writer rather than the note lane, refusing to overwrite anything you already have unless told to. Everything that changes a file is gated by the same folder rules, and everything Connor then says about it is built from this plugin's actual reply — the path it wrote, the byte count, the new modification time — never from what he asked for.
  • Not clobbering you: every read→transform→write act (section edits, canvas cards, base definitions, properties) sends back the file's modification time and the write carries it as if_mtime. If you touched the note while Connor was thinking, the write is refused and he says so. Nothing is silently overwritten.
  • Where you are (context): as you move around, the plugin tells Connor which note is open and what line the cursor is on, so he can answer about "this note" without asking. It sends the path, never the note's text — the single exception is a passage you have selected in a note that already syncs to him, whose words are in his memory anyway. One toggle in settings turns the whole thing off. He can also ask on the spot (the get_context tool).
  • Where Connor is (one click, both directions): every vault path he names in chat becomes an obsidian://open?vault=…&file=… link, and every act he performs reports the link to the note it touched. The panel's where is connor? button (and the command Where is Connor?) lists the notes he has actually changed, newest first, click to open; Reveal Connor's last note jumps straight to the most recent. He can also open a note in your Obsidian himself, at a heading or a line (the reveal tool).
  • Specific powers, one at a time: the command tool runs an Obsidian command by id — but only ids on the allowlist in the plugin's settings, which ships empty. Nothing Connor says can add to it. That is how you hand him "toggle reading view" or one Templater template without opening the app.
  • The panel: Connor's real chat page in a right-side pane (a <webview> of the live UI) under a toolbar that follows your session — knock and sign in while signed out, sign out, connect to connor, queue and where is connor? once in, sync always. Under the pane, a status strip: link state, last sync, last context sent, and the last error — so a failure is something you can see rather than something in the developer console.

Security model

No credentials live in this plugin. Every outbound call is a cookie-credentialed request to the same edge a browser uses: the knock cookie, then your passkey sign-in (GET /auth/login?next=/ in the panel, GET /auth/me to check). Older services are still supported: if /auth/me isn't there, the plugin falls back to the previous session probe, so plugin and service can be upgraded on their own schedules. Writes are principal-keyed server-side; you can only touch vaults you own. The knock URL is stored in per-device localStorage, never in data.json (which syncs with the vault). The inbound MCP server binds loopback only, its URL carries a random path token, browser-origin requests are refused, and its write/modify tools honour the same folder rules the service enforces (Connor/ only, until you open more). Network access: one configured outbound host plus the loopback listener; filesystem ✓ vault-only via the Obsidian API; clipboard ✗.

Notes, not files

A vault holds more than notes: PDFs you dropped in, screenshots, audio, .obsidian/data.json. Three rules keep Connor to the notes, and all three live in one place (isTextPath / textRefusal, used by every lane rather than copied into each):

  • No hidden paths. No path segment may begin with a dot, so .obsidian (this plugin's own data.json, service token included), .git and .trash are unreachable through any tool — read, write or otherwise.
  • Text only, on the way out. read and list hand over .md, .canvas, .base and .txt. A PDF is refused rather than streamed through a text channel as bytes.
  • Text only, on the way in. write and modify refuse the same set. A write aimed at a .png in a folder you have opened to Connor is refused outright and the file is left byte-identical — it would otherwise have prepended markdown frontmatter and replaced the image with text.
  • Files come in by their own door. attach is the only way to write a non-note: it takes the file's raw bytes (base64 on the wire) and writes them with Obsidian's binary writer, so nothing is stamped or re-encoded. It is walled by the same folder rules as write, it refuses a note path (a note written this way would skip the connor: authored stamp and the if_mtime check), and it will not replace an existing file unless the call says overwrite: true. That is how a PDF gets into the vault without the note lane ever being the route.

read_meta is the exception that makes him useful rather than merely safe: asked about diagram.png it answers with the path, size, mtime and extension it legitimately has, plus text: false and content_withheld: true, so Connor can say "that's a PNG, not a note — 1.2 MB, last changed Tuesday" instead of either dumping the bytes or pretending the file isn't there. For a note, the same call carries text: true, content_withheld: false and the text.

Many notes from one template

render is the way a hundred notes of the same shape get filed without a hundred separate calls. It takes a template — Knap, kepano's MIT templating language, the engine behind Obsidian Web Clipper and Importer — plus data, and an output path:

argument what it is
template / template_path the template text, or a note in this vault holding it. One or the other, never both
data a JSON object for one note, or an array of objects for many
output the path to write, or a path pattern with template variables in it
dry_run render and hand back the result without writing anything

Each element of a data array renders to its own note, so the output pattern has to tell them apart — Connor/People/{{name}}.md. Two fields are always available for rows that carry no unique value of their own: _index (1-based) and _count. A row's own fields always win, so nothing here silently replaces caller data. Folders in the rendered path are created as needed. The reply carries paths (every note actually written) and notes (one outcome per element: path, status, bytes, mtime, or the error).

Nothing about the writing is new, and that is the point:

  • One write lane. Every rendered note goes through the same writeNote that write uses, so the folder rules, the text gate, the connor: authored stamp and the byte-count-and-mtime confirmation all apply unchanged. There is no second way into the vault.
  • The whole batch is planned before any of it is written. Every render, every path check and every folder check happens with nothing on disk. Bad data, a pattern that escapes the folders you opened, a template that fails to render, or two rows landing on the same file are all refused with the vault untouched — a hundred-row payload with a bad row 84 does not leave 83 notes behind.
  • A partial batch is a failure, and says so. If a write fails partway the batch stops, and the refusal names which notes exist, which one failed and why, and which were never attempted. It is never reported as a success.
  • dry_run writes nothing at all, and still applies the folder wall — a dry run that ignored it would promise a write that committing would refuse. The first three rendered bodies come back in full; the rest come back as a path and a byte count, on read_meta's content_withheld vocabulary.

Knap is pinned to an exact version (0.6.0) because it is a fast-moving 0.x package whose filters have changed behaviour between minors, and it is bundled into main.js — nothing extra is installed in the vault. Its own filters are not registered by default; the engine here loads its standard set, and regex filters are switched off — templates arrive over the wire, and a template language with regex in it is not something to hand an untrusted string without an isolate to run it in.

Install (private plugin — not in the community directory)

cd <vault>/.obsidian/plugins
git clone https://git.zeinestone.me/ZSDev/obsidian-connor connor
cd connor && npm install && npm run build

Enable Connor in Settings → Community plugins, then in the plugin settings: set the vault name (e.g. ttrpg), right-click the folders Connor should see, and run the command "Set knock URL" once per device. Open the panel — its toolbar follows your session — hit knock, then sign in (the login happens inside the pane). The link opens on its own once the session exists (run Sync now to register the vault and open it immediately) — nothing to paste anywhere. connect to connor is only for a Connor service on the same machine: it registers the loopback MCP address (the read-only field in settings is the manual fallback).

Updating

After the first install, the plugin updates itself from Connor. On load and on the settings row's check for updates button it asks the service which version it is serving (GET /vault/plugin); when that is newer than this manifest.json, the row reads "Update available: 0.4.0 → 0.5.0" and update to 0.5.0 does the rest: every artifact is downloaded, each is checked against the sha256 the service served, all of them are staged under .connor-update/staged/, the running version is copied to .connor-update/rollback/, and only then are the files swapped in and the plugin disabled + re-enabled in place. No Obsidian restart, and no git pull.

Atomic or nothing: a failed download, a hash that does not match, or a write that fails mid-swap leaves the version you are running exactly as it was, and says why in that same settings row. If a new version installs cleanly and then misbehaves, the previous files are still in <vault>/.obsidian/plugins/connor/.connor-update/rollback/ — copy them back over the plugin directory and toggle the plugin off and on.

First-run checklist (the empirically-untestable trio)

  1. Panel loads the page after knock + login (SameSite=None + iframe).
  2. A note in an enabled folder appears in Connor's recall after edit.
  3. With the status bar reading linked: <vault>, ask Connor to vaultnote something; it lands under Connor/ immediately (down the held link — no poll interval, no address to configure), and his confirmation carries a clickable obsidian:// link to the file he wrote.
  4. Open a note, then ask Connor "what am I looking at?" — he should name it without you telling him.
  5. Ask him to add a card to a canvas under Connor/, then open it; the card is there, placed to the right of anything already on the board.

Developing

npm ci          # the toolchain, plus knap — which is bundled into main.js, not shipped beside it
npm run gate    # check + build + test. This is what CI runs, and what "green" means.

gate is three things, and each of them has failed on its own before:

script what it runs what it catches
npm run check tsc -noEmit -skipLibCheck type and compile errors, in src/ and test/
npm run build check, then the esbuild bundle anything that only breaks when actually bundled
npm run verify:build deletes main.js, runs npm run build, checks what came out the build producing no artefact — or a wrong one
npm test bundles test/*.test.ts, then node --test the logic being wrong while it still compiles

gate runs verify:build in place of a bare build — it is the build, with assertions after it, so nothing is built twice. Checked: main.js, manifest.json and styles.css all exist and are non-empty, and the version main.js reports matches manifest.json. That last one is not circular: esbuild.config.mjs stamps the bundle /*! obsidian-connor <version> */ from package.json, and verify-build.mjs compares that stamp with manifest.json (and both with versions.json), so the two files drifting apart is a red gate rather than a plugin that reports one version and runs another. main.js stays gitignored; CI additionally asserts the checkout arrives without one, so the artefact it checks can only have come from the build it just ran.

Run npm run gate before pushing. .forgejo/workflows/gate.yml runs exactly the same command on every PR and publishes the status check gate / gate (pull_request), which branch protection requires. A release once went out "green" with a TypeScript error in it because only the Python suite had run; the gate is the answer to that.

How the tests are laid out

  • src/lib/*.ts — the pure logic, one module per decision: the link's SSE framing (link.ts), the change-feed seq and batching arithmetic (feed.ts), the folder sync flags and write permits (permits.ts), the .base filter evaluator (base.ts), the write/modify refusals (edits.ts), the path denylist and the text-extension set (paths.ts), the five gated vault lanes — read/list/read_meta/write/modify, over a path-addressed VaultPort (vaultio.ts), the render batch's planning, walls and ledger (render.ts) and the one file that imports Knap (knap.ts), the folder tree (tree.ts), the two-probe sign-in check (session.ts), the MCP framing (mcp.ts), the knock URL check (knock.ts) and the small formatters (format.ts). None of them touch app, the network or Obsidian's runtime.
  • src/main.ts — everything that genuinely needs the vault: the tool handlers, the transports, the views. It imports from src/lib rather than repeating the logic, so what the tests pin is what runs.
  • test/*.test.ts — one file per lib module, using Node's built-in node:test and node:assert. No test framework is installed: the runner is the Node binary, which is why npm ci adds nothing for tests.
  • test/.build/ — generated, gitignored. esbuild.test.mjs bundles each test file to ESM there (Node cannot run TypeScript, and ts-node/tsx would be a dependency this repo does not want). npm test runs pretest automatically, so you never invoke it yourself.

The obsidian stub

import { Plugin } from "obsidian" resolves to nothing runnable outside Electron — the real module is supplied by Obsidian at runtime and exists on disk only as a .d.ts. So esbuild.test.mjs aliases that import to test/stubs/obsidian.ts, a deliberately thin stand-in: TFile, TFolder, Plugin, Notice, Modal and friends as empty shapes, plus the one piece of real behaviour the lib code leans on — normalizePath, implemented as Obsidian implements it, because a write rule for Connor/ has to govern Connor/x.md.

Keep src/lib free of anything but normalizePath. The moment a lib module needs a live app, it belongs in main.ts instead — and the thing worth testing should be split back out of it as an argument.

Adding a test

Write test/thing.test.ts, import from ../src/lib/thing, and run npm test. The bundler picks up any test/*.test.ts automatically.

Building for use in a vault

npm ci && npm run build     # writes main.js, which the vault loads

main.js is a build artefact and is gitignored — build it in place in <vault>/.obsidian/plugins/connor after pulling. The service-side halves of this logic have their own suite in the Connor repo (tests/test_vault_partner.py).