UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

309 lines 11.2 kB
/** * Logseq Storage Adapter * * Writes and reads markdown files in a Logseq graph on disk. Like the * Obsidian adapter, this is filesystem-shaped — Logseq graphs are * directories of `.md` (or `.org`) files plus a `logseq/` config dir * we never touch. * * About the Logseq HTTP API: as of April 2025 the DB-backed Logseq * rewrite is still in development; file-backed graphs remain primary. * Logseq exposes an HTTP API at `http://127.0.0.1:12315/api` (with a * bearer token issued from Settings → Features → Developer mode), but * verifying its exact command surface requires hands-on Logseq access. * * What this adapter does: * - Reads / writes / lists / deletes markdown files via fs * - Optional HTTP API reachability probe (when `useApi: true`) — * emits a one-time stderr warning if `LOGSEQ_API_TOKEN` is unset or * the API is unreachable * - Refuses paths into the graph's `logseq/` config directory * - Transforms `WriteMeta.frontmatter` from YAML into Logseq's * page-level `property:: value` syntax * - Never writes block IDs (`id::`) — Logseq auto-assigns them * * Path layout (mirrors Logseq's on-disk conventions): * pages/<title>.md regular pages * journals/YYYY_MM_DD.md journal entries * logseq/* REFUSED (Logseq config) * * @design @.aiwg/architecture/storage-design.md (§5.3) * @issue #934 * @issue #958 */ import { existsSync } from 'fs'; import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import { dirname, join, relative, resolve, sep } from 'path'; const LOGSEQ_CONFIG_DIR = 'logseq'; const DEFAULT_API_URL = 'http://127.0.0.1:12315/api'; export class LogseqAdapter { /** Absolute path to the Logseq graph root. */ graph; apiUrl; useApi; apiWarned = false; constructor(config) { this.graph = resolve(expandHome(config.graph)); this.apiUrl = config.apiUrl ?? DEFAULT_API_URL; this.useApi = config.useApi ?? true; } async init() { if (!existsSync(this.graph)) { throw new Error(`storage(logseq): graph does not exist: ${this.graph}`); } if (this.useApi) await this.probeApi(); } /** * One-shot API reachability probe. Warns once on missing token or * unreachable endpoint. Direct fs writes still work without it. */ async probeApi() { if (this.apiWarned) return; const token = process.env['LOGSEQ_API_TOKEN']; if (!token) { this.apiWarned = true; process.stderr.write(`storage(logseq): API requested (useApi: true) but LOGSEQ_API_TOKEN is not set.\n` + ` AIWG will continue with direct file writes against the graph.\n` + ` Set LOGSEQ_API_TOKEN (Settings → Features → Developer mode) or set "useApi": false to suppress.\n`); return; } try { // Lightweight reachability probe — most Logseq API servers respond // to a simple POST. We don't synthesize a specific command; just // confirm the endpoint is reachable. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); try { await fetch(this.apiUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'logseq.App.getCurrentGraph' }), signal: controller.signal, }); } finally { clearTimeout(timer); } } catch { this.apiWarned = true; process.stderr.write(`storage(logseq): API server at ${this.apiUrl} is not reachable.\n` + ` AIWG will continue with direct file writes against the graph.\n` + ` Start Logseq with the API server enabled, or set "useApi": false to suppress.\n`); } } /** * Validate a subsystem-relative path. Rejects traversal, absolute * paths, backslashes, and any path inside the graph's `logseq/` dir. */ resolveSafe(relPath) { if (typeof relPath !== 'string' || relPath.length === 0) { throw new Error('storage(logseq): path must be a non-empty string'); } if (relPath.includes('\\')) { throw new Error(`storage(logseq): backslash not allowed in path "${relPath}"`); } if (relPath.startsWith('/') || relPath.startsWith('~')) { throw new Error(`storage(logseq): absolute paths not allowed: "${relPath}"`); } const segments = relPath.split('/'); if (segments.some((s) => s === '..')) { throw new Error(`storage(logseq): ".." traversal not allowed in "${relPath}"`); } const abs = resolve(this.graph, relPath); if (abs !== this.graph && !abs.startsWith(this.graph + sep)) { throw new Error(`storage(logseq): resolved path escapes graph root: "${relPath}"`); } const relFromGraph = relative(this.graph, abs); if (relFromGraph === LOGSEQ_CONFIG_DIR || relFromGraph.startsWith(LOGSEQ_CONFIG_DIR + sep)) { throw new Error(`storage(logseq): refusing to operate on logseq/ graph config (${relPath})`); } return abs; } async read(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return null; return readFile(abs, 'utf-8'); } async write(path, content, meta) { const abs = this.resolveSafe(path); await mkdir(dirname(abs), { recursive: true }); let body = content; // Strip YAML frontmatter from the content (if any) — Logseq doesn't // recognize it. Move what we can to page-level properties. const yaml = extractYamlFrontmatter(body); if (yaml) { body = yaml.body; } // Merge supplied frontmatter + extracted YAML into Logseq page-properties const merged = { ...(yaml?.fields ?? {}), ...(meta?.frontmatter ?? {}) }; if (Object.keys(merged).length > 0) { body = renderPageProperties(merged) + body; } // Defensive: refuse to write `id::` block IDs ourselves. Logseq // auto-assigns them; if the caller smuggled one in, strip it. body = body.replace(/^\s*id::\s*[^\n]+\n/gm, ''); await writeFile(abs, body, 'utf-8'); } async list(prefix) { if (typeof prefix !== 'string') { throw new Error('storage(logseq): list prefix must be a string'); } if (prefix.length > 0) this.resolveSafe(prefix); if (!existsSync(this.graph)) return []; const out = []; await walkInto(this.graph, this.graph, prefix, out); return out; } async delete(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return; await rm(abs, { force: true }); } } async function walkInto(root, dir, prefix, out) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { // Skip the graph config directory at the root level if (e.name === LOGSEQ_CONFIG_DIR && dir === root) continue; const full = join(dir, e.name); const rel = relative(root, full).split(sep).join('/'); if (e.isDirectory()) { if (prefix === '' || rel.startsWith(prefix) || prefix.startsWith(rel + '/')) { await walkInto(root, full, prefix, out); } continue; } if (!e.isFile()) continue; if (prefix !== '' && !rel.startsWith(prefix)) continue; let size; let modifiedAt; try { const s = await stat(full); size = s.size; modifiedAt = s.mtime; } catch { // ignore } const entry = { path: rel }; if (size !== undefined) entry.size = size; if (modifiedAt !== undefined) entry.modifiedAt = modifiedAt; out.push(entry); } } function expandHome(p) { if (p.startsWith('~/')) { const { homedir } = require('os'); return join(homedir(), p.slice(2)); } if (p === '~') { const { homedir } = require('os'); return homedir(); } return p; } /** * If `content` starts with a `---` YAML block, parse the block and * return the parsed fields plus the body without the YAML. Otherwise * return null. * * Minimal parser — handles `key: value` and `key: [a, b, c]` lines. * Anything more complex falls back to dropping the value as a string. */ function extractYamlFrontmatter(content) { const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/.exec(content); if (!match) return null; const [, yamlBody, body] = match; const fields = {}; for (const line of yamlBody.split(/\r?\n/)) { if (line.trim().length === 0 || line.trim().startsWith('#')) continue; const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line); if (!m) continue; const [, k, rawV] = m; fields[k] = parseYamlScalar(rawV.trim()); } return { fields, body }; } function parseYamlScalar(raw) { if (raw === '') return ''; if (raw === 'true') return true; if (raw === 'false') return false; if (raw === 'null' || raw === '~') return null; if (/^-?\d+$/.test(raw)) return Number(raw); if (/^-?\d+\.\d+$/.test(raw)) return Number(raw); // Quoted string if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) { try { return JSON.parse(raw.replace(/^'/, '"').replace(/'$/, '"')); } catch { return raw.slice(1, -1); } } // Inline array if (raw.startsWith('[') && raw.endsWith(']')) { return raw .slice(1, -1) .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0) .map(parseYamlScalar); } return raw; } /** * Render a flat object as Logseq page-level properties: * * tags:: ai, note * created:: 2026-04-28 * * <body follows here> */ function renderPageProperties(fields) { const lines = []; for (const [k, v] of Object.entries(fields)) { lines.push(`${k}:: ${formatLogseqValue(v)}`); } lines.push(''); return lines.join('\n'); } function formatLogseqValue(v) { if (v === null || v === undefined) return ''; if (typeof v === 'boolean' || typeof v === 'number') return String(v); if (Array.isArray(v)) return v.map((x) => formatLogseqValue(x)).join(', '); if (typeof v === 'string') return v; return JSON.stringify(v); } //# sourceMappingURL=logseq.js.map