diff --git a/extensions/memory-wiki/api.ts b/extensions/memory-wiki/api.ts index dcd1149f9b4..9181c2f95b7 100644 --- a/extensions/memory-wiki/api.ts +++ b/extensions/memory-wiki/api.ts @@ -2,6 +2,7 @@ export { buildPluginConfigSchema, definePluginEntry, type AnyAgentTool, + type OpenClawConfig, type OpenClawPluginApi, type OpenClawPluginConfigSchema, } from "openclaw/plugin-sdk/core"; diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index f7c832050e2..0dbf7d2be8e 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -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: [ diff --git a/extensions/memory-wiki/skills/obsidian-vault-maintainer/SKILL.md b/extensions/memory-wiki/skills/obsidian-vault-maintainer/SKILL.md index 7b9c5aa5b4f..adec894d43c 100644 --- a/extensions/memory-wiki/skills/obsidian-vault-maintainer/SKILL.md +++ b/extensions/memory-wiki/skills/obsidian-vault-maintainer/SKILL.md @@ -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. diff --git a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md index 8c1cdc97c82..f04fa72df0a 100644 --- a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md +++ b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md @@ -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. diff --git a/extensions/memory-wiki/src/bridge.test.ts b/extensions/memory-wiki/src/bridge.test.ts new file mode 100644 index 00000000000..98ddd5c34ee --- /dev/null +++ b/extensions/memory-wiki/src/bridge.test.ts @@ -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: [], + }); + }); +}); diff --git a/extensions/memory-wiki/src/bridge.ts b/extensions/memory-wiki/src/bridge.ts new file mode 100644 index 00000000000..bc2c173d3fb --- /dev/null +++ b/extensions/memory-wiki/src/bridge.ts @@ -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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function listMarkdownFilesRecursive(rootDir: string): Promise { + 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 { + 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 { + 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(); + 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", + "", + "", + "", + ].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 { + 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, + }; +} diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 9089fa2ccd4..64a6e1bc4b0 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -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 = process.stdout) { writer.write(output.endsWith("\n") ? output : `${output}\n`); } export async function runWikiStatus(params: { config: ResolvedMemoryWikiConfig; + appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { + 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; }) { + 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; }) { + 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; }) { + 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; }) { + 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; +}) { + 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; +}) { + 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; +}) { + 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; +}) { + 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; +}) { + 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; +}) { + 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("", "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("", "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("", "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 }); + }); } diff --git a/extensions/memory-wiki/src/obsidian.test.ts b/extensions/memory-wiki/src/obsidian.test.ts new file mode 100644 index 00000000000..7ae346340e2 --- /dev/null +++ b/extensions/memory-wiki/src/obsidian.test.ts @@ -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[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."); + }); +}); diff --git a/extensions/memory-wiki/src/obsidian.ts b/extensions/memory-wiki/src/obsidian.ts new file mode 100644 index 00000000000..3c7c793c2f4 --- /dev/null +++ b/extensions/memory-wiki/src/obsidian.ts @@ -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; +}; + +async function isExecutableFile(inputPath: string): Promise { + 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 { + 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, +): Promise { + 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 { + 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, + }); +} diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 9584257d79f..6714fad4dae 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -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 { } } -async function isExecutableFile(inputPath: string): Promise { - 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 { - 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> { const counts: Record = { entity: 0, @@ -172,9 +139,8 @@ export async function resolveMemoryWikiStatus( deps?: ResolveMemoryWikiStatusDeps, ): Promise { 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 }), }; } diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index c6d84402cb6..c6e0a69a7c3 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -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,