feat(memory-wiki): add gateway control methods

This commit is contained in:
Vincent Koc
2026-04-05 21:22:09 +01:00
parent 5a6d80da7f
commit c2a8aac282
4 changed files with 305 additions and 3 deletions

View File

@@ -0,0 +1,111 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js";
import type { OpenClawPluginApi } from "../api.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { registerMemoryWikiGatewayMethods } from "./gateway.js";
import { renderWikiMarkdown } from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
function createGatewayApi() {
const registerGatewayMethod = vi.fn();
const api = createTestPluginApi({
id: "memory-wiki",
name: "Memory Wiki",
source: "test",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
registerGatewayMethod,
}) as OpenClawPluginApi;
return { api, registerGatewayMethod };
}
function findGatewayHandler(
registerGatewayMethod: ReturnType<typeof vi.fn>,
method: string,
):
| ((ctx: {
params: Record<string, unknown>;
respond: (ok: boolean, payload?: unknown, error?: unknown) => void;
}) => Promise<void>)
| undefined {
return registerGatewayMethod.mock.calls.find((call) => call[0] === method)?.[1];
}
describe("memory-wiki gateway methods", () => {
it("returns wiki status over the gateway", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-gateway-"));
tempDirs.push(rootDir);
const { api, registerGatewayMethod } = createGatewayApi();
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.status");
if (!handler) {
throw new Error("wiki.status handler missing");
}
const respond = vi.fn();
await handler({
params: {},
respond,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
vaultMode: "isolated",
vaultExists: true,
}),
);
});
it("validates required query params for wiki.search", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-gateway-"));
tempDirs.push(rootDir);
const { api, registerGatewayMethod } = createGatewayApi();
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha" },
body: "# Alpha\n",
}),
"utf8",
);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.search");
if (!handler) {
throw new Error("wiki.search handler missing");
}
const respond = vi.fn();
await handler({
params: {},
respond,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "query is required." }),
);
});
});

View File

@@ -0,0 +1,178 @@
import type { OpenClawConfig, OpenClawPluginApi } from "../api.js";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { lintMemoryWikiVault } from "./lint.js";
import { probeObsidianCli } from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { buildMemoryWikiDoctorReport, resolveMemoryWikiStatus } from "./status.js";
const READ_SCOPE = "operator.read" as const;
const WRITE_SCOPE = "operator.write" as const;
function readStringParam(
params: Record<string, unknown>,
key: string,
options?: { required?: boolean },
): string | undefined {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (options?.required) {
throw new Error(`${key} is required.`);
}
return undefined;
}
function readNumberParam(params: Record<string, unknown>, key: string): number | undefined {
const value = params[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function respondError(
respond: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1] extends (
ctx: infer T,
) => unknown
? T["respond"]
: never,
error: unknown,
) {
const message = error instanceof Error ? error.message : String(error);
respond(false, undefined, { message });
}
async function syncImportedSourcesIfNeeded(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
) {
await syncMemoryWikiImportedSources({ config, appConfig });
}
export function registerMemoryWikiGatewayMethods(params: {
api: OpenClawPluginApi;
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}) {
const { api, config, appConfig } = params;
api.registerGatewayMethod(
"wiki.status",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await resolveMemoryWikiStatus(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.doctor",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
const status = await resolveMemoryWikiStatus(config);
respond(true, buildMemoryWikiDoctorReport(status));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.compile",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await compileMemoryWikiVault(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.lint",
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await lintMemoryWikiVault(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.search",
async ({ params: requestParams, respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
const query = readStringParam(requestParams, "query", { required: true });
const maxResults = readNumberParam(requestParams, "maxResults");
respond(
true,
await searchMemoryWiki({
config,
query,
maxResults,
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.get",
async ({ params: requestParams, respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
const lookup = readStringParam(requestParams, "lookup", { required: true });
const fromLine = readNumberParam(requestParams, "fromLine");
const lineCount = readNumberParam(requestParams, "lineCount");
respond(
true,
await getMemoryWikiPage({
config,
lookup,
fromLine,
lineCount,
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.obsidian.status",
async ({ respond }) => {
try {
respond(true, await probeObsidianCli());
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
}