- TypeScript 96.7%
- JavaScript 3%
- CSS 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| src | ||
| test | ||
| .gitignore | ||
| buildstamp.mjs | ||
| esbuild.config.mjs | ||
| esbuild.test.mjs | ||
| manifest.json | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| styles.css | ||
| tsconfig.json | ||
| verify-build.mjs | ||
| versions.json | ||
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: truefrontmatter 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 intoPOST /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 shortPOST /vault/link/reply. The link redials itself with backoff whenever it drops, and the status bar sayslinked: <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'sConnor/folder is open. A write outside an open folder is refused, never relocated. His notes are stamped withconnor: authoredfrontmatter. 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 on127.0.0.1:27125still 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 (theget_contexttool). - 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 (therevealtool). - Specific powers, one at a time: the
commandtool 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 owndata.json, service token included),.gitand.trashare unreachable through any tool — read, write or otherwise. - Text only, on the way out.
readandlisthand over.md,.canvas,.baseand.txt. A PDF is refused rather than streamed through a text channel as bytes. - Text only, on the way in.
writeandmodifyrefuse the same set. A write aimed at a.pngin 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.
attachis 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 aswrite, it refuses a note path (a note written this way would skip theconnor: authoredstamp and theif_mtimecheck), and it will not replace an existing file unless the call saysoverwrite: 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
writeNotethatwriteuses, so the folder rules, the text gate, theconnor: authoredstamp 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_runwrites 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, onread_meta'scontent_withheldvocabulary.
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)
- Panel loads the page after knock + login (SameSite=None + iframe).
- A note in an enabled folder appears in Connor's recall after edit.
- With the status bar reading
linked: <vault>, ask Connor tovaultnotesomething; it lands underConnor/immediately (down the held link — no poll interval, no address to configure), and his confirmation carries a clickableobsidian://link to the file he wrote. - Open a note, then ask Connor "what am I looking at?" — he should name it without you telling him.
- 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.basefilter 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-addressedVaultPort(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 touchapp, the network or Obsidian's runtime.src/main.ts— everything that genuinely needs the vault: the tool handlers, the transports, the views. It imports fromsrc/librather than repeating the logic, so what the tests pin is what runs.test/*.test.ts— one file per lib module, using Node's built-innode:testandnode:assert. No test framework is installed: the runner is the Node binary, which is whynpm ciadds nothing for tests.test/.build/— generated, gitignored.esbuild.test.mjsbundles each test file to ESM there (Node cannot run TypeScript, and ts-node/tsx would be a dependency this repo does not want).npm testrunspretestautomatically, 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).