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 MAX_ENTRIES = 10000; 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.entries.filter(e => e.type === 'file').length; result.truncated = listing.truncated; 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 = '') { this.ready(); const base = relative ? this.resolve(relative) : this.root; const entries = []; let visited = 0; let truncated = false; const walk = (dir, depth) => { if (depth > 32) { truncated = true; return; } for (const name of fs.readdirSync(dir).sort()) { if (++visited > MAX_ENTRIES) { truncated = true; return; } 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()) { entries.push({ path: rel, type: 'folder' }); walk(file, depth + 1); } else if (stat.isFile()) entries.push({ path: rel, type: 'file', bytes: stat.size }); if (truncated) return; } }; checked(base); walk(base, 0); return { entries, truncated }; } read(relative, encoding = 'utf8') { this.ready(); const file = this.resolve(relative); if (!['utf8', 'base64'].includes(encoding)) throw new Error('Encoding must be utf8 or base64'); if (fs.statSync(file).size > MAX_BYTES) throw new Error('File exceeds 2 MiB bridge limit'); const data = fs.readFileSync(file); return { path: relative, content: data.toString(encoding), encoding, revision: digest(data) }; } 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.read(relative, 'base64').revision !== 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.read(relative, 'base64').revision !== 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.read(relative, 'base64').revision !== 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 } = {}) { if (typeof query !== 'string' || query.length > 500) throw new Error('Query must be text up to 500 characters'); this.ready(); if (memory && !fs.existsSync(this.resolve('memory'))) return { matches: [], truncated: false }; const listing = this.list(memory ? 'memory' : ''); const matches = []; let scannedBytes = 0; let truncated = listing.truncated; for (const entry of listing.entries) { if (entry.type !== 'file' || !entry.path.toLowerCase().endsWith('.md') || entry.bytes > MAX_BYTES) continue; if ((scannedBytes += entry.bytes) > 16 * MAX_BYTES) { truncated = true; break; } const note = this.read(entry.path); const index = note.content.toLowerCase().indexOf(query.toLowerCase()); if (index >= 0 || entry.path.toLowerCase().includes(query.toLowerCase())) matches.push({ path: entry.path, revision: note.revision, snippet: note.content.slice(Math.max(0, index - 100), Math.max(0, index) + 500) }); if (matches.length >= 50) { truncated = true; break; } } return { matches, truncated }; } execute(args = {}) { switch (args.action) { case 'status': return this.status(); case 'list': return this.list(args.path); case 'read': return this.read(args.path, args.encoding); case 'write': return this.write(args.path, args.content, args.revision, args.encoding); case 'mkdir': return this.mkdir(args.path); case 'move': return this.move(args.path, args.destination, args.revision); case 'delete': return this.remove(args.path, args.revision); case 'trash': return this.trash(); case 'restore': return this.restore(args.trashId, args.destination); case 'search': return this.search(args.query); case 'memory_search': return this.search(args.query, { memory: true }); default: throw new Error('Unknown vault action'); } } }