feat: init

This commit is contained in:
h
2026-05-21 10:23:01 +02:00
commit 2b00fa44d5
13 changed files with 1189 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { App, FuzzySuggestModal } from "obsidian";
export class AgentPickerModal extends FuzzySuggestModal<string> {
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<string | null> {
return new Promise((resolve) => {
new AgentPickerModal(app, agents, resolve).open();
});
}
+91
View File
@@ -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<string, string> {
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<RequestUrlParam, "url"> & { path: string },
): Promise<unknown> {
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<string[]> {
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<ChatResponse> {
return (await call(settings, {
path: "/chat",
method: "POST",
contentType: "application/json",
body: JSON.stringify(req),
})) as ChatResponse;
}
+194
View File
@@ -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<void> {
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<void> {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
}
async saveSettings(): Promise<void> {
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<void> {
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<string[] | null> {
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<void> {
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<void> {
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);
}
}
+75
View File
@@ -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);
}
}),
);
}
}