From 2b00fa44d52c1d8fce653fa16b0a34ab24d5bfe3 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 21 May 2026 10:23:01 +0200 Subject: [PATCH] feat: init --- .gitignore | 16 ++ Makefile | 25 ++ README.md | 56 +++++ esbuild.config.mjs | 40 +++ manifest.json | 9 + package-lock.json | 602 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++ src/agentPicker.ts | 42 ++++ src/api.ts | 91 +++++++ src/main.ts | 194 +++++++++++++++ src/settings.ts | 75 ++++++ tsconfig.json | 18 ++ versions.json | 3 + 13 files changed, 1189 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/agentPicker.ts create mode 100644 src/api.ts create mode 100644 src/main.ts create mode 100644 src/settings.ts create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07608b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# deps +node_modules/ + +# build output (regenerated by `make install` / `make zip`) +main.js +*.zip + +# plugin runtime — Obsidian writes settings here when the symlinked dir +# is used as the live plugin; never want it in the repo +data.json + +# misc +*.log +.DS_Store +.vscode/ +.idea/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..28c0e4e --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PLUGIN_ID := beaver +ASSETS := manifest.json main.js versions.json +PM := $(shell command -v pnpm 2>/dev/null || command -v npm 2>/dev/null) + +.PHONY: install zip _build + +_build: + @test -n "$(PM)" || (echo "no pnpm or npm in PATH" >&2; exit 1) + $(PM) install --silent + $(PM) run build + +install: _build + @test -n "$(VAULT)" || (echo "set VAULT=" >&2; exit 1) + @test -d "$(VAULT)/.obsidian" || (echo "no .obsidian dir at $(VAULT)" >&2; exit 1) + @target="$(VAULT)/.obsidian/plugins/$(PLUGIN_ID)"; \ + mkdir -p "$$target"; \ + cp $(ASSETS) "$$target/"; \ + echo "installed → $$target" + +zip: _build + @version=$$(node -p "require('./manifest.json').version"); \ + out="beaver-plugin-$$version.zip"; \ + rm -f "$$out"; \ + zip -q -j "$$out" $(ASSETS); \ + echo "wrote $$out" diff --git a/README.md b/README.md new file mode 100644 index 0000000..73f5d0f --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Beaver — Obsidian plugin + +[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) + +Sends the current note to Beaver's `markdown frontend` (`POST /chat` on `beaver-gateway`) and writes the agent's reply back into the file. Skips the Obsidian Sync round-trip by POSTing the buffer contents directly. + +## Commands + +Both commands appear in the command palette only when the active note's YAML frontmatter has an `agent:` field: + +- **Beaver: Send using selected agent** — uses `frontmatter.agent`. +- **Beaver: Send using different agent** — fetches the agent list from the gateway and opens a fuzzy picker. + +## Settings + +- **Base URL** — markdown-frontend root, e.g. `http://localhost:62993`. +- **Bearer token** — a token with the `messages` scope (mint via the admin frontend or `BOOTSTRAP_TOKENS`). +- **Test connection** — calls `GET /agents`. + +## Note format + +A minimal note that the plugin can send: + +```markdown +--- +agent: beaver-opus-medium +--- + +hi there +``` + +The gateway parses `### User:` / `### Assistant:` markers; a note with no markers is treated as a single user turn. After a successful send, the gateway returns the new file content (including an `### Assistant:` block and a fresh `### User:` scaffold), and the plugin writes it back to the buffer. + +## Install + +``` +make install VAULT=~/Obsidian/my-vault # build + copy manifest.json + main.js into /.obsidian/plugins/beaver +make zip # build → beaver-plugin-.zip you can carry to any vault +``` + +Then enable **Beaver** in Settings → Community plugins. + +### Mobile + +The plugin is desktop-and-mobile (`isDesktopOnly: false`). Two things to get right on a phone: + +- The base URL in plugin settings has to be reachable from the phone — `http://localhost:62993` won't work; use the gateway's LAN IP, a tunnel, or a public hostname. +- Get the plugin files onto the phone vault via Obsidian Sync (toggle "Installed community plugins" in your sync settings) or any file-sync tool you already use (Working Copy, Syncthing, etc.). `make install` only knows how to write to a local path. + +### Hand-rolled + +Three files are all Obsidian needs — `manifest.json`, `main.js`, `versions.json`. Drop them in `/.obsidian/plugins/beaver/` however you like (unzip, `scp`, `rsync`, your own git, …). + +## Bumping the version + +Edit the version field in `manifest.json`, `package.json`, and `versions.json` by hand. Three files, one change each. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..d28cdcc --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,40 @@ +import esbuild from "esbuild"; +import process from "node:process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const ctx = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2022", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await ctx.rebuild(); + await ctx.dispose(); +} else { + await ctx.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3872e5e --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "beaver", + "name": "Beaver", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Send the current note to a Beaver agent via the markdown frontend.", + "author": "Beaver", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2c04ba --- /dev/null +++ b/package-lock.json @@ -0,0 +1,602 @@ +{ + "name": "beaver-plugin-obsidian", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "beaver-plugin-obsidian", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.21.5", + "obsidian": "^1.5.7", + "typescript": "^5.4.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3fcfdb --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "beaver-plugin-obsidian", + "version": "0.1.0", + "description": "Obsidian plugin for Beaver's markdown frontend.", + "main": "main.js", + "private": true, + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.21.5", + "obsidian": "^1.5.7", + "typescript": "^5.4.5" + } +} diff --git a/src/agentPicker.ts b/src/agentPicker.ts new file mode 100644 index 0000000..074cae6 --- /dev/null +++ b/src/agentPicker.ts @@ -0,0 +1,42 @@ +import { App, FuzzySuggestModal } from "obsidian"; + +export class AgentPickerModal extends FuzzySuggestModal { + private agents: string[]; + private resolve: (agent: string | null) => void; + private resolved = false; + + constructor( + app: App, + agents: string[], + resolve: (agent: string | null) => void, + ) { + super(app); + this.agents = agents; + this.resolve = resolve; + this.setPlaceholder("Pick a Beaver agent…"); + } + + getItems(): string[] { + return this.agents; + } + + getItemText(item: string): string { + return item; + } + + onChooseItem(item: string): void { + this.resolved = true; + this.resolve(item); + } + + onClose(): void { + super.onClose(); + if (!this.resolved) this.resolve(null); + } +} + +export function pickAgent(app: App, agents: string[]): Promise { + return new Promise((resolve) => { + new AgentPickerModal(app, agents, resolve).open(); + }); +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..55a1cea --- /dev/null +++ b/src/api.ts @@ -0,0 +1,91 @@ +import { requestUrl, RequestUrlParam } from "obsidian"; +import type { BeaverSettings } from "./settings"; + +export interface ChatRequest { + filename: string; + content: string; + agent?: string; +} + +export interface ChatResponse { + status: "ok" | "nothing_to_do" | "in_progress"; + reason?: string; + agent?: string; + turns_appended?: number; + new_content?: string; +} + +export class BeaverApiError extends Error { + status: number; + body: unknown; + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +function baseUrl(settings: BeaverSettings): string { + const url = settings.baseUrl.trim().replace(/\/+$/, ""); + if (!url) throw new Error("Beaver: base URL is not configured"); + return url; +} + +function authHeader(settings: BeaverSettings): Record { + const token = settings.token.trim(); + if (!token) throw new Error("Beaver: bearer token is not configured"); + return { Authorization: `Bearer ${token}` }; +} + +async function call( + settings: BeaverSettings, + init: Omit & { path: string }, +): Promise { + const { path, ...rest } = init; + const url = `${baseUrl(settings)}${path}`; + const headers = { + ...authHeader(settings), + ...(rest.headers ?? {}), + }; + // throw=false → we handle non-2xx ourselves so we can extract FastAPI's + // {detail: "..."} body and surface a useful message. + const res = await requestUrl({ url, throw: false, ...rest, headers }); + if (res.status < 200 || res.status >= 300) { + let detail: unknown; + try { + detail = res.json; + } catch { + detail = res.text; + } + const detailMsg = + detail && typeof detail === "object" && "detail" in detail + ? String((detail as { detail: unknown }).detail) + : typeof detail === "string" + ? detail + : `HTTP ${res.status}`; + throw new BeaverApiError(res.status, detailMsg, detail); + } + return res.json; +} + +export async function listAgents(settings: BeaverSettings): Promise { + const body = (await call(settings, { path: "/agents", method: "GET" })) as { + agents?: Array<{ name?: unknown }>; + }; + const list = body.agents ?? []; + return list + .map((a) => (typeof a?.name === "string" ? a.name : null)) + .filter((n): n is string => !!n); +} + +export async function sendChat( + settings: BeaverSettings, + req: ChatRequest, +): Promise { + return (await call(settings, { + path: "/chat", + method: "POST", + contentType: "application/json", + body: JSON.stringify(req), + })) as ChatResponse; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..baebf9e --- /dev/null +++ b/src/main.ts @@ -0,0 +1,194 @@ +import { + Editor, + MarkdownView, + Notice, + Plugin, + TFile, +} from "obsidian"; +import { + BeaverApiError, + ChatResponse, + listAgents, + sendChat, +} from "./api"; +import { pickAgent } from "./agentPicker"; +import { + BeaverSettings, + BeaverSettingsTab, + DEFAULT_SETTINGS, +} from "./settings"; + +const AGENT_CACHE_TTL_MS = 5 * 60 * 1000; + +export default class BeaverPlugin extends Plugin { + settings: BeaverSettings = { ...DEFAULT_SETTINGS }; + + private cachedAgents: string[] | null = null; + private cachedAt = 0; + private lastListFailed = false; + + async onload(): Promise { + await this.loadSettings(); + this.addSettingTab(new BeaverSettingsTab(this.app, this)); + + this.addCommand({ + id: "send-selected", + name: "Send using selected agent", + checkCallback: (checking) => { + const ctx = this.getFrontmatterAgentContext(); + if (!ctx) return false; + if (!checking) void this.dispatch(ctx.file, ctx.agent); + return true; + }, + }); + + this.addCommand({ + id: "send-different", + name: "Send using different agent", + checkCallback: (checking) => { + const ctx = this.getFrontmatterAgentContext(); + if (!ctx) return false; + if (!checking) void this.runDifferent(ctx.file); + return true; + }, + }); + } + + async loadSettings(): Promise { + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData(), + ); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + cacheAgents(agents: string[]): void { + this.cachedAgents = agents; + this.cachedAt = Date.now(); + this.lastListFailed = false; + } + + private getFrontmatterAgentContext(): + | { file: TFile; agent: string } + | null { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") return null; + const cache = this.app.metadataCache.getFileCache(file); + const raw = cache?.frontmatter?.agent; + if (typeof raw !== "string" || !raw.trim()) return null; + return { file, agent: raw.trim() }; + } + + private async runDifferent(file: TFile): Promise { + const agents = await this.getAgents(); + if (!agents) return; + if (agents.length === 0) { + new Notice("Beaver: no agents available"); + return; + } + const picked = await pickAgent(this.app, agents); + if (!picked) return; + await this.dispatch(file, picked); + } + + private async getAgents(): Promise { + const fresh = + this.cachedAgents && + !this.lastListFailed && + Date.now() - this.cachedAt < AGENT_CACHE_TTL_MS; + if (fresh) return this.cachedAgents; + try { + const agents = await listAgents(this.settings); + this.cacheAgents(agents); + return agents; + } catch (err) { + this.lastListFailed = true; + this.notifyError("listing agents", err); + return null; + } + } + + private async dispatch(file: TFile, agent: string): Promise { + const { editor, view } = this.findEditorFor(file); + const content = editor + ? editor.getValue() + : await this.app.vault.read(file); + + const notice = new Notice(`Beaver: sending to ${agent}…`, 0); + let response: ChatResponse; + try { + response = await sendChat(this.settings, { + filename: file.path, + content, + agent, + }); + } catch (err) { + notice.hide(); + if (err instanceof BeaverApiError && err.status === 409) { + new Notice("Beaver: already running for this file", 6000); + return; + } + this.notifyError(`sending to ${agent}`, err); + return; + } finally { + notice.hide(); + } + + if (response.status === "nothing_to_do") { + new Notice(`Beaver: nothing to do (${response.reason ?? "no reason"})`); + return; + } + + if (typeof response.new_content === "string") { + await this.writeBack(file, editor, view, response.new_content); + } + new Notice(`Beaver: ${agent} replied`); + } + + private findEditorFor(file: TFile): { + editor: Editor | null; + view: MarkdownView | null; + } { + // Walk open markdown views — we want the editor instance that owns + // ``file`` so we can use setValue (keeps the buffer's edit history + // intact) instead of falling back to vault.modify. + const leaves = this.app.workspace.getLeavesOfType("markdown"); + for (const leaf of leaves) { + const view = leaf.view as MarkdownView; + if (view?.file?.path === file.path) { + return { editor: view.editor, view }; + } + } + return { editor: null, view: null }; + } + + private async writeBack( + file: TFile, + editor: Editor | null, + _view: MarkdownView | null, + newContent: string, + ): Promise { + if (editor && editor.getValue() !== newContent) { + editor.setValue(newContent); + return; + } + if (!editor) { + await this.app.vault.modify(file, newContent); + } + } + + private notifyError(action: string, err: unknown): void { + const msg = + err instanceof BeaverApiError + ? `${err.status}: ${err.message}` + : err instanceof Error + ? err.message + : String(err); + new Notice(`Beaver (${action}): ${msg}`, 8000); + console.error("Beaver:", action, err); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..a2dc82a --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,75 @@ +import { App, Notice, PluginSettingTab, Setting } from "obsidian"; +import { listAgents, BeaverApiError } from "./api"; +import type BeaverPlugin from "./main"; + +export interface BeaverSettings { + baseUrl: string; + token: string; +} + +export const DEFAULT_SETTINGS: BeaverSettings = { + baseUrl: "http://localhost:62993", + token: "", +}; + +export class BeaverSettingsTab extends PluginSettingTab { + plugin: BeaverPlugin; + + constructor(app: App, plugin: BeaverPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName("Base URL") + .setDesc("Markdown frontend root, e.g. http://localhost:62993") + .addText((text) => + text + .setPlaceholder("http://localhost:62993") + .setValue(this.plugin.settings.baseUrl) + .onChange(async (value) => { + this.plugin.settings.baseUrl = value.trim().replace(/\/+$/, ""); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Bearer token") + .setDesc("Token with the `messages` scope.") + .addText((text) => { + text.inputEl.type = "password"; + text + .setPlaceholder("paste token") + .setValue(this.plugin.settings.token) + .onChange(async (value) => { + this.plugin.settings.token = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Test connection") + .setDesc("Calls GET /agents and reports the count.") + .addButton((btn) => + btn.setButtonText("Test").onClick(async () => { + try { + const agents = await listAgents(this.plugin.settings); + this.plugin.cacheAgents(agents); + new Notice(`Beaver: found ${agents.length} agents`); + } catch (err) { + const msg = + err instanceof BeaverApiError + ? `${err.status}: ${err.message}` + : err instanceof Error + ? err.message + : String(err); + new Notice(`Beaver: ${msg}`, 8000); + } + }), + ); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..386c676 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2022", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "esModuleInterop": true, + "lib": ["DOM", "ES2022"] + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}