Files
gnome-jarvis/daemon/obsidian.js
T
snxraven 507405c6ca
Rolling release / release (push) Successful in 7m42s
Updates
2026-09-14 14:43:33 -04:00

239 lines
15 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash, randomUUID } from 'node:crypto';
const MARKER = '.jarvis-vault.json';
const MAX_BYTES = 2 * 1024 * 1024;
const PAGE_BYTES = 1024;
const PAGE_ENTRIES = 20;
const digest = data => createHash('sha256').update(data).digest('hex');
export const defaultVaultPath = () => path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share'), 'jarvis/obsidian-agent');
// Reject links in every existing component, including ancestors of the vault.
// This boundary deliberately excludes Obsidian configuration and plugin code.
function checked(file) {
const absolute = path.resolve(file);
let current = path.resolve(path.sep);
for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
let stat;
try { stat = fs.lstatSync(current); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile()) || (stat.isFile() && stat.nlink > 1)) throw new Error('Vault paths must not contain links or special files');
}
return absolute;
}
export class ObsidianVault {
constructor(settings = {}) { this.configure(settings); }
configure(settings) {
this.enabled = settings.obsidianEnabled === true;
this.memoryEnabled = settings.obsidianMemoryEnabled === true;
this.root = settings.obsidianVaultPath || defaultVaultPath();
}
guard() {
if (!this.enabled) throw new Error('Obsidian integration is disabled in Settings');
if (!path.isAbsolute(this.root) || path.resolve(this.root) === path.resolve(path.sep) || path.resolve(this.root) === os.homedir()) throw new Error('Choose an absolute path to a dedicated agent vault');
checked(this.root);
}
ready() {
this.guard();
const marker = checked(path.join(this.root, MARKER));
if (!fs.existsSync(marker) || fs.readFileSync(marker, 'utf8') !== '{"owner":"jarvis","version":1}\n') throw new Error('Initialize a dedicated agent vault in Settings first');
}
resolve(relative, { internal = false } = {}) {
if (typeof relative !== 'string' || !relative || relative.includes('\\') || relative.includes('\0') || path.isAbsolute(relative) || relative.split('/').some(p => !p || p === '..' || p === '.' || (!internal && p.startsWith('.')))) throw new Error('Use a relative vault path without hidden components or traversal');
if (!internal && (relative === 'memory' || relative.startsWith('memory/')) && !this.memoryEnabled) throw new Error('Vault memory access is disabled');
return checked(path.join(this.root, relative));
}
initialize() {
this.guard();
if (fs.existsSync(path.join(this.root, MARKER))) { this.ready(); return this.status(); }
if (fs.existsSync(this.root) && fs.readdirSync(this.root).length) throw new Error('Use an empty directory; existing personal vaults cannot be adopted');
fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
fs.mkdirSync(path.join(this.root, '.obsidian'), { mode: 0o700 });
fs.writeFileSync(path.join(this.root, MARKER), '{"owner":"jarvis","version":1}\n', { flag: 'wx', mode: 0o600 });
fs.writeFileSync(path.join(this.root, 'Welcome.md'), '# Agent vault\n\nDedicated Jarvis notes, projects, attachments, and optional memory.\n\nOpen this folder as a vault in Obsidian once. Deleted files are kept in `.trash`.\n', { flag: 'wx', mode: 0o600 });
return this.status();
}
status() {
const result = { enabled: this.enabled, path: this.root, memoryEnabled: this.memoryEnabled, ready: false, readable: false, writable: false };
if (!this.enabled) return result;
try {
this.ready(); result.ready = true;
fs.accessSync(this.root, fs.constants.R_OK); result.readable = true;
fs.accessSync(this.root, fs.constants.W_OK); result.writable = true;
const listing = this.list(); result.files = listing.totalFiles; result.truncated = false;
result.uri = `obsidian://open?path=${encodeURIComponent(path.join(this.root, 'Welcome.md'))}`;
} catch (error) { result.error = error.message; }
return result;
}
verify() {
this.ready();
const probe = checked(path.join(this.root, `.jarvis-probe-${randomUUID()}`));
const token = randomUUID();
try {
fs.writeFileSync(probe, token, { flag: 'wx', mode: 0o600 });
if (fs.readFileSync(probe, 'utf8') !== token) throw new Error('Read/write verification failed');
} finally { if (fs.existsSync(probe)) fs.unlinkSync(probe); }
const result = { ...this.status(), readWriteVerified: true, memoryVerified: false };
if (this.memoryEnabled) {
const dir = this.resolve('memory'); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const name = `memory/verification-${randomUUID()}.md`;
try { this.write(name, token); if (this.read(name).content !== token) throw new Error('Memory verification failed'); result.memoryVerified = true; }
finally { const file = this.resolve(name); if (fs.existsSync(file)) fs.unlinkSync(file); }
}
return result;
}
list(relative = '', { offset = 0, limit = PAGE_ENTRIES } = {}) {
this.ready();
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > PAGE_ENTRIES) throw new Error('Invalid listing page');
const base = relative ? this.resolve(relative) : this.root;
const entries = []; let visited = 0; let totalFiles = 0;
const walk = dir => {
for (const name of fs.readdirSync(dir).sort()) {
if (name.startsWith('.')) continue;
const file = path.join(dir, name); const rel = path.relative(this.root, file).split(path.sep).join('/');
if (!this.memoryEnabled && (rel === 'memory' || rel.startsWith('memory/'))) continue;
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1)) continue;
if (!stat.isDirectory() && !stat.isFile()) continue;
if (stat.isFile()) totalFiles++;
if (visited++ >= offset && entries.length < limit) entries.push({ path: rel, type: stat.isDirectory() ? 'folder' : 'file', bytes: stat.size });
if (stat.isDirectory()) walk(file);
}
};
checked(base); walk(base);
const nextOffset = offset + entries.length < visited ? offset + entries.length : null;
return { entries, totalFiles, totalEntries: visited, nextOffset, truncated: nextOffset !== null };
}
revision(relative) {
const file = this.resolve(relative); const hash = createHash('sha256');
const fd = fs.openSync(file, 'r'); const buffer = Buffer.alloc(64 * 1024);
try { let count; let position = 0; while ((count = fs.readSync(fd, buffer, 0, buffer.length, position)) > 0) { hash.update(buffer.subarray(0, count)); position += count; } }
finally { fs.closeSync(fd); }
return hash.digest('hex');
}
read(relative, encoding = 'utf8', { offset = 0, limit = PAGE_BYTES, revision } = {}) {
this.ready(); const file = this.resolve(relative);
if (!['utf8', 'base64'].includes(encoding)) throw new Error('Encoding must be utf8 or base64');
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 4 || limit > PAGE_BYTES) throw new Error('Invalid read page');
const current = this.revision(relative);
if (revision && revision !== current) throw new Error('Revision conflict: restart reading the changed file');
const bytes = fs.statSync(file).size;
if (offset > bytes) throw new Error('Read offset exceeds file size');
const fd = fs.openSync(file, 'r'); const buffer = Buffer.alloc(limit + 4); let count;
try { count = fs.readSync(fd, buffer, 0, buffer.length, offset); } finally { fs.closeSync(fd); }
let end = Math.min(limit, count);
// Do not split a UTF-8 code point between pages. Offsets are always bytes.
if (encoding === 'utf8' && end < count) while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
const nextOffset = offset + end < bytes ? offset + end : null;
return { path: relative, encoding, revision: current, offset, bytes, nextOffset, complete: nextOffset === null, content: buffer.subarray(0, end).toString(encoding) };
}
write(relative, content, revision, encoding = 'utf8') {
this.ready(); const file = this.resolve(relative);
if (typeof content !== 'string' || !['utf8', 'base64'].includes(encoding) || content.length > MAX_BYTES * 2) throw new Error('Provide text or base64 content within the 2 MiB limit');
const data = Buffer.from(content, encoding);
if (data.length > MAX_BYTES) throw new Error('File exceeds 2 MiB bridge limit');
if (fs.existsSync(file)) {
if (!revision || this.revision(relative) !== revision) throw new Error('Revision conflict: read the current file before replacing it');
} else if (revision) throw new Error('Revision conflict: file no longer exists');
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const temp = checked(path.join(path.dirname(file), `.jarvis-write-${randomUUID()}`));
try { fs.writeFileSync(temp, data, { flag: 'wx', mode: 0o600 }); fs.renameSync(temp, checked(file)); }
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
return { path: relative, revision: digest(data), bytes: data.length };
}
mkdir(relative) { this.ready(); fs.mkdirSync(this.resolve(relative), { recursive: true, mode: 0o700 }); return { path: relative }; }
move(relative, destination, revision) {
this.ready(); const source = this.resolve(relative); const dest = this.resolve(destination);
if (!fs.statSync(source).isFile()) throw new Error('Move individual files; remove empty folders separately');
if (!revision || this.revision(relative) !== revision) throw new Error('Revision conflict: read before moving');
if (fs.existsSync(dest)) throw new Error('Destination already exists');
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); fs.renameSync(source, dest);
return { path: destination, revision, linksUpdated: false };
}
remove(relative, revision) {
this.ready(); const file = this.resolve(relative);
if (fs.statSync(file).isDirectory()) { fs.rmdirSync(file); return { removedEmptyFolder: relative }; }
if (!revision || this.revision(relative) !== revision) throw new Error('Revision conflict: read before deleting');
const id = randomUUID(); const trash = this.resolve(`.trash/${id}`, { internal: true });
fs.mkdirSync(trash, { recursive: true, mode: 0o700 });
fs.writeFileSync(path.join(trash, 'metadata.json'), JSON.stringify({ path: relative, deletedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
fs.renameSync(file, path.join(trash, 'content')); return { trashId: id, path: relative };
}
trash() {
this.ready(); const dir = this.resolve('.trash', { internal: true });
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).slice(0, 1000).filter(id => /^[a-f0-9-]{36}$/.test(id)).flatMap(id => {
try { const meta = JSON.parse(fs.readFileSync(this.resolve(`.trash/${id}/metadata.json`, { internal: true }), 'utf8')); this.resolve(meta.path); return [{ id, ...meta }]; } catch { return []; }
});
}
restore(id, destination) {
this.ready(); if (!/^[a-f0-9-]{36}$/.test(id || '')) throw new Error('Invalid trash ID');
const meta = JSON.parse(fs.readFileSync(this.resolve(`.trash/${id}/metadata.json`, { internal: true }), 'utf8'));
this.resolve(meta.path); const relative = destination || meta.path; const dest = this.resolve(relative);
if (fs.existsSync(dest)) throw new Error('Destination already exists');
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
fs.renameSync(this.resolve(`.trash/${id}/content`, { internal: true }), dest);
fs.unlinkSync(this.resolve(`.trash/${id}/metadata.json`, { internal: true })); fs.rmdirSync(this.resolve(`.trash/${id}`, { internal: true }));
return { path: relative };
}
search(query, { memory = false, offset = 0 } = {}) {
if (typeof query !== 'string' || query.length > 500) throw new Error('Query must be text up to 500 characters');
this.ready();
if (memory && !this.memoryEnabled) throw new Error('Vault memory access is disabled');
// Durable knowledge can live anywhere in the vault, not only memory/.
const listing = this.list('', { offset }); const matches = [];
for (const entry of listing.entries) {
if (entry.type !== 'file' || !entry.path.toLowerCase().endsWith('.md')) continue;
const needle = query.toLowerCase(); let tail = '';
const fd = fs.openSync(this.resolve(entry.path), 'r'); const buffer = Buffer.alloc(64 * 1024);
try {
let count; let position = 0;
do {
count = fs.readSync(fd, buffer, 0, buffer.length, position);
position += count;
// Search bytes so even a multibyte character spanning chunks is preserved.
const text = tail + buffer.subarray(0, count).toString('latin1');
const decoded = Buffer.from(text, 'latin1').toString('utf8');
const index = decoded.toLowerCase().indexOf(needle);
if (index >= 0 || entry.path.toLowerCase().includes(needle)) {
matches.push({ path: entry.path, revision: this.revision(entry.path), snippet: decoded.slice(Math.max(0, index - 40), Math.max(0, index) + 160) }); break;
}
tail = text.slice(-Math.max(4, Buffer.byteLength(query) + 4));
} while (count > 0);
} finally { fs.closeSync(fd); }
}
return { matches, nextOffset: listing.nextOffset, truncated: listing.truncated };
}
execute(args = {}) {
const action = String(args.action || '').trim().toLowerCase().replace(/[\s-]+/g, '_').replace(/^(obsidian|vault)_/, '');
const path = args.path || args.file || args.note || '';
const content = args.content != null ? args.content : args.text != null ? args.text : args.body != null ? args.body : args.contents;
const query = args.query || args.q || '';
const destination = args.destination || args.dest || args.to;
const trashId = args.trashId || args.trash_id || args.id;
const revision = args.revision || args.rev;
const encoding = args.encoding || 'utf8';
const offset = args.offset == null || args.offset === '' ? undefined : Number(args.offset);
const limit = args.limit == null || args.limit === '' ? undefined : Number(args.limit);
const page = { offset, limit };
switch (action) {
case 'status': return this.status();
case 'list': return this.list(path, page);
case 'read': return this.read(path, encoding, { offset, limit, revision });
case 'write': return this.write(path, content, revision, encoding);
case 'mkdir': return this.mkdir(path);
case 'move': return this.move(path, destination, revision);
case 'delete': return this.remove(path, revision);
case 'trash': return this.trash();
case 'restore': return this.restore(trashId, destination);
case 'search': return this.search(query, { offset });
case 'memory_search': return this.search(query, { offset, memory: true });
default: throw new Error('Unknown vault action. Call obsidian with action set to status, list, read, write, mkdir, move, delete, trash, restore, search, or memory_search.');
}
}
}