feat(memory-wiki): add bridge sync and obsidian cli adapter

This commit is contained in:
Vincent Koc 2026-04-05 20:52:23 +01:00
parent 82710f2add
commit fbbe2a1675
11 changed files with 825 additions and 52 deletions

View File

@ -2,6 +2,7 @@ export {
buildPluginConfigSchema,
definePluginEntry,
type AnyAgentTool,
type OpenClawConfig,
type OpenClawPluginApi,
type OpenClawPluginConfigSchema,
} from "openclaw/plugin-sdk/core";

View File

@ -11,12 +11,12 @@ export default definePluginEntry({
register(api) {
const config = resolveMemoryWikiConfig(api.pluginConfig);
api.registerTool(createWikiStatusTool(config), { name: "wiki_status" });
api.registerTool(createWikiSearchTool(config), { name: "wiki_search" });
api.registerTool(createWikiGetTool(config), { name: "wiki_get" });
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiSearchTool(config, api.config), { name: "wiki_search" });
api.registerTool(createWikiGetTool(config, api.config), { name: "wiki_get" });
api.registerCli(
({ program }) => {
registerWikiCli(program, config);
registerWikiCli(program, config, api.config);
},
{
descriptors: [

View File

@ -6,6 +6,7 @@ description: Maintain an Obsidian-friendly memory wiki vault with wikilinks, fro
Use this skill when the memory-wiki vault render mode is `obsidian` or the user wants the wiki to play nicely with Obsidian.
- Start from `openclaw wiki status` to confirm the vault mode and whether the official Obsidian CLI is available.
- Use `openclaw wiki obsidian status` before shelling out, then prefer the dedicated helpers like `openclaw wiki obsidian search`, `openclaw wiki obsidian open`, `openclaw wiki obsidian command`, and `openclaw wiki obsidian daily`.
- Prefer `[[Wikilinks]]`, stable filenames, and frontmatter that works with Obsidian dashboards and Dataview-style queries.
- Keep generated sections deterministic so Obsidian users can safely add handwritten notes around them.
- If the official Obsidian CLI is enabled, probe it before depending on it. Do not assume the app is installed, running, or configured.

View File

@ -8,6 +8,7 @@ Use this skill when working inside a memory-wiki vault.
- Prefer `wiki_status` first when you need to understand the vault mode, path, or Obsidian CLI availability.
- Use `wiki_search` to discover candidate pages, then `wiki_get` to inspect the exact page before editing or citing it.
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory-core artifacts pulled in.
- Keep generated sections inside managed markers. Do not overwrite human note blocks.
- Treat raw sources, memory artifacts, and daily notes as evidence. Do not let wiki pages become the only source of truth for new claims.
- Keep page identity stable. Favor updating existing entities and concepts over spawning duplicates with slightly different names.

View File

@ -0,0 +1,109 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { syncMemoryWikiBridgeSources } from "./bridge.js";
import { resolveMemoryWikiConfig } from "./config.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("syncMemoryWikiBridgeSources", () => {
it("imports public memory-core artifacts and stays idempotent across reruns", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-ws-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-vault-"));
tempDirs.push(workspaceDir, vaultDir);
await fs.mkdir(path.join(workspaceDir, "memory", "dreaming"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
await fs.writeFile(
path.join(workspaceDir, "memory", "2026-04-05.md"),
"# Daily Note\n",
"utf8",
);
await fs.writeFile(
path.join(workspaceDir, "memory", "dreaming", "2026-04-05.md"),
"# Dream Report\n",
"utf8",
);
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
vault: { path: vaultDir },
bridge: {
enabled: true,
readMemoryCore: true,
indexMemoryRoot: true,
indexDailyNotes: true,
indexDreamReports: true,
},
},
{ homedir: "/Users/tester" },
);
const appConfig: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {},
},
},
},
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(first.workspaces).toBe(1);
expect(first.artifactCount).toBe(3);
expect(first.importedCount).toBe(3);
expect(first.updatedCount).toBe(0);
expect(first.skippedCount).toBe(0);
expect(first.pagePaths).toHaveLength(3);
const sourcePages = await fs.readdir(path.join(vaultDir, "sources"));
expect(sourcePages.filter((name) => name.startsWith("bridge-"))).toHaveLength(3);
const memoryPage = await fs.readFile(path.join(vaultDir, first.pagePaths[0] ?? ""), "utf8");
expect(memoryPage).toContain("sourceType: memory-bridge");
expect(memoryPage).toContain("## Bridge Source");
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(second.importedCount).toBe(0);
expect(second.updatedCount).toBe(0);
expect(second.skippedCount).toBe(3);
const logLines = (await fs.readFile(path.join(vaultDir, ".openclaw-wiki", "log.jsonl"), "utf8"))
.trim()
.split("\n");
expect(logLines).toHaveLength(2);
});
it("returns a no-op result outside bridge mode", async () => {
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-isolated-"));
tempDirs.push(vaultDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: vaultDir } },
{ homedir: "/Users/tester" },
);
const result = await syncMemoryWikiBridgeSources({ config });
expect(result).toMatchObject({
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
});
});
});

View File

@ -0,0 +1,280 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-host-status";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
type BridgeArtifact = {
workspaceDir: string;
relativePath: string;
absolutePath: string;
};
export type BridgeMemoryWikiResult = {
importedCount: number;
updatedCount: number;
skippedCount: number;
artifactCount: number;
workspaces: number;
pagePaths: string[];
};
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function resolveArtifactKey(absolutePath: string): Promise<string> {
const canonicalPath = await fs.realpath(absolutePath).catch(() => path.resolve(absolutePath));
return process.platform === "win32" ? canonicalPath.toLowerCase() : canonicalPath;
}
async function collectWorkspaceArtifacts(
workspaceDir: string,
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
): Promise<BridgeArtifact[]> {
const artifacts: BridgeArtifact[] = [];
if (bridgeConfig.indexMemoryRoot) {
for (const relPath of ["MEMORY.md", "memory.md"]) {
const absolutePath = path.join(workspaceDir, relPath);
if (await pathExists(absolutePath)) {
artifacts.push({
workspaceDir,
relativePath: relPath,
absolutePath,
});
}
}
}
if (bridgeConfig.indexDailyNotes) {
const memoryDir = path.join(workspaceDir, "memory");
const files = await listMarkdownFilesRecursive(memoryDir);
for (const absolutePath of files) {
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
if (!relativePath.startsWith("memory/dreaming/")) {
artifacts.push({
workspaceDir,
relativePath,
absolutePath,
});
}
}
}
if (bridgeConfig.indexDreamReports) {
const dreamingDir = path.join(workspaceDir, "memory", "dreaming");
const files = await listMarkdownFilesRecursive(dreamingDir);
for (const absolutePath of files) {
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
artifacts.push({
workspaceDir,
relativePath,
absolutePath,
});
}
}
const deduped = new Map<string, BridgeArtifact>();
for (const artifact of artifacts) {
deduped.set(await resolveArtifactKey(artifact.absolutePath), artifact);
}
return [...deduped.values()];
}
function resolveBridgeTitle(relativePath: string, agentIds: string[]): string {
const base = relativePath
.replace(/\.md$/i, "")
.replace(/^memory\//, "")
.replace(/\//g, " / ");
if (agentIds.length === 0) {
return `Memory Bridge: ${base}`;
}
return `Memory Bridge (${agentIds.join(", ")}): ${base}`;
}
function resolveBridgePagePath(params: { workspaceDir: string; relativePath: string }): {
pageId: string;
pagePath: string;
workspaceSlug: string;
artifactSlug: string;
} {
const workspaceBaseSlug = slugifyWikiSegment(path.basename(params.workspaceDir));
const workspaceHash = createHash("sha1").update(path.resolve(params.workspaceDir)).digest("hex");
const artifactBaseSlug = slugifyWikiSegment(
params.relativePath.replace(/\.md$/i, "").replace(/\//g, "-"),
);
const artifactHash = createHash("sha1").update(params.relativePath).digest("hex");
const workspaceSlug = `${workspaceBaseSlug}-${workspaceHash.slice(0, 8)}`;
const artifactSlug = `${artifactBaseSlug}-${artifactHash.slice(0, 8)}`;
return {
pageId: `source.bridge.${workspaceSlug}.${artifactSlug}`,
pagePath: path
.join("sources", `bridge-${workspaceSlug}-${artifactSlug}.md`)
.replace(/\\/g, "/"),
workspaceSlug,
artifactSlug,
};
}
async function writeBridgeSourcePage(params: {
config: ResolvedMemoryWikiConfig;
artifact: BridgeArtifact;
agentIds: string[];
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const { pageId, pagePath } = resolveBridgePagePath({
workspaceDir: params.artifact.workspaceDir,
relativePath: params.artifact.relativePath,
});
const title = resolveBridgeTitle(params.artifact.relativePath, params.agentIds);
const pageAbsPath = path.join(params.config.vault.path, pagePath);
const created = !(await pathExists(pageAbsPath));
const raw = await fs.readFile(params.artifact.absolutePath, "utf8");
const stats = await fs.stat(params.artifact.absolutePath);
const sourceUpdatedAt = stats.mtime.toISOString();
const rendered = renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title,
sourceType: "memory-bridge",
sourcePath: params.artifact.absolutePath,
bridgeRelativePath: params.artifact.relativePath,
bridgeWorkspaceDir: params.artifact.workspaceDir,
bridgeAgentIds: params.agentIds,
status: "active",
updatedAt: sourceUpdatedAt,
},
body: [
`# ${title}`,
"",
"## Bridge Source",
`- Workspace: \`${params.artifact.workspaceDir}\``,
`- Relative path: \`${params.artifact.relativePath}\``,
`- Agents: ${params.agentIds.length > 0 ? params.agentIds.join(", ") : "unknown"}`,
`- Updated: ${sourceUpdatedAt}`,
"",
"## Content",
renderMarkdownFence(raw, "markdown"),
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
if (existing === rendered) {
return { pagePath, changed: false, created };
}
await fs.writeFile(pageAbsPath, rendered, "utf8");
return { pagePath, changed: true, created };
}
export async function syncMemoryWikiBridgeSources(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}): Promise<BridgeMemoryWikiResult> {
await initializeMemoryWikiVault(params.config);
if (
params.config.vaultMode !== "bridge" ||
!params.config.bridge.enabled ||
!params.config.bridge.readMemoryCore ||
!params.appConfig
) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const memoryPluginConfig = resolveMemoryCorePluginConfig(params.appConfig);
if (!memoryPluginConfig) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const workspaces = resolveMemoryDreamingWorkspaces(params.appConfig);
const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = [];
let artifactCount = 0;
for (const workspace of workspaces) {
const artifacts = await collectWorkspaceArtifacts(workspace.workspaceDir, params.config.bridge);
artifactCount += artifacts.length;
for (const artifact of artifacts) {
results.push(
await writeBridgeSourcePage({
config: params.config,
artifact,
agentIds: workspace.agentIds,
}),
);
}
}
const importedCount = results.filter((result) => result.changed && result.created).length;
const updatedCount = results.filter((result) => result.changed && !result.created).length;
const skippedCount = results.filter((result) => !result.changed).length;
const pagePaths = results
.map((result) => result.pagePath)
.toSorted((left, right) => left.localeCompare(right));
if (importedCount > 0 || updatedCount > 0) {
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp: new Date().toISOString(),
details: {
sourceType: "memory-bridge",
workspaces: workspaces.length,
artifactCount,
importedCount,
updatedCount,
skippedCount,
},
});
}
return {
importedCount,
updatedCount,
skippedCount,
artifactCount,
workspaces: workspaces.length,
pagePaths,
};
}

View File

@ -1,9 +1,18 @@
import type { Command } from "commander";
import type { OpenClawConfig } from "../api.js";
import { syncMemoryWikiBridgeSources } from "./bridge.js";
import { compileMemoryWikiVault } from "./compile.js";
import type { MemoryWikiPluginConfig, ResolvedMemoryWikiConfig } from "./config.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { lintMemoryWikiVault } from "./lint.js";
import {
probeObsidianCli,
runObsidianCommand,
runObsidianDaily,
runObsidianOpen,
runObsidianSearch,
} from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
@ -40,15 +49,50 @@ type WikiGetCommandOptions = {
lines?: number;
};
type WikiBridgeImportCommandOptions = {
json?: boolean;
};
type WikiObsidianSearchCommandOptions = {
json?: boolean;
};
type WikiObsidianOpenCommandOptions = {
json?: boolean;
};
type WikiObsidianCommandCommandOptions = {
json?: boolean;
};
type WikiObsidianDailyCommandOptions = {
json?: boolean;
};
function isResolvedMemoryWikiConfig(
config: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig | undefined,
): config is ResolvedMemoryWikiConfig {
return Boolean(
config &&
"vaultMode" in config &&
"vault" in config &&
"bridge" in config &&
"obsidian" in config &&
"unsafeLocal" in config,
);
}
function writeOutput(output: string, writer: Pick<NodeJS.WriteStream, "write"> = process.stdout) {
writer.write(output.endsWith("\n") ? output : `${output}\n`);
}
export async function runWikiStatus(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiBridgeSources({ config: params.config, appConfig: params.appConfig });
const status = await resolveMemoryWikiStatus(params.config);
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
@ -72,9 +116,11 @@ export async function runWikiInit(params: {
export async function runWikiCompile(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiBridgeSources({ config: params.config, appConfig: params.appConfig });
const result = await compileMemoryWikiVault(params.config);
const summary = params.json
? JSON.stringify(result, null, 2)
@ -85,9 +131,11 @@ export async function runWikiCompile(params: {
export async function runWikiLint(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiBridgeSources({ config: params.config, appConfig: params.appConfig });
const result = await lintMemoryWikiVault(params.config);
const summary = params.json
? JSON.stringify(result, null, 2)
@ -117,11 +165,13 @@ export async function runWikiIngest(params: {
export async function runWikiSearch(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
query: string;
maxResults?: number;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiBridgeSources({ config: params.config, appConfig: params.appConfig });
const results = await searchMemoryWiki({
config: params.config,
query: params.query,
@ -143,12 +193,14 @@ export async function runWikiSearch(params: {
export async function runWikiGet(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
lookup: string;
fromLine?: number;
lineCount?: number;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiBridgeSources({ config: params.config, appConfig: params.appConfig });
const result = await getMemoryWikiPage({
config: params.config,
lookup: params.lookup,
@ -162,8 +214,99 @@ export async function runWikiGet(params: {
return result;
}
export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPluginConfig) {
const config = resolveMemoryWikiConfig(pluginConfig);
export async function runWikiBridgeImport(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await syncMemoryWikiBridgeSources({
config: params.config,
appConfig: params.appConfig,
});
const summary = params.json
? JSON.stringify(result, null, 2)
: `Bridge import synced ${result.artifactCount} artifacts across ${result.workspaces} workspaces (${result.importedCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged).`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianStatus(params: {
config: ResolvedMemoryWikiConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await probeObsidianCli();
const summary = params.json
? JSON.stringify(result, null, 2)
: result.available
? `Obsidian CLI available at ${result.command}`
: "Obsidian CLI is not available on PATH.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianSearch(params: {
config: ResolvedMemoryWikiConfig;
query: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianSearch({ config: params.config, query: params.query });
const summary = params.json ? JSON.stringify(result, null, 2) : result.stdout.trim();
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianOpenCli(params: {
config: ResolvedMemoryWikiConfig;
vaultPath: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianOpen({ config: params.config, vaultPath: params.vaultPath });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Opened in Obsidian.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianCommandCli(params: {
config: ResolvedMemoryWikiConfig;
id: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianCommand({ config: params.config, id: params.id });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Command sent to Obsidian.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianDailyCli(params: {
config: ResolvedMemoryWikiConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianDaily({ config: params.config });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Opened today's daily note.";
writeOutput(summary, params.stdout);
return result;
}
export function registerWikiCli(
program: Command,
pluginConfig?: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
) {
const config = isResolvedMemoryWikiConfig(pluginConfig)
? pluginConfig
: resolveMemoryWikiConfig(pluginConfig);
const wiki = program.command("wiki").description("Inspect and initialize the memory wiki vault");
wiki
@ -171,7 +314,7 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi
.description("Show wiki vault status")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
await runWikiStatus({ config, json: opts.json });
await runWikiStatus({ config, appConfig, json: opts.json });
});
wiki
@ -187,7 +330,7 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi
.description("Refresh generated wiki indexes")
.option("--json", "Print JSON")
.action(async (opts: WikiCompileCommandOptions) => {
await runWikiCompile({ config, json: opts.json });
await runWikiCompile({ config, appConfig, json: opts.json });
});
wiki
@ -195,7 +338,7 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi
.description("Lint the wiki vault and write a report")
.option("--json", "Print JSON")
.action(async (opts: WikiLintCommandOptions) => {
await runWikiLint({ config, json: opts.json });
await runWikiLint({ config, appConfig, json: opts.json });
});
wiki
@ -217,6 +360,7 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi
.action(async (query: string, opts: WikiSearchCommandOptions) => {
await runWikiSearch({
config,
appConfig,
query,
maxResults: opts.maxResults,
json: opts.json,
@ -233,10 +377,62 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi
.action(async (lookup: string, opts: WikiGetCommandOptions) => {
await runWikiGet({
config,
appConfig,
lookup,
fromLine: opts.from,
lineCount: opts.lines,
json: opts.json,
});
});
const bridge = wiki
.command("bridge")
.description("Import public memory-core artifacts into the wiki vault");
bridge
.command("import")
.description("Sync bridge-backed memory-core artifacts into wiki source pages")
.option("--json", "Print JSON")
.action(async (opts: WikiBridgeImportCommandOptions) => {
await runWikiBridgeImport({ config, appConfig, json: opts.json });
});
const obsidian = wiki.command("obsidian").description("Run official Obsidian CLI helpers");
obsidian
.command("status")
.description("Probe the Obsidian CLI")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
await runWikiObsidianStatus({ config, json: opts.json });
});
obsidian
.command("search")
.description("Search the current Obsidian vault")
.argument("<query>", "Search query")
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiObsidianSearchCommandOptions) => {
await runWikiObsidianSearch({ config, query, json: opts.json });
});
obsidian
.command("open")
.description("Open a file in Obsidian by vault-relative path")
.argument("<path>", "Vault-relative path")
.option("--json", "Print JSON")
.action(async (vaultPath: string, opts: WikiObsidianOpenCommandOptions) => {
await runWikiObsidianOpenCli({ config, vaultPath, json: opts.json });
});
obsidian
.command("command")
.description("Execute an Obsidian command palette command by id")
.argument("<id>", "Obsidian command id")
.option("--json", "Print JSON")
.action(async (id: string, opts: WikiObsidianCommandCommandOptions) => {
await runWikiObsidianCommandCli({ config, id, json: opts.json });
});
obsidian
.command("daily")
.description("Open today's daily note in Obsidian")
.option("--json", "Print JSON")
.action(async (opts: WikiObsidianDailyCommandOptions) => {
await runWikiObsidianDailyCli({ config, json: opts.json });
});
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { resolveMemoryWikiConfig } from "./config.js";
import { runObsidianDaily, runObsidianSearch } from "./obsidian.js";
describe("runObsidianSearch", () => {
it("builds the official obsidian cli argv with the configured vault name", async () => {
const config = resolveMemoryWikiConfig(
{
obsidian: {
enabled: true,
useOfficialCli: true,
vaultName: "OpenClaw Wiki",
},
},
{ homedir: "/Users/tester" },
);
const calls: Array<{ command: string; argv: string[] }> = [];
const exec: NonNullable<
NonNullable<Parameters<typeof runObsidianSearch>[0]["deps"]>["exec"]
> = async (command, argv) => {
calls.push({ command, argv: [...argv] });
return { stdout: "search output\n", stderr: "" };
};
const result = await runObsidianSearch({
config,
query: "agent memory",
deps: {
exec,
resolveCommand: async () => "/usr/local/bin/obsidian",
},
});
expect(calls).toEqual([
{
command: "/usr/local/bin/obsidian",
argv: ["vault=OpenClaw Wiki", "search", "query=agent memory"],
},
]);
expect(result.stdout).toBe("search output\n");
});
});
describe("runObsidianDaily", () => {
it("fails cleanly when the obsidian cli is not installed", async () => {
const config = resolveMemoryWikiConfig(undefined, { homedir: "/Users/tester" });
await expect(
runObsidianDaily({
config,
deps: {
resolveCommand: async () => null,
},
}),
).rejects.toThrow("Obsidian CLI is not available on PATH.");
});
});

View File

@ -0,0 +1,145 @@
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { ResolvedMemoryWikiConfig } from "./config.js";
const execFileAsync = promisify(execFile);
export type ObsidianCliProbe = {
available: boolean;
command: string | null;
};
export type ObsidianCliResult = {
command: string;
argv: string[];
stdout: string;
stderr: string;
};
type ObsidianCliDeps = {
exec?: typeof execFileAsync;
resolveCommand?: (command: string) => Promise<string | null>;
};
async function isExecutableFile(inputPath: string): Promise<boolean> {
try {
await fs.access(inputPath, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolveCommandOnPath(command: string): Promise<string | null> {
const pathValue = process.env.PATH ?? "";
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
const windowsExts =
process.platform === "win32"
? (process.env.PATHEXT?.split(";").filter(Boolean) ?? [".EXE", ".CMD", ".BAT"])
: [""];
if (command.includes(path.sep)) {
return (await isExecutableFile(command)) ? command : null;
}
for (const dir of pathEntries) {
for (const extension of windowsExts) {
const candidate = path.join(dir, extension ? `${command}${extension}` : command);
if (await isExecutableFile(candidate)) {
return candidate;
}
}
}
return null;
}
function buildVaultPrefix(config: ResolvedMemoryWikiConfig): string[] {
return config.obsidian.vaultName ? [`vault=${config.obsidian.vaultName}`] : [];
}
export async function probeObsidianCli(
deps?: Pick<ObsidianCliDeps, "resolveCommand">,
): Promise<ObsidianCliProbe> {
const resolveCommand = deps?.resolveCommand ?? resolveCommandOnPath;
const command = await resolveCommand("obsidian");
return {
available: command !== null,
command,
};
}
export async function runObsidianCli(params: {
config: ResolvedMemoryWikiConfig;
subcommand: string;
args?: string[];
deps?: ObsidianCliDeps;
}): Promise<ObsidianCliResult> {
const resolveCommand = params.deps?.resolveCommand ?? resolveCommandOnPath;
const exec = params.deps?.exec ?? execFileAsync;
const probe = await probeObsidianCli({ resolveCommand });
if (!probe.command) {
throw new Error("Obsidian CLI is not available on PATH.");
}
const argv = [...buildVaultPrefix(params.config), params.subcommand, ...(params.args ?? [])];
const { stdout, stderr } = await exec(probe.command, argv, { encoding: "utf8" });
return {
command: probe.command,
argv,
stdout,
stderr,
};
}
export async function runObsidianSearch(params: {
config: ResolvedMemoryWikiConfig;
query: string;
deps?: ObsidianCliDeps;
}) {
return await runObsidianCli({
config: params.config,
subcommand: "search",
args: [`query=${params.query}`],
deps: params.deps,
});
}
export async function runObsidianOpen(params: {
config: ResolvedMemoryWikiConfig;
vaultPath: string;
deps?: ObsidianCliDeps;
}) {
return await runObsidianCli({
config: params.config,
subcommand: "open",
args: [`path=${params.vaultPath}`],
deps: params.deps,
});
}
export async function runObsidianCommand(params: {
config: ResolvedMemoryWikiConfig;
id: string;
deps?: ObsidianCliDeps;
}) {
return await runObsidianCli({
config: params.config,
subcommand: "command",
args: [`id=${params.id}`],
deps: params.deps,
});
}
export async function runObsidianDaily(params: {
config: ResolvedMemoryWikiConfig;
deps?: ObsidianCliDeps;
}) {
return await runObsidianCli({
config: params.config,
subcommand: "daily",
deps: params.deps,
});
}

View File

@ -1,8 +1,8 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { inferWikiPageKind, type WikiPageKind } from "./markdown.js";
import { probeObsidianCli } from "./obsidian.js";
export type MemoryWikiStatusWarning = {
code:
@ -49,39 +49,6 @@ async function pathExists(inputPath: string): Promise<boolean> {
}
}
async function isExecutableFile(inputPath: string): Promise<boolean> {
try {
await fs.access(inputPath, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function resolveCommandOnPath(command: string): Promise<string | null> {
const pathValue = process.env.PATH ?? "";
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
const windowsExts =
process.platform === "win32"
? (process.env.PATHEXT?.split(";").filter(Boolean) ?? [".EXE", ".CMD", ".BAT"])
: [""];
if (command.includes(path.sep)) {
return (await isExecutableFile(command)) ? command : null;
}
for (const dir of pathEntries) {
for (const extension of windowsExts) {
const candidate = path.join(dir, extension ? `${command}${extension}` : command);
if (await isExecutableFile(candidate)) {
return candidate;
}
}
}
return null;
}
async function collectPageCounts(vaultPath: string): Promise<Record<WikiPageKind, number>> {
const counts: Record<WikiPageKind, number> = {
entity: 0,
@ -172,9 +139,8 @@ export async function resolveMemoryWikiStatus(
deps?: ResolveMemoryWikiStatusDeps,
): Promise<MemoryWikiStatus> {
const exists = deps?.pathExists ?? pathExists;
const resolveCommand = deps?.resolveCommand ?? resolveCommandOnPath;
const vaultExists = await exists(config.vault.path);
const obsidianCommand = await resolveCommand("obsidian");
const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand });
const pageCounts = vaultExists
? await collectPageCounts(config.vault.path)
: {
@ -194,15 +160,15 @@ export async function resolveMemoryWikiStatus(
obsidianCli: {
enabled: config.obsidian.enabled,
requested: config.obsidian.enabled && config.obsidian.useOfficialCli,
available: obsidianCommand !== null,
command: obsidianCommand,
available: obsidianProbe.available,
command: obsidianProbe.command,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: config.unsafeLocal.allowPrivateMemoryCoreAccess,
pathCount: config.unsafeLocal.paths.length,
},
pageCounts,
warnings: buildWarnings({ config, vaultExists, obsidianCommand }),
warnings: buildWarnings({ config, vaultExists, obsidianCommand: obsidianProbe.command }),
};
}

View File

@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool } from "../api.js";
import type { AnyAgentTool, OpenClawConfig } from "../api.js";
import { syncMemoryWikiBridgeSources } from "./bridge.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
@ -21,7 +22,14 @@ const WikiGetSchema = Type.Object(
{ additionalProperties: false },
);
export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgentTool {
async function syncBridgeIfNeeded(config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig) {
await syncMemoryWikiBridgeSources({ config, appConfig });
}
export function createWikiStatusTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_status",
label: "Wiki Status",
@ -29,6 +37,7 @@ export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgent
"Inspect the current memory wiki vault mode, health, and Obsidian CLI availability.",
parameters: WikiStatusSchema,
execute: async () => {
await syncBridgeIfNeeded(config, appConfig);
const status = await resolveMemoryWikiStatus(config);
return {
content: [{ type: "text", text: renderMemoryWikiStatus(status) }],
@ -38,7 +47,10 @@ export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgent
};
}
export function createWikiSearchTool(config: ResolvedMemoryWikiConfig): AnyAgentTool {
export function createWikiSearchTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_search",
label: "Wiki Search",
@ -46,6 +58,7 @@ export function createWikiSearchTool(config: ResolvedMemoryWikiConfig): AnyAgent
parameters: WikiSearchSchema,
execute: async (_toolCallId, rawParams) => {
const params = rawParams as { query: string; maxResults?: number };
await syncBridgeIfNeeded(config, appConfig);
const results = await searchMemoryWiki({
config,
query: params.query,
@ -68,7 +81,10 @@ export function createWikiSearchTool(config: ResolvedMemoryWikiConfig): AnyAgent
};
}
export function createWikiGetTool(config: ResolvedMemoryWikiConfig): AnyAgentTool {
export function createWikiGetTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_get",
label: "Wiki Get",
@ -76,6 +92,7 @@ export function createWikiGetTool(config: ResolvedMemoryWikiConfig): AnyAgentToo
parameters: WikiGetSchema,
execute: async (_toolCallId, rawParams) => {
const params = rawParams as { lookup: string; fromLine?: number; lineCount?: number };
await syncBridgeIfNeeded(config, appConfig);
const result = await getMemoryWikiPage({
config,
lookup: params.lookup,