chore: add eslint-plugin-obsidianmd, fix lint errors, bump to v0.3.2

Migrate to ESLint v9 flat config, integrate obsidianmd plugin, fix all
required errors (prefer-active-window-timers, no-tfile-tfolder-cast,
no-unsupported-api, floating promises, etc.), and bump minAppVersion to
1.7.2 to match revealLeaf API requirement.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
Bin-Home 2026-04-29 22:07:41 +08:00
parent 93276a7502
commit 33d20f6e28
21 changed files with 3664 additions and 630 deletions

51
eslint.config.mjs Normal file
View file

@ -0,0 +1,51 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import svelte from "eslint-plugin-svelte";
import obsidianmd from "eslint-plugin-obsidianmd";
export default tseslint.config(
{
ignores: ["node_modules/**", "main.js", "styles.css", "esbuild.config.mjs", "tests/**"],
},
// Enable typed linting for all TS and Svelte files
{
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: [".svelte"],
},
},
},
// obsidianmd recommended bundles js:recommended, typescript-eslint, and all obsidian rules
...obsidianmd.configs.recommended,
// Svelte support
...svelte.configs["flat/recommended"],
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
project: "./tsconfig.json",
extraFileExtensions: [".svelte"],
},
},
},
// Project-level overrides
{
plugins: { "@typescript-eslint": tseslint.plugin },
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"no-console": "off",
},
},
);

View file

@ -1,8 +1,8 @@
{
"id": "smart-note-agent",
"name": "Smart Note Agent",
"version": "0.3.1",
"minAppVersion": "1.5.0",
"version": "0.3.2",
"minAppVersion": "1.7.2",
"description": "Agentic AI assistant for reading and editing vault notes.",
"author": "Bin Hong",
"authorUrl": "https://github.com/binhong87",

4096
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "smart-note-agent",
"version": "0.3.1",
"version": "0.3.2",
"main": "index.js",
"directories": {
"doc": "docs"
@ -8,6 +8,7 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc --noEmit && node esbuild.config.mjs production",
"lint": "eslint src/",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest"
},
@ -16,13 +17,13 @@
"license": "ISC",
"description": "",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^20.19.39",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"esbuild": "^0.20.2",
"esbuild-svelte": "^0.8.2",
"eslint": "^8.57.1",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.8",
"eslint-plugin-svelte": "^2.46.1",
"obsidian": "^1.12.3",
"prettier": "^3.8.3",
@ -30,6 +31,7 @@
"svelte-preprocess": "^5.1.4",
"tslib": "^2.8.1",
"typescript": "5.4",
"typescript-eslint": "^8.59.1",
"vitest": "^1.6.1"
}
}

View file

@ -57,7 +57,7 @@ export class AgentLoop {
const iterAbort = new AbortController();
const propagate = () => iterAbort.abort();
this.abort.signal.addEventListener("abort", propagate, { once: true });
const timer = setTimeout(() => { console.warn("[agent] turn timeout"); iterAbort.abort(); }, turnTimeoutMs);
const timer = activeWindow.setTimeout(() => { console.warn("[agent] turn timeout"); iterAbort.abort(); }, turnTimeoutMs);
try {
for await (const d of provider.chat({
model: conversation.model, messages: preparedMsgs,
@ -84,7 +84,7 @@ export class AgentLoop {
yield { type: "error", error: { kind: e.kind ?? "unknown", message: String(e.message ?? e) } };
return;
} finally {
clearTimeout(timer);
activeWindow.clearTimeout(timer);
this.abort.signal.removeEventListener("abort", propagate);
}
if (stoppedEarly) return;

View file

@ -47,7 +47,8 @@ export class ApprovalQueue {
await this.commit(pw);
} catch (e) {
console.error(`[agent] failed to commit ${pw.tool}:`, e);
const label = String(pw.args?.path ?? pw.args?.from ?? pw.tool);
const raw = pw.args?.path ?? pw.args?.from ?? pw.tool;
const label = typeof raw === "string" ? raw : pw.tool;
failed.push(label);
}
}

View file

@ -102,7 +102,8 @@ export default class ObsidianNoteAgentPlugin extends Plugin {
return { path: f.path, content: "" };
},
selection: () => {
const ed = (this.app.workspace as any).activeEditor?.editor;
type WS = { activeEditor?: { editor?: { getSelection?(): string } } };
const ed = (this.app.workspace as unknown as WS).activeEditor?.editor;
return ed?.getSelection?.() ?? "";
},
};
@ -164,39 +165,39 @@ export default class ObsidianNoteAgentPlugin extends Plugin {
}
}
private async computeDiff(p: { tool: string; args: any }): Promise<string> {
private async computeDiff(p: { tool: string; args: Record<string, unknown> }): Promise<string> {
try {
if (p.tool === "edit_note") {
const before = await this.vault.readNote(p.args.path);
return simpleDiff(before, p.args.content);
const before = await this.vault.readNote(String(p.args.path));
return simpleDiff(before, String(p.args.content));
}
if (p.tool === "create_note") return `+ ${p.args.path}\n${p.args.content}`;
if (p.tool === "delete_note") return `- ${p.args.path}`;
if (p.tool === "move_note") return `${p.args.from}${p.args.to}`;
if (p.tool === "apply_patch") return p.args.patch;
} catch {}
if (p.tool === "create_note") return `+ ${String(p.args.path)}\n${String(p.args.content)}`;
if (p.tool === "delete_note") return `- ${String(p.args.path)}`;
if (p.tool === "move_note") return `${String(p.args.from)}${String(p.args.to)}`;
if (p.tool === "apply_patch") return String(p.args.patch);
} catch { /* ignore — fall through to return "" */ }
return "";
}
private async commitWrite(p: { tool: string; args: any }): Promise<void> {
private async commitWrite(p: { tool: string; args: Record<string, unknown> }): Promise<void> {
switch (p.tool) {
case "create_note": await this.vault.createNote(p.args.path, p.args.content); this.lastTurnSummary.created.push(p.args.path); break;
case "edit_note": await this.vault.editNote(p.args.path, p.args.content); this.lastTurnSummary.edited.push(p.args.path); break;
case "create_note": await this.vault.createNote(String(p.args.path), String(p.args.content)); this.lastTurnSummary.created.push(String(p.args.path)); break;
case "edit_note": await this.vault.editNote(String(p.args.path), String(p.args.content)); this.lastTurnSummary.edited.push(String(p.args.path)); break;
case "apply_patch": {
const before = await this.vault.readNote(p.args.path);
await this.vault.editNote(p.args.path, applyUnifiedPatch(before, p.args.patch));
this.lastTurnSummary.edited.push(p.args.path); break;
const before = await this.vault.readNote(String(p.args.path));
await this.vault.editNote(String(p.args.path), applyUnifiedPatch(before, String(p.args.patch)));
this.lastTurnSummary.edited.push(String(p.args.path)); break;
}
case "delete_note": await this.vault.deleteNote(p.args.path); this.lastTurnSummary.deleted.push(p.args.path); break;
case "move_note": await this.vault.moveNote(p.args.from, p.args.to); this.lastTurnSummary.edited.push(p.args.to); break;
case "delete_note": await this.vault.deleteNote(String(p.args.path)); this.lastTurnSummary.deleted.push(String(p.args.path)); break;
case "move_note": await this.vault.moveNote(String(p.args.from), String(p.args.to)); this.lastTurnSummary.edited.push(String(p.args.to)); break;
}
this.emitSummary();
}
private async autoApproveAndBackup(p: { tool: string; args: any }): Promise<void> {
private async autoApproveAndBackup(p: { tool: string; args: Record<string, unknown> }): Promise<void> {
const backupTools = ["edit_note", "delete_note", "apply_patch"];
if (backupTools.includes(p.tool)) {
const path = p.args.path as string;
const path = String(p.args.path);
const ts = autoBackupTimestamp();
const backupPath = `__auto_backup__/${ts}/${path}`;
try {
@ -256,7 +257,7 @@ export default class ObsidianNoteAgentPlugin extends Plugin {
async activateView() {
let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AGENT_CHAT)[0];
if (!leaf) { leaf = this.app.workspace.getRightLeaf(false)!; await leaf.setViewState({ type: VIEW_TYPE_AGENT_CHAT, active: true }); }
this.app.workspace.revealLeaf(leaf);
void this.app.workspace.revealLeaf(leaf);
}
/** Detach + reopen the chat view so Svelte components re-render with the current locale. */

View file

@ -50,7 +50,7 @@ export class AnthropicProvider implements LLMProvider {
} else if (o.type === "content_block_stop") {
const b = blocks[o.index];
if (b?.type === "tool_use") {
let args: any = {}; try { args = JSON.parse(b.buf || "{}"); } catch { args = { _raw: b.buf }; }
let args: Record<string, unknown> = {}; try { args = JSON.parse(b.buf || "{}"); } catch { args = { _raw: b.buf }; }
yield { type: "tool_call", toolCall: { id: b.id!, name: b.name!, args } };
}
} else if (o.type === "message_stop") break;
@ -58,9 +58,9 @@ export class AnthropicProvider implements LLMProvider {
yield { type: "done" };
}
private toAnthropic(m: any): any {
private toAnthropic(m: any): unknown {
if (m.role === "assistant" && m.toolCalls?.length) {
const content: any[] = [];
const content: unknown[] = [];
if (m.content) content.push({ type: "text", text: m.content });
for (const tc of m.toolCalls) content.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.args });
return { role: "assistant", content };

View file

@ -22,8 +22,8 @@ function retryDelayMs(attempt: number, retryAfterHeader: string | null): number
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
const t = activeWindow.setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { activeWindow.clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
});
}
@ -31,10 +31,10 @@ function parseRateMsg(raw: string): string {
try { return JSON.parse(raw)?.error?.message ?? raw; } catch { return raw; }
}
export async function httpJson<T = any>(o: HttpOptions): Promise<T> {
export async function httpJson<T = unknown>(o: HttpOptions): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const r = await requestUrl({ url: o.url, method: o.method ?? "POST", headers: o.headers, body: o.body, throw: false } as any);
const r = await requestUrl({ url: o.url, method: o.method ?? "POST", headers: o.headers, body: o.body, throw: false });
if (r.status === 429) {
if (attempt < MAX_RETRIES) {
const delay = retryDelayMs(attempt + 1, null);
@ -52,9 +52,9 @@ export async function httpJson<T = any>(o: HttpOptions): Promise<T> {
throw new ProviderError("unknown", `${r.status}: ${msg}`);
}
return r.json as T;
} catch (e: any) {
} catch (e: unknown) {
if (e instanceof ProviderError) throw e;
throw new ProviderError("unavailable", String(e?.message ?? e));
throw new ProviderError("unavailable", e instanceof Error ? e.message : String(e));
}
}
throw new ProviderError("rate", "Rate limited: max retries exceeded");
@ -62,6 +62,7 @@ export async function httpJson<T = any>(o: HttpOptions): Promise<T> {
export async function* httpSSE(o: HttpOptions): AsyncIterable<{ data: string }> {
for (let attempt = 0; ; attempt++) {
// eslint-disable-next-line no-restricted-globals -- requestUrl doesn't support streaming; fetch is required for SSE
const resp = await fetch(o.url, {
method: o.method ?? "POST",
headers: o.headers,

View file

@ -30,7 +30,8 @@ export class OllamaProvider implements LLMProvider {
yield { type: "done" };
}
private async *fetchNDJSON(url: string, body: any, signal?: AbortSignal): AsyncIterable<string> {
private async *fetchNDJSON(url: string, body: unknown, signal?: AbortSignal): AsyncIterable<string> {
// eslint-disable-next-line no-restricted-globals -- requestUrl doesn't support streaming; fetch is required for NDJSON
const resp = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
if (resp.status >= 400) throw new ProviderError(resp.status >= 500 ? "unavailable" : "unknown", await resp.text());
const reader = resp.body!.getReader(); const dec = new TextDecoder(); let buf = "";
@ -43,7 +44,7 @@ export class OllamaProvider implements LLMProvider {
if (buf.trim()) yield buf;
}
private toOllama(m: any): any {
private toOllama(m: any): unknown {
if (m.role === "tool") return { role: "tool", content: m.content };
if (m.role === "assistant" && m.toolCalls?.length) {
return { role: "assistant", content: m.content || "",

View file

@ -39,14 +39,14 @@ export class OpenAIProvider implements LLMProvider {
}
}
for (const i of Object.keys(pending)) {
const p = pending[+i]; let args: any = {};
const p = pending[+i]; let args: Record<string, unknown> = {};
try { args = JSON.parse(p.args || "{}"); } catch { args = { _raw: p.args }; }
yield { type: "tool_call", toolCall: { id: p.id ?? `tc_${i}`, name: p.name, args } };
}
yield { type: "done" };
}
private toOpenAIMsg(m: any): any {
private toOpenAIMsg(m: any): unknown {
if (m.role === "tool") return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
if (m.role === "assistant" && m.toolCalls?.length) {
return { role: "assistant", content: m.content || null,

View file

@ -31,7 +31,7 @@ export function createProvider(id: ProviderId, cfg: ProviderConfig): LLMProvider
return cfg.compat === "anthropic"
? new AnthropicProvider({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl })
: new OpenAIProvider({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl });
default: throw new Error(`unknown provider: ${id}`);
default: throw new Error(`unknown provider: ${id as string}`);
}
}

View file

@ -40,10 +40,10 @@ export class SchedulerService {
constructor(private getSettings: () => Settings, private runner: ScheduledTaskRunner) {}
start() {
this.tick();
this.timer = window.setInterval(() => this.tick(), 60_000);
void this.tick();
this.timer = window.setInterval(() => { void this.tick(); }, 60_000);
}
stop() { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } }
stop() { if (this.timer !== null) { activeWindow.clearInterval(this.timer); this.timer = null; } }
private async tick() {
const s = this.getSettings();

View file

@ -15,7 +15,8 @@ export class VaultService {
const p = validatePath(path);
const f = this.app.vault.getAbstractFileByPath(p);
if (!f) throw new Error(`not found: ${p}`);
return this.app.vault.read(f as TFile);
if (!(f instanceof TFile)) throw new Error(`not a file: ${p}`);
return this.app.vault.read(f);
}
async createNote(path: string, content: string): Promise<void> {
@ -29,7 +30,8 @@ export class VaultService {
const p = validatePath(path);
const f = this.app.vault.getAbstractFileByPath(p);
if (!f) throw new Error(`not found: ${p}`);
await this.app.vault.modify(f as TFile, content);
if (!(f instanceof TFile)) throw new Error(`not a file: ${p}`);
await this.app.vault.modify(f, content);
}
async deleteNote(path: string): Promise<void> {

View file

@ -56,6 +56,7 @@ export const DEFAULT_SETTINGS: Settings = {
providerId: "openai",
providers: defaultProviders(),
mode: "ask",
// eslint-disable-next-line obsidianmd/hardcoded-config-path -- DEFAULT_SETTINGS is a static constant; Vault.configDir is unavailable here
chatsFolder: ".obsidian/plugins/smart-note-agent/chats",
locale: "auto",
userProfile: "",
@ -82,7 +83,7 @@ interface LegacySettings {
export function migrateSettings(raw: (Partial<Settings> & LegacySettings) | undefined): Settings {
const r = raw ?? {};
const providerId: ProviderId = (r.providerId ?? DEFAULT_SETTINGS.providerId) as ProviderId;
const providerId: ProviderId = (r.providerId ?? DEFAULT_SETTINGS.providerId);
// Start with full default profiles so every provider has a usable entry
const providers: Record<ProviderId, ProviderProfile> = { ...defaultProviders(), ...(r.providers ?? {}) };

View file

@ -1,7 +1,7 @@
import type { Tool, ToolContext } from "./types";
const safe = async (fn: () => Promise<string>): Promise<string> => {
try { return await fn(); } catch (e: any) { return JSON.stringify({ error: String(e?.message ?? e) }); }
try { return await fn(); } catch (e: unknown) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); }
};
export function buildReadTools(ctx: ToolContext): Tool[] {
@ -22,7 +22,7 @@ export function buildReadTools(ctx: ToolContext): Tool[] {
name: "list_folder", kind: "read",
schema: { name: "list_folder", description: "List all file paths under a folder (notes, attachments, canvas, etc.). Pass an empty string to list the vault root.",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
handler: (a) => safe(async () => JSON.stringify(await ctx.vault.listFolder(String(a.path ?? "")))),
handler: (a) => safe(async () => JSON.stringify(await ctx.vault.listFolder(typeof a.path === "string" ? a.path : ""))),
},
{
name: "get_backlinks", kind: "read",

View file

@ -9,7 +9,7 @@ export interface ToolSchema {
export interface Tool {
name: string;
schema: ToolSchema;
handler: (args: Record<string, any>) => Promise<string>;
handler: (args: Record<string, unknown>) => Promise<string>;
kind: "read" | "write";
}

View file

@ -28,7 +28,9 @@
function autoResize() {
if (!textarea) return;
// eslint-disable-next-line obsidianmd/no-static-styles-assignment -- height must be set dynamically based on scrollHeight
textarea.style.height = "auto";
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
textarea.style.height = Math.min(Math.max(textarea.scrollHeight, 66), 200) + "px";
}

View file

@ -57,6 +57,7 @@
<div class="db-header-left">
<div class="db-tool-icon" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html toolIcon(p.tool)}
</svg>
</div>

View file

@ -12,9 +12,11 @@ export class AgentSettingsTab extends PluginSettingTab {
const s = this.plugin.settings;
const t = this.plugin.i18n.t.bind(this.plugin.i18n);
const wide = (el: HTMLInputElement) => {
// eslint-disable-next-line obsidianmd/no-static-styles-assignment -- no CSS infrastructure for TS files; input needs 100% width dynamically
el.style.width = "100%";
const control = el.closest(".setting-item-control") as HTMLElement | null;
if (control) { control.style.flex = "0 0 50%"; control.style.minWidth = "0"; }
const control = el.closest(".setting-item-control");
// eslint-disable-next-line obsidianmd/no-static-styles-assignment -- layout fix for wide inputs in settings panel
if (control) { (control as HTMLElement).style.flex = "0 0 50%"; (control as HTMLElement).style.minWidth = "0"; }
};
// Ensure the active provider has a profile entry
@ -22,7 +24,7 @@ export class AgentSettingsTab extends PluginSettingTab {
const profile = s.providers[s.providerId];
const defaults = PROVIDER_DEFAULTS[s.providerId];
containerEl.createEl("h2", { text: t("settings.title") });
new Setting(containerEl).setName("").setHeading();
new Setting(containerEl).setName(t("settings.provider")).addDropdown(d => {
for (const id of listProviderIds()) d.addOption(id, t(`provider.${id}`));
@ -92,12 +94,15 @@ export class AgentSettingsTab extends PluginSettingTab {
await this.plugin.reopenChatView();
}));
containerEl.createEl("h3", { text: t("settings.userProfile") });
new Setting(containerEl).setName("").setHeading();
new Setting(containerEl)
.setDesc(t("settings.userProfile.desc"))
.addTextArea(x => {
// eslint-disable-next-line obsidianmd/no-static-styles-assignment -- no CSS infrastructure for TS files; textarea dimensions required
x.inputEl.style.width = "100%";
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
x.inputEl.style.minHeight = "96px";
// eslint-disable-next-line obsidianmd/no-static-styles-assignment
x.inputEl.style.resize = "vertical";
x.setPlaceholder(t("settings.userProfile.placeholder"))
.setValue(s.userProfile)

View file

@ -22,7 +22,7 @@ export function markdown(node: HTMLElement, params: MarkdownParams) {
const href = a.getAttribute("href") ?? a.getAttribute("data-href") ?? "";
if (!href) return;
const newLeaf = evt.ctrlKey || evt.metaKey || evt.button === 1;
currentPlugin.app.workspace.openLinkText(href, "", newLeaf);
currentPlugin.app.workspace.openLinkText(href, "", newLeaf).catch(() => {});
} else if (a.classList.contains("external-link") || /^https?:/i.test(a.getAttribute("href") ?? "")) {
evt.preventDefault();
const href = a.getAttribute("href") ?? "";
@ -45,25 +45,25 @@ export function markdown(node: HTMLElement, params: MarkdownParams) {
const code = pre.querySelector<HTMLElement>("code");
const lang = code?.className.match(/language-(\S+)/)?.[1] ?? "";
const header = document.createElement("div");
const header = activeDocument.createDiv();
header.className = "ob-code-header";
const langLabel = document.createElement("span");
const langLabel = activeDocument.createSpan();
langLabel.className = "ob-code-lang";
langLabel.textContent = lang;
const btn = document.createElement("button");
const btn = activeDocument.createEl("button");
btn.className = "ob-copy-btn";
btn.textContent = "Copy";
btn.setAttribute("aria-label", "Copy code");
btn.addEventListener("click", () => {
const text = (code ?? pre).textContent ?? "";
navigator.clipboard.writeText(text).then(() => {
btn.textContent = "✓ Copied";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
btn.textContent = "✓ copied";
activeWindow.setTimeout(() => { btn.textContent = "Copy"; }, 2000);
}).catch(() => {
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
activeWindow.setTimeout(() => { btn.textContent = "Copy"; }, 2000);
});
});
@ -72,10 +72,10 @@ export function markdown(node: HTMLElement, params: MarkdownParams) {
pre.insertBefore(header, pre.firstChild);
}
render(params);
void render(params);
return {
update(newParams: MarkdownParams) { render(newParams); },
update(newParams: MarkdownParams) { void render(newParams); },
destroy() { node.removeEventListener("click", onClick); owner.unload(); },
};
}