import fs from "node:fs/promises"; import type { Command } from "commander"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; import type { OpenClawConfig } from "../api.js"; import { applyMemoryWikiMutation } from "./apply.js"; import { importChatGptConversations, rollbackChatGptImportRun, type ChatGptImportResult, type ChatGptRollbackResult, } from "./chatgpt-import.js"; import { compileMemoryWikiVault } from "./compile.js"; import { resolveMemoryWikiConfig, WIKI_SEARCH_BACKENDS, WIKI_SEARCH_CORPORA, type MemoryWikiPluginConfig, type ResolvedMemoryWikiConfig, } 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, WIKI_SEARCH_MODES, type WikiSearchMode, } from "./query.js"; import { syncMemoryWikiImportedSources } from "./source-sync.js"; import type { MemoryWikiImportedSourceSyncResult } from "./source-sync.js"; import { buildMemoryWikiDoctorReport, renderMemoryWikiDoctor, renderMemoryWikiStatus, type MemoryWikiDoctorReport, type MemoryWikiStatus, resolveMemoryWikiStatus, } from "./status.js"; import { initializeMemoryWikiVault } from "./vault.js"; const WIKI_GATEWAY_TIMEOUT_MS = "30000"; const GATEWAY_TERMINAL_STRING_MAX_CHARS = 2_000; const GATEWAY_RESPONSE_MAX_ARRAY_ITEMS = 10_000; const GATEWAY_RESPONSE_MAX_STRING_CHARS = 10_000; const GATEWAY_RESPONSE_MAX_CODE_CHARS = 256; const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp( String.raw`(?:\x1B\[[0-?]*[ -/]*[@-~]|\x1B[@-Z\\-_]|\x9B[0-?]*[ -/]*[@-~])`, "g", ); const TERMINAL_CONTROL_CHARACTER_PATTERN = new RegExp(String.raw`[\x00-\x1F\x7F-\x9F]+`, "g"); const UNICODE_FORMAT_CONTROL_PATTERN = /[\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g; type WikiStatusCommandOptions = { json?: boolean; }; type WikiDoctorCommandOptions = { json?: boolean; }; type WikiInitCommandOptions = { json?: boolean; }; type WikiCompileCommandOptions = { json?: boolean; }; type WikiLintCommandOptions = { json?: boolean; }; type WikiIngestCommandOptions = { json?: boolean; title?: string; }; type WikiSearchCommandOptions = { json?: boolean; maxResults?: number; backend?: ResolvedMemoryWikiConfig["search"]["backend"]; corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; mode?: WikiSearchMode; }; type WikiGetCommandOptions = { json?: boolean; from?: number; lines?: number; backend?: ResolvedMemoryWikiConfig["search"]["backend"]; corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; }; type WikiApplySynthesisCommandOptions = { json?: boolean; body?: string; bodyFile?: string; sourceId?: string[]; contradiction?: string[]; question?: string[]; confidence?: number; status?: string; }; type WikiApplyMetadataCommandOptions = { json?: boolean; sourceId?: string[]; contradiction?: string[]; question?: string[]; confidence?: number; clearConfidence?: boolean; status?: string; }; type WikiBridgeImportCommandOptions = { json?: boolean; }; type WikiUnsafeLocalImportCommandOptions = { json?: boolean; }; type WikiChatGptImportCommandOptions = { json?: boolean; dryRun?: boolean; export?: string; }; type WikiChatGptRollbackCommandOptions = { 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 sanitizeGatewayStringForTerminal(value: string): string { const truncated = value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS ? value.slice(0, GATEWAY_TERMINAL_STRING_MAX_CHARS) : value; const sanitized = truncated .replace(ANSI_ESCAPE_SEQUENCE_PATTERN, "") .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, " ") .replace(UNICODE_FORMAT_CONTROL_PATTERN, ""); return value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS ? `${sanitized}... [truncated]` : sanitized; } function escapeGatewayJsonForTerminal(json: string): string { return json.replace(UNICODE_FORMAT_CONTROL_PATTERN, (char) => { const codePoint = char.codePointAt(0); return typeof codePoint === "number" ? `\\u${codePoint.toString(16).padStart(4, "0")}` : ""; }); } function writeOutput(output: string, writer: Pick = process.stdout) { writer.write(output.endsWith("\n") ? output : `${output}\n`); } function shouldRouteBridgeRuntimeThroughGateway(config: ResolvedMemoryWikiConfig): boolean { return ( config.vaultMode === "bridge" && config.bridge.enabled && config.bridge.readMemoryArtifacts ); } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function isBoundedGatewayString( value: unknown, maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS, ): value is string { return typeof value === "string" && value.length <= maxChars; } function isStringArray( value: unknown, maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS, ): value is string[] { return ( Array.isArray(value) && value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS && value.every((item) => isBoundedGatewayString(item, maxChars)) ); } function hasNumberFields(value: Record, keys: readonly string[]): boolean { return keys.every((key) => typeof value[key] === "number"); } function isWarningList(value: unknown): boolean { return ( Array.isArray(value) && value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS && value.every( (item) => isRecord(item) && isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(item.message), ) ); } function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus { if (!isRecord(value)) { return false; } const bridge = value.bridge; const obsidianCli = value.obsidianCli; const unsafeLocal = value.unsafeLocal; const pageCounts = value.pageCounts; const sourceCounts = value.sourceCounts; return ( isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(value.vaultPath) && typeof value.vaultExists === "boolean" && (typeof value.bridgePublicArtifactCount === "number" || value.bridgePublicArtifactCount === null) && isRecord(bridge) && typeof bridge.enabled === "boolean" && isRecord(obsidianCli) && typeof obsidianCli.enabled === "boolean" && typeof obsidianCli.requested === "boolean" && typeof obsidianCli.available === "boolean" && (isBoundedGatewayString(obsidianCli.command) || obsidianCli.command === null) && isRecord(unsafeLocal) && typeof unsafeLocal.allowPrivateMemoryCoreAccess === "boolean" && typeof unsafeLocal.pathCount === "number" && isRecord(pageCounts) && hasNumberFields(pageCounts, ["source", "entity", "concept", "synthesis", "report"]) && isRecord(sourceCounts) && hasNumberFields(sourceCounts, ["native", "bridge", "bridgeEvents", "unsafeLocal", "other"]) && isWarningList(value.warnings) ); } function isMemoryWikiDoctorReport(value: unknown): value is MemoryWikiDoctorReport { return ( isRecord(value) && typeof value.healthy === "boolean" && typeof value.warningCount === "number" && isMemoryWikiStatus(value.status) && Array.isArray(value.fixes) && value.fixes.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS && value.fixes.every( (item) => isRecord(item) && isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(item.message), ) ); } function isMemoryWikiImportResult(value: unknown): value is MemoryWikiImportedSourceSyncResult { return ( isRecord(value) && hasNumberFields(value, [ "importedCount", "updatedCount", "skippedCount", "removedCount", "artifactCount", "workspaces", ]) && isStringArray(value.pagePaths) && typeof value.indexesRefreshed === "boolean" && isStringArray(value.indexUpdatedFiles) && isBoundedGatewayString(value.indexRefreshReason, GATEWAY_RESPONSE_MAX_CODE_CHARS) ); } function validateWikiGatewayResult( method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import", value: unknown, ): MemoryWikiStatus | MemoryWikiDoctorReport | MemoryWikiImportedSourceSyncResult { if (method === "wiki.status" && isMemoryWikiStatus(value)) { return value; } if (method === "wiki.doctor" && isMemoryWikiDoctorReport(value)) { return value; } if (method === "wiki.bridge.import" && isMemoryWikiImportResult(value)) { return value; } throw new Error(`Invalid Gateway response for ${method}.`); } async function callWikiGateway(method: "wiki.status"): Promise; async function callWikiGateway(method: "wiki.doctor"): Promise; async function callWikiGateway( method: "wiki.bridge.import", ): Promise; async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") { const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, { progress: false, }); return validateWikiGatewayResult(method, result); } function normalizeCliStringList(values?: string[]): string[] | undefined { if (!values) { return undefined; } const normalized = values .map((value) => value.trim()) .filter(Boolean) .filter((value, index, all) => all.indexOf(value) === index); return normalized.length > 0 ? normalized : undefined; } function collectCliValues(value: string, acc: string[] = []) { acc.push(value); return acc; } function parseWikiSearchEnumOption( value: string, allowed: readonly T[], label: string, ): T { if ((allowed as readonly string[]).includes(value)) { return value as T; } throw new Error(`Invalid ${label}: ${value}. Expected one of: ${allowed.join(", ")}`); } async function resolveWikiApplyBody(params: { body?: string; bodyFile?: string }): Promise { if (params.body?.trim()) { return params.body; } if (params.bodyFile?.trim()) { return await fs.readFile(params.bodyFile, "utf8"); } throw new Error("wiki apply synthesis requires --body or --body-file."); } type MemoryWikiMutationResult = Awaited>; function formatMemoryWikiMutationSummary(result: MemoryWikiMutationResult, json?: boolean): string { if (json) { return JSON.stringify(result, null, 2); } return `${result.changed ? "Updated" : "No changes for"} ${result.pagePath} via ${result.operation}. ${result.compile.updatedFiles.length > 0 ? `Refreshed ${result.compile.updatedFiles.length} index file${result.compile.updatedFiles.length === 1 ? "" : "s"}.` : "Indexes unchanged."}`; } function formatJsonOrText( result: T, json: boolean | undefined, render: (result: T) => string, ): string { return json ? JSON.stringify(result, null, 2) : render(result); } function formatGatewayJsonOrText( result: T, json: boolean | undefined, render: (result: T) => string, ): string { return json ? escapeGatewayJsonForTerminal(JSON.stringify(result, null, 2)) : sanitizeGatewayStringForTerminal(render(result)); } async function runWikiCommandWithSummary(params: { json?: boolean; stdout?: Pick; run: () => Promise; render: (result: T) => string; }): Promise { const result = await params.run(); writeOutput(formatJsonOrText(result, params.json, params.render), params.stdout); return result; } async function runSyncedWikiCommandWithSummary(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; run: () => Promise; render: (result: T) => string; }): Promise { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); return runWikiCommandWithSummary(params); } function addWikiSearchConfigOptions(command: T): T { return command .option( "--backend ", `Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`, (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"), ) .option( "--corpus ", `Search corpus (${WIKI_SEARCH_CORPORA.join(", ")})`, (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_CORPORA, "corpus"), ); } function addWikiApplyMutationOptions(command: T): T { return command .option("--source-id ", "Source id", collectCliValues) .option("--contradiction ", "Contradiction note", collectCliValues) .option("--question ", "Open question", collectCliValues) .option("--confidence ", "Confidence score between 0 and 1", (value: string) => Number(value), ) .option("--status ", "Page status"); } export async function runWikiStatus(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config); const status = routeThroughGateway ? await callWikiGateway("wiki.status") : await (async () => { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); return await resolveMemoryWikiStatus(params.config, { appConfig: params.appConfig, }); })(); writeOutput( routeThroughGateway ? formatGatewayJsonOrText(status, params.json, renderMemoryWikiStatus) : formatJsonOrText(status, params.json, renderMemoryWikiStatus), params.stdout, ); return status; } export async function runWikiDoctor(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config); const report = routeThroughGateway ? await callWikiGateway("wiki.doctor") : await (async () => { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); return buildMemoryWikiDoctorReport( await resolveMemoryWikiStatus(params.config, { appConfig: params.appConfig, }), ); })(); if (!report.healthy) { process.exitCode = 1; } writeOutput( routeThroughGateway ? formatGatewayJsonOrText(report, params.json, renderMemoryWikiDoctor) : formatJsonOrText(report, params.json, renderMemoryWikiDoctor), params.stdout, ); return report; } export async function runWikiInit(params: { config: ResolvedMemoryWikiConfig; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => initializeMemoryWikiVault(params.config), render: (value) => `Initialized wiki vault at ${value.rootDir} (${value.createdDirectories.length} dirs, ${value.createdFiles.length} files).`, }); } export async function runWikiCompile(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { return runSyncedWikiCommandWithSummary({ config: params.config, appConfig: params.appConfig, json: params.json, stdout: params.stdout, run: () => compileMemoryWikiVault(params.config), render: (value) => `Compiled wiki vault at ${value.vaultRoot} (${value.pages.length} pages, ${value.updatedFiles.length} indexes updated).`, }); } export async function runWikiLint(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { return runSyncedWikiCommandWithSummary({ config: params.config, appConfig: params.appConfig, json: params.json, stdout: params.stdout, run: () => lintMemoryWikiVault(params.config), render: (value) => `Linted wiki vault at ${value.vaultRoot} (${value.issueCount} issues, report: ${value.reportPath}).`, }); } export async function runWikiIngest(params: { config: ResolvedMemoryWikiConfig; inputPath: string; title?: string; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => ingestMemoryWikiSource({ config: params.config, inputPath: params.inputPath, title: params.title, }), render: (value) => `Ingested ${value.sourcePath} into ${value.pagePath}. Refreshed ${value.indexUpdatedFiles.length} index file${value.indexUpdatedFiles.length === 1 ? "" : "s"}.`, }); } export async function runWikiSearch(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; query: string; maxResults?: number; searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"]; searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; mode?: WikiSearchMode; json?: boolean; stdout?: Pick; }) { if (params.mode && !(WIKI_SEARCH_MODES as readonly string[]).includes(params.mode)) { throw new Error(`wiki search --mode must be one of: ${WIKI_SEARCH_MODES.join(", ")}.`); } await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); const results = await searchMemoryWiki({ config: params.config, appConfig: params.appConfig, query: params.query, maxResults: params.maxResults, searchBackend: params.searchBackend, searchCorpus: params.searchCorpus, mode: params.mode, }); const summary = params.json ? JSON.stringify(results, null, 2) : results.length === 0 ? "No wiki or memory results." : results .map( (result, index) => `${index + 1}. ${result.title} (${result.corpus}/${result.kind})\nPath: ${result.path}${typeof result.startLine === "number" && typeof result.endLine === "number" ? `\nLines: ${result.startLine}-${result.endLine}` : ""}${result.provenanceLabel ? `\nProvenance: ${result.provenanceLabel}` : ""}${result.matchedClaimId ? `\nClaim: ${result.matchedClaimId}` : ""}${result.evidenceKinds && result.evidenceKinds.length > 0 ? `\nEvidence: ${result.evidenceKinds.join(", ")}` : ""}\nSnippet: ${result.snippet}`, ) .join("\n\n"); writeOutput(summary, params.stdout); return results; } export async function runWikiGet(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; lookup: string; fromLine?: number; lineCount?: number; searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"]; searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; json?: boolean; stdout?: Pick; }) { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); const result = await getMemoryWikiPage({ config: params.config, appConfig: params.appConfig, lookup: params.lookup, fromLine: params.fromLine, lineCount: params.lineCount, searchBackend: params.searchBackend, searchCorpus: params.searchCorpus, }); const summary = params.json ? JSON.stringify(result, null, 2) : (result?.content ?? `Wiki page not found: ${params.lookup}`); writeOutput(summary, params.stdout); return result; } export async function runWikiApplySynthesis(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; title: string; body?: string; bodyFile?: string; sourceIds?: string[]; contradictions?: string[]; questions?: string[]; confidence?: number; status?: string; json?: boolean; stdout?: Pick; }) { const sourceIds = normalizeCliStringList(params.sourceIds); if (!sourceIds) { throw new Error("wiki apply synthesis requires at least one --source-id."); } const body = await resolveWikiApplyBody({ body: params.body, bodyFile: params.bodyFile }); await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); const result = await applyMemoryWikiMutation({ config: params.config, mutation: { op: "create_synthesis", title: params.title, body, sourceIds, ...(normalizeCliStringList(params.contradictions) ? { contradictions: normalizeCliStringList(params.contradictions) } : {}), ...(normalizeCliStringList(params.questions) ? { questions: normalizeCliStringList(params.questions) } : {}), ...(typeof params.confidence === "number" ? { confidence: params.confidence } : {}), ...(params.status?.trim() ? { status: params.status.trim() } : {}), }, }); writeOutput(formatMemoryWikiMutationSummary(result, params.json), params.stdout); return result; } export async function runWikiApplyMetadata(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; lookup: string; sourceIds?: string[]; contradictions?: string[]; questions?: string[]; confidence?: number; clearConfidence?: boolean; status?: string; json?: boolean; stdout?: Pick; }) { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); const result = await applyMemoryWikiMutation({ config: params.config, mutation: { op: "update_metadata", lookup: params.lookup, ...(normalizeCliStringList(params.sourceIds) ? { sourceIds: normalizeCliStringList(params.sourceIds) } : {}), ...(normalizeCliStringList(params.contradictions) ? { contradictions: normalizeCliStringList(params.contradictions) } : {}), ...(normalizeCliStringList(params.questions) ? { questions: normalizeCliStringList(params.questions) } : {}), ...(params.clearConfidence ? { confidence: null } : typeof params.confidence === "number" ? { confidence: params.confidence } : {}), ...(params.status?.trim() ? { status: params.status.trim() } : {}), }, }); writeOutput(formatMemoryWikiMutationSummary(result, params.json), params.stdout); return result; } export async function runWikiBridgeImport(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { const render = (value: MemoryWikiImportedSourceSyncResult) => `Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`; if (shouldRouteBridgeRuntimeThroughGateway(params.config)) { const result = await callWikiGateway("wiki.bridge.import"); writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout); return result; } return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig, }), render, }); } export async function runWikiUnsafeLocalImport(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig, }), render: (value) => `Unsafe-local import synced ${value.artifactCount} artifacts (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`, }); } export async function runWikiObsidianStatus(params: { config: ResolvedMemoryWikiConfig; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => probeObsidianCli(), render: (value) => value.available ? `Obsidian CLI available at ${value.command}` : "Obsidian CLI is not available on PATH.", }); } export async function runWikiObsidianSearch(params: { config: ResolvedMemoryWikiConfig; query: string; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => runObsidianSearch({ config: params.config, query: params.query }), render: (value) => value.stdout.trim(), }); } export async function runWikiObsidianOpenCli(params: { config: ResolvedMemoryWikiConfig; vaultPath: string; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => runObsidianOpen({ config: params.config, vaultPath: params.vaultPath }), render: (value) => value.stdout.trim() || "Opened in Obsidian.", }); } export async function runWikiObsidianCommandCli(params: { config: ResolvedMemoryWikiConfig; id: string; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => runObsidianCommand({ config: params.config, id: params.id }), render: (value) => value.stdout.trim() || "Command sent to Obsidian.", }); } export async function runWikiObsidianDailyCli(params: { config: ResolvedMemoryWikiConfig; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => runObsidianDaily({ config: params.config }), render: (value) => value.stdout.trim() || "Opened today's daily note.", }); } function formatChatGptImportSummary(result: ChatGptImportResult): string { if (result.dryRun) { return `ChatGPT import dry run scanned ${result.conversationCount} conversations (${result.createdCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged).`; } const runSuffix = result.runId ? ` Run id: ${result.runId}.` : ""; return `ChatGPT import applied ${result.conversationCount} conversations (${result.createdCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged). Refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.${runSuffix}`; } function formatChatGptRollbackSummary(result: ChatGptRollbackResult): string { if (result.alreadyRolledBack) { return `ChatGPT import run ${result.runId} was already rolled back.`; } return `Rolled back ChatGPT import run ${result.runId} (${result.removedCount} removed, ${result.restoredCount} restored). Refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`; } export async function runWikiChatGptImport(params: { config: ResolvedMemoryWikiConfig; exportPath: string; dryRun?: boolean; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => importChatGptConversations({ config: params.config, exportPath: params.exportPath, dryRun: params.dryRun, }), render: formatChatGptImportSummary, }); } export async function runWikiChatGptRollback(params: { config: ResolvedMemoryWikiConfig; runId: string; json?: boolean; stdout?: Pick; }) { return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, run: () => rollbackChatGptImportRun({ config: params.config, runId: params.runId, }), render: formatChatGptRollbackSummary, }); } 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 .command("status") .description("Show wiki vault status") .option("--json", "Print JSON") .action(async (opts: WikiStatusCommandOptions) => { await runWikiStatus({ config, appConfig, json: opts.json }); }); wiki .command("doctor") .description("Audit wiki vault setup and report actionable fixes") .option("--json", "Print JSON") .action(async (opts: WikiDoctorCommandOptions) => { await runWikiDoctor({ config, appConfig, json: opts.json }); }); wiki .command("init") .description("Initialize the wiki vault layout") .option("--json", "Print JSON") .action(async (opts: WikiInitCommandOptions) => { await runWikiInit({ config, json: opts.json }); }); wiki .command("compile") .description("Refresh generated wiki indexes") .option("--json", "Print JSON") .action(async (opts: WikiCompileCommandOptions) => { await runWikiCompile({ config, appConfig, json: opts.json }); }); wiki .command("lint") .description("Lint the wiki vault and write a report") .option("--json", "Print JSON") .action(async (opts: WikiLintCommandOptions) => { await runWikiLint({ config, appConfig, json: opts.json }); }); wiki .command("ingest") .description("Ingest a local file into the wiki sources folder") .argument("", "Local file path to ingest") .option("--title ", "Override the source title") .option("--json", "Print JSON") .action(async (inputPath: string, opts: WikiIngestCommandOptions) => { await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json }); }); addWikiSearchConfigOptions( wiki .command("search") .description("Search wiki pages and, when configured, the active memory corpus") .argument("<query>", "Search query") .option("--max-results <n>", "Maximum results", (value: string) => Number(value)) .option("--mode <mode>", `Search mode (${WIKI_SEARCH_MODES.join(", ")})`), ) .option("--json", "Print JSON") .action(async (query: string, opts: WikiSearchCommandOptions) => { await runWikiSearch({ config, appConfig, query, maxResults: opts.maxResults, searchBackend: opts.backend, searchCorpus: opts.corpus, mode: opts.mode, json: opts.json, }); }); addWikiSearchConfigOptions( wiki .command("get") .description("Read a wiki page by id or relative path, with optional active-memory fallback") .argument("<lookup>", "Relative path or page id") .option("--from <n>", "Start line", (value: string) => Number(value)) .option("--lines <n>", "Number of lines", (value: string) => Number(value)), ) .option("--json", "Print JSON") .action(async (lookup: string, opts: WikiGetCommandOptions) => { await runWikiGet({ config, appConfig, lookup, fromLine: opts.from, lineCount: opts.lines, searchBackend: opts.backend, searchCorpus: opts.corpus, json: opts.json, }); }); const apply = wiki.command("apply").description("Apply narrow wiki mutations"); addWikiApplyMutationOptions( apply .command("synthesis") .description("Create or refresh a synthesis page with managed summary content") .argument("<title>", "Synthesis title") .option("--body <text>", "Summary body text") .option("--body-file <path>", "Read summary body text from a file"), ) .option("--json", "Print JSON") .action(async (title: string, opts: WikiApplySynthesisCommandOptions) => { await runWikiApplySynthesis({ config, appConfig, title, body: opts.body, bodyFile: opts.bodyFile, sourceIds: opts.sourceId, contradictions: opts.contradiction, questions: opts.question, confidence: opts.confidence, status: opts.status, json: opts.json, }); }); addWikiApplyMutationOptions( apply .command("metadata") .description("Update metadata on an existing page") .argument("<lookup>", "Relative path or page id"), ) .option("--clear-confidence", "Remove any stored confidence value") .option("--json", "Print JSON") .action(async (lookup: string, opts: WikiApplyMetadataCommandOptions) => { await runWikiApplyMetadata({ config, appConfig, lookup, sourceIds: opts.sourceId, contradictions: opts.contradiction, questions: opts.question, confidence: opts.confidence, clearConfidence: opts.clearConfidence, status: opts.status, json: opts.json, }); }); const bridge = wiki .command("bridge") .description("Import public memory artifacts into the wiki vault"); bridge .command("import") .description("Sync bridge-backed memory artifacts into wiki source pages") .option("--json", "Print JSON") .action(async (opts: WikiBridgeImportCommandOptions) => { await runWikiBridgeImport({ config, appConfig, json: opts.json }); }); const unsafeLocal = wiki .command("unsafe-local") .description("Import explicitly configured private local paths into wiki source pages"); unsafeLocal .command("import") .description("Sync unsafe-local configured paths into wiki source pages") .option("--json", "Print JSON") .action(async (opts: WikiUnsafeLocalImportCommandOptions) => { await runWikiUnsafeLocalImport({ config, appConfig, json: opts.json }); }); const chatgpt = wiki .command("chatgpt") .description("Import ChatGPT export history into wiki source pages"); chatgpt .command("import") .description("Import a ChatGPT export into draft wiki source pages") .requiredOption("--export <path>", "ChatGPT export directory or conversations.json path") .option("--dry-run", "Preview changes without writing", false) .option("--json", "Print JSON") .action(async (opts: WikiChatGptImportCommandOptions) => { await runWikiChatGptImport({ config, exportPath: opts.export!, dryRun: opts.dryRun, json: opts.json, }); }); chatgpt .command("rollback") .description("Roll back a previously applied ChatGPT import run") .argument("<run-id>", "Import run id") .option("--json", "Print JSON") .action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => { await runWikiChatGptRollback({ config, runId, 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 }); }); }