Files
hermes-mcp/src/clients/obsidian.ts
2026-04-29 09:52:53 -04:00

273 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFile, writeFile, readdir, stat, mkdir } from 'fs/promises';
import { join, dirname, basename, extname } from 'path';
const VAULT_PATH = process.env['OBSIDIAN_VAULT_PATH'] ?? '/vaults';
const SYNCTHING_URL = process.env['SYNCTHING_URL'] ?? 'http://host.docker.internal:8384';
const SYNCTHING_API_KEY = process.env['SYNCTHING_API_KEY'] ?? '';
const SYNCTHING_FOLDER_ID = process.env['SYNCTHING_FOLDER_ID'] ?? 'obsidian-vault';
function parseFrontmatter(content: string): { meta: Record<string, unknown>; body: string } {
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
return { meta: {}, body: content };
}
const end = content.indexOf('\n---', 4);
if (end === -1) return { meta: {}, body: content };
const yaml = content.slice(4, end);
const body = content.slice(end + 4).replace(/^\r?\n/, '');
const meta: Record<string, unknown> = {};
for (const line of yaml.split('\n')) {
const colon = line.indexOf(':');
if (colon === -1) continue;
const key = line.slice(0, colon).trim();
const val = line.slice(colon + 1).trim();
if (!key) continue;
if (key === 'tags') {
// Support "tags: [a, b]" or "tags: a, b"
meta['tags'] = val
.replace(/[\[\]]/g, '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
} else {
meta[key] = val;
}
}
return { meta, body };
}
function extractTitle(filePath: string, content: string): string {
const { meta, body } = parseFrontmatter(content);
if (meta['title']) return String(meta['title']);
const h1 = body.match(/^#\s+(.+)/m);
if (h1) return h1[1].trim();
return basename(filePath, '.md');
}
async function getAllNotes(dir: string = VAULT_PATH): Promise<string[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllNotes(full)));
} else if (entry.isFile() && extname(entry.name) === '.md') {
files.push(full);
}
}
return files;
}
function relPath(fullPath: string): string {
return fullPath.startsWith(VAULT_PATH)
? fullPath.slice(VAULT_PATH.length).replace(/^\//, '')
: fullPath;
}
export interface NoteResult {
path: string;
title: string;
excerpt: string;
tags: string[];
modified_date: string;
}
export interface FullNote {
path: string;
title: string;
content: string;
tags: string[];
links: string[];
modified_date: string;
}
export async function searchNotes(
query: string,
tags?: string[],
limit = 10,
pathFilter?: string
): Promise<NoteResult[]> {
const files = await getAllNotes();
const results: NoteResult[] = [];
const q = query.toLowerCase();
for (const file of files) {
if (results.length >= limit) break;
const rel = relPath(file);
if (pathFilter && !rel.toLowerCase().includes(pathFilter.toLowerCase())) continue;
let content: string;
try {
content = await readFile(file, 'utf-8');
} catch {
continue;
}
const { meta, body } = parseFrontmatter(content);
const fileTags = (meta['tags'] as string[]) ?? [];
const title = extractTitle(file, content);
if (tags && tags.length > 0) {
const hasAllTags = tags.every((t) => fileTags.includes(t));
if (!hasAllTags) continue;
}
const matchesQuery =
!q ||
title.toLowerCase().includes(q) ||
body.toLowerCase().includes(q) ||
rel.toLowerCase().includes(q);
if (!matchesQuery) continue;
let excerpt = body.slice(0, 200).replace(/\n+/g, ' ').trim();
if (q) {
const idx = body.toLowerCase().indexOf(q);
if (idx > 0) {
excerpt = body.slice(Math.max(0, idx - 50), idx + 150).replace(/\n+/g, ' ').trim();
}
}
if (excerpt.length >= 150) excerpt += '...';
const s = await stat(file);
results.push({
path: rel,
title,
excerpt,
tags: fileTags,
modified_date: s.mtime.toISOString(),
});
}
return results;
}
export async function getNote(notePath: string): Promise<FullNote> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
// If path doesn't end in .md, try adding it
if (!fullPath.endsWith('.md')) fullPath += '.md';
try {
await stat(fullPath);
} catch {
// Fall back to searching by title / filename match
const files = await getAllNotes();
const needle = notePath.replace(/\.md$/i, '').toLowerCase();
const match = files.find(
(f) =>
basename(f, '.md').toLowerCase() === needle ||
relPath(f).replace(/\.md$/i, '').toLowerCase() === needle
);
if (!match) throw new Error(`Note not found: ${notePath}`);
fullPath = match;
}
const content = await readFile(fullPath, 'utf-8');
const { meta, body } = parseFrontmatter(content);
const title = extractTitle(fullPath, content);
const s = await stat(fullPath);
const links = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) => m[1].trim());
return {
path: relPath(fullPath),
title,
content,
tags: (meta['tags'] as string[]) ?? [],
links: [...new Set(links)],
modified_date: s.mtime.toISOString(),
};
}
export async function appendToNote(
notePath: string,
content: string,
createIfMissing = true,
header?: string
): Promise<{ success: boolean; path: string; bytes_written: number }> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
if (!fullPath.endsWith('.md')) fullPath += '.md';
let existing = '';
try {
existing = await readFile(fullPath, 'utf-8');
} catch {
if (!createIfMissing) throw new Error(`Note not found: ${notePath}`);
await mkdir(dirname(fullPath), { recursive: true });
}
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
const toAppend = header
? `${separator}\n## ${header}\n${content}\n`
: `${separator}${content}\n`;
await writeFile(fullPath, existing + toAppend, 'utf-8');
return {
success: true,
path: relPath(fullPath),
bytes_written: Buffer.byteLength(toAppend, 'utf-8'),
};
}
export async function updateNote(
notePath: string,
content: string,
): Promise<{ success: boolean; path: string; bytes_written: number }> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
if (!fullPath.endsWith('.md')) fullPath += '.md';
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf-8');
return { success: true, path: relPath(fullPath), bytes_written: Buffer.byteLength(content, 'utf-8') };
}
export async function getSyncStatus(): Promise<{
status: string;
last_sync: string | null;
vault_size: number;
pending_changes: number;
}> {
if (!SYNCTHING_API_KEY) {
return { status: 'unconfigured set SYNCTHING_API_KEY', last_sync: null, vault_size: 0, pending_changes: 0 };
}
try {
const url = `${SYNCTHING_URL}/rest/db/status?folder=${SYNCTHING_FOLDER_ID}`;
const res = await fetch(url, {
headers: { 'X-API-Key': SYNCTHING_API_KEY },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Syncthing API returned ${res.status}`);
const data = (await res.json()) as {
state: string;
stateChanged: string;
localBytes: number;
needFiles: number;
};
return {
status: data.state,
last_sync: data.stateChanged ?? null,
vault_size: data.localBytes ?? 0,
pending_changes: data.needFiles ?? 0,
};
} catch (err) {
return {
status: `error: ${(err as Error).message}`,
last_sync: null,
vault_size: 0,
pending_changes: 0,
};
}
}