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
JavaScript
/**
* 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