feat(memory-wiki): scaffold wiki vault plugin

This commit is contained in:
Vincent Koc
2026-04-05 20:34:41 +01:00
parent b0c7bac9ce
commit 57d1685a65
18 changed files with 1206 additions and 1 deletions

4
.github/labeler.yml vendored
View File

@@ -222,6 +222,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-lancedb/**"
"extensions: memory-wiki":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-wiki/**"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -0,0 +1,8 @@
export {
buildPluginConfigSchema,
definePluginEntry,
type AnyAgentTool,
type OpenClawPluginApi,
type OpenClawPluginConfigSchema,
} from "openclaw/plugin-sdk/core";
export { z } from "openclaw/plugin-sdk/zod";

View File

@@ -0,0 +1,24 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
export default definePluginEntry({
id: "memory-wiki",
name: "Memory Wiki",
description: "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.",
register(api) {
api.registerCli(
async ({ program }) => {
const { registerWikiCli } = await import("./src/cli.js");
registerWikiCli(program);
},
{
descriptors: [
{
name: "wiki",
description: "Inspect and initialize the memory wiki vault",
hasSubcommands: true,
},
],
},
);
},
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import plugin from "./index.js";
function createApi() {
const registerCli = vi.fn();
const registerTool = vi.fn();
const api = createTestPluginApi({
id: "memory-wiki",
name: "Memory Wiki",
source: "test",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
registerCli,
registerTool,
}) as OpenClawPluginApi;
return { api, registerCli, registerTool };
}
describe("memory-wiki plugin", () => {
it("registers the status tool and wiki cli surface", async () => {
const { api, registerCli, registerTool } = createApi();
await plugin.register(api);
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerTool.mock.calls[0]?.[1]).toMatchObject({ name: "wiki_status" });
expect(registerCli).toHaveBeenCalledTimes(1);
expect(registerCli.mock.calls[0]?.[1]).toMatchObject({
descriptors: [
expect.objectContaining({
name: "wiki",
hasSubcommands: true,
}),
],
});
});
});

View File

@@ -0,0 +1,30 @@
import { definePluginEntry } from "./api.js";
import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import { createWikiStatusTool } from "./src/tool.js";
export default definePluginEntry({
id: "memory-wiki",
name: "Memory Wiki",
description: "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.",
configSchema: memoryWikiConfigSchema,
register(api) {
const config = resolveMemoryWikiConfig(api.pluginConfig);
api.registerTool(createWikiStatusTool(config), { name: "wiki_status" });
api.registerCli(
({ program }) => {
registerWikiCli(program, config);
},
{
descriptors: [
{
name: "wiki",
description: "Inspect and initialize the memory wiki vault",
hasSubcommands: true,
},
],
},
);
},
});

View File

@@ -0,0 +1,157 @@
{
"id": "memory-wiki",
"name": "Memory Wiki",
"description": "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.",
"skills": ["./skills"],
"uiHints": {
"vaultMode": {
"label": "Vault Mode",
"help": "Choose isolated, bridge, or unsafe-local mode for the wiki vault."
},
"vault.path": {
"label": "Vault Path",
"help": "Filesystem path for the wiki vault root."
},
"vault.renderMode": {
"label": "Render Mode",
"help": "Render markdown in native OpenClaw format or Obsidian-friendly format."
},
"obsidian.useOfficialCli": {
"label": "Use Obsidian CLI",
"help": "Probe and use the official Obsidian CLI when available."
},
"bridge.enabled": {
"label": "Enable Bridge Mode",
"help": "Read public memory artifacts and events from the selected memory plugin."
},
"unsafeLocal.allowPrivateMemoryCoreAccess": {
"label": "Allow Private Memory Access",
"help": "Experimental same-repo escape hatch for reading memory-core private paths."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"vaultMode": {
"type": "string",
"enum": ["isolated", "bridge", "unsafe-local"]
},
"vault": {
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"renderMode": {
"type": "string",
"enum": ["native", "obsidian"]
}
}
},
"obsidian": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"useOfficialCli": {
"type": "boolean"
},
"vaultName": {
"type": "string"
},
"openAfterWrites": {
"type": "boolean"
}
}
},
"bridge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"readMemoryCore": {
"type": "boolean"
},
"indexDreamReports": {
"type": "boolean"
},
"indexDailyNotes": {
"type": "boolean"
},
"indexMemoryRoot": {
"type": "boolean"
},
"followMemoryEvents": {
"type": "boolean"
}
}
},
"unsafeLocal": {
"type": "object",
"additionalProperties": false,
"properties": {
"allowPrivateMemoryCoreAccess": {
"type": "boolean"
},
"paths": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"ingest": {
"type": "object",
"additionalProperties": false,
"properties": {
"autoCompile": {
"type": "boolean"
},
"maxConcurrentJobs": {
"type": "number",
"minimum": 1
},
"allowUrlIngest": {
"type": "boolean"
}
}
},
"search": {
"type": "object",
"additionalProperties": false,
"properties": {
"backend": {
"type": "string",
"enum": ["shared", "local"]
},
"corpus": {
"type": "string",
"enum": ["wiki", "memory", "all"]
}
}
},
"render": {
"type": "object",
"additionalProperties": false,
"properties": {
"preserveHumanBlocks": {
"type": "boolean"
},
"createBacklinks": {
"type": "boolean"
},
"createDashboards": {
"type": "boolean"
}
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "@openclaw/memory-wiki",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw persistent wiki plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.4"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,11 @@
---
name: obsidian-vault-maintainer
description: Maintain an Obsidian-friendly memory wiki vault with wikilinks, frontmatter, and official Obsidian CLI awareness.
---
Use this skill when the memory-wiki vault render mode is `obsidian` or the user wants the wiki to play nicely with Obsidian.
- 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.
- Avoid destructive renames unless you also have a link-repair plan.

View File

@@ -0,0 +1,12 @@
---
name: wiki-maintainer
description: Maintain the OpenClaw memory wiki vault with deterministic pages, managed blocks, and source-backed updates.
---
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.
- 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.
- When creating or refreshing indexes, preserve Obsidian-friendly wikilinks if the vault render mode is `obsidian`.

View File

@@ -0,0 +1,64 @@
import type { Command } from "commander";
import type { MemoryWikiPluginConfig, ResolvedMemoryWikiConfig } from "./config.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
type WikiStatusCommandOptions = {
json?: boolean;
};
type WikiInitCommandOptions = {
json?: boolean;
};
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;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const status = await resolveMemoryWikiStatus(params.config);
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
params.stdout,
);
return status;
}
export async function runWikiInit(params: {
config: ResolvedMemoryWikiConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await initializeMemoryWikiVault(params.config);
const summary = params.json
? JSON.stringify(result, null, 2)
: `Initialized wiki vault at ${result.rootDir} (${result.createdDirectories.length} dirs, ${result.createdFiles.length} files).`;
writeOutput(summary, params.stdout);
return result;
}
export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPluginConfig) {
const config = 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, 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 });
});
}

View File

@@ -0,0 +1,80 @@
import fs from "node:fs";
import AjvPkg from "ajv";
import { describe, expect, it } from "vitest";
import {
DEFAULT_WIKI_RENDER_MODE,
DEFAULT_WIKI_SEARCH_BACKEND,
DEFAULT_WIKI_SEARCH_CORPUS,
DEFAULT_WIKI_VAULT_MODE,
resolveDefaultMemoryWikiVaultPath,
resolveMemoryWikiConfig,
} from "./config.js";
function compileManifestConfigSchema() {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema: Record<string, unknown> };
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
return ajv.compile(manifest.configSchema);
}
describe("resolveMemoryWikiConfig", () => {
it("returns isolated defaults", () => {
const config = resolveMemoryWikiConfig(undefined, { homedir: "/Users/tester" });
expect(config.vaultMode).toBe(DEFAULT_WIKI_VAULT_MODE);
expect(config.vault.renderMode).toBe(DEFAULT_WIKI_RENDER_MODE);
expect(config.vault.path).toBe(resolveDefaultMemoryWikiVaultPath("/Users/tester"));
expect(config.search.backend).toBe(DEFAULT_WIKI_SEARCH_BACKEND);
expect(config.search.corpus).toBe(DEFAULT_WIKI_SEARCH_CORPUS);
});
it("expands ~/ paths and preserves explicit modes", () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
vault: {
path: "~/vaults/wiki",
renderMode: "obsidian",
},
},
{ homedir: "/Users/tester" },
);
expect(config.vaultMode).toBe("bridge");
expect(config.vault.path).toBe("/Users/tester/vaults/wiki");
expect(config.vault.renderMode).toBe("obsidian");
});
});
describe("memory-wiki manifest config schema", () => {
it("accepts the documented config shape", () => {
const validate = compileManifestConfigSchema();
const config = {
vaultMode: "unsafe-local",
vault: {
path: "~/wiki",
renderMode: "obsidian",
},
obsidian: {
enabled: true,
useOfficialCli: true,
},
bridge: {
enabled: true,
followMemoryEvents: true,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: ["extensions/memory-core/src"],
},
search: {
backend: "shared",
corpus: "all",
},
};
expect(validate(config)).toBe(true);
});
});

View File

@@ -0,0 +1,244 @@
import os from "node:os";
import path from "node:path";
import { buildPluginConfigSchema, z, type OpenClawPluginConfigSchema } from "../api.js";
export const WIKI_VAULT_MODES = ["isolated", "bridge", "unsafe-local"] as const;
export const WIKI_RENDER_MODES = ["native", "obsidian"] as const;
export const WIKI_SEARCH_BACKENDS = ["shared", "local"] as const;
export const WIKI_SEARCH_CORPORA = ["wiki", "memory", "all"] as const;
export type WikiVaultMode = (typeof WIKI_VAULT_MODES)[number];
export type WikiRenderMode = (typeof WIKI_RENDER_MODES)[number];
export type WikiSearchBackend = (typeof WIKI_SEARCH_BACKENDS)[number];
export type WikiSearchCorpus = (typeof WIKI_SEARCH_CORPORA)[number];
export type MemoryWikiPluginConfig = {
vaultMode?: WikiVaultMode;
vault?: {
path?: string;
renderMode?: WikiRenderMode;
};
obsidian?: {
enabled?: boolean;
useOfficialCli?: boolean;
vaultName?: string;
openAfterWrites?: boolean;
};
bridge?: {
enabled?: boolean;
readMemoryCore?: boolean;
indexDreamReports?: boolean;
indexDailyNotes?: boolean;
indexMemoryRoot?: boolean;
followMemoryEvents?: boolean;
};
unsafeLocal?: {
allowPrivateMemoryCoreAccess?: boolean;
paths?: string[];
};
ingest?: {
autoCompile?: boolean;
maxConcurrentJobs?: number;
allowUrlIngest?: boolean;
};
search?: {
backend?: WikiSearchBackend;
corpus?: WikiSearchCorpus;
};
render?: {
preserveHumanBlocks?: boolean;
createBacklinks?: boolean;
createDashboards?: boolean;
};
};
export type ResolvedMemoryWikiConfig = {
vaultMode: WikiVaultMode;
vault: {
path: string;
renderMode: WikiRenderMode;
};
obsidian: {
enabled: boolean;
useOfficialCli: boolean;
vaultName?: string;
openAfterWrites: boolean;
};
bridge: {
enabled: boolean;
readMemoryCore: boolean;
indexDreamReports: boolean;
indexDailyNotes: boolean;
indexMemoryRoot: boolean;
followMemoryEvents: boolean;
};
unsafeLocal: {
allowPrivateMemoryCoreAccess: boolean;
paths: string[];
};
ingest: {
autoCompile: boolean;
maxConcurrentJobs: number;
allowUrlIngest: boolean;
};
search: {
backend: WikiSearchBackend;
corpus: WikiSearchCorpus;
};
render: {
preserveHumanBlocks: boolean;
createBacklinks: boolean;
createDashboards: boolean;
};
};
export const DEFAULT_WIKI_VAULT_MODE: WikiVaultMode = "isolated";
export const DEFAULT_WIKI_RENDER_MODE: WikiRenderMode = "native";
export const DEFAULT_WIKI_SEARCH_BACKEND: WikiSearchBackend = "shared";
export const DEFAULT_WIKI_SEARCH_CORPUS: WikiSearchCorpus = "wiki";
const MemoryWikiConfigSource = z.strictObject({
vaultMode: z.enum(WIKI_VAULT_MODES).optional(),
vault: z
.strictObject({
path: z.string().optional(),
renderMode: z.enum(WIKI_RENDER_MODES).optional(),
})
.optional(),
obsidian: z
.strictObject({
enabled: z.boolean().optional(),
useOfficialCli: z.boolean().optional(),
vaultName: z.string().optional(),
openAfterWrites: z.boolean().optional(),
})
.optional(),
bridge: z
.strictObject({
enabled: z.boolean().optional(),
readMemoryCore: z.boolean().optional(),
indexDreamReports: z.boolean().optional(),
indexDailyNotes: z.boolean().optional(),
indexMemoryRoot: z.boolean().optional(),
followMemoryEvents: z.boolean().optional(),
})
.optional(),
unsafeLocal: z
.strictObject({
allowPrivateMemoryCoreAccess: z.boolean().optional(),
paths: z.array(z.string()).optional(),
})
.optional(),
ingest: z
.strictObject({
autoCompile: z.boolean().optional(),
maxConcurrentJobs: z.number().int().min(1).optional(),
allowUrlIngest: z.boolean().optional(),
})
.optional(),
search: z
.strictObject({
backend: z.enum(WIKI_SEARCH_BACKENDS).optional(),
corpus: z.enum(WIKI_SEARCH_CORPORA).optional(),
})
.optional(),
render: z
.strictObject({
preserveHumanBlocks: z.boolean().optional(),
createBacklinks: z.boolean().optional(),
createDashboards: z.boolean().optional(),
})
.optional(),
});
const memoryWikiConfigSchemaBase = buildPluginConfigSchema(MemoryWikiConfigSource, {
safeParse(value: unknown) {
if (value === undefined) {
return { success: true, data: resolveMemoryWikiConfig(undefined) };
}
const result = MemoryWikiConfigSource.safeParse(value);
if (result.success) {
return { success: true, data: resolveMemoryWikiConfig(result.data) };
}
return {
success: false,
error: {
issues: result.error.issues.map((issue) => ({
path: issue.path.filter((segment): segment is string | number => {
const kind = typeof segment;
return kind === "string" || kind === "number";
}),
message: issue.message,
})),
},
};
},
});
export const memoryWikiConfigSchema: OpenClawPluginConfigSchema = memoryWikiConfigSchemaBase;
function expandHomePath(inputPath: string, homedir: string): string {
if (inputPath === "~") {
return homedir;
}
if (inputPath.startsWith("~/")) {
return path.join(homedir, inputPath.slice(2));
}
return inputPath;
}
export function resolveDefaultMemoryWikiVaultPath(homedir = os.homedir()): string {
return path.join(homedir, ".openclaw", "wiki", "main");
}
export function resolveMemoryWikiConfig(
config: MemoryWikiPluginConfig | undefined,
options?: { homedir?: string },
): ResolvedMemoryWikiConfig {
const homedir = options?.homedir ?? os.homedir();
const parsed = config ? MemoryWikiConfigSource.safeParse(config) : null;
const safeConfig = parsed?.success ? parsed.data : (config ?? {});
return {
vaultMode: safeConfig.vaultMode ?? DEFAULT_WIKI_VAULT_MODE,
vault: {
path: expandHomePath(
safeConfig.vault?.path ?? resolveDefaultMemoryWikiVaultPath(homedir),
homedir,
),
renderMode: safeConfig.vault?.renderMode ?? DEFAULT_WIKI_RENDER_MODE,
},
obsidian: {
enabled: safeConfig.obsidian?.enabled ?? false,
useOfficialCli: safeConfig.obsidian?.useOfficialCli ?? false,
...(safeConfig.obsidian?.vaultName ? { vaultName: safeConfig.obsidian.vaultName } : {}),
openAfterWrites: safeConfig.obsidian?.openAfterWrites ?? false,
},
bridge: {
enabled: safeConfig.bridge?.enabled ?? false,
readMemoryCore: safeConfig.bridge?.readMemoryCore ?? true,
indexDreamReports: safeConfig.bridge?.indexDreamReports ?? true,
indexDailyNotes: safeConfig.bridge?.indexDailyNotes ?? true,
indexMemoryRoot: safeConfig.bridge?.indexMemoryRoot ?? true,
followMemoryEvents: safeConfig.bridge?.followMemoryEvents ?? true,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: safeConfig.unsafeLocal?.allowPrivateMemoryCoreAccess ?? false,
paths: safeConfig.unsafeLocal?.paths ?? [],
},
ingest: {
autoCompile: safeConfig.ingest?.autoCompile ?? true,
maxConcurrentJobs: safeConfig.ingest?.maxConcurrentJobs ?? 1,
allowUrlIngest: safeConfig.ingest?.allowUrlIngest ?? true,
},
search: {
backend: safeConfig.search?.backend ?? DEFAULT_WIKI_SEARCH_BACKEND,
corpus: safeConfig.search?.corpus ?? DEFAULT_WIKI_SEARCH_CORPUS,
},
render: {
preserveHumanBlocks: safeConfig.render?.preserveHumanBlocks ?? true,
createBacklinks: safeConfig.render?.createBacklinks ?? true,
createDashboards: safeConfig.render?.createDashboards ?? true,
},
};
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { resolveMemoryWikiConfig } from "./config.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
describe("resolveMemoryWikiStatus", () => {
it("reports missing vault and missing requested obsidian cli", async () => {
const config = resolveMemoryWikiConfig(
{
vault: { path: "/tmp/wiki" },
obsidian: { enabled: true, useOfficialCli: true },
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
pathExists: async () => false,
resolveCommand: async () => null,
});
expect(status.vaultExists).toBe(false);
expect(status.obsidianCli.requested).toBe(true);
expect(status.warnings.map((warning) => warning.code)).toEqual([
"vault-missing",
"obsidian-cli-missing",
]);
});
it("warns when unsafe-local is selected without explicit private access", async () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "unsafe-local",
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
pathExists: async () => true,
resolveCommand: async () => "/usr/local/bin/obsidian",
});
expect(status.warnings.map((warning) => warning.code)).toContain("unsafe-local-disabled");
});
});
describe("renderMemoryWikiStatus", () => {
it("includes warnings in the text output", () => {
const rendered = renderMemoryWikiStatus({
vaultMode: "isolated",
renderMode: "native",
vaultPath: "/tmp/wiki",
vaultExists: false,
bridge: {
enabled: false,
readMemoryCore: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
obsidianCli: {
enabled: true,
requested: true,
available: false,
command: null,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: false,
pathCount: 0,
},
warnings: [{ code: "vault-missing", message: "Wiki vault has not been initialized yet." }],
});
expect(rendered).toContain("Wiki vault mode: isolated");
expect(rendered).toContain("Warnings:");
expect(rendered).toContain("Wiki vault has not been initialized yet.");
});
});

View File

@@ -0,0 +1,189 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemoryWikiConfig } from "./config.js";
export type MemoryWikiStatusWarning = {
code:
| "vault-missing"
| "obsidian-cli-missing"
| "bridge-disabled"
| "unsafe-local-disabled"
| "unsafe-local-paths-missing"
| "unsafe-local-without-mode";
message: string;
};
export type MemoryWikiStatus = {
vaultMode: ResolvedMemoryWikiConfig["vaultMode"];
renderMode: ResolvedMemoryWikiConfig["vault"]["renderMode"];
vaultPath: string;
vaultExists: boolean;
bridge: ResolvedMemoryWikiConfig["bridge"];
obsidianCli: {
enabled: boolean;
requested: boolean;
available: boolean;
command: string | null;
};
unsafeLocal: {
allowPrivateMemoryCoreAccess: boolean;
pathCount: number;
};
warnings: MemoryWikiStatusWarning[];
};
type ResolveMemoryWikiStatusDeps = {
pathExists?: (inputPath: string) => Promise<boolean>;
resolveCommand?: (command: string) => Promise<string | null>;
};
async function pathExists(inputPath: string): Promise<boolean> {
try {
await fs.access(inputPath);
return true;
} catch {
return false;
}
}
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;
}
function buildWarnings(params: {
config: ResolvedMemoryWikiConfig;
vaultExists: boolean;
obsidianCommand: string | null;
}): MemoryWikiStatusWarning[] {
const warnings: MemoryWikiStatusWarning[] = [];
if (!params.vaultExists) {
warnings.push({
code: "vault-missing",
message: "Wiki vault has not been initialized yet.",
});
}
if (
params.config.obsidian.enabled &&
params.config.obsidian.useOfficialCli &&
!params.obsidianCommand
) {
warnings.push({
code: "obsidian-cli-missing",
message: "Obsidian CLI is enabled in config but `obsidian` is not available on PATH.",
});
}
if (params.config.vaultMode === "bridge" && !params.config.bridge.enabled) {
warnings.push({
code: "bridge-disabled",
message: "vaultMode is `bridge` but bridge.enabled is false.",
});
}
if (
params.config.vaultMode === "unsafe-local" &&
!params.config.unsafeLocal.allowPrivateMemoryCoreAccess
) {
warnings.push({
code: "unsafe-local-disabled",
message: "vaultMode is `unsafe-local` but private memory-core access is disabled.",
});
}
if (
params.config.vaultMode === "unsafe-local" &&
params.config.unsafeLocal.allowPrivateMemoryCoreAccess &&
params.config.unsafeLocal.paths.length === 0
) {
warnings.push({
code: "unsafe-local-paths-missing",
message: "unsafe-local access is enabled but no private paths are configured.",
});
}
if (
params.config.vaultMode !== "unsafe-local" &&
params.config.unsafeLocal.allowPrivateMemoryCoreAccess
) {
warnings.push({
code: "unsafe-local-without-mode",
message: "Private memory-core access is enabled outside unsafe-local mode.",
});
}
return warnings;
}
export async function resolveMemoryWikiStatus(
config: ResolvedMemoryWikiConfig,
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");
return {
vaultMode: config.vaultMode,
renderMode: config.vault.renderMode,
vaultPath: config.vault.path,
vaultExists,
bridge: config.bridge,
obsidianCli: {
enabled: config.obsidian.enabled,
requested: config.obsidian.enabled && config.obsidian.useOfficialCli,
available: obsidianCommand !== null,
command: obsidianCommand,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: config.unsafeLocal.allowPrivateMemoryCoreAccess,
pathCount: config.unsafeLocal.paths.length,
},
warnings: buildWarnings({ config, vaultExists, obsidianCommand }),
};
}
export function renderMemoryWikiStatus(status: MemoryWikiStatus): string {
const lines = [
`Wiki vault mode: ${status.vaultMode}`,
`Vault: ${status.vaultExists ? "ready" : "missing"} (${status.vaultPath})`,
`Render mode: ${status.renderMode}`,
`Obsidian CLI: ${status.obsidianCli.available ? "available" : "missing"}${status.obsidianCli.requested ? " (requested)" : ""}`,
`Bridge: ${status.bridge.enabled ? "enabled" : "disabled"}`,
`Unsafe local: ${status.unsafeLocal.allowPrivateMemoryCoreAccess ? `enabled (${status.unsafeLocal.pathCount} paths)` : "disabled"}`,
];
if (status.warnings.length > 0) {
lines.push("", "Warnings:");
for (const warning of status.warnings) {
lines.push(`- ${warning.message}`);
}
}
return lines.join("\n");
}

View File

@@ -0,0 +1,23 @@
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
const WikiStatusSchema = Type.Object({}, { additionalProperties: false });
export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgentTool {
return {
name: "wiki_status",
label: "Wiki Status",
description:
"Inspect the current memory wiki vault mode, health, and Obsidian CLI availability.",
parameters: WikiStatusSchema,
execute: async () => {
const status = await resolveMemoryWikiStatus(config);
return {
content: [{ type: "text", text: renderMemoryWikiStatus(status) }],
details: status,
};
},
};
}

View File

@@ -0,0 +1,72 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveMemoryWikiConfig } from "./config.js";
import { initializeMemoryWikiVault, WIKI_VAULT_DIRECTORIES } from "./vault.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
}),
);
});
describe("initializeMemoryWikiVault", () => {
it("creates the wiki layout and seed files", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: {
path: rootDir,
renderMode: "obsidian",
},
},
{ homedir: "/Users/tester" },
);
const result = await initializeMemoryWikiVault(config, {
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
expect(result.created).toBe(true);
await Promise.all(
WIKI_VAULT_DIRECTORIES.map(async (relativeDir) => {
await expect(fs.stat(path.join(rootDir, relativeDir))).resolves.toBeTruthy();
}),
);
await expect(fs.readFile(path.join(rootDir, "AGENTS.md"), "utf8")).resolves.toContain(
"Memory Wiki Agent Guide",
);
await expect(fs.readFile(path.join(rootDir, "WIKI.md"), "utf8")).resolves.toContain(
"Render mode: `obsidian`",
);
await expect(
fs.readFile(path.join(rootDir, ".openclaw-wiki", "state.json"), "utf8"),
).resolves.toContain('"renderMode": "obsidian"');
});
it("is idempotent when the vault already exists", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: {
path: rootDir,
},
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
const second = await initializeMemoryWikiVault(config);
expect(second.created).toBe(false);
expect(second.createdDirectories).toHaveLength(0);
expect(second.createdFiles).toHaveLength(0);
});
});

View File

@@ -0,0 +1,144 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import type { ResolvedMemoryWikiConfig } from "./config.js";
export const WIKI_VAULT_DIRECTORIES = [
"entities",
"concepts",
"syntheses",
"sources",
"reports",
"_attachments",
"_views",
".openclaw-wiki",
".openclaw-wiki/locks",
".openclaw-wiki/cache",
] as const;
export type InitializeMemoryWikiVaultResult = {
rootDir: string;
created: boolean;
createdDirectories: string[];
createdFiles: string[];
};
function buildIndexMarkdown(): string {
return withTrailingNewline(
replaceManagedMarkdownBlock({
original: "# Wiki Index\n",
heading: "## Generated",
startMarker: "<!-- openclaw:wiki:index:start -->",
endMarker: "<!-- openclaw:wiki:index:end -->",
body: "- No compiled pages yet.",
}),
);
}
function buildAgentsMarkdown(): string {
return withTrailingNewline(`\
# Memory Wiki Agent Guide
- Treat generated blocks as plugin-owned.
- Preserve human notes outside managed markers.
- Prefer source-backed claims over wiki-to-wiki citation loops.
`);
}
function buildWikiOverviewMarkdown(config: ResolvedMemoryWikiConfig): string {
return withTrailingNewline(`\
# Memory Wiki
This vault is maintained by the OpenClaw memory-wiki plugin.
- Vault mode: \`${config.vaultMode}\`
- Render mode: \`${config.vault.renderMode}\`
- Search corpus default: \`${config.search.corpus}\`
## Notes
<!-- openclaw:human:start -->
<!-- openclaw:human:end -->
`);
}
async function pathExists(inputPath: string): Promise<boolean> {
try {
await fs.access(inputPath);
return true;
} catch {
return false;
}
}
async function writeFileIfMissing(
filePath: string,
content: string,
createdFiles: string[],
): Promise<void> {
if (await pathExists(filePath)) {
return;
}
await fs.writeFile(filePath, content, "utf8");
createdFiles.push(filePath);
}
export async function initializeMemoryWikiVault(
config: ResolvedMemoryWikiConfig,
options?: { nowMs?: number },
): Promise<InitializeMemoryWikiVaultResult> {
const rootDir = config.vault.path;
const createdDirectories: string[] = [];
const createdFiles: string[] = [];
if (!(await pathExists(rootDir))) {
createdDirectories.push(rootDir);
}
await fs.mkdir(rootDir, { recursive: true });
for (const relativeDir of WIKI_VAULT_DIRECTORIES) {
const fullPath = path.join(rootDir, relativeDir);
if (!(await pathExists(fullPath))) {
createdDirectories.push(fullPath);
}
await fs.mkdir(fullPath, { recursive: true });
}
await writeFileIfMissing(path.join(rootDir, "AGENTS.md"), buildAgentsMarkdown(), createdFiles);
await writeFileIfMissing(
path.join(rootDir, "WIKI.md"),
buildWikiOverviewMarkdown(config),
createdFiles,
);
await writeFileIfMissing(path.join(rootDir, "index.md"), buildIndexMarkdown(), createdFiles);
await writeFileIfMissing(
path.join(rootDir, "inbox.md"),
withTrailingNewline("# Inbox\n\nDrop raw ideas, questions, and source links here.\n"),
createdFiles,
);
await writeFileIfMissing(
path.join(rootDir, ".openclaw-wiki", "state.json"),
withTrailingNewline(
JSON.stringify(
{
version: 1,
createdAt: new Date(options?.nowMs ?? Date.now()).toISOString(),
renderMode: config.vault.renderMode,
},
null,
2,
),
),
createdFiles,
);
await writeFileIfMissing(path.join(rootDir, ".openclaw-wiki", "log.jsonl"), "", createdFiles);
return {
rootDir,
created: createdDirectories.length > 0 || createdFiles.length > 0,
createdDirectories,
createdFiles,
};
}

View File

@@ -1,4 +1,8 @@
export const memoryExtensionTestRoots = ["extensions/memory-core", "extensions/memory-lancedb"];
export const memoryExtensionTestRoots = [
"extensions/memory-core",
"extensions/memory-lancedb",
"extensions/memory-wiki",
];
export function isMemoryExtensionRoot(root) {
return memoryExtensionTestRoots.includes(root);