revert(memory-wiki): back out llm wiki stack

This commit is contained in:
Vincent Koc
2026-04-05 22:43:13 +01:00
parent e29d370969
commit 94256ea1a0
82 changed files with 94 additions and 8846 deletions

4
.github/labeler.yml vendored
View File

@@ -222,10 +222,6 @@
- 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

@@ -1,2 +1,2 @@
daee639272244b49495fccf9304ffa27268db81fde6e3521402a9441fe4cc757 plugin-sdk-api-baseline.json
2d1360e3845652a225e43243f4e1310627a3f54e862ea5f9ef771919149a12d8 plugin-sdk-api-baseline.jsonl
97509287d728c8f5d1736f7ea07521451ada4b9d7ef56555dbe860a89e1b6e08 plugin-sdk-api-baseline.json
a22b3d427953cc8394b28c87ef7a992d2eb4f2c9f6a76fa58b33079e2306661b plugin-sdk-api-baseline.jsonl

View File

@@ -287,17 +287,10 @@ Current bundled provider examples:
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |
| `plugin-sdk/memory-core-host-query` | Memory host query helpers | Memory host query helpers |
| `plugin-sdk/memory-core-host-secret` | Memory host secret helpers | Memory host secret helpers |
| `plugin-sdk/memory-core-host-events` | Memory host event journal helpers | Memory host event journal helpers |
| `plugin-sdk/memory-core-host-status` | Memory host status helpers | Memory host status helpers |
| `plugin-sdk/memory-core-host-runtime-cli` | Memory host CLI runtime | Memory host CLI runtime helpers |
| `plugin-sdk/memory-core-host-runtime-core` | Memory host core runtime | Memory host core runtime helpers |
| `plugin-sdk/memory-core-host-runtime-files` | Memory host file/runtime helpers | Memory host file/runtime helpers |
| `plugin-sdk/memory-host-core` | Memory host core runtime alias | Vendor-neutral alias for memory host core runtime helpers |
| `plugin-sdk/memory-host-events` | Memory host event journal alias | Vendor-neutral alias for memory host event journal helpers |
| `plugin-sdk/memory-host-files` | Memory host file/runtime alias | Vendor-neutral alias for memory host file/runtime helpers |
| `plugin-sdk/memory-host-markdown` | Managed markdown helpers | Shared managed-markdown helpers for memory-adjacent plugins |
| `plugin-sdk/memory-host-search` | Active memory search facade | Lazy active-memory search-manager runtime facade |
| `plugin-sdk/memory-host-status` | Memory host status alias | Vendor-neutral alias for memory host status helpers |
| `plugin-sdk/memory-lancedb` | Bundled memory-lancedb helpers | Memory-lancedb helper surface |
| `plugin-sdk/testing` | Test utilities | Test helpers and mocks |
</Accordion>

View File

@@ -253,17 +253,10 @@ explicitly promotes one as public.
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |
| `plugin-sdk/memory-core-host-query` | Memory host query helpers |
| `plugin-sdk/memory-core-host-secret` | Memory host secret helpers |
| `plugin-sdk/memory-core-host-events` | Memory host event journal helpers |
| `plugin-sdk/memory-core-host-status` | Memory host status helpers |
| `plugin-sdk/memory-core-host-runtime-cli` | Memory host CLI runtime helpers |
| `plugin-sdk/memory-core-host-runtime-core` | Memory host core runtime helpers |
| `plugin-sdk/memory-core-host-runtime-files` | Memory host file/runtime helpers |
| `plugin-sdk/memory-host-core` | Vendor-neutral alias for memory host core runtime helpers |
| `plugin-sdk/memory-host-events` | Vendor-neutral alias for memory host event journal helpers |
| `plugin-sdk/memory-host-files` | Vendor-neutral alias for memory host file/runtime helpers |
| `plugin-sdk/memory-host-markdown` | Shared managed-markdown helpers for memory-adjacent plugins |
| `plugin-sdk/memory-host-search` | Active memory runtime facade for search-manager access |
| `plugin-sdk/memory-host-status` | Vendor-neutral alias for memory host status helpers |
| `plugin-sdk/memory-lancedb` | Bundled memory-lancedb helper surface |
</Accordion>
@@ -308,16 +301,14 @@ methods:
### Infrastructure
| Method | What it registers |
| ---------------------------------------------- | --------------------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
| `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus |
| Method | What it registers |
| ---------------------------------------------- | --------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,
`update.*`) always stay `operator.admin`, even if a plugin tries to assign a

View File

@@ -62,8 +62,6 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerCommand() {},
registerContextEngine() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryCorpusSupplement() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},

View File

@@ -5,11 +5,6 @@ import {
type MemoryDreamingPhaseName,
type MemoryDreamingStorageConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
light: "## Light Sleep",
@@ -32,6 +27,36 @@ function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
};
}
function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
function replaceManagedBlock(params: {
original: string;
heading: string;
startMarker: string;
endMarker: string;
body: string;
}): string {
const managedBlock = `${params.heading}\n${params.startMarker}\n${params.body}\n${params.endMarker}`;
const existingPattern = new RegExp(
`${escapeRegex(params.heading)}\\n${escapeRegex(params.startMarker)}[\\s\\S]*?${escapeRegex(params.endMarker)}`,
"m",
);
if (existingPattern.test(params.original)) {
return params.original.replace(existingPattern, managedBlock);
}
const trimmed = params.original.trimEnd();
if (trimmed.length === 0) {
return `${managedBlock}\n`;
}
return `${trimmed}\n\n${managedBlock}\n`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveDailyMemoryPath(workspaceDir: string, epochMs: number, timezone?: string): string {
const isoDay = formatMemoryDreamingDay(epochMs, timezone);
return path.join(workspaceDir, "memory", `${isoDay}.md`);
@@ -78,7 +103,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
throw err;
});
const markers = resolvePhaseMarkers(params.phase);
const updated = replaceManagedMarkdownBlock({
const updated = replaceManagedBlock({
original,
heading: DAILY_PHASE_HEADINGS[params.phase],
startMarker: markers.start,
@@ -105,16 +130,6 @@ export async function writeDailyDreamingPhaseBlock(params: {
await fs.writeFile(reportPath, report, "utf-8");
}
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: new Date(nowMs).toISOString(),
phase: params.phase,
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
});
return {
...(inlinePath ? { inlinePath } : {}),
...(reportPath ? { reportPath } : {}),
@@ -136,13 +151,5 @@ export async function writeDeepDreamingReport(params: {
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
await appendMemoryHostEvent(params.workspaceDir, {
type: "memory.dream.completed",
timestamp: new Date(nowMs).toISOString(),
phase: "deep",
reportPath,
lineCount: params.bodyLines.length,
storageMode: params.storage.mode,
});
return reportPath;
}

View File

@@ -1,105 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { readMemoryHostEvents } from "openclaw/plugin-sdk/memory-host-events";
import { afterEach, describe, expect, it } from "vitest";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import {
applyShortTermPromotions,
rankShortTermPromotionCandidates,
recordShortTermRecalls,
} from "./short-term-promotion.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("memory host event journal integration", () => {
it("records recall and promotion events from short-term promotion flows", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-events-"));
tempDirs.push(workspaceDir);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "memory", "2026-04-05.md"),
"# Daily\n\nalpha\nbeta\ngamma\n",
"utf8",
);
await recordShortTermRecalls({
workspaceDir,
query: "alpha memory",
results: [
{
path: "memory/2026-04-05.md",
startLine: 3,
endLine: 4,
score: 0.92,
snippet: "alpha beta",
source: "memory",
},
],
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.UTC(2026, 3, 5, 12, 5, 0),
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.UTC(2026, 3, 5, 12, 10, 0),
});
expect(applied.applied).toBe(1);
const events = await readMemoryHostEvents({ workspaceDir });
expect(events.map((event) => event.type)).toEqual([
"memory.recall.recorded",
"memory.promotion.applied",
]);
expect(events[0]).toMatchObject({
type: "memory.recall.recorded",
resultCount: 1,
query: "alpha memory",
});
expect(events[1]).toMatchObject({
type: "memory.promotion.applied",
applied: 1,
});
});
it("records dreaming completion events when phase artifacts are written", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-dream-events-"));
tempDirs.push(workspaceDir);
const written = await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "light",
bodyLines: ["- staged note", "- second note"],
nowMs: Date.UTC(2026, 3, 5, 13, 0, 0),
storage: { mode: "both", separateReports: true },
});
const events = await readMemoryHostEvents({ workspaceDir });
expect(written.inlinePath).toBeTruthy();
expect(written.reportPath).toBeTruthy();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "memory.dream.completed",
phase: "light",
lineCount: 2,
storageMode: "both",
});
});
});

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
import {
deriveConceptTags,
MAX_CONCEPT_TAGS,
@@ -632,18 +631,6 @@ export async function recordShortTermRecalls(params: {
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: nowIso,
query,
resultCount: relevant.length,
results: relevant.map((result) => ({
path: normalizeMemoryPath(result.path),
startLine: Math.max(1, Math.floor(result.startLine)),
endLine: Math.max(1, Math.floor(result.endLine)),
score: clampScore(result.score),
})),
});
});
}
@@ -1055,20 +1042,6 @@ export async function applyShortTermPromotions(
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.promotion.applied",
timestamp: nowIso,
memoryPath,
applied: rehydratedSelected.length,
candidates: rehydratedSelected.map((candidate) => ({
key: candidate.key,
path: candidate.path,
startLine: candidate.startLine,
endLine: candidate.endLine,
score: candidate.score,
recallCount: candidate.recallCount,
})),
});
return {
memoryPath,

View File

@@ -2,10 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import {
clearMemoryPluginState,
registerMemoryCorpusSupplement,
} from "../../../src/plugins/memory-state.js";
import {
getMemorySearchManagerMockCalls,
getReadAgentMemoryFileMockCalls,
@@ -45,7 +41,6 @@ async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Pro
}
beforeEach(() => {
clearMemoryPluginState();
resetMemoryToolMockState({
backend: "builtin",
searchImpl: async () => [
@@ -213,99 +208,4 @@ describe("memory tools", () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("searches registered wiki corpus supplements without calling memory search", async () => {
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 4,
snippet: "Alpha wiki entry",
},
],
get: async () => null,
});
const tool = createMemorySearchToolOrThrow();
const result = await tool.execute("call_wiki_only", { query: "alpha", corpus: "wiki" });
expect(result.details).toMatchObject({
results: [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 4,
snippet: "Alpha wiki entry",
},
],
});
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it("merges memory and wiki corpus search results for corpus=all", async () => {
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 1.1,
snippet: "Alpha wiki entry",
},
],
get: async () => null,
});
const tool = createMemorySearchToolOrThrow();
const result = await tool.execute("call_all_corpus", { query: "alpha", corpus: "all" });
const details = result.details as { results: Array<{ corpus: string; path: string }> };
expect(details.results.map((entry) => [entry.corpus, entry.path])).toEqual([
["wiki", "entities/alpha.md"],
["memory", "MEMORY.md"],
]);
expect(getMemorySearchManagerMockCalls()).toBe(1);
});
it("falls back to a wiki corpus supplement for memory_get corpus=all", async () => {
setMemoryReadFileImpl(async () => {
throw new Error("path required");
});
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get: async () => ({
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
content: "Alpha wiki entry",
fromLine: 3,
lineCount: 5,
}),
});
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_get_all_fallback", {
path: "entities/alpha.md",
from: 3,
lines: 5,
corpus: "all",
});
expect(result.details).toEqual({
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
text: "Alpha wiki entry",
fromLine: 3,
lineCount: 5,
});
});
});

View File

@@ -1,10 +1,7 @@
import { Type } from "@sinclair/typebox";
import {
listMemoryCorpusSupplements,
resolveMemorySearchConfig,
resolveSessionAgentId,
type MemoryCorpusGetResult,
type MemoryCorpusSearchResult,
type AnyAgentTool,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
@@ -25,18 +22,12 @@ export const MemorySearchSchema = Type.Object({
query: Type.String(),
maxResults: Type.Optional(Type.Number()),
minScore: Type.Optional(Type.Number()),
corpus: Type.Optional(
Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]),
),
});
export const MemoryGetSchema = Type.Object({
path: Type.String(),
from: Type.Optional(Type.Number()),
lines: Type.Optional(Type.Number()),
corpus: Type.Optional(
Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]),
),
});
export function resolveMemoryToolContext(options: {
@@ -134,50 +125,3 @@ export function buildMemorySearchUnavailableResult(error: string | undefined) {
action,
};
}
export async function searchMemoryCorpusSupplements(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}): Promise<MemoryCorpusSearchResult[]> {
if (params.corpus === "memory") {
return [];
}
const supplements = listMemoryCorpusSupplements();
if (supplements.length === 0) {
return [];
}
const results = (
await Promise.all(
supplements.map(async (registration) => await registration.supplement.search(params)),
)
).flat();
return results
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(1, params.maxResults ?? 10));
}
export async function getMemoryCorpusSupplementResult(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}): Promise<MemoryCorpusGetResult | null> {
if (params.corpus === "memory") {
return null;
}
for (const registration of listMemoryCorpusSupplements()) {
const result = await registration.supplement.get(params);
if (result) {
return result;
}
}
return null;
}

View File

@@ -20,13 +20,11 @@ import {
import {
buildMemorySearchUnavailableResult,
createMemoryTool,
getMemoryCorpusSupplementResult,
getMemoryManagerContext,
getMemoryManagerContextWithPurpose,
loadMemoryToolRuntime,
MemoryGetSchema,
MemorySearchSchema,
searchMemoryCorpusSupplements,
} from "./tools.shared.js";
function buildRecallKey(
@@ -70,30 +68,6 @@ function queueShortTermRecallTracking(params: {
});
}
async function getSupplementMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentSessionKey?: string;
corpus?: "memory" | "wiki" | "all";
}) {
const supplement = await getMemoryCorpusSupplementResult({
lookup: params.relPath,
fromLine: params.from,
lineCount: params.lines,
agentSessionKey: params.agentSessionKey,
corpus: params.corpus,
});
if (!supplement) {
return null;
}
const { content, ...rest } = supplement;
return {
...rest,
text: content,
};
}
export function createMemorySearchTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
@@ -103,7 +77,7 @@ export function createMemorySearchTool(options: {
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
parameters: MemorySearchSchema,
execute:
({ cfg, agentId }) =>
@@ -111,16 +85,9 @@ export function createMemorySearchTool(options: {
const query = readStringParam(params, "query", { required: true });
const maxResults = readNumberParam(params, "maxResults");
const minScore = readNumberParam(params, "minScore");
const requestedCorpus = readStringParam(params, "corpus") as
| "memory"
| "wiki"
| "all"
| undefined;
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQueryMemory = requestedCorpus !== "wiki";
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const memory = shouldQueryMemory ? await getMemoryManagerContext({ cfg, agentId }) : null;
if (shouldQueryMemory && memory && "error" in memory && !shouldQuerySupplements) {
const memory = await getMemoryManagerContext({ cfg, agentId });
if ("error" in memory) {
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
}
try {
@@ -129,66 +96,35 @@ export function createMemorySearchTool(options: {
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
let rawResults: MemorySearchResult[] = [];
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
let provider: string | undefined;
let model: string | undefined;
let fallback: unknown;
let searchMode: string | undefined;
if (shouldQueryMemory && memory && !("error" in memory)) {
rawResults = await memory.manager.search(query, {
maxResults,
minScore,
sessionKey: options.agentSessionKey,
});
const status = memory.manager.status();
const decorated = decorateCitations(rawResults, includeCitations);
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const memoryResults =
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
surfacedMemoryResults = memoryResults.map((result) => ({
...result,
corpus: "memory" as const,
}));
const sleepTimezone = resolveMemoryDeepDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
}).timezone;
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
query,
rawResults,
surfacedResults: memoryResults,
timezone: sleepTimezone,
});
provider = status.provider;
model = status.model;
fallback = status.fallback;
searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
}
const supplementResults = shouldQuerySupplements
? await searchMemoryCorpusSupplements({
query,
maxResults,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
})
: [];
const results = [...surfacedMemoryResults, ...supplementResults]
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(1, maxResults ?? 10));
const rawResults = await memory.manager.search(query, {
maxResults,
minScore,
sessionKey: options.agentSessionKey,
});
const status = memory.manager.status();
const decorated = decorateCitations(rawResults, includeCitations);
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const results =
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
const sleepTimezone = resolveMemoryDeepDreamingConfig({
pluginConfig: resolveMemoryCorePluginConfig(cfg),
cfg,
}).timezone;
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
query,
rawResults,
surfacedResults: results,
timezone: sleepTimezone,
});
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
return jsonResult({
results,
provider,
model,
fallback,
provider: status.provider,
model: status.model,
fallback: status.fallback,
citations: citationsMode,
mode: searchMode,
});
@@ -209,7 +145,7 @@ export function createMemoryGetTool(options: {
label: "Memory Get",
name: "memory_get",
description:
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; `corpus=wiki` reads from registered compiled-wiki supplements. Use after search to pull only the needed lines and keep context small.",
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
parameters: MemoryGetSchema,
execute:
({ cfg, agentId }) =>
@@ -217,29 +153,7 @@ export function createMemoryGetTool(options: {
const relPath = readStringParam(params, "path", { required: true });
const from = readNumberParam(params, "from", { integer: true });
const lines = readNumberParam(params, "lines", { integer: true });
const requestedCorpus = readStringParam(params, "corpus") as
| "memory"
| "wiki"
| "all"
| undefined;
const { readAgentMemoryFile, resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
if (requestedCorpus === "wiki") {
const supplement = await getSupplementMemoryReadResult({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
return jsonResult(
supplement ?? {
path: relPath,
text: "",
disabled: true,
error: "wiki corpus result not found",
},
);
}
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
if (resolved.backend === "builtin") {
try {
@@ -252,18 +166,6 @@ export function createMemoryGetTool(options: {
});
return jsonResult(result);
} catch (err) {
if (requestedCorpus === "all") {
const supplement = await getSupplementMemoryReadResult({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
if (supplement) {
return jsonResult(supplement);
}
}
const message = err instanceof Error ? err.message : String(err);
return jsonResult({ path: relPath, text: "", disabled: true, error: message });
}
@@ -284,18 +186,6 @@ export function createMemoryGetTool(options: {
});
return jsonResult(result);
} catch (err) {
if (requestedCorpus === "all") {
const supplement = await getSupplementMemoryReadResult({
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentSessionKey: options.agentSessionKey,
corpus: requestedCorpus,
});
if (supplement) {
return jsonResult(supplement);
}
}
const message = err instanceof Error ? err.message : String(err);
return jsonResult({ path: relPath, text: "", disabled: true, error: message });
}

View File

@@ -1,166 +0,0 @@
# @openclaw/memory-wiki
Persistent wiki compiler and Obsidian-friendly knowledge vault for **OpenClaw**.
This plugin is separate from the active memory plugin. `memory-core` still handles recall, promotion, and dreaming. `memory-wiki` compiles durable knowledge into a navigable markdown vault with deterministic indexes, provenance, and optional Obsidian CLI workflows.
When the active memory plugin exposes shared recall, agents can use `memory_search` with `corpus=all` to search durable memory and the compiled wiki in one pass, then fall back to `wiki_search` / `wiki_get` when wiki-specific ranking or provenance matters.
## Modes
- `isolated`: own vault, own sources, no dependency on `memory-core`
- `bridge`: reads public `memory-core` artifacts and memory events through public seams
- `unsafe-local`: explicit same-machine escape hatch for private local paths
Default mode is `isolated`.
## Config
Put config under `plugins.entries.memory-wiki.config`:
```json5
{
vaultMode: "isolated",
vault: {
path: "~/.openclaw/wiki/main",
renderMode: "obsidian", // or "native"
},
obsidian: {
enabled: true,
useOfficialCli: true,
vaultName: "OpenClaw Wiki",
openAfterWrites: false,
},
bridge: {
enabled: false,
readMemoryCore: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: false,
paths: [],
},
ingest: {
autoCompile: true,
maxConcurrentJobs: 1,
allowUrlIngest: true,
},
search: {
backend: "shared", // or "local"
corpus: "wiki", // or "memory" | "all"
},
render: {
preserveHumanBlocks: true,
createBacklinks: true, // writes managed ## Related blocks with sources, backlinks, and related pages
createDashboards: true,
},
}
```
## Vault shape
The plugin initializes a vault like this:
```text
<vault>/
AGENTS.md
WIKI.md
index.md
inbox.md
entities/
concepts/
syntheses/
sources/
reports/
_attachments/
_views/
.openclaw-wiki/
```
Generated content stays inside managed blocks. Human note blocks are preserved.
When `render.createBacklinks` is enabled, compile adds deterministic `## Related` blocks to pages. Those blocks list source pages, pages that reference the current page, and nearby pages that share the same source ids.
When `render.createDashboards` is enabled, compile also maintains report dashboards under `reports/` for open questions, contradictions, low-confidence pages, and stale pages.
## CLI
```bash
openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
openclaw wiki get entity.alpha --from 1 --lines 80
openclaw wiki apply synthesis "Alpha Summary" \
--body "Short synthesis body" \
--source-id source.alpha
openclaw wiki apply metadata entity.alpha \
--source-id source.alpha \
--status review \
--question "Still active?"
openclaw wiki bridge import
openclaw wiki unsafe-local import
openclaw wiki obsidian status
openclaw wiki obsidian search "alpha"
openclaw wiki obsidian open syntheses/alpha-summary.md
openclaw wiki obsidian command workspace:quick-switcher
openclaw wiki obsidian daily
```
## Agent tools
- `wiki_status`
- `wiki_lint`
- `wiki_apply`
- `wiki_search`
- `wiki_get`
The plugin also registers a non-exclusive memory corpus supplement, so shared `memory_search` / `memory_get` flows can reach the wiki when the active memory plugin supports corpus selection.
## Gateway RPC
Read methods:
- `wiki.status`
- `wiki.doctor`
- `wiki.search`
- `wiki.get`
- `wiki.obsidian.status`
- `wiki.obsidian.search`
Write methods:
- `wiki.init`
- `wiki.compile`
- `wiki.ingest`
- `wiki.lint`
- `wiki.bridge.import`
- `wiki.unsafeLocal.import`
- `wiki.apply`
- `wiki.obsidian.open`
- `wiki.obsidian.command`
- `wiki.obsidian.daily`
## Notes
- `unsafe-local` is intentionally experimental and non-portable.
- Bridge mode reads `memory-core` through public seams only.
- Wiki pages are compiled artifacts, not the ultimate source of truth. Keep provenance attached to raw sources, memory artifacts, and daily notes.
- Obsidian CLI support requires the official `obsidian` CLI to be installed and available on `PATH`.

View File

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

View File

@@ -1,24 +0,0 @@
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

@@ -1,85 +0,0 @@
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 registerGatewayMethod = vi.fn();
const registerMemoryCorpusSupplement = vi.fn();
const registerMemoryPromptSupplement = vi.fn();
const registerTool = vi.fn();
const api = createTestPluginApi({
id: "memory-wiki",
name: "Memory Wiki",
source: "test",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
registerCli,
registerGatewayMethod,
registerMemoryCorpusSupplement,
registerMemoryPromptSupplement,
registerTool,
}) as OpenClawPluginApi;
return {
api,
registerCli,
registerGatewayMethod,
registerMemoryCorpusSupplement,
registerMemoryPromptSupplement,
registerTool,
};
}
describe("memory-wiki plugin", () => {
it("registers prompt supplement, gateway methods, tools, and wiki cli surface", async () => {
const {
api,
registerCli,
registerGatewayMethod,
registerMemoryCorpusSupplement,
registerMemoryPromptSupplement,
registerTool,
} = createApi();
await plugin.register(api);
expect(registerMemoryCorpusSupplement).toHaveBeenCalledTimes(1);
expect(registerMemoryPromptSupplement).toHaveBeenCalledTimes(1);
expect(registerGatewayMethod.mock.calls.map((call) => call[0])).toEqual([
"wiki.status",
"wiki.init",
"wiki.doctor",
"wiki.compile",
"wiki.ingest",
"wiki.lint",
"wiki.bridge.import",
"wiki.unsafeLocal.import",
"wiki.search",
"wiki.apply",
"wiki.get",
"wiki.obsidian.status",
"wiki.obsidian.search",
"wiki.obsidian.open",
"wiki.obsidian.command",
"wiki.obsidian.daily",
]);
expect(registerTool).toHaveBeenCalledTimes(5);
expect(registerTool.mock.calls.map((call) => call[1]?.name)).toEqual([
"wiki_status",
"wiki_lint",
"wiki_apply",
"wiki_search",
"wiki_get",
]);
expect(registerCli).toHaveBeenCalledTimes(1);
expect(registerCli.mock.calls[0]?.[1]).toMatchObject({
descriptors: [
expect.objectContaining({
name: "wiki",
hasSubcommands: true,
}),
],
});
});
});

View File

@@ -1,48 +0,0 @@
import { definePluginEntry } from "./api.js";
import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import { createWikiCorpusSupplement } from "./src/corpus-supplement.js";
import { registerMemoryWikiGatewayMethods } from "./src/gateway.js";
import { buildWikiPromptSection } from "./src/prompt-section.js";
import {
createWikiApplyTool,
createWikiGetTool,
createWikiLintTool,
createWikiSearchTool,
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.registerMemoryPromptSupplement(buildWikiPromptSection);
api.registerMemoryCorpusSupplement(
createWikiCorpusSupplement({ config, appConfig: api.config }),
);
registerMemoryWikiGatewayMethods({ api, config, appConfig: api.config });
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });
api.registerTool(createWikiApplyTool(config, api.config), { name: "wiki_apply" });
api.registerTool(createWikiSearchTool(config, api.config), { name: "wiki_search" });
api.registerTool(createWikiGetTool(config, api.config), { name: "wiki_get" });
api.registerCli(
({ program }) => {
registerWikiCli(program, config, api.config);
},
{
descriptors: [
{
name: "wiki",
description: "Inspect and initialize the memory wiki vault",
hasSubcommands: true,
},
],
},
);
},
});

View File

@@ -1,157 +0,0 @@
{
"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

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

View File

@@ -1,13 +0,0 @@
---
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.
- 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.
- Avoid destructive renames unless you also have a link-repair plan.

View File

@@ -1,19 +0,0 @@
---
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.
- Prefer `memory_search` with `corpus=all` when the shared memory tools are available and you want one recall pass across durable memory plus the compiled wiki.
- Use `wiki_search` to discover candidate pages when you want wiki-specific ranking/provenance, then `wiki_get` to inspect the exact page before editing or citing it.
- Use `wiki_apply` for narrow synthesis filing and metadata updates when a tool-level mutation is enough.
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
- 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.
- In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access.
- 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

@@ -1,133 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyMemoryWikiMutation } from "./apply.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, 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 })));
});
describe("applyMemoryWikiMutation", () => {
it("creates synthesis pages with managed summary blocks and refreshed indexes", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-apply-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
const result = await applyMemoryWikiMutation({
config,
mutation: {
op: "create_synthesis",
title: "Alpha Synthesis",
body: "Alpha summary body.",
sourceIds: ["source.alpha", "source.beta"],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
},
});
expect(result.changed).toBe(true);
expect(result.pagePath).toBe("syntheses/alpha-synthesis.md");
expect(result.pageId).toBe("synthesis.alpha-synthesis");
expect(result.compile.pageCounts.synthesis).toBe(1);
const page = await fs.readFile(path.join(rootDir, result.pagePath), "utf8");
const parsed = parseWikiMarkdown(page);
expect(parsed.frontmatter).toMatchObject({
pageType: "synthesis",
id: "synthesis.alpha-synthesis",
title: "Alpha Synthesis",
sourceIds: ["source.alpha", "source.beta"],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
status: "active",
});
expect(parsed.body).toContain("## Summary");
expect(parsed.body).toContain("<!-- openclaw:wiki:generated:start -->");
expect(parsed.body).toContain("Alpha summary body.");
expect(parsed.body).toContain("## Notes");
expect(parsed.body).toContain("<!-- openclaw:human:start -->");
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Alpha Synthesis](syntheses/alpha-synthesis.md)",
);
});
it("updates page metadata without overwriting existing human notes", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-apply-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
const targetPath = path.join(rootDir, "entities", "alpha.md");
await fs.writeFile(
targetPath,
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.old"],
confidence: 0.3,
},
body: `# Alpha
## Notes
<!-- openclaw:human:start -->
keep this note
<!-- openclaw:human:end -->
`,
}),
"utf8",
);
const result = await applyMemoryWikiMutation({
config,
mutation: {
op: "update_metadata",
lookup: "entity.alpha",
sourceIds: ["source.new"],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
confidence: null,
status: "review",
},
});
expect(result.changed).toBe(true);
expect(result.pagePath).toBe("entities/alpha.md");
expect(result.compile.pageCounts.entity).toBe(1);
const updated = await fs.readFile(targetPath, "utf8");
const parsed = parseWikiMarkdown(updated);
expect(parsed.frontmatter).toMatchObject({
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.new"],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
status: "review",
});
expect(parsed.frontmatter).not.toHaveProperty("confidence");
expect(parsed.body).toContain("keep this note");
expect(parsed.body).toContain("<!-- openclaw:human:start -->");
await expect(
fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md)");
});
});

View File

@@ -1,302 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import {
parseWikiMarkdown,
renderWikiMarkdown,
slugifyWikiSegment,
normalizeSourceIds,
} from "./markdown.js";
import {
readQueryableWikiPages,
resolveQueryableWikiPageByLookup,
type QueryableWikiPage,
} from "./query.js";
import { initializeMemoryWikiVault } from "./vault.js";
const GENERATED_START = "<!-- openclaw:wiki:generated:start -->";
const GENERATED_END = "<!-- openclaw:wiki:generated:end -->";
const HUMAN_START = "<!-- openclaw:human:start -->";
const HUMAN_END = "<!-- openclaw:human:end -->";
export type CreateSynthesisMemoryWikiMutation = {
op: "create_synthesis";
title: string;
body: string;
sourceIds: string[];
contradictions?: string[];
questions?: string[];
confidence?: number;
status?: string;
};
export type UpdateMetadataMemoryWikiMutation = {
op: "update_metadata";
lookup: string;
sourceIds?: string[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
status?: string;
};
export type ApplyMemoryWikiMutation =
| CreateSynthesisMemoryWikiMutation
| UpdateMetadataMemoryWikiMutation;
export type ApplyMemoryWikiMutationResult = {
changed: boolean;
operation: ApplyMemoryWikiMutation["op"];
pagePath: string;
pageId?: string;
compile: CompileMemoryWikiResult;
};
export function normalizeMemoryWikiMutationInput(rawParams: unknown): ApplyMemoryWikiMutation {
const params = rawParams as {
op: ApplyMemoryWikiMutation["op"];
title?: string;
body?: string;
lookup?: string;
sourceIds?: string[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
status?: string;
};
if (params.op === "create_synthesis") {
if (!params.title?.trim()) {
throw new Error("wiki mutation requires title for create_synthesis.");
}
if (!params.body?.trim()) {
throw new Error("wiki mutation requires body for create_synthesis.");
}
if (!params.sourceIds || params.sourceIds.length === 0) {
throw new Error("wiki mutation requires at least one sourceId for create_synthesis.");
}
return {
op: "create_synthesis",
title: params.title,
body: params.body,
sourceIds: params.sourceIds,
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(typeof params.confidence === "number" ? { confidence: params.confidence } : {}),
...(params.status ? { status: params.status } : {}),
};
}
if (!params.lookup?.trim()) {
throw new Error("wiki mutation requires lookup for update_metadata.");
}
return {
op: "update_metadata",
lookup: params.lookup,
...(params.sourceIds ? { sourceIds: params.sourceIds } : {}),
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(params.confidence !== undefined ? { confidence: params.confidence } : {}),
...(params.status ? { status: params.status } : {}),
};
}
function normalizeUniqueStrings(values: string[] | undefined): 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;
}
function ensureHumanNotesBlock(body: string): string {
if (body.includes(HUMAN_START) && body.includes(HUMAN_END)) {
return body;
}
const trimmed = body.trimEnd();
const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : "";
return `${prefix}## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
}
function buildSynthesisBody(params: {
title: string;
originalBody?: string;
generatedBody: string;
}): string {
const base = params.originalBody?.trim().length
? params.originalBody
: `# ${params.title}\n\n## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
const withGenerated = replaceManagedMarkdownBlock({
original: base,
heading: "## Summary",
startMarker: GENERATED_START,
endMarker: GENERATED_END,
body: params.generatedBody,
});
return ensureHumanNotesBlock(withGenerated);
}
async function writeWikiPage(params: {
absolutePath: string;
frontmatter: Record<string, unknown>;
body: string;
}): Promise<boolean> {
const rendered = withTrailingNewline(
renderWikiMarkdown({
frontmatter: params.frontmatter,
body: params.body,
}),
);
const existing = await fs.readFile(params.absolutePath, "utf8").catch(() => "");
if (existing === rendered) {
return false;
}
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
await fs.writeFile(params.absolutePath, rendered, "utf8");
return true;
}
async function resolveWritablePage(params: {
config: ResolvedMemoryWikiConfig;
lookup: string;
}): Promise<QueryableWikiPage | null> {
const pages = await readQueryableWikiPages(params.config.vault.path);
return resolveQueryableWikiPageByLookup(pages, params.lookup);
}
async function applyCreateSynthesisMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: CreateSynthesisMemoryWikiMutation;
}): Promise<{ changed: boolean; pagePath: string; pageId: string }> {
const slug = slugifyWikiSegment(params.mutation.title);
const pagePath = path.join("syntheses", `${slug}.md`).replace(/\\/g, "/");
const absolutePath = path.join(params.config.vault.path, pagePath);
const existing = await fs.readFile(absolutePath, "utf8").catch(() => "");
const parsed = parseWikiMarkdown(existing);
const pageId =
(typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()) ||
`synthesis.${slug}`;
const changed = await writeWikiPage({
absolutePath,
frontmatter: {
...parsed.frontmatter,
pageType: "synthesis",
id: pageId,
title: params.mutation.title,
sourceIds: normalizeSourceIds(params.mutation.sourceIds),
...(normalizeUniqueStrings(params.mutation.contradictions)
? { contradictions: normalizeUniqueStrings(params.mutation.contradictions) }
: {}),
...(normalizeUniqueStrings(params.mutation.questions)
? { questions: normalizeUniqueStrings(params.mutation.questions) }
: {}),
...(typeof params.mutation.confidence === "number"
? { confidence: params.mutation.confidence }
: {}),
status: params.mutation.status?.trim() || "active",
updatedAt: new Date().toISOString(),
},
body: buildSynthesisBody({
title: params.mutation.title,
originalBody: parsed.body,
generatedBody: params.mutation.body.trim(),
}),
});
return { changed, pagePath, pageId };
}
function buildUpdatedFrontmatter(params: {
original: Record<string, unknown>;
mutation: UpdateMetadataMemoryWikiMutation;
}): Record<string, unknown> {
const frontmatter: Record<string, unknown> = {
...params.original,
updatedAt: new Date().toISOString(),
};
if (params.mutation.sourceIds) {
frontmatter.sourceIds = normalizeSourceIds(params.mutation.sourceIds);
}
if (params.mutation.contradictions) {
const contradictions = normalizeUniqueStrings(params.mutation.contradictions) ?? [];
if (contradictions.length > 0) {
frontmatter.contradictions = contradictions;
} else {
delete frontmatter.contradictions;
}
}
if (params.mutation.questions) {
const questions = normalizeUniqueStrings(params.mutation.questions) ?? [];
if (questions.length > 0) {
frontmatter.questions = questions;
} else {
delete frontmatter.questions;
}
}
if (params.mutation.confidence === null) {
delete frontmatter.confidence;
} else if (typeof params.mutation.confidence === "number") {
frontmatter.confidence = params.mutation.confidence;
}
if (params.mutation.status?.trim()) {
frontmatter.status = params.mutation.status.trim();
}
return frontmatter;
}
async function applyUpdateMetadataMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: UpdateMetadataMemoryWikiMutation;
}): Promise<{ changed: boolean; pagePath: string; pageId?: string }> {
const page = await resolveWritablePage({
config: params.config,
lookup: params.mutation.lookup,
});
if (!page) {
throw new Error(`Wiki page not found: ${params.mutation.lookup}`);
}
const parsed = parseWikiMarkdown(page.raw);
const changed = await writeWikiPage({
absolutePath: page.absolutePath,
frontmatter: buildUpdatedFrontmatter({
original: parsed.frontmatter,
mutation: params.mutation,
}),
body: parsed.body,
});
return {
changed,
pagePath: page.relativePath,
...(page.id ? { pageId: page.id } : {}),
};
}
export async function applyMemoryWikiMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: ApplyMemoryWikiMutation;
}): Promise<ApplyMemoryWikiMutationResult> {
await initializeMemoryWikiVault(params.config);
const result =
params.mutation.op === "create_synthesis"
? await applyCreateSynthesisMutation({
config: params.config,
mutation: params.mutation,
})
: await applyUpdateMetadataMutation({
config: params.config,
mutation: params.mutation,
});
const compile = await compileMemoryWikiVault(params.config);
return {
changed: result.changed,
operation: params.mutation.op,
pagePath: result.pagePath,
...(result.pageId ? { pageId: result.pageId } : {}),
compile,
};
}

View File

@@ -1,217 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
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.removedCount).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);
expect(second.removedCount).toBe(0);
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,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
});
});
it("imports the public memory event journal when followMemoryEvents is enabled", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-events-ws-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-events-vault-"));
tempDirs.push(workspaceDir, vaultDir);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: "2026-04-05T12:00:00.000Z",
query: "bridge events",
resultCount: 1,
results: [
{
path: "memory/2026-04-05.md",
startLine: 1,
endLine: 2,
score: 0.8,
},
],
});
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
vault: { path: vaultDir },
bridge: {
enabled: true,
followMemoryEvents: true,
},
},
{ homedir: "/Users/tester" },
);
const appConfig: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {},
},
},
},
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const result = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(result.artifactCount).toBe(1);
expect(result.importedCount).toBe(1);
expect(result.removedCount).toBe(0);
const page = await fs.readFile(path.join(vaultDir, result.pagePaths[0] ?? ""), "utf8");
expect(page).toContain("sourceType: memory-bridge-events");
expect(page).toContain('"type":"memory.recall.recorded"');
});
it("prunes stale bridge pages when the source artifact disappears", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-prune-ws-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-bridge-prune-vault-"));
tempDirs.push(workspaceDir, vaultDir);
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
vault: { path: vaultDir },
bridge: {
enabled: true,
indexMemoryRoot: true,
indexDailyNotes: false,
indexDreamReports: false,
followMemoryEvents: false,
},
},
{ 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 });
const firstPagePath = first.pagePaths[0] ?? "";
await expect(fs.stat(path.join(vaultDir, firstPagePath))).resolves.toBeTruthy();
await fs.rm(path.join(workspaceDir, "MEMORY.md"));
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(second.artifactCount).toBe(0);
expect(second.removedCount).toBe(1);
await expect(fs.stat(path.join(vaultDir, firstPagePath))).rejects.toMatchObject({
code: "ENOENT",
});
});
});

View File

@@ -1,378 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-host-status";
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import {
pruneImportedSourceEntries,
readMemoryWikiSourceSyncState,
setImportedSourceEntry,
shouldSkipImportedSourceWrite,
writeMemoryWikiSourceSyncState,
} from "./source-sync-state.js";
import { initializeMemoryWikiVault } from "./vault.js";
type BridgeArtifact = {
syncKey: string;
artifactType: "markdown" | "memory-events";
workspaceDir: string;
relativePath: string;
absolutePath: string;
};
export type BridgeMemoryWikiResult = {
importedCount: number;
updatedCount: number;
skippedCount: number;
removedCount: number;
artifactCount: number;
workspaces: number;
pagePaths: string[];
};
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function resolveArtifactKey(absolutePath: string): Promise<string> {
const canonicalPath = await fs.realpath(absolutePath).catch(() => path.resolve(absolutePath));
return process.platform === "win32" ? canonicalPath.toLowerCase() : canonicalPath;
}
async function collectWorkspaceArtifacts(
workspaceDir: string,
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
): Promise<BridgeArtifact[]> {
const artifacts: BridgeArtifact[] = [];
if (bridgeConfig.indexMemoryRoot) {
for (const relPath of ["MEMORY.md", "memory.md"]) {
const absolutePath = path.join(workspaceDir, relPath);
if (await pathExists(absolutePath)) {
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
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/")) {
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
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, "/");
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
workspaceDir,
relativePath,
absolutePath,
});
}
}
if (bridgeConfig.followMemoryEvents) {
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
if (await pathExists(eventLogPath)) {
const syncKey = await resolveArtifactKey(eventLogPath);
artifacts.push({
syncKey,
artifactType: "memory-events",
workspaceDir,
relativePath: path.relative(workspaceDir, eventLogPath).replace(/\\/g, "/"),
absolutePath: eventLogPath,
});
}
}
const deduped = new Map<string, BridgeArtifact>();
for (const artifact of artifacts) {
deduped.set(artifact.syncKey, artifact);
}
return [...deduped.values()];
}
function resolveBridgeTitle(artifact: BridgeArtifact, agentIds: string[]): string {
if (artifact.artifactType === "memory-events") {
if (agentIds.length === 0) {
return "Memory Bridge: event journal";
}
return `Memory Bridge (${agentIds.join(", ")}): event journal`;
}
const base = artifact.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[];
sourceUpdatedAtMs: number;
sourceSize: number;
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const { pageId, pagePath } = resolveBridgePagePath({
workspaceDir: params.artifact.workspaceDir,
relativePath: params.artifact.relativePath,
});
const title = resolveBridgeTitle(params.artifact, params.agentIds);
const pageAbsPath = path.join(params.config.vault.path, pagePath);
const created = !(await pathExists(pageAbsPath));
const sourceUpdatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
const renderFingerprint = createHash("sha1")
.update(
JSON.stringify({
artifactType: params.artifact.artifactType,
workspaceDir: params.artifact.workspaceDir,
relativePath: params.artifact.relativePath,
agentIds: params.agentIds,
}),
)
.digest("hex");
const shouldSkip = await shouldSkipImportedSourceWrite({
vaultRoot: params.config.vault.path,
syncKey: params.artifact.syncKey,
expectedPagePath: pagePath,
expectedSourcePath: params.artifact.absolutePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,
sourceSize: params.sourceSize,
renderFingerprint,
state: params.state,
});
if (shouldSkip) {
return { pagePath, changed: false, created };
}
const raw = await fs.readFile(params.artifact.absolutePath, "utf8");
const contentLanguage = params.artifact.artifactType === "memory-events" ? "json" : "markdown";
const rendered = renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title,
sourceType:
params.artifact.artifactType === "memory-events" ? "memory-bridge-events" : "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}\``,
`- Kind: \`${params.artifact.artifactType}\``,
`- Agents: ${params.agentIds.length > 0 ? params.agentIds.join(", ") : "unknown"}`,
`- Updated: ${sourceUpdatedAt}`,
"",
"## Content",
renderMarkdownFence(raw, contentLanguage),
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
}
setImportedSourceEntry({
syncKey: params.artifact.syncKey,
state: params.state,
entry: {
group: "bridge",
pagePath,
sourcePath: params.artifact.absolutePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,
sourceSize: params.sourceSize,
renderFingerprint,
},
});
return { pagePath, changed: existing !== rendered, created };
}
export async function syncMemoryWikiBridgeSources(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}): Promise<BridgeMemoryWikiResult> {
await initializeMemoryWikiVault(params.config);
if (
params.config.vaultMode !== "bridge" ||
!params.config.bridge.enabled ||
!params.config.bridge.readMemoryCore ||
!params.appConfig
) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const memoryPluginConfig = resolveMemoryCorePluginConfig(params.appConfig);
if (!memoryPluginConfig) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const workspaces = resolveMemoryDreamingWorkspaces(params.appConfig);
const state = await readMemoryWikiSourceSyncState(params.config.vault.path);
const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = [];
let artifactCount = 0;
const activeKeys = new Set<string>();
for (const workspace of workspaces) {
const artifacts = await collectWorkspaceArtifacts(workspace.workspaceDir, params.config.bridge);
artifactCount += artifacts.length;
for (const artifact of artifacts) {
const stats = await fs.stat(artifact.absolutePath);
activeKeys.add(artifact.syncKey);
results.push(
await writeBridgeSourcePage({
config: params.config,
artifact,
agentIds: workspace.agentIds,
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
state,
}),
);
}
}
const removedCount = await pruneImportedSourceEntries({
vaultRoot: params.config.vault.path,
group: "bridge",
activeKeys,
state,
});
await writeMemoryWikiSourceSyncState(params.config.vault.path, state);
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 || removedCount > 0) {
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp: new Date().toISOString(),
details: {
sourceType: "memory-bridge",
workspaces: workspaces.length,
artifactCount,
importedCount,
updatedCount,
skippedCount,
removedCount,
},
});
}
return {
importedCount,
updatedCount,
skippedCount,
removedCount,
artifactCount,
workspaces: workspaces.length,
pagePaths,
};
}

View File

@@ -1,148 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli } from "./cli.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, 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 })));
});
describe("memory-wiki cli", () => {
beforeEach(() => {
vi.spyOn(process.stdout, "write").mockImplementation(
(() => true) as typeof process.stdout.write,
);
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
it("registers apply synthesis and writes a synthesis page", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(
[
"wiki",
"apply",
"synthesis",
"CLI Alpha",
"--body",
"Alpha from CLI.",
"--source-id",
"source.alpha",
"--source-id",
"source.beta",
],
{ from: "user" },
);
const page = await fs.readFile(path.join(rootDir, "syntheses", "cli-alpha.md"), "utf8");
expect(page).toContain("Alpha from CLI.");
expect(page).toContain("source.alpha");
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[CLI Alpha](syntheses/cli-alpha.md)",
);
});
it("registers apply metadata and preserves the page body", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "entities", "alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.old"],
confidence: 0.2,
},
body: `# Alpha
## Notes
<!-- openclaw:human:start -->
cli note
<!-- openclaw:human:end -->
`,
}),
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(
[
"wiki",
"apply",
"metadata",
"entity.alpha",
"--source-id",
"source.new",
"--contradiction",
"Conflicts with source.beta",
"--question",
"Still active?",
"--status",
"review",
"--clear-confidence",
],
{ from: "user" },
);
const page = await fs.readFile(path.join(rootDir, "entities", "alpha.md"), "utf8");
const parsed = parseWikiMarkdown(page);
expect(parsed.frontmatter).toMatchObject({
sourceIds: ["source.new"],
contradictions: ["Conflicts with source.beta"],
questions: ["Still active?"],
status: "review",
});
expect(parsed.frontmatter).not.toHaveProperty("confidence");
expect(parsed.body).toContain("cli note");
});
it("runs wiki doctor and sets a non-zero exit code when warnings exist", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
obsidian: { enabled: true, useOfficialCli: true },
},
{ homedir: "/Users/tester" },
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await fs.rm(rootDir, { recursive: true, force: true });
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
});
});

View File

@@ -1,766 +0,0 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import type { OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation } from "./apply.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 } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import {
buildMemoryWikiDoctorReport,
renderMemoryWikiDoctor,
renderMemoryWikiStatus,
resolveMemoryWikiStatus,
} from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
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"];
};
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 WikiObsidianSearchCommandOptions = {
json?: boolean;
};
type WikiObsidianOpenCommandOptions = {
json?: boolean;
};
type WikiObsidianCommandCommandOptions = {
json?: boolean;
};
type WikiObsidianDailyCommandOptions = {
json?: boolean;
};
function isResolvedMemoryWikiConfig(
config: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig | undefined,
): config is ResolvedMemoryWikiConfig {
return Boolean(
config &&
"vaultMode" in config &&
"vault" in config &&
"bridge" in config &&
"obsidian" in config &&
"unsafeLocal" in config,
);
}
function writeOutput(output: string, writer: Pick<NodeJS.WriteStream, "write"> = process.stdout) {
writer.write(output.endsWith("\n") ? output : `${output}\n`);
}
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 parseWikiSearchEnumOption<T extends string>(
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<string> {
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.");
}
export async function runWikiStatus(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const status = await resolveMemoryWikiStatus(params.config);
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
params.stdout,
);
return status;
}
export async function runWikiDoctor(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const report = buildMemoryWikiDoctorReport(await resolveMemoryWikiStatus(params.config));
if (!report.healthy) {
process.exitCode = 1;
}
writeOutput(
params.json ? JSON.stringify(report, null, 2) : renderMemoryWikiDoctor(report),
params.stdout,
);
return report;
}
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 async function runWikiCompile(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const result = await compileMemoryWikiVault(params.config);
const summary = params.json
? JSON.stringify(result, null, 2)
: `Compiled wiki vault at ${result.vaultRoot} (${result.pages.length} pages, ${result.updatedFiles.length} indexes updated).`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiLint(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const result = await lintMemoryWikiVault(params.config);
const summary = params.json
? JSON.stringify(result, null, 2)
: `Linted wiki vault at ${result.vaultRoot} (${result.issueCount} issues, report: ${result.reportPath}).`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiIngest(params: {
config: ResolvedMemoryWikiConfig;
inputPath: string;
title?: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await ingestMemoryWikiSource({
config: params.config,
inputPath: params.inputPath,
title: params.title,
});
const summary = params.json
? JSON.stringify(result, null, 2)
: `Ingested ${result.sourcePath} into ${result.pagePath}. Refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiSearch(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
query: string;
maxResults?: number;
searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"];
searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
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,
});
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}` : ""}\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<NodeJS.WriteStream, "write">;
}) {
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<NodeJS.WriteStream, "write">;
}) {
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() } : {}),
},
});
const summary = params.json
? JSON.stringify(result, null, 2)
: `${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."}`;
writeOutput(summary, 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<NodeJS.WriteStream, "write">;
}) {
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() } : {}),
},
});
const summary = params.json
? JSON.stringify(result, null, 2)
: `${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."}`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiBridgeImport(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await syncMemoryWikiImportedSources({
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, ${result.removedCount} removed). Indexes ${result.indexesRefreshed ? `refreshed (${result.indexUpdatedFiles.length} files)` : `not refreshed (${result.indexRefreshReason})`}.`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiUnsafeLocalImport(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await syncMemoryWikiImportedSources({
config: params.config,
appConfig: params.appConfig,
});
const summary = params.json
? JSON.stringify(result, null, 2)
: `Unsafe-local import synced ${result.artifactCount} artifacts (${result.importedCount} new, ${result.updatedCount} updated, ${result.skippedCount} unchanged, ${result.removedCount} removed). Indexes ${result.indexesRefreshed ? `refreshed (${result.indexUpdatedFiles.length} files)` : `not refreshed (${result.indexRefreshReason})`}.`;
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianStatus(params: {
config: ResolvedMemoryWikiConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await probeObsidianCli();
const summary = params.json
? JSON.stringify(result, null, 2)
: result.available
? `Obsidian CLI available at ${result.command}`
: "Obsidian CLI is not available on PATH.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianSearch(params: {
config: ResolvedMemoryWikiConfig;
query: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianSearch({ config: params.config, query: params.query });
const summary = params.json ? JSON.stringify(result, null, 2) : result.stdout.trim();
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianOpenCli(params: {
config: ResolvedMemoryWikiConfig;
vaultPath: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianOpen({ config: params.config, vaultPath: params.vaultPath });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Opened in Obsidian.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianCommandCli(params: {
config: ResolvedMemoryWikiConfig;
id: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianCommand({ config: params.config, id: params.id });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Command sent to Obsidian.";
writeOutput(summary, params.stdout);
return result;
}
export async function runWikiObsidianDailyCli(params: {
config: ResolvedMemoryWikiConfig;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const result = await runObsidianDaily({ config: params.config });
const summary = params.json
? JSON.stringify(result, null, 2)
: result.stdout.trim() || "Opened today's daily note.";
writeOutput(summary, params.stdout);
return result;
}
export function registerWikiCli(
program: Command,
pluginConfig?: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
) {
const config = isResolvedMemoryWikiConfig(pluginConfig)
? pluginConfig
: resolveMemoryWikiConfig(pluginConfig);
const wiki = program.command("wiki").description("Inspect and initialize the memory wiki vault");
wiki
.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("<path>", "Local file path to ingest")
.option("--title <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 });
});
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(
"--backend <backend>",
`Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"),
)
.option(
"--corpus <corpus>",
`Search corpus (${WIKI_SEARCH_CORPORA.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_CORPORA, "corpus"),
)
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiSearchCommandOptions) => {
await runWikiSearch({
config,
appConfig,
query,
maxResults: opts.maxResults,
searchBackend: opts.backend,
searchCorpus: opts.corpus,
json: opts.json,
});
});
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(
"--backend <backend>",
`Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"),
)
.option(
"--corpus <corpus>",
`Search corpus (${WIKI_SEARCH_CORPORA.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_CORPORA, "corpus"),
)
.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");
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("--source-id <id>", "Source id", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--contradiction <text>", "Contradiction note", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--question <text>", "Open question", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--confidence <n>", "Confidence score between 0 and 1", (value: string) =>
Number(value),
)
.option("--status <status>", "Page status")
.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,
});
});
apply
.command("metadata")
.description("Update metadata on an existing page")
.argument("<lookup>", "Relative path or page id")
.option("--source-id <id>", "Source id", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--contradiction <text>", "Contradiction note", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--question <text>", "Open question", (value: string, acc: string[] = []) => {
acc.push(value);
return acc;
})
.option("--confidence <n>", "Confidence score between 0 and 1", (value: string) =>
Number(value),
)
.option("--clear-confidence", "Remove any stored confidence value")
.option("--status <status>", "Page status")
.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-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 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 obsidian = wiki.command("obsidian").description("Run official Obsidian CLI helpers");
obsidian
.command("status")
.description("Probe the Obsidian CLI")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
await runWikiObsidianStatus({ config, json: opts.json });
});
obsidian
.command("search")
.description("Search the current Obsidian vault")
.argument("<query>", "Search query")
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiObsidianSearchCommandOptions) => {
await runWikiObsidianSearch({ config, query, json: opts.json });
});
obsidian
.command("open")
.description("Open a file in Obsidian by vault-relative path")
.argument("<path>", "Vault-relative path")
.option("--json", "Print JSON")
.action(async (vaultPath: string, opts: WikiObsidianOpenCommandOptions) => {
await runWikiObsidianOpenCli({ config, vaultPath, json: opts.json });
});
obsidian
.command("command")
.description("Execute an Obsidian command palette command by id")
.argument("<id>", "Obsidian command id")
.option("--json", "Print JSON")
.action(async (id: string, opts: WikiObsidianCommandCommandOptions) => {
await runWikiObsidianCommandCli({ config, id, json: opts.json });
});
obsidian
.command("daily")
.description("Open today's daily note in Obsidian")
.option("--json", "Print JSON")
.action(async (opts: WikiObsidianDailyCommandOptions) => {
await runWikiObsidianDailyCli({ config, json: opts.json });
});
}

View File

@@ -1,259 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { compileMemoryWikiVault } from "./compile.js";
import { resolveMemoryWikiConfig } from "./config.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 })));
});
describe("compileMemoryWikiVault", () => {
it("writes root and directory indexes for native markdown", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
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",
);
const result = await compileMemoryWikiVault(config);
expect(result.pageCounts.source).toBe(1);
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Alpha](sources/alpha.md)",
);
await expect(fs.readFile(path.join(rootDir, "sources", "index.md"), "utf8")).resolves.toContain(
"[Alpha](sources/alpha.md)",
);
});
it("renders obsidian-friendly links when configured", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir, renderMode: "obsidian" } },
{ 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",
);
await compileMemoryWikiVault(config);
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[[sources/alpha|Alpha]]",
);
});
it("writes related blocks from source ids and shared sources", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
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",
);
await fs.writeFile(
path.join(rootDir, "entities", "beta.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.beta",
title: "Beta",
sourceIds: ["source.alpha"],
},
body: "# Beta\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "concepts", "gamma.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "concept",
id: "concept.gamma",
title: "Gamma",
sourceIds: ["source.alpha"],
},
body: "# Gamma\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain(
"## Related",
);
await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain(
"[Alpha](sources/alpha.md)",
);
await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain(
"[Gamma](concepts/gamma.md)",
);
await expect(fs.readFile(path.join(rootDir, "sources", "alpha.md"), "utf8")).resolves.toContain(
"[Beta](entities/beta.md)",
);
await expect(fs.readFile(path.join(rootDir, "sources", "alpha.md"), "utf8")).resolves.toContain(
"[Gamma](concepts/gamma.md)",
);
});
it("writes dashboard report pages when createDashboards is enabled", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "entities", "alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.alpha"],
questions: ["What changed after launch?"],
contradictions: ["Conflicts with source.beta"],
confidence: 0.3,
},
body: "# Alpha\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.alpha",
title: "Alpha Source",
updatedAt: "2026-01-01T00:00:00.000Z",
},
body: "# Alpha Source\n",
}),
"utf8",
);
const result = await compileMemoryWikiVault(config);
expect(result.pageCounts.report).toBeGreaterThanOrEqual(4);
await expect(
fs.readFile(path.join(rootDir, "reports", "open-questions.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md): What changed after launch?");
await expect(
fs.readFile(path.join(rootDir, "reports", "contradictions.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md): Conflicts with source.beta");
await expect(
fs.readFile(path.join(rootDir, "reports", "low-confidence.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md): confidence 0.30");
await expect(
fs.readFile(path.join(rootDir, "reports", "stale-pages.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md): missing updatedAt");
});
it("skips dashboard report pages when createDashboards is disabled", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
render: { createDashboards: false },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "entities", "alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.alpha"],
questions: ["What changed after launch?"],
},
body: "# Alpha\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
await expect(fs.access(path.join(rootDir, "reports", "open-questions.md"))).rejects.toThrow();
});
it("ignores generated related links when computing backlinks on repeated compile", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "entities", "beta.md"),
renderWikiMarkdown({
frontmatter: { pageType: "entity", id: "entity.beta", title: "Beta" },
body: "# Beta\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "concepts", "gamma.md"),
renderWikiMarkdown({
frontmatter: { pageType: "concept", id: "concept.gamma", title: "Gamma" },
body: "# Gamma\n\nSee [Beta](entities/beta.md).\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
const second = await compileMemoryWikiVault(config);
expect(second.updatedFiles).toEqual([]);
await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain(
"[Gamma](concepts/gamma.md)",
);
await expect(
fs.readFile(path.join(rootDir, "concepts", "gamma.md"), "utf8"),
).resolves.not.toContain("### Referenced By");
});
});

View File

@@ -1,657 +0,0 @@
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";
import { appendMemoryWikiLog } from "./log.js";
import {
formatWikiLink,
parseWikiMarkdown,
renderWikiMarkdown,
toWikiPageSummary,
type WikiPageKind,
type WikiPageSummary,
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
const COMPILE_PAGE_GROUPS: Array<{ kind: WikiPageKind; dir: string; heading: string }> = [
{ kind: "source", dir: "sources", heading: "Sources" },
{ kind: "entity", dir: "entities", heading: "Entities" },
{ kind: "concept", dir: "concepts", heading: "Concepts" },
{ kind: "synthesis", dir: "syntheses", heading: "Syntheses" },
{ kind: "report", dir: "reports", heading: "Reports" },
];
const DASHBOARD_STALE_PAGE_DAYS = 30;
type DashboardPageDefinition = {
id: string;
title: string;
relativePath: string;
buildBody: (params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
now: Date;
}) => string;
};
const DASHBOARD_PAGES: DashboardPageDefinition[] = [
{
id: "report.open-questions",
title: "Open Questions",
relativePath: "reports/open-questions.md",
buildBody: ({ config, pages }) => {
const matches = pages.filter((page) => page.questions.length > 0);
if (matches.length === 0) {
return "- No open questions right now.";
}
return [
`- Pages with open questions: ${matches.length}`,
"",
...matches.map(
(page) =>
`- ${formatWikiLink({
renderMode: config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}: ${page.questions.join(" | ")}`,
),
].join("\n");
},
},
{
id: "report.contradictions",
title: "Contradictions",
relativePath: "reports/contradictions.md",
buildBody: ({ config, pages }) => {
const matches = pages.filter((page) => page.contradictions.length > 0);
if (matches.length === 0) {
return "- No contradictions flagged right now.";
}
return [
`- Pages with contradictions: ${matches.length}`,
"",
...matches.map(
(page) =>
`- ${formatWikiLink({
renderMode: config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}: ${page.contradictions.join(" | ")}`,
),
].join("\n");
},
},
{
id: "report.low-confidence",
title: "Low Confidence",
relativePath: "reports/low-confidence.md",
buildBody: ({ config, pages }) => {
const matches = pages
.filter((page) => typeof page.confidence === "number" && page.confidence < 0.5)
.toSorted((left, right) => (left.confidence ?? 1) - (right.confidence ?? 1));
if (matches.length === 0) {
return "- No low-confidence pages right now.";
}
return [
`- Low-confidence pages: ${matches.length}`,
"",
...matches.map(
(page) =>
`- ${formatWikiLink({
renderMode: config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}: confidence ${(page.confidence ?? 0).toFixed(2)}`,
),
].join("\n");
},
},
{
id: "report.stale-pages",
title: "Stale Pages",
relativePath: "reports/stale-pages.md",
buildBody: ({ config, pages, now }) => {
const staleBeforeMs = now.getTime() - DASHBOARD_STALE_PAGE_DAYS * 24 * 60 * 60 * 1000;
const matches = pages
.filter((page) => page.kind !== "report")
.flatMap((page) => {
if (!page.updatedAt) {
return [{ page, reason: "missing updatedAt" }];
}
const updatedAtMs = Date.parse(page.updatedAt);
if (!Number.isFinite(updatedAtMs) || updatedAtMs > staleBeforeMs) {
return [];
}
return [{ page, reason: `updated ${page.updatedAt}` }];
})
.toSorted((left, right) => left.page.title.localeCompare(right.page.title));
if (matches.length === 0) {
return `- No stale pages older than ${DASHBOARD_STALE_PAGE_DAYS} days.`;
}
return [
`- Stale pages: ${matches.length}`,
"",
...matches.map(
({ page, reason }) =>
`- ${formatWikiLink({
renderMode: config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}: ${reason}`,
),
].join("\n");
},
},
];
export type CompileMemoryWikiResult = {
vaultRoot: string;
pageCounts: Record<WikiPageKind, number>;
pages: WikiPageSummary[];
updatedFiles: string[];
};
export type RefreshMemoryWikiIndexesResult = {
refreshed: boolean;
reason: "auto-compile-disabled" | "no-import-changes" | "missing-indexes" | "import-changed";
compile?: CompileMemoryWikiResult;
};
async function collectMarkdownFiles(rootDir: string, relativeDir: string): Promise<string[]> {
const dirPath = path.join(rootDir, relativeDir);
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
.map((entry) => path.join(relativeDir, entry.name))
.filter((relativePath) => path.basename(relativePath) !== "index.md")
.toSorted((left, right) => left.localeCompare(right));
}
async function readPageSummaries(rootDir: string): Promise<WikiPageSummary[]> {
const filePaths = (
await Promise.all(COMPILE_PAGE_GROUPS.map((group) => collectMarkdownFiles(rootDir, group.dir)))
).flat();
const pages = await Promise.all(
filePaths.map(async (relativePath) => {
const absolutePath = path.join(rootDir, relativePath);
const raw = await fs.readFile(absolutePath, "utf8");
return toWikiPageSummary({ absolutePath, relativePath, raw });
}),
);
return pages
.flatMap((page) => (page ? [page] : []))
.toSorted((left, right) => left.title.localeCompare(right.title));
}
function buildPageCounts(pages: WikiPageSummary[]): Record<WikiPageKind, number> {
return {
entity: pages.filter((page) => page.kind === "entity").length,
concept: pages.filter((page) => page.kind === "concept").length,
source: pages.filter((page) => page.kind === "source").length,
synthesis: pages.filter((page) => page.kind === "synthesis").length,
report: pages.filter((page) => page.kind === "report").length,
};
}
function normalizeComparableTarget(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/\.md$/i, "")
.replace(/^\.\/+/, "")
.replace(/\/+$/, "")
.toLowerCase();
}
function uniquePages(pages: WikiPageSummary[]): WikiPageSummary[] {
const seen = new Set<string>();
const unique: WikiPageSummary[] = [];
for (const page of pages) {
const key = page.id ?? page.relativePath;
if (seen.has(key)) {
continue;
}
seen.add(key);
unique.push(page);
}
return unique;
}
function buildPageLookupKeys(page: WikiPageSummary): Set<string> {
const keys = new Set<string>();
keys.add(normalizeComparableTarget(page.relativePath));
keys.add(normalizeComparableTarget(page.relativePath.replace(/\.md$/i, "")));
keys.add(normalizeComparableTarget(page.title));
if (page.id) {
keys.add(normalizeComparableTarget(page.id));
}
return keys;
}
function renderWikiPageLinks(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
}): string {
return params.pages
.map(
(page) =>
`- ${formatWikiLink({
renderMode: params.config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}`,
)
.join("\n");
}
function buildRelatedBlockBody(params: {
config: ResolvedMemoryWikiConfig;
page: WikiPageSummary;
allPages: WikiPageSummary[];
}): string {
const candidatePages = params.allPages.filter((candidate) => candidate.kind !== "report");
const pagesById = new Map(
candidatePages.flatMap((candidate) =>
candidate.id ? [[candidate.id, candidate] as const] : [],
),
);
const sourcePages = uniquePages(
params.page.sourceIds.flatMap((sourceId) => {
const page = pagesById.get(sourceId);
return page ? [page] : [];
}),
);
const backlinkKeys = buildPageLookupKeys(params.page);
const backlinks = uniquePages(
candidatePages.filter((candidate) => {
if (candidate.relativePath === params.page.relativePath) {
return false;
}
if (candidate.sourceIds.includes(params.page.id ?? "")) {
return true;
}
return candidate.linkTargets.some((target) =>
backlinkKeys.has(normalizeComparableTarget(target)),
);
}),
);
const relatedPages = uniquePages(
candidatePages.filter((candidate) => {
if (candidate.relativePath === params.page.relativePath) {
return false;
}
if (sourcePages.some((sourcePage) => sourcePage.relativePath === candidate.relativePath)) {
return false;
}
if (backlinks.some((backlink) => backlink.relativePath === candidate.relativePath)) {
return false;
}
if (params.page.sourceIds.length === 0 || candidate.sourceIds.length === 0) {
return false;
}
return params.page.sourceIds.some((sourceId) => candidate.sourceIds.includes(sourceId));
}),
);
const sections: string[] = [];
if (sourcePages.length > 0) {
sections.push(
"### Sources",
renderWikiPageLinks({ config: params.config, pages: sourcePages }),
);
}
if (backlinks.length > 0) {
sections.push(
"### Referenced By",
renderWikiPageLinks({ config: params.config, pages: backlinks }),
);
}
if (relatedPages.length > 0) {
sections.push(
"### Related Pages",
renderWikiPageLinks({ config: params.config, pages: relatedPages }),
);
}
if (sections.length === 0) {
return "- No related pages yet.";
}
return sections.join("\n\n");
}
async function refreshPageRelatedBlocks(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
}): Promise<string[]> {
if (!params.config.render.createBacklinks) {
return [];
}
const updatedFiles: string[] = [];
for (const page of params.pages) {
if (page.kind === "report") {
continue;
}
const original = await fs.readFile(page.absolutePath, "utf8");
const updated = withTrailingNewline(
replaceManagedMarkdownBlock({
original,
heading: "## Related",
startMarker: WIKI_RELATED_START_MARKER,
endMarker: WIKI_RELATED_END_MARKER,
body: buildRelatedBlockBody({
config: params.config,
page,
allPages: params.pages,
}),
}),
);
if (updated === original) {
continue;
}
await fs.writeFile(page.absolutePath, updated, "utf8");
updatedFiles.push(page.absolutePath);
}
return updatedFiles;
}
function renderSectionList(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
emptyText: string;
}): string {
if (params.pages.length === 0) {
return `- ${params.emptyText}`;
}
return params.pages
.map(
(page) =>
`- ${formatWikiLink({
renderMode: params.config.vault.renderMode,
relativePath: page.relativePath,
title: page.title,
})}`,
)
.join("\n");
}
async function writeManagedMarkdownFile(params: {
filePath: string;
title: string;
startMarker: string;
endMarker: string;
body: string;
}): Promise<boolean> {
const original = await fs.readFile(params.filePath, "utf8").catch(() => `# ${params.title}\n`);
const updated = replaceManagedMarkdownBlock({
original,
heading: "## Generated",
startMarker: params.startMarker,
endMarker: params.endMarker,
body: params.body,
});
const rendered = withTrailingNewline(updated);
if (rendered === original) {
return false;
}
await fs.writeFile(params.filePath, rendered, "utf8");
return true;
}
async function writeDashboardPage(params: {
config: ResolvedMemoryWikiConfig;
rootDir: string;
definition: DashboardPageDefinition;
pages: WikiPageSummary[];
now: Date;
}): Promise<boolean> {
const filePath = path.join(params.rootDir, params.definition.relativePath);
const original = await fs.readFile(filePath, "utf8").catch(() =>
renderWikiMarkdown({
frontmatter: {
pageType: "report",
id: params.definition.id,
title: params.definition.title,
status: "active",
},
body: `# ${params.definition.title}\n`,
}),
);
const parsed = parseWikiMarkdown(original);
const originalBody =
parsed.body.trim().length > 0 ? parsed.body : `# ${params.definition.title}\n`;
const updatedBody = replaceManagedMarkdownBlock({
original: originalBody,
heading: "## Generated",
startMarker: `<!-- openclaw:wiki:${path.basename(params.definition.relativePath, ".md")}:start -->`,
endMarker: `<!-- openclaw:wiki:${path.basename(params.definition.relativePath, ".md")}:end -->`,
body: params.definition.buildBody({
config: params.config,
pages: params.pages,
now: params.now,
}),
});
const preservedUpdatedAt =
typeof parsed.frontmatter.updatedAt === "string" && parsed.frontmatter.updatedAt.trim()
? parsed.frontmatter.updatedAt
: params.now.toISOString();
const stableRendered = withTrailingNewline(
renderWikiMarkdown({
frontmatter: {
...parsed.frontmatter,
pageType: "report",
id: params.definition.id,
title: params.definition.title,
status:
typeof parsed.frontmatter.status === "string" && parsed.frontmatter.status.trim()
? parsed.frontmatter.status
: "active",
updatedAt: preservedUpdatedAt,
},
body: updatedBody,
}),
);
if (stableRendered === original) {
return false;
}
const rendered = withTrailingNewline(
renderWikiMarkdown({
frontmatter: {
...parsed.frontmatter,
pageType: "report",
id: params.definition.id,
title: params.definition.title,
status:
typeof parsed.frontmatter.status === "string" && parsed.frontmatter.status.trim()
? parsed.frontmatter.status
: "active",
updatedAt: params.now.toISOString(),
},
body: updatedBody,
}),
);
await fs.writeFile(filePath, rendered, "utf8");
return true;
}
async function refreshDashboardPages(params: {
config: ResolvedMemoryWikiConfig;
rootDir: string;
pages: WikiPageSummary[];
}): Promise<string[]> {
if (!params.config.render.createDashboards) {
return [];
}
const now = new Date();
const updatedFiles: string[] = [];
for (const definition of DASHBOARD_PAGES) {
if (
await writeDashboardPage({
config: params.config,
rootDir: params.rootDir,
definition,
pages: params.pages,
now,
})
) {
updatedFiles.push(path.join(params.rootDir, definition.relativePath));
}
}
return updatedFiles;
}
function buildRootIndexBody(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
counts: Record<WikiPageKind, number>;
}): string {
const lines = [
`- Render mode: \`${params.config.vault.renderMode}\``,
`- Total pages: ${params.pages.length}`,
`- Sources: ${params.counts.source}`,
`- Entities: ${params.counts.entity}`,
`- Concepts: ${params.counts.concept}`,
`- Syntheses: ${params.counts.synthesis}`,
`- Reports: ${params.counts.report}`,
];
for (const group of COMPILE_PAGE_GROUPS) {
lines.push("", `### ${group.heading}`);
lines.push(
renderSectionList({
config: params.config,
pages: params.pages.filter((page) => page.kind === group.kind),
emptyText: `No ${group.heading.toLowerCase()} yet.`,
}),
);
}
return lines.join("\n");
}
function buildDirectoryIndexBody(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
group: { kind: WikiPageKind; dir: string; heading: string };
}): string {
return renderSectionList({
config: params.config,
pages: params.pages.filter((page) => page.kind === params.group.kind),
emptyText: `No ${params.group.heading.toLowerCase()} yet.`,
});
}
export async function compileMemoryWikiVault(
config: ResolvedMemoryWikiConfig,
): Promise<CompileMemoryWikiResult> {
await initializeMemoryWikiVault(config);
const rootDir = config.vault.path;
let pages = await readPageSummaries(rootDir);
const updatedFiles = await refreshPageRelatedBlocks({ config, pages });
if (updatedFiles.length > 0) {
pages = await readPageSummaries(rootDir);
}
const dashboardUpdatedFiles = await refreshDashboardPages({ config, rootDir, pages });
updatedFiles.push(...dashboardUpdatedFiles);
if (dashboardUpdatedFiles.length > 0) {
pages = await readPageSummaries(rootDir);
}
const counts = buildPageCounts(pages);
const rootIndexPath = path.join(rootDir, "index.md");
if (
await writeManagedMarkdownFile({
filePath: rootIndexPath,
title: "Wiki Index",
startMarker: "<!-- openclaw:wiki:index:start -->",
endMarker: "<!-- openclaw:wiki:index:end -->",
body: buildRootIndexBody({ config, pages, counts }),
})
) {
updatedFiles.push(rootIndexPath);
}
for (const group of COMPILE_PAGE_GROUPS) {
const filePath = path.join(rootDir, group.dir, "index.md");
if (
await writeManagedMarkdownFile({
filePath,
title: group.heading,
startMarker: `<!-- openclaw:wiki:${group.dir}:index:start -->`,
endMarker: `<!-- openclaw:wiki:${group.dir}:index:end -->`,
body: buildDirectoryIndexBody({ config, pages, group }),
})
) {
updatedFiles.push(filePath);
}
}
if (updatedFiles.length > 0) {
await appendMemoryWikiLog(rootDir, {
type: "compile",
timestamp: new Date().toISOString(),
details: {
pageCounts: counts,
updatedFiles: updatedFiles.map((filePath) => path.relative(rootDir, filePath)),
},
});
}
return {
vaultRoot: rootDir,
pageCounts: counts,
pages,
updatedFiles,
};
}
async function hasMissingWikiIndexes(rootDir: string): Promise<boolean> {
const required = [
path.join(rootDir, "index.md"),
...COMPILE_PAGE_GROUPS.map((group) => path.join(rootDir, group.dir, "index.md")),
];
for (const filePath of required) {
const exists = await fs
.access(filePath)
.then(() => true)
.catch(() => false);
if (!exists) {
return true;
}
}
return false;
}
export async function refreshMemoryWikiIndexesAfterImport(params: {
config: ResolvedMemoryWikiConfig;
syncResult: { importedCount: number; updatedCount: number; removedCount: number };
}): Promise<RefreshMemoryWikiIndexesResult> {
await initializeMemoryWikiVault(params.config);
if (!params.config.ingest.autoCompile) {
return {
refreshed: false,
reason: "auto-compile-disabled",
};
}
const importChanged =
params.syncResult.importedCount > 0 ||
params.syncResult.updatedCount > 0 ||
params.syncResult.removedCount > 0;
const missingIndexes = await hasMissingWikiIndexes(params.config.vault.path);
if (!importChanged && !missingIndexes) {
return {
refreshed: false,
reason: "no-import-changes",
};
}
const compile = await compileMemoryWikiVault(params.config);
return {
refreshed: true,
reason: missingIndexes && !importChanged ? "missing-indexes" : "import-changed",
compile,
};
}

View File

@@ -1,80 +0,0 @@
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

@@ -1,244 +0,0 @@
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

@@ -1,35 +0,0 @@
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
export function createWikiCorpusSupplement(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}) {
return {
search: async (input: { query: string; maxResults?: number; agentSessionKey?: string }) =>
await searchMemoryWiki({
config: params.config,
appConfig: params.appConfig,
query: input.query,
maxResults: input.maxResults,
searchBackend: "local",
searchCorpus: "wiki",
}),
get: async (input: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
}) =>
await getMemoryWikiPage({
config: params.config,
appConfig: params.appConfig,
lookup: input.lookup,
fromLine: input.fromLine,
lineCount: input.lineCount,
searchBackend: "local",
searchCorpus: "wiki",
}),
};
}

View File

@@ -1,182 +0,0 @@
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." }),
);
});
it("ingests local files over the gateway and refreshes indexes", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-gateway-"));
tempDirs.push(rootDir);
const inputPath = path.join(rootDir, "alpha-notes.txt");
await fs.writeFile(inputPath, "alpha over gateway\n", "utf8");
const { api, registerGatewayMethod } = createGatewayApi();
const config = resolveMemoryWikiConfig(
{ vault: { path: path.join(rootDir, "vault") } },
{ homedir: "/Users/tester" },
);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.ingest");
if (!handler) {
throw new Error("wiki.ingest handler missing");
}
const respond = vi.fn();
await handler({
params: {
inputPath,
},
respond,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
pagePath: "sources/alpha-notes.md",
}),
);
await expect(fs.readFile(path.join(config.vault.path, "index.md"), "utf8")).resolves.toContain(
"[alpha notes](sources/alpha-notes.md)",
);
});
it("applies wiki mutations 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" },
);
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.apply");
if (!handler) {
throw new Error("wiki.apply handler missing");
}
const respond = vi.fn();
await handler({
params: {
op: "create_synthesis",
title: "Gateway Alpha",
body: "Gateway summary.",
sourceIds: ["source.alpha"],
},
respond,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
operation: "create_synthesis",
pagePath: "syntheses/gateway-alpha.md",
}),
);
});
});

View File

@@ -1,355 +0,0 @@
import type { OpenClawConfig, OpenClawPluginApi } from "../api.js";
import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js";
import { compileMemoryWikiVault } from "./compile.js";
import {
WIKI_SEARCH_BACKENDS,
WIKI_SEARCH_CORPORA,
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 } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { buildMemoryWikiDoctorReport, resolveMemoryWikiStatus } from "./status.js";
import { initializeMemoryWikiVault } from "./vault.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 readEnumParam<T extends string>(
params: Record<string, unknown>,
key: string,
allowed: readonly T[],
): T | undefined {
const value = readStringParam(params, key);
if (!value) {
return undefined;
}
if ((allowed as readonly string[]).includes(value)) {
return value as T;
}
throw new Error(`${key} must be one of: ${allowed.join(", ")}.`);
}
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.init",
async ({ respond }) => {
try {
respond(true, await initializeMemoryWikiVault(config));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_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.ingest",
async ({ params: requestParams, respond }) => {
try {
const inputPath = readStringParam(requestParams, "inputPath", { required: true });
const title = readStringParam(requestParams, "title");
respond(
true,
await ingestMemoryWikiSource({
config,
inputPath,
...(title ? { title } : {}),
}),
);
} 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.bridge.import",
async ({ respond }) => {
try {
respond(
true,
await syncMemoryWikiImportedSources({
config: { ...config, vaultMode: "bridge" },
appConfig,
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.unsafeLocal.import",
async ({ respond }) => {
try {
respond(
true,
await syncMemoryWikiImportedSources({
config: { ...config, vaultMode: "unsafe-local" },
appConfig,
}),
);
} 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");
const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS);
const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA);
respond(
true,
await searchMemoryWiki({
config,
appConfig,
query,
maxResults,
searchBackend,
searchCorpus,
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.apply",
async ({ params: requestParams, respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(
true,
await applyMemoryWikiMutation({
config,
mutation: normalizeMemoryWikiMutationInput(requestParams),
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_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");
const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS);
const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA);
respond(
true,
await getMemoryWikiPage({
config,
appConfig,
lookup,
fromLine,
lineCount,
searchBackend,
searchCorpus,
}),
);
} 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 },
);
api.registerGatewayMethod(
"wiki.obsidian.search",
async ({ params: requestParams, respond }) => {
try {
const query = readStringParam(requestParams, "query", { required: true });
respond(true, await runObsidianSearch({ config, query }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"wiki.obsidian.open",
async ({ params: requestParams, respond }) => {
try {
const vaultPath = readStringParam(requestParams, "path", { required: true });
respond(true, await runObsidianOpen({ config, vaultPath }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.obsidian.command",
async ({ params: requestParams, respond }) => {
try {
const id = readStringParam(requestParams, "id", { required: true });
respond(true, await runObsidianCommand({ config, id }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.obsidian.daily",
async ({ respond }) => {
try {
respond(true, await runObsidianDaily({ config }));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
}

View File

@@ -1,41 +0,0 @@
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 { ingestMemoryWikiSource } from "./ingest.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("ingestMemoryWikiSource", () => {
it("copies a local text file into sources markdown", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-ingest-"));
tempDirs.push(rootDir);
const inputPath = path.join(rootDir, "meeting-notes.txt");
await fs.writeFile(inputPath, "hello from source\n", "utf8");
const config = resolveMemoryWikiConfig(
{ vault: { path: path.join(rootDir, "vault") } },
{ homedir: "/Users/tester" },
);
const result = await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
expect(result.pageId).toBe("source.meeting-notes");
expect(result.pagePath).toBe("sources/meeting-notes.md");
expect(result.indexUpdatedFiles.length).toBeGreaterThan(0);
await expect(
fs.readFile(path.join(config.vault.path, "sources", "meeting-notes.md"), "utf8"),
).resolves.toContain("hello from source");
await expect(fs.readFile(path.join(config.vault.path, "index.md"), "utf8")).resolves.toContain(
"[meeting notes](sources/meeting-notes.md)",
);
});
});

View File

@@ -1,112 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
export type IngestMemoryWikiSourceResult = {
sourcePath: string;
pageId: string;
pagePath: string;
title: string;
bytes: number;
created: boolean;
indexUpdatedFiles: string[];
};
function pathExists(filePath: string): Promise<boolean> {
return fs
.access(filePath)
.then(() => true)
.catch(() => false);
}
function resolveSourceTitle(sourcePath: string, explicitTitle?: string): string {
if (explicitTitle?.trim()) {
return explicitTitle.trim();
}
return path.basename(sourcePath, path.extname(sourcePath)).replace(/[-_]+/g, " ").trim();
}
function assertUtf8Text(buffer: Buffer, sourcePath: string): string {
const preview = buffer.subarray(0, Math.min(buffer.length, 4096));
if (preview.includes(0)) {
throw new Error(`Cannot ingest binary file as markdown source: ${sourcePath}`);
}
return buffer.toString("utf8");
}
export async function ingestMemoryWikiSource(params: {
config: ResolvedMemoryWikiConfig;
inputPath: string;
title?: string;
nowMs?: number;
}): Promise<IngestMemoryWikiSourceResult> {
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
const sourcePath = path.resolve(params.inputPath);
const buffer = await fs.readFile(sourcePath);
const content = assertUtf8Text(buffer, sourcePath);
const title = resolveSourceTitle(sourcePath, params.title);
const slug = slugifyWikiSegment(title);
const pageId = `source.${slug}`;
const pageRelativePath = path.join("sources", `${slug}.md`);
const pagePath = path.join(params.config.vault.path, pageRelativePath);
const created = !(await pathExists(pagePath));
const timestamp = new Date(params.nowMs ?? Date.now()).toISOString();
const markdown = renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title,
sourceType: "local-file",
sourcePath,
ingestedAt: timestamp,
updatedAt: timestamp,
status: "active",
},
body: [
`# ${title}`,
"",
"## Source",
`- Type: \`local-file\``,
`- Path: \`${sourcePath}\``,
`- Bytes: ${buffer.byteLength}`,
`- Updated: ${timestamp}`,
"",
"## Content",
renderMarkdownFence(content, "text"),
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
await fs.writeFile(pagePath, markdown, "utf8");
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp,
details: {
inputPath: sourcePath,
pageId,
pagePath: pageRelativePath.split(path.sep).join("/"),
bytes: buffer.byteLength,
created,
},
});
const compile = await compileMemoryWikiVault(params.config);
return {
sourcePath,
pageId,
pagePath: pageRelativePath.split(path.sep).join("/"),
title,
bytes: buffer.byteLength,
created,
indexUpdatedFiles: compile.updatedFiles,
};
}

View File

@@ -1,74 +0,0 @@
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 { lintMemoryWikiVault } from "./lint.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 })));
});
describe("lintMemoryWikiVault", () => {
it("detects duplicate ids, provenance gaps, contradictions, and open questions", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-lint-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir, renderMode: "obsidian" } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
const duplicate = renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
confidence: 0.2,
},
body: "# Alpha\n\n[[missing-page]]\n",
});
await fs.writeFile(path.join(rootDir, "entities", "alpha.md"), duplicate, "utf8");
await fs.writeFile(path.join(rootDir, "concepts", "alpha.md"), duplicate, "utf8");
await fs.writeFile(
path.join(rootDir, "sources", "bridge-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.bridge.alpha",
title: "Bridge Alpha",
sourceType: "memory-bridge",
},
body: "# Bridge Alpha\n",
}),
"utf8",
);
const result = await lintMemoryWikiVault(config);
expect(result.issueCount).toBeGreaterThan(0);
expect(result.issues.map((issue) => issue.code)).toContain("duplicate-id");
expect(result.issues.map((issue) => issue.code)).toContain("missing-source-ids");
expect(result.issues.map((issue) => issue.code)).toContain("missing-import-provenance");
expect(result.issues.map((issue) => issue.code)).toContain("broken-wikilink");
expect(result.issues.map((issue) => issue.code)).toContain("contradiction-present");
expect(result.issues.map((issue) => issue.code)).toContain("open-question");
expect(result.issues.map((issue) => issue.code)).toContain("low-confidence");
expect(result.issuesByCategory.contradictions).toHaveLength(2);
expect(result.issuesByCategory["open-questions"]).toHaveLength(2);
expect(
result.issuesByCategory.provenance.some(
(issue) => issue.code === "missing-import-provenance",
),
).toBe(true);
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Errors");
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Contradictions");
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Open Questions");
});
});

View File

@@ -1,311 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./markdown.js";
export type MemoryWikiLintIssue = {
severity: "error" | "warning";
category: "structure" | "provenance" | "links" | "contradictions" | "open-questions" | "quality";
code:
| "missing-id"
| "duplicate-id"
| "missing-page-type"
| "page-type-mismatch"
| "missing-title"
| "missing-source-ids"
| "missing-import-provenance"
| "broken-wikilink"
| "contradiction-present"
| "open-question"
| "low-confidence";
path: string;
message: string;
};
export type LintMemoryWikiResult = {
vaultRoot: string;
issueCount: number;
issues: MemoryWikiLintIssue[];
issuesByCategory: Record<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]>;
reportPath: string;
};
function toExpectedPageType(page: WikiPageSummary): string {
return page.kind;
}
function collectBrokenLinkIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
const validTargets = new Set<string>();
for (const page of pages) {
const withoutExtension = page.relativePath.replace(/\.md$/i, "");
validTargets.add(withoutExtension);
validTargets.add(path.basename(withoutExtension));
}
const issues: MemoryWikiLintIssue[] = [];
for (const page of pages) {
for (const linkTarget of page.linkTargets) {
if (!validTargets.has(linkTarget)) {
issues.push({
severity: "warning",
category: "links",
code: "broken-wikilink",
path: page.relativePath,
message: `Broken wikilink target \`${linkTarget}\`.`,
});
}
}
}
return issues;
}
function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
const issues: MemoryWikiLintIssue[] = [];
const pagesById = new Map<string, WikiPageSummary[]>();
for (const page of pages) {
if (!page.id) {
issues.push({
severity: "error",
category: "structure",
code: "missing-id",
path: page.relativePath,
message: "Missing `id` frontmatter.",
});
} else {
const current = pagesById.get(page.id) ?? [];
current.push(page);
pagesById.set(page.id, current);
}
if (!page.pageType) {
issues.push({
severity: "error",
category: "structure",
code: "missing-page-type",
path: page.relativePath,
message: "Missing `pageType` frontmatter.",
});
} else if (page.pageType !== toExpectedPageType(page)) {
issues.push({
severity: "error",
category: "structure",
code: "page-type-mismatch",
path: page.relativePath,
message: `Expected pageType \`${toExpectedPageType(page)}\`, found \`${page.pageType}\`.`,
});
}
if (!page.title.trim()) {
issues.push({
severity: "error",
category: "structure",
code: "missing-title",
path: page.relativePath,
message: "Missing page title.",
});
}
if (page.kind !== "source" && page.kind !== "report" && page.sourceIds.length === 0) {
issues.push({
severity: "warning",
category: "provenance",
code: "missing-source-ids",
path: page.relativePath,
message: "Non-source page is missing `sourceIds` provenance.",
});
}
if (
(page.sourceType === "memory-bridge" || page.sourceType === "memory-bridge-events") &&
(!page.sourcePath || !page.bridgeRelativePath || !page.bridgeWorkspaceDir)
) {
issues.push({
severity: "warning",
category: "provenance",
code: "missing-import-provenance",
path: page.relativePath,
message:
"Bridge-imported source page is missing `sourcePath`, `bridgeRelativePath`, or `bridgeWorkspaceDir` provenance.",
});
}
if (
(page.provenanceMode === "unsafe-local" || page.sourceType === "memory-unsafe-local") &&
(!page.sourcePath || !page.unsafeLocalConfiguredPath || !page.unsafeLocalRelativePath)
) {
issues.push({
severity: "warning",
category: "provenance",
code: "missing-import-provenance",
path: page.relativePath,
message:
"Unsafe-local source page is missing `sourcePath`, `unsafeLocalConfiguredPath`, or `unsafeLocalRelativePath` provenance.",
});
}
if (page.contradictions.length > 0) {
issues.push({
severity: "warning",
category: "contradictions",
code: "contradiction-present",
path: page.relativePath,
message: `Page lists ${page.contradictions.length} contradiction${page.contradictions.length === 1 ? "" : "s"} to resolve.`,
});
}
if (page.questions.length > 0) {
issues.push({
severity: "warning",
category: "open-questions",
code: "open-question",
path: page.relativePath,
message: `Page lists ${page.questions.length} open question${page.questions.length === 1 ? "" : "s"}.`,
});
}
if (typeof page.confidence === "number" && page.confidence < 0.5) {
issues.push({
severity: "warning",
category: "quality",
code: "low-confidence",
path: page.relativePath,
message: `Page confidence is low (${page.confidence.toFixed(2)}).`,
});
}
}
for (const [id, matches] of pagesById.entries()) {
if (matches.length > 1) {
for (const match of matches) {
issues.push({
severity: "error",
category: "structure",
code: "duplicate-id",
path: match.relativePath,
message: `Duplicate page id \`${id}\`.`,
});
}
}
}
issues.push(...collectBrokenLinkIssues(pages));
return issues.toSorted((left, right) => left.path.localeCompare(right.path));
}
function buildIssuesByCategory(
issues: MemoryWikiLintIssue[],
): Record<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]> {
return {
structure: issues.filter((issue) => issue.category === "structure"),
provenance: issues.filter((issue) => issue.category === "provenance"),
links: issues.filter((issue) => issue.category === "links"),
contradictions: issues.filter((issue) => issue.category === "contradictions"),
"open-questions": issues.filter((issue) => issue.category === "open-questions"),
quality: issues.filter((issue) => issue.category === "quality"),
};
}
function buildLintReportBody(issues: MemoryWikiLintIssue[]): string {
if (issues.length === 0) {
return "No issues found.";
}
const errors = issues.filter((issue) => issue.severity === "error");
const warnings = issues.filter((issue) => issue.severity === "warning");
const byCategory = buildIssuesByCategory(issues);
const lines = [`- Errors: ${errors.length}`, `- Warnings: ${warnings.length}`];
if (errors.length > 0) {
lines.push("", "### Errors");
for (const issue of errors) {
lines.push(`- \`${issue.path}\`: ${issue.message}`);
}
}
if (warnings.length > 0) {
lines.push("", "### Warnings");
for (const issue of warnings) {
lines.push(`- \`${issue.path}\`: ${issue.message}`);
}
}
if (byCategory.contradictions.length > 0) {
lines.push("", "### Contradictions");
for (const issue of byCategory.contradictions) {
lines.push(`- \`${issue.path}\`: ${issue.message}`);
}
}
if (byCategory["open-questions"].length > 0) {
lines.push("", "### Open Questions");
for (const issue of byCategory["open-questions"]) {
lines.push(`- \`${issue.path}\`: ${issue.message}`);
}
}
if (byCategory.provenance.length > 0 || byCategory.quality.length > 0) {
lines.push("", "### Quality Follow-Up");
for (const issue of [...byCategory.provenance, ...byCategory.quality]) {
lines.push(`- \`${issue.path}\`: ${issue.message}`);
}
}
return lines.join("\n");
}
async function writeLintReport(rootDir: string, issues: MemoryWikiLintIssue[]): Promise<string> {
const reportPath = path.join(rootDir, "reports", "lint.md");
const original = await fs.readFile(reportPath, "utf8").catch(() =>
renderWikiMarkdown({
frontmatter: {
pageType: "report",
id: "report.lint",
title: "Lint Report",
status: "active",
},
body: "# Lint Report\n",
}),
);
const updated = replaceManagedMarkdownBlock({
original,
heading: "## Generated",
startMarker: "<!-- openclaw:wiki:lint:start -->",
endMarker: "<!-- openclaw:wiki:lint:end -->",
body: buildLintReportBody(issues),
});
await fs.writeFile(reportPath, withTrailingNewline(updated), "utf8");
return reportPath;
}
export async function lintMemoryWikiVault(
config: ResolvedMemoryWikiConfig,
): Promise<LintMemoryWikiResult> {
const compileResult = await compileMemoryWikiVault(config);
const issues = collectPageIssues(compileResult.pages);
const issuesByCategory = buildIssuesByCategory(issues);
const reportPath = await writeLintReport(config.vault.path, issues);
await appendMemoryWikiLog(config.vault.path, {
type: "lint",
timestamp: new Date().toISOString(),
details: {
issueCount: issues.length,
reportPath: path.relative(config.vault.path, reportPath),
},
});
return {
vaultRoot: config.vault.path,
issueCount: issues.length,
issues,
issuesByCategory,
reportPath,
};
}

View File

@@ -1,17 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
export type MemoryWikiLogEntry = {
type: "init" | "ingest" | "compile" | "lint";
timestamp: string;
details?: Record<string, unknown>;
};
export async function appendMemoryWikiLog(
vaultRoot: string,
entry: MemoryWikiLogEntry,
): Promise<void> {
const logPath = path.join(vaultRoot, ".openclaw-wiki", "log.jsonl");
await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
}

View File

@@ -1,211 +0,0 @@
import path from "node:path";
import YAML from "yaml";
export const WIKI_PAGE_KINDS = ["entity", "concept", "source", "synthesis", "report"] as const;
export const WIKI_RELATED_START_MARKER = "<!-- openclaw:wiki:related:start -->";
export const WIKI_RELATED_END_MARKER = "<!-- openclaw:wiki:related:end -->";
export type WikiPageKind = (typeof WIKI_PAGE_KINDS)[number];
export type ParsedWikiMarkdown = {
frontmatter: Record<string, unknown>;
body: string;
};
export type WikiPageSummary = {
absolutePath: string;
relativePath: string;
kind: WikiPageKind;
title: string;
id?: string;
pageType?: string;
sourceIds: string[];
linkTargets: string[];
contradictions: string[];
questions: string[];
confidence?: number;
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
bridgeRelativePath?: string;
bridgeWorkspaceDir?: string;
unsafeLocalConfiguredPath?: string;
unsafeLocalRelativePath?: string;
updatedAt?: string;
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
const OBSIDIAN_LINK_PATTERN = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
const MARKDOWN_LINK_PATTERN = /\[[^\]]+\]\(([^)]+)\)/g;
const RELATED_BLOCK_PATTERN = new RegExp(
`${WIKI_RELATED_START_MARKER}[\\s\\S]*?${WIKI_RELATED_END_MARKER}`,
"g",
);
export function slugifyWikiSegment(raw: string): string {
const slug = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "page";
}
export function parseWikiMarkdown(content: string): ParsedWikiMarkdown {
const match = content.match(FRONTMATTER_PATTERN);
if (!match) {
return { frontmatter: {}, body: content };
}
const parsed = YAML.parse(match[1]) as unknown;
return {
frontmatter:
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {},
body: content.slice(match[0].length),
};
}
export function renderWikiMarkdown(params: {
frontmatter: Record<string, unknown>;
body: string;
}): string {
const frontmatter = YAML.stringify(params.frontmatter).trimEnd();
return `---\n${frontmatter}\n---\n\n${params.body.trimStart()}`;
}
export function extractTitleFromMarkdown(body: string): string | undefined {
const match = body.match(/^#\s+(.+?)\s*$/m);
return match?.[1]?.trim() || undefined;
}
export function normalizeSourceIds(value: unknown): string[] {
if (Array.isArray(value)) {
return value.flatMap((item) => (typeof item === "string" && item.trim() ? [item.trim()] : []));
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
function normalizeStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.flatMap((item) => (typeof item === "string" && item.trim() ? [item.trim()] : []));
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
export function extractWikiLinks(markdown: string): string[] {
const searchable = markdown.replace(RELATED_BLOCK_PATTERN, "");
const links: string[] = [];
for (const match of searchable.matchAll(OBSIDIAN_LINK_PATTERN)) {
const target = match[1]?.trim();
if (target) {
links.push(target);
}
}
for (const match of searchable.matchAll(MARKDOWN_LINK_PATTERN)) {
const rawTarget = match[1]?.trim();
if (!rawTarget || rawTarget.startsWith("#") || /^[a-z]+:/i.test(rawTarget)) {
continue;
}
const target = rawTarget.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
if (target) {
links.push(target);
}
}
return links;
}
export function formatWikiLink(params: {
renderMode: "native" | "obsidian";
relativePath: string;
title: string;
}): string {
const withoutExtension = params.relativePath.replace(/\.md$/i, "");
return params.renderMode === "obsidian"
? `[[${withoutExtension}|${params.title}]]`
: `[${params.title}](${params.relativePath})`;
}
export function renderMarkdownFence(content: string, infoString = "text"): string {
const fenceSize = Math.max(
3,
...Array.from(content.matchAll(/`+/g), (match) => match[0].length + 1),
);
const fence = "`".repeat(fenceSize);
return `${fence}${infoString}\n${content}\n${fence}`;
}
export function inferWikiPageKind(relativePath: string): WikiPageKind | null {
const normalized = relativePath.split(path.sep).join("/");
if (normalized.startsWith("entities/")) {
return "entity";
}
if (normalized.startsWith("concepts/")) {
return "concept";
}
if (normalized.startsWith("sources/")) {
return "source";
}
if (normalized.startsWith("syntheses/")) {
return "synthesis";
}
if (normalized.startsWith("reports/")) {
return "report";
}
return null;
}
export function toWikiPageSummary(params: {
absolutePath: string;
relativePath: string;
raw: string;
}): WikiPageSummary | null {
const kind = inferWikiPageKind(params.relativePath);
if (!kind) {
return null;
}
const parsed = parseWikiMarkdown(params.raw);
const title =
(typeof parsed.frontmatter.title === "string" && parsed.frontmatter.title.trim()) ||
extractTitleFromMarkdown(parsed.body) ||
path.basename(params.relativePath, ".md");
return {
absolutePath: params.absolutePath,
relativePath: params.relativePath.split(path.sep).join("/"),
kind,
title,
id: normalizeOptionalString(parsed.frontmatter.id),
pageType: normalizeOptionalString(parsed.frontmatter.pageType),
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
linkTargets: extractWikiLinks(params.raw),
contradictions: normalizeStringList(parsed.frontmatter.contradictions),
questions: normalizeStringList(parsed.frontmatter.questions),
confidence:
typeof parsed.frontmatter.confidence === "number" &&
Number.isFinite(parsed.frontmatter.confidence)
? parsed.frontmatter.confidence
: undefined,
sourceType: normalizeOptionalString(parsed.frontmatter.sourceType),
provenanceMode: normalizeOptionalString(parsed.frontmatter.provenanceMode),
sourcePath: normalizeOptionalString(parsed.frontmatter.sourcePath),
bridgeRelativePath: normalizeOptionalString(parsed.frontmatter.bridgeRelativePath),
bridgeWorkspaceDir: normalizeOptionalString(parsed.frontmatter.bridgeWorkspaceDir),
unsafeLocalConfiguredPath: normalizeOptionalString(
parsed.frontmatter.unsafeLocalConfiguredPath,
),
unsafeLocalRelativePath: normalizeOptionalString(parsed.frontmatter.unsafeLocalRelativePath),
updatedAt: normalizeOptionalString(parsed.frontmatter.updatedAt),
};
}

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildWikiPromptSection } from "./prompt-section.js";
describe("buildWikiPromptSection", () => {
it("prefers shared memory corpus guidance when memory tools are available", () => {
const lines = buildWikiPromptSection({
availableTools: new Set(["memory_search", "memory_get", "wiki_search", "wiki_get"]),
});
expect(lines.join("\n")).toContain("`memory_search` with `corpus=all`");
expect(lines.join("\n")).toContain("`memory_get` with `corpus=wiki` or `corpus=all`");
expect(lines.join("\n")).toContain("wiki-specific ranking or provenance details");
});
it("stays empty when no wiki or memory-adjacent tools are registered", () => {
expect(buildWikiPromptSection({ availableTools: new Set(["web_search"]) })).toEqual([]);
});
});

View File

@@ -1,62 +0,0 @@
import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-host-core";
export const buildWikiPromptSection: MemoryPromptSectionBuilder = ({ availableTools }) => {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
const hasWikiSearch = availableTools.has("wiki_search");
const hasWikiGet = availableTools.has("wiki_get");
const hasWikiApply = availableTools.has("wiki_apply");
const hasWikiLint = availableTools.has("wiki_lint");
if (
!hasMemorySearch &&
!hasMemoryGet &&
!hasWikiSearch &&
!hasWikiGet &&
!hasWikiApply &&
!hasWikiLint
) {
return [];
}
const lines = [
"## Compiled Wiki",
"Use the wiki when the answer depends on accumulated project knowledge, prior syntheses, entity pages, or source-backed notes that should survive beyond one conversation.",
];
if (hasMemorySearch) {
lines.push(
"Prefer `memory_search` with `corpus=all` for one recall pass across durable memory and the compiled wiki when both are relevant.",
);
}
if (hasMemoryGet) {
lines.push(
"Use `memory_get` with `corpus=wiki` or `corpus=all` when you already know the page path and want a small excerpt without leaving the shared memory tool flow.",
);
}
if (hasWikiSearch && hasWikiGet) {
lines.push(
"Workflow: `wiki_search` first, then `wiki_get` for the exact page or imported memory file you need. Use this when you want wiki-specific ranking or provenance details instead of the broader shared memory flow.",
);
} else if (hasWikiSearch) {
lines.push(
"Use `wiki_search` before answering from stored knowledge when you want wiki-specific ranking or provenance details.",
);
} else if (hasWikiGet) {
lines.push(
"Use `wiki_get` to inspect specific wiki pages or imported memory files by path/id.",
);
}
if (hasWikiApply) {
lines.push(
"Use `wiki_apply` for narrow synthesis filing and metadata repair instead of rewriting managed markdown blocks by hand.",
);
}
if (hasWikiLint) {
lines.push("After meaningful wiki updates, run `wiki_lint` before trusting the vault.");
}
lines.push("");
return lines;
};

View File

@@ -1,422 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { renderWikiMarkdown } from "./markdown.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { initializeMemoryWikiVault } from "./vault.js";
const { getActiveMemorySearchManagerMock } = vi.hoisted(() => ({
getActiveMemorySearchManagerMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({
getActiveMemorySearchManager: getActiveMemorySearchManagerMock,
}));
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
beforeEach(() => {
getActiveMemorySearchManagerMock.mockReset();
getActiveMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "unavailable" });
});
function createAppConfig(): OpenClawConfig {
return {
agents: {
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
}
function createMemoryManager(overrides?: {
searchResults?: Array<{
path: string;
startLine: number;
endLine: number;
score: number;
snippet: string;
source: "memory" | "sessions";
citation?: string;
}>;
readResult?: { text: string; path: string };
}) {
return {
search: vi.fn().mockResolvedValue(overrides?.searchResults ?? []),
readFile: vi.fn().mockImplementation(async () => {
if (!overrides?.readResult) {
throw new Error("missing");
}
return overrides.readResult;
}),
status: vi.fn().mockReturnValue({ backend: "builtin", provider: "builtin" }),
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
probeVectorAvailability: vi.fn().mockResolvedValue(false),
close: vi.fn().mockResolvedValue(undefined),
};
}
describe("searchMemoryWiki", () => {
it("finds wiki pages by title and body", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
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 Source" },
body: "# Alpha Source\n\nalpha body text\n",
}),
"utf8",
);
const results = await searchMemoryWiki({ config, query: "alpha" });
expect(results).toHaveLength(1);
expect(results[0]?.corpus).toBe("wiki");
expect(results[0]?.path).toBe("sources/alpha.md");
expect(getActiveMemorySearchManagerMock).not.toHaveBeenCalled();
});
it("surfaces bridge provenance for imported source pages", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "bridge-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.bridge.alpha",
title: "Bridge Alpha",
sourceType: "memory-bridge",
sourcePath: "/tmp/workspace/MEMORY.md",
bridgeRelativePath: "MEMORY.md",
bridgeWorkspaceDir: "/tmp/workspace",
updatedAt: "2026-04-05T12:00:00.000Z",
},
body: "# Bridge Alpha\n\nalpha bridge body\n",
}),
"utf8",
);
const results = await searchMemoryWiki({ config, query: "alpha" });
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
corpus: "wiki",
sourceType: "memory-bridge",
sourcePath: "/tmp/workspace/MEMORY.md",
provenanceLabel: "bridge: MEMORY.md",
updatedAt: "2026-04-05T12:00:00.000Z",
});
});
it("includes active memory results when shared search and all corpora are enabled", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
search: { backend: "shared", corpus: "all" },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha Source" },
body: "# Alpha Source\n\nalpha body text\n",
}),
"utf8",
);
const manager = createMemoryManager({
searchResults: [
{
path: "MEMORY.md",
startLine: 4,
endLine: 8,
score: 42,
snippet: "alpha durable memory",
source: "memory",
citation: "MEMORY.md#L4-L8",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
query: "alpha",
maxResults: 5,
});
expect(results).toHaveLength(2);
expect(results.some((result) => result.corpus === "wiki")).toBe(true);
expect(results.some((result) => result.corpus === "memory")).toBe(true);
expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 });
});
it("allows per-call corpus overrides without changing config defaults", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
search: { backend: "shared", corpus: "wiki" },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha Source" },
body: "# Alpha Source\n\nalpha body text\n",
}),
"utf8",
);
const manager = createMemoryManager({
searchResults: [
{
path: "MEMORY.md",
startLine: 10,
endLine: 12,
score: 99,
snippet: "memory-only alpha",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const memoryOnly = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
query: "alpha",
searchCorpus: "memory",
});
expect(memoryOnly).toHaveLength(1);
expect(memoryOnly[0]?.corpus).toBe("memory");
expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 10 });
});
it("keeps memory search disabled when the backend is local", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
search: { backend: "local", corpus: "all" },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha Source" },
body: "# Alpha Source\n\nalpha only wiki\n",
}),
"utf8",
);
const manager = createMemoryManager({
searchResults: [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 50,
snippet: "alpha memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
query: "alpha",
});
expect(results).toHaveLength(1);
expect(results[0]?.corpus).toBe("wiki");
expect(manager.search).not.toHaveBeenCalled();
});
});
describe("getMemoryWikiPage", () => {
it("reads wiki pages by relative path and slices line ranges", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
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 Source" },
body: "# Alpha Source\n\nline one\nline two\nline three\n",
}),
"utf8",
);
const result = await getMemoryWikiPage({
config,
lookup: "sources/alpha.md",
fromLine: 4,
lineCount: 2,
});
expect(result?.corpus).toBe("wiki");
expect(result?.path).toBe("sources/alpha.md");
expect(result?.content).toContain("line one");
expect(result?.content).toContain("line two");
expect(result?.content).not.toContain("line three");
});
it("returns provenance for imported wiki source pages", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "unsafe-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.unsafe.alpha",
title: "Unsafe Alpha",
sourceType: "memory-unsafe-local",
provenanceMode: "unsafe-local",
sourcePath: "/tmp/private/alpha.md",
unsafeLocalConfiguredPath: "/tmp/private",
unsafeLocalRelativePath: "alpha.md",
updatedAt: "2026-04-05T13:00:00.000Z",
},
body: "# Unsafe Alpha\n\nsecret alpha\n",
}),
"utf8",
);
const result = await getMemoryWikiPage({
config,
lookup: "sources/unsafe-alpha.md",
});
expect(result).toMatchObject({
corpus: "wiki",
path: "sources/unsafe-alpha.md",
sourceType: "memory-unsafe-local",
provenanceMode: "unsafe-local",
sourcePath: "/tmp/private/alpha.md",
provenanceLabel: "unsafe-local: alpha.md",
updatedAt: "2026-04-05T13:00:00.000Z",
});
});
it("falls back to active memory reads when memory corpus is selected", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
search: { backend: "shared", corpus: "memory" },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
const manager = createMemoryManager({
readResult: {
path: "MEMORY.md",
text: "durable alpha memory\nline two",
},
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const result = await getMemoryWikiPage({
config,
appConfig: createAppConfig(),
lookup: "MEMORY.md",
fromLine: 2,
lineCount: 2,
});
expect(result).toEqual({
corpus: "memory",
path: "MEMORY.md",
title: "MEMORY",
kind: "memory",
content: "durable alpha memory\nline two",
fromLine: 2,
lineCount: 2,
});
expect(manager.readFile).toHaveBeenCalledWith({
relPath: "MEMORY.md",
from: 2,
lines: 2,
});
});
it("allows per-call get overrides to bypass wiki and force memory fallback", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{
vault: { path: rootDir },
search: { backend: "shared", corpus: "wiki" },
},
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
await fs.writeFile(
path.join(rootDir, "sources", "MEMORY.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.memory.shadow", title: "Shadow Memory" },
body: "# Shadow Memory\n\nwiki copy\n",
}),
"utf8",
);
const manager = createMemoryManager({
readResult: {
path: "MEMORY.md",
text: "forced memory read",
},
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const result = await getMemoryWikiPage({
config,
appConfig: createAppConfig(),
lookup: "MEMORY.md",
searchCorpus: "memory",
});
expect(result?.corpus).toBe("memory");
expect(result?.content).toBe("forced memory read");
expect(manager.readFile).toHaveBeenCalled();
});
});

View File

@@ -1,378 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-host-files";
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js";
import { parseWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
export type WikiSearchResult = {
corpus: "wiki" | "memory";
path: string;
title: string;
kind: WikiPageSummary["kind"] | "memory";
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
memorySource?: MemorySearchResult["source"];
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
};
export type WikiGetResult = {
corpus: "wiki" | "memory";
path: string;
title: string;
kind: WikiPageSummary["kind"] | "memory";
content: string;
fromLine: number;
lineCount: number;
id?: string;
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
};
export type QueryableWikiPage = WikiPageSummary & {
raw: string;
};
type QuerySearchOverrides = {
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
};
async function listWikiMarkdownFiles(rootDir: string): Promise<string[]> {
const files = (
await Promise.all(
QUERY_DIRS.map(async (relativeDir) => {
const dirPath = path.join(rootDir, relativeDir);
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
return entries
.filter(
(entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md",
)
.map((entry) => path.join(relativeDir, entry.name));
}),
)
).flat();
return files.toSorted((left, right) => left.localeCompare(right));
}
export async function readQueryableWikiPages(rootDir: string): Promise<QueryableWikiPage[]> {
const files = await listWikiMarkdownFiles(rootDir);
const pages = await Promise.all(
files.map(async (relativePath) => {
const absolutePath = path.join(rootDir, relativePath);
const raw = await fs.readFile(absolutePath, "utf8");
const summary = toWikiPageSummary({ absolutePath, relativePath, raw });
return summary ? { ...summary, raw } : null;
}),
);
return pages.flatMap((page) => (page ? [page] : []));
}
function buildSnippet(raw: string, query: string): string {
const queryLower = query.toLowerCase();
const matchingLine = raw
.split(/\r?\n/)
.find((line) => line.toLowerCase().includes(queryLower) && line.trim().length > 0);
return (
matchingLine?.trim() ||
raw
.split(/\r?\n/)
.find((line) => line.trim().length > 0)
?.trim() ||
""
);
}
function scorePage(page: QueryableWikiPage, query: string): number {
const queryLower = query.toLowerCase();
const titleLower = page.title.toLowerCase();
const pathLower = page.relativePath.toLowerCase();
const idLower = page.id?.toLowerCase() ?? "";
const rawLower = page.raw.toLowerCase();
if (
!(
titleLower.includes(queryLower) ||
pathLower.includes(queryLower) ||
idLower.includes(queryLower) ||
rawLower.includes(queryLower)
)
) {
return 0;
}
let score = 1;
if (titleLower === queryLower) {
score += 50;
} else if (titleLower.includes(queryLower)) {
score += 20;
}
if (pathLower.includes(queryLower)) {
score += 10;
}
if (idLower.includes(queryLower)) {
score += 10;
}
const bodyOccurrences = rawLower.split(queryLower).length - 1;
score += Math.min(20, bodyOccurrences);
return score;
}
function normalizeLookupKey(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
function buildLookupCandidates(lookup: string): string[] {
const normalized = normalizeLookupKey(lookup);
const withExtension = normalized.endsWith(".md") ? normalized : `${normalized}.md`;
return [...new Set([normalized, withExtension])];
}
function shouldSearchWiki(config: ResolvedMemoryWikiConfig): boolean {
return config.search.corpus === "wiki" || config.search.corpus === "all";
}
function shouldSearchSharedMemory(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): boolean {
return (
config.search.backend === "shared" &&
appConfig !== undefined &&
(config.search.corpus === "memory" || config.search.corpus === "all")
);
}
async function resolveActiveMemoryManager(appConfig?: OpenClawConfig) {
if (!appConfig) {
return null;
}
try {
const { manager } = await getActiveMemorySearchManager({
cfg: appConfig,
agentId: resolveDefaultAgentId(appConfig),
});
return manager;
} catch {
return null;
}
}
function buildMemorySearchTitle(resultPath: string): string {
const basename = path.basename(resultPath, path.extname(resultPath));
return basename.length > 0 ? basename : resultPath;
}
function applySearchOverrides(
config: ResolvedMemoryWikiConfig,
overrides?: QuerySearchOverrides,
): ResolvedMemoryWikiConfig {
if (!overrides?.searchBackend && !overrides?.searchCorpus) {
return config;
}
return {
...config,
search: {
backend: overrides.searchBackend ?? config.search.backend,
corpus: overrides.searchCorpus ?? config.search.corpus,
},
};
}
function buildWikiProvenanceLabel(
page: Pick<
WikiPageSummary,
| "sourceType"
| "provenanceMode"
| "bridgeRelativePath"
| "unsafeLocalRelativePath"
| "relativePath"
>,
): string | undefined {
if (page.sourceType === "memory-bridge-events") {
return `bridge events: ${page.bridgeRelativePath ?? page.relativePath}`;
}
if (page.sourceType === "memory-bridge") {
return `bridge: ${page.bridgeRelativePath ?? page.relativePath}`;
}
if (page.provenanceMode === "unsafe-local" || page.sourceType === "memory-unsafe-local") {
return `unsafe-local: ${page.unsafeLocalRelativePath ?? page.relativePath}`;
}
return undefined;
}
function toWikiSearchResult(page: QueryableWikiPage, query: string): WikiSearchResult {
return {
corpus: "wiki",
path: page.relativePath,
title: page.title,
kind: page.kind,
score: scorePage(page, query),
snippet: buildSnippet(page.raw, query),
...(page.id ? { id: page.id } : {}),
...(page.sourceType ? { sourceType: page.sourceType } : {}),
...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}),
...(page.sourcePath ? { sourcePath: page.sourcePath } : {}),
...(buildWikiProvenanceLabel(page) ? { provenanceLabel: buildWikiProvenanceLabel(page) } : {}),
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
};
}
function toMemoryWikiSearchResult(result: MemorySearchResult): WikiSearchResult {
return {
corpus: "memory",
path: result.path,
title: buildMemorySearchTitle(result.path),
kind: "memory",
score: result.score,
snippet: result.snippet,
startLine: result.startLine,
endLine: result.endLine,
memorySource: result.source,
...(result.citation ? { citation: result.citation } : {}),
};
}
export function resolveQueryableWikiPageByLookup(
pages: QueryableWikiPage[],
lookup: string,
): QueryableWikiPage | null {
const key = normalizeLookupKey(lookup);
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
return (
pages.find((page) => page.relativePath === key) ??
pages.find((page) => page.relativePath === withExtension) ??
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
pages.find((page) => page.id === key) ??
null
);
}
export async function searchMemoryWiki(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
query: string;
maxResults?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
}): Promise<WikiSearchResult[]> {
const effectiveConfig = applySearchOverrides(params.config, params);
await initializeMemoryWikiVault(effectiveConfig);
const maxResults = Math.max(1, params.maxResults ?? 10);
const wikiResults = shouldSearchWiki(effectiveConfig)
? (await readQueryableWikiPages(effectiveConfig.vault.path))
.map((page) => toWikiSearchResult(page, params.query))
.filter((page) => page.score > 0)
: [];
const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig)
? await resolveActiveMemoryManager(params.appConfig)
: null;
const memoryResults = sharedMemoryManager
? (await sharedMemoryManager.search(params.query, { maxResults })).map((result) =>
toMemoryWikiSearchResult(result),
)
: [];
return [...wikiResults, ...memoryResults]
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.title.localeCompare(right.title);
})
.slice(0, maxResults);
}
export async function getMemoryWikiPage(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
lookup: string;
fromLine?: number;
lineCount?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
}): Promise<WikiGetResult | null> {
const effectiveConfig = applySearchOverrides(params.config, params);
await initializeMemoryWikiVault(effectiveConfig);
const fromLine = Math.max(1, params.fromLine ?? 1);
const lineCount = Math.max(1, params.lineCount ?? 200);
if (shouldSearchWiki(effectiveConfig)) {
const pages = await readQueryableWikiPages(effectiveConfig.vault.path);
const page = resolveQueryableWikiPageByLookup(pages, params.lookup);
if (page) {
const parsed = parseWikiMarkdown(page.raw);
const lines = parsed.body.split(/\r?\n/);
const slice = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n");
return {
corpus: "wiki",
path: page.relativePath,
title: page.title,
kind: page.kind,
content: slice,
fromLine,
lineCount,
...(page.id ? { id: page.id } : {}),
...(page.sourceType ? { sourceType: page.sourceType } : {}),
...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}),
...(page.sourcePath ? { sourcePath: page.sourcePath } : {}),
...(buildWikiProvenanceLabel(page)
? { provenanceLabel: buildWikiProvenanceLabel(page) }
: {}),
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
};
}
}
if (!shouldSearchSharedMemory(effectiveConfig, params.appConfig)) {
return null;
}
const manager = await resolveActiveMemoryManager(params.appConfig);
if (!manager) {
return null;
}
for (const relPath of buildLookupCandidates(params.lookup)) {
try {
const result = await manager.readFile({
relPath,
from: fromLine,
lines: lineCount,
});
return {
corpus: "memory",
path: result.path,
title: buildMemorySearchTitle(result.path),
kind: "memory",
content: result.text,
fromLine,
lineCount,
};
} catch {
continue;
}
}
return null;
}

View File

@@ -1,123 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
export type MemoryWikiImportedSourceStateEntry = {
group: MemoryWikiImportedSourceGroup;
pagePath: string;
sourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
};
type MemoryWikiImportedSourceState = {
version: 1;
entries: Record<string, MemoryWikiImportedSourceStateEntry>;
};
const EMPTY_STATE: MemoryWikiImportedSourceState = {
version: 1,
entries: {},
};
export function resolveMemoryWikiSourceSyncStatePath(vaultRoot: string): string {
return path.join(vaultRoot, ".openclaw-wiki", "source-sync.json");
}
export async function readMemoryWikiSourceSyncState(
vaultRoot: string,
): Promise<MemoryWikiImportedSourceState> {
const statePath = resolveMemoryWikiSourceSyncStatePath(vaultRoot);
const raw = await fs.readFile(statePath, "utf8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
if (!raw.trim()) {
return {
version: EMPTY_STATE.version,
entries: {},
};
}
try {
const parsed = JSON.parse(raw) as Partial<MemoryWikiImportedSourceState>;
return {
version: 1,
entries: { ...(parsed.entries ?? {}) },
};
} catch {
return {
version: EMPTY_STATE.version,
entries: {},
};
}
}
export async function writeMemoryWikiSourceSyncState(
vaultRoot: string,
state: MemoryWikiImportedSourceState,
): Promise<void> {
const statePath = resolveMemoryWikiSourceSyncStatePath(vaultRoot);
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
export async function shouldSkipImportedSourceWrite(params: {
vaultRoot: string;
syncKey: string;
expectedPagePath: string;
expectedSourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
state: MemoryWikiImportedSourceState;
}): Promise<boolean> {
const entry = params.state.entries[params.syncKey];
if (!entry) {
return false;
}
if (
entry.pagePath !== params.expectedPagePath ||
entry.sourcePath !== params.expectedSourcePath ||
entry.sourceUpdatedAtMs !== params.sourceUpdatedAtMs ||
entry.sourceSize !== params.sourceSize ||
entry.renderFingerprint !== params.renderFingerprint
) {
return false;
}
const pagePath = path.join(params.vaultRoot, params.expectedPagePath);
return await fs
.access(pagePath)
.then(() => true)
.catch(() => false);
}
export async function pruneImportedSourceEntries(params: {
vaultRoot: string;
group: MemoryWikiImportedSourceGroup;
activeKeys: Set<string>;
state: MemoryWikiImportedSourceState;
}): Promise<number> {
let removedCount = 0;
for (const [syncKey, entry] of Object.entries(params.state.entries)) {
if (entry.group !== params.group || params.activeKeys.has(syncKey)) {
continue;
}
const pageAbsPath = path.join(params.vaultRoot, entry.pagePath);
await fs.rm(pageAbsPath, { force: true }).catch(() => undefined);
delete params.state.entries[syncKey];
removedCount += 1;
}
return removedCount;
}
export function setImportedSourceEntry(params: {
syncKey: string;
entry: MemoryWikiImportedSourceStateEntry;
state: MemoryWikiImportedSourceState;
}): void {
params.state.entries[params.syncKey] = params.entry;
}

View File

@@ -1,86 +0,0 @@
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 { syncMemoryWikiImportedSources } from "./source-sync.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("syncMemoryWikiImportedSources", () => {
it("refreshes indexes when imported sources change and skips when they do not", async () => {
const privateDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-sync-private-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-sync-vault-"));
tempDirs.push(privateDir, vaultDir);
const sourcePath = path.join(privateDir, "alpha.md");
await fs.writeFile(sourcePath, "# Alpha\n", "utf8");
const config = resolveMemoryWikiConfig(
{
vaultMode: "unsafe-local",
vault: { path: vaultDir },
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [sourcePath],
},
},
{ homedir: "/Users/tester" },
);
const first = await syncMemoryWikiImportedSources({ config });
expect(first.indexesRefreshed).toBe(true);
expect(first.indexRefreshReason).toBe("import-changed");
await expect(fs.readFile(path.join(vaultDir, "index.md"), "utf8")).resolves.toContain(
"Unsafe Local Import: alpha.md",
);
const second = await syncMemoryWikiImportedSources({ config });
expect(second.indexesRefreshed).toBe(false);
expect(second.indexRefreshReason).toBe("no-import-changes");
await fs.rm(path.join(vaultDir, "sources", "index.md"));
const third = await syncMemoryWikiImportedSources({ config });
expect(third.indexesRefreshed).toBe(true);
expect(third.indexRefreshReason).toBe("missing-indexes");
await expect(
fs.readFile(path.join(vaultDir, "sources", "index.md"), "utf8"),
).resolves.toContain("Unsafe Local Import: alpha.md");
});
it("respects ingest.autoCompile=false", async () => {
const privateDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-sync-private-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-sync-vault-"));
tempDirs.push(privateDir, vaultDir);
const sourcePath = path.join(privateDir, "alpha.md");
await fs.writeFile(sourcePath, "# Alpha\n", "utf8");
const config = resolveMemoryWikiConfig(
{
vaultMode: "unsafe-local",
vault: { path: vaultDir },
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [sourcePath],
},
ingest: {
autoCompile: false,
},
},
{ homedir: "/Users/tester" },
);
const result = await syncMemoryWikiImportedSources({ config });
expect(result.indexesRefreshed).toBe(false);
expect(result.indexRefreshReason).toBe("auto-compile-disabled");
});
});

View File

@@ -1,46 +0,0 @@
import type { OpenClawConfig } from "../api.js";
import { syncMemoryWikiBridgeSources, type BridgeMemoryWikiResult } from "./bridge.js";
import {
refreshMemoryWikiIndexesAfterImport,
type RefreshMemoryWikiIndexesResult,
} from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { syncMemoryWikiUnsafeLocalSources } from "./unsafe-local.js";
export type MemoryWikiImportedSourceSyncResult = BridgeMemoryWikiResult & {
indexesRefreshed: boolean;
indexUpdatedFiles: string[];
indexRefreshReason: RefreshMemoryWikiIndexesResult["reason"];
};
export async function syncMemoryWikiImportedSources(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}): Promise<MemoryWikiImportedSourceSyncResult> {
let syncResult: BridgeMemoryWikiResult;
if (params.config.vaultMode === "bridge") {
syncResult = await syncMemoryWikiBridgeSources(params);
} else if (params.config.vaultMode === "unsafe-local") {
syncResult = await syncMemoryWikiUnsafeLocalSources(params.config);
} else {
syncResult = {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const refreshResult = await refreshMemoryWikiIndexesAfterImport({
config: params.config,
syncResult,
});
return {
...syncResult,
indexesRefreshed: refreshResult.refreshed,
indexUpdatedFiles: refreshResult.compile?.updatedFiles ?? [],
indexRefreshReason: refreshResult.reason,
};
}

View File

@@ -1,213 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveMemoryWikiConfig } from "./config.js";
import { renderWikiMarkdown } from "./markdown.js";
import {
buildMemoryWikiDoctorReport,
renderMemoryWikiDoctor,
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",
]);
expect(status.sourceCounts).toEqual({
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
});
});
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");
});
it("counts source provenance from the vault", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-status-"));
await fs.mkdir(path.join(rootDir, "sources"), { recursive: true });
await fs.mkdir(path.join(rootDir, "entities"), { recursive: true });
await fs.mkdir(path.join(rootDir, "concepts"), { recursive: true });
await fs.mkdir(path.join(rootDir, "syntheses"), { recursive: true });
await fs.mkdir(path.join(rootDir, "reports"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "sources", "native.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.native", title: "Native Source" },
body: "# Native Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "bridge.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.bridge",
title: "Bridge Source",
sourceType: "memory-bridge",
},
body: "# Bridge Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "events.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.events",
title: "Event Source",
sourceType: "memory-bridge-events",
},
body: "# Event Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "unsafe.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.unsafe",
title: "Unsafe Source",
sourceType: "memory-unsafe-local",
provenanceMode: "unsafe-local",
},
body: "# Unsafe Source\n",
}),
"utf8",
);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
pathExists: async () => true,
resolveCommand: async () => null,
});
expect(status.pageCounts.source).toBe(4);
expect(status.sourceCounts).toEqual({
native: 1,
bridge: 1,
bridgeEvents: 1,
unsafeLocal: 1,
other: 0,
});
await fs.rm(rootDir, { recursive: true, force: true });
});
});
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,
},
pageCounts: {
source: 0,
entity: 0,
concept: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
warnings: [{ code: "vault-missing", message: "Wiki vault has not been initialized yet." }],
});
expect(rendered).toContain("Wiki vault mode: isolated");
expect(rendered).toContain("Pages: 0 sources, 0 entities, 0 concepts, 0 syntheses, 0 reports");
expect(rendered).toContain(
"Source provenance: 0 native, 0 bridge, 0 bridge-events, 0 unsafe-local, 0 other",
);
expect(rendered).toContain("Warnings:");
expect(rendered).toContain("Wiki vault has not been initialized yet.");
});
});
describe("memory wiki doctor", () => {
it("builds actionable fixes from status warnings", 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,
});
const report = buildMemoryWikiDoctorReport(status);
const rendered = renderMemoryWikiDoctor(report);
expect(report.healthy).toBe(false);
expect(report.warningCount).toBe(2);
expect(report.fixes.map((fix) => fix.code)).toEqual(["vault-missing", "obsidian-cli-missing"]);
expect(rendered).toContain("Suggested fixes:");
expect(rendered).toContain("openclaw wiki init");
});
});

View File

@@ -1,304 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { inferWikiPageKind, toWikiPageSummary, type WikiPageKind } from "./markdown.js";
import { probeObsidianCli } from "./obsidian.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;
};
pageCounts: Record<WikiPageKind, number>;
sourceCounts: {
native: number;
bridge: number;
bridgeEvents: number;
unsafeLocal: number;
other: number;
};
warnings: MemoryWikiStatusWarning[];
};
export type MemoryWikiDoctorFix = {
code: MemoryWikiStatusWarning["code"];
message: string;
};
export type MemoryWikiDoctorReport = {
healthy: boolean;
warningCount: number;
status: MemoryWikiStatus;
fixes: MemoryWikiDoctorFix[];
};
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 collectVaultCounts(vaultPath: string): Promise<{
pageCounts: Record<WikiPageKind, number>;
sourceCounts: MemoryWikiStatus["sourceCounts"];
}> {
const pageCounts: Record<WikiPageKind, number> = {
entity: 0,
concept: 0,
source: 0,
synthesis: 0,
report: 0,
};
const sourceCounts: MemoryWikiStatus["sourceCounts"] = {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
};
const dirs = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
for (const dir of dirs) {
const entries = await fs
.readdir(path.join(vaultPath, dir), { withFileTypes: true })
.catch(() => []);
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") {
continue;
}
const kind = inferWikiPageKind(path.join(dir, entry.name));
if (kind) {
pageCounts[kind] += 1;
}
if (dir === "sources") {
const absolutePath = path.join(vaultPath, dir, entry.name);
const raw = await fs.readFile(absolutePath, "utf8").catch(() => null);
if (!raw) {
continue;
}
const page = toWikiPageSummary({
absolutePath,
relativePath: path.join(dir, entry.name),
raw,
});
if (!page) {
continue;
}
if (page.sourceType === "memory-bridge-events") {
sourceCounts.bridgeEvents += 1;
} else if (page.sourceType === "memory-bridge") {
sourceCounts.bridge += 1;
} else if (
page.provenanceMode === "unsafe-local" ||
page.sourceType === "memory-unsafe-local"
) {
sourceCounts.unsafeLocal += 1;
} else if (!page.sourceType) {
sourceCounts.native += 1;
} else {
sourceCounts.other += 1;
}
}
}
}
return { pageCounts, sourceCounts };
}
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 vaultExists = await exists(config.vault.path);
const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand });
const counts = vaultExists
? await collectVaultCounts(config.vault.path)
: {
pageCounts: {
entity: 0,
concept: 0,
source: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
};
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: obsidianProbe.available,
command: obsidianProbe.command,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: config.unsafeLocal.allowPrivateMemoryCoreAccess,
pathCount: config.unsafeLocal.paths.length,
},
pageCounts: counts.pageCounts,
sourceCounts: counts.sourceCounts,
warnings: buildWarnings({ config, vaultExists, obsidianCommand: obsidianProbe.command }),
};
}
export function buildMemoryWikiDoctorReport(status: MemoryWikiStatus): MemoryWikiDoctorReport {
const fixes = status.warnings.map((warning) => ({
code: warning.code,
message:
warning.code === "vault-missing"
? "Run `openclaw wiki init` to create the vault layout."
: warning.code === "obsidian-cli-missing"
? "Install the official Obsidian CLI or disable `obsidian.useOfficialCli`."
: warning.code === "bridge-disabled"
? "Enable `plugins.entries.memory-wiki.config.bridge.enabled` or switch vaultMode away from `bridge`."
: warning.code === "unsafe-local-disabled"
? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`."
: warning.code === "unsafe-local-paths-missing"
? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports."
: "Disable private memory-core access unless you explicitly want unsafe-local mode.",
}));
return {
healthy: status.warnings.length === 0,
warningCount: status.warnings.length,
status,
fixes,
};
}
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"}`,
`Pages: ${status.pageCounts.source} sources, ${status.pageCounts.entity} entities, ${status.pageCounts.concept} concepts, ${status.pageCounts.synthesis} syntheses, ${status.pageCounts.report} reports`,
`Source provenance: ${status.sourceCounts.native} native, ${status.sourceCounts.bridge} bridge, ${status.sourceCounts.bridgeEvents} bridge-events, ${status.sourceCounts.unsafeLocal} unsafe-local, ${status.sourceCounts.other} other`,
];
if (status.warnings.length > 0) {
lines.push("", "Warnings:");
for (const warning of status.warnings) {
lines.push(`- ${warning.message}`);
}
}
return lines.join("\n");
}
export function renderMemoryWikiDoctor(report: MemoryWikiDoctorReport): string {
const lines = [
report.healthy ? "Wiki doctor: healthy" : `Wiki doctor: ${report.warningCount} issue(s) found`,
"",
renderMemoryWikiStatus(report.status),
];
if (report.fixes.length > 0) {
lines.push("", "Suggested fixes:");
for (const fix of report.fixes) {
lines.push(`- ${fix.message}`);
}
}
return lines.join("\n");
}

View File

@@ -1,233 +0,0 @@
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js";
import {
WIKI_SEARCH_BACKENDS,
WIKI_SEARCH_CORPORA,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { lintMemoryWikiVault } from "./lint.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
const WikiStatusSchema = Type.Object({}, { additionalProperties: false });
const WikiLintSchema = Type.Object({}, { additionalProperties: false });
const WikiSearchBackendSchema = Type.Union(
WIKI_SEARCH_BACKENDS.map((value) => Type.Literal(value)),
);
const WikiSearchCorpusSchema = Type.Union(WIKI_SEARCH_CORPORA.map((value) => Type.Literal(value)));
const WikiSearchSchema = Type.Object(
{
query: Type.String({ minLength: 1 }),
maxResults: Type.Optional(Type.Number({ minimum: 1 })),
backend: Type.Optional(WikiSearchBackendSchema),
corpus: Type.Optional(WikiSearchCorpusSchema),
},
{ additionalProperties: false },
);
const WikiGetSchema = Type.Object(
{
lookup: Type.String({ minLength: 1 }),
fromLine: Type.Optional(Type.Number({ minimum: 1 })),
lineCount: Type.Optional(Type.Number({ minimum: 1 })),
backend: Type.Optional(WikiSearchBackendSchema),
corpus: Type.Optional(WikiSearchCorpusSchema),
},
{ additionalProperties: false },
);
const WikiApplySchema = Type.Object(
{
op: Type.Union([Type.Literal("create_synthesis"), Type.Literal("update_metadata")]),
title: Type.Optional(Type.String({ minLength: 1 })),
body: Type.Optional(Type.String({ minLength: 1 })),
lookup: Type.Optional(Type.String({ minLength: 1 })),
sourceIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
contradictions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
questions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
confidence: Type.Optional(Type.Union([Type.Number({ minimum: 0, maximum: 1 }), Type.Null()])),
status: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
async function syncImportedSourcesIfNeeded(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
) {
await syncMemoryWikiImportedSources({ config, appConfig });
}
export function createWikiStatusTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): 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 () => {
await syncImportedSourcesIfNeeded(config, appConfig);
const status = await resolveMemoryWikiStatus(config);
return {
content: [{ type: "text", text: renderMemoryWikiStatus(status) }],
details: status,
};
},
};
}
export function createWikiSearchTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_search",
label: "Wiki Search",
description:
"Search wiki pages and, when shared search is enabled, the active memory corpus by title, path, id, or body text.",
parameters: WikiSearchSchema,
execute: async (_toolCallId, rawParams) => {
const params = rawParams as {
query: string;
maxResults?: number;
backend?: ResolvedMemoryWikiConfig["search"]["backend"];
corpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
};
await syncImportedSourcesIfNeeded(config, appConfig);
const results = await searchMemoryWiki({
config,
appConfig,
query: params.query,
maxResults: params.maxResults,
...(params.backend ? { searchBackend: params.backend } : {}),
...(params.corpus ? { searchCorpus: params.corpus } : {}),
});
const text =
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}` : ""}\nSnippet: ${result.snippet}`,
)
.join("\n\n");
return {
content: [{ type: "text", text }],
details: { results },
};
},
};
}
export function createWikiLintTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_lint",
label: "Wiki Lint",
description:
"Lint the wiki vault and surface structural issues, provenance gaps, contradictions, and open questions.",
parameters: WikiLintSchema,
execute: async () => {
await syncImportedSourcesIfNeeded(config, appConfig);
const result = await lintMemoryWikiVault(config);
const contradictions = result.issuesByCategory.contradictions.length;
const openQuestions = result.issuesByCategory["open-questions"].length;
const provenance = result.issuesByCategory.provenance.length;
const errors = result.issues.filter((issue) => issue.severity === "error").length;
const warnings = result.issues.filter((issue) => issue.severity === "warning").length;
const summary =
result.issueCount === 0
? "No wiki lint issues."
: [
`Issues: ${result.issueCount} total (${errors} errors, ${warnings} warnings)`,
`Contradictions: ${contradictions}`,
`Open questions: ${openQuestions}`,
`Provenance gaps: ${provenance}`,
`Report: ${result.reportPath}`,
].join("\n");
return {
content: [{ type: "text", text: summary }],
details: result,
};
},
};
}
export function createWikiApplyTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_apply",
label: "Wiki Apply",
description:
"Apply narrow wiki mutations for syntheses and page metadata without freeform markdown surgery.",
parameters: WikiApplySchema,
execute: async (_toolCallId, rawParams) => {
const mutation = normalizeMemoryWikiMutationInput(rawParams);
await syncImportedSourcesIfNeeded(config, appConfig);
const result = await applyMemoryWikiMutation({ config, mutation });
const action = result.changed ? "Updated" : "No changes for";
const compileSummary =
result.compile.updatedFiles.length > 0
? `Refreshed ${result.compile.updatedFiles.length} index file${result.compile.updatedFiles.length === 1 ? "" : "s"}.`
: "Indexes unchanged.";
return {
content: [
{
type: "text",
text: `${action} ${result.pagePath} via ${result.operation}. ${compileSummary}`,
},
],
details: result,
};
},
};
}
export function createWikiGetTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_get",
label: "Wiki Get",
description:
"Read a wiki page by id or relative path, or fall back to the active memory corpus when shared search is enabled.",
parameters: WikiGetSchema,
execute: async (_toolCallId, rawParams) => {
const params = rawParams as {
lookup: string;
fromLine?: number;
lineCount?: number;
backend?: ResolvedMemoryWikiConfig["search"]["backend"];
corpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
};
await syncImportedSourcesIfNeeded(config, appConfig);
const result = await getMemoryWikiPage({
config,
appConfig,
lookup: params.lookup,
fromLine: params.fromLine,
lineCount: params.lineCount,
...(params.backend ? { searchBackend: params.backend } : {}),
...(params.corpus ? { searchCorpus: params.corpus } : {}),
});
if (!result) {
return {
content: [{ type: "text", text: `Wiki page not found: ${params.lookup}` }],
details: { found: false },
};
}
return {
content: [{ type: "text", text: result.content }],
details: { found: true, ...result },
};
},
};
}

View File

@@ -1,92 +0,0 @@
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 { syncMemoryWikiUnsafeLocalSources } from "./unsafe-local.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("syncMemoryWikiUnsafeLocalSources", () => {
it("imports explicit private paths and preserves unsafe-local provenance", async () => {
const privateDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-private-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-unsafe-vault-"));
tempDirs.push(privateDir, vaultDir);
await fs.mkdir(path.join(privateDir, "nested"), { recursive: true });
await fs.writeFile(path.join(privateDir, "nested", "state.md"), "# internal state\n", "utf8");
await fs.writeFile(path.join(privateDir, "nested", "cache.json"), '{"ok":true}\n', "utf8");
await fs.writeFile(path.join(privateDir, "nested", "blob.bin"), "\u0000\u0001", "utf8");
const directPath = path.join(privateDir, "events.log");
await fs.writeFile(directPath, "private log\n", "utf8");
const config = resolveMemoryWikiConfig(
{
vaultMode: "unsafe-local",
vault: { path: vaultDir },
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [path.join(privateDir, "nested"), directPath],
},
},
{ homedir: "/Users/tester" },
);
const first = await syncMemoryWikiUnsafeLocalSources(config);
expect(first.artifactCount).toBe(3);
expect(first.importedCount).toBe(3);
expect(first.updatedCount).toBe(0);
expect(first.skippedCount).toBe(0);
expect(first.removedCount).toBe(0);
const page = await fs.readFile(path.join(vaultDir, first.pagePaths[0] ?? ""), "utf8");
expect(page).toContain("sourceType: memory-unsafe-local");
expect(page).toContain("provenanceMode: unsafe-local");
const second = await syncMemoryWikiUnsafeLocalSources(config);
expect(second.importedCount).toBe(0);
expect(second.updatedCount).toBe(0);
expect(second.skippedCount).toBe(3);
expect(second.removedCount).toBe(0);
});
it("prunes stale unsafe-local pages when configured files disappear", async () => {
const privateDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-private-prune-"));
const vaultDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-unsafe-prune-vault-"));
tempDirs.push(privateDir, vaultDir);
const secretPath = path.join(privateDir, "secret.md");
await fs.writeFile(secretPath, "# private\n", "utf8");
const config = resolveMemoryWikiConfig(
{
vaultMode: "unsafe-local",
vault: { path: vaultDir },
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [secretPath],
},
},
{ homedir: "/Users/tester" },
);
const first = await syncMemoryWikiUnsafeLocalSources(config);
const firstPagePath = first.pagePaths[0] ?? "";
await expect(fs.stat(path.join(vaultDir, firstPagePath))).resolves.toBeTruthy();
await fs.rm(secretPath);
const second = await syncMemoryWikiUnsafeLocalSources(config);
expect(second.artifactCount).toBe(0);
expect(second.removedCount).toBe(1);
await expect(fs.stat(path.join(vaultDir, firstPagePath))).rejects.toMatchObject({
code: "ENOENT",
});
});
});

View File

@@ -1,296 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { BridgeMemoryWikiResult } from "./bridge.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import {
pruneImportedSourceEntries,
readMemoryWikiSourceSyncState,
setImportedSourceEntry,
shouldSkipImportedSourceWrite,
writeMemoryWikiSourceSyncState,
} from "./source-sync-state.js";
import { initializeMemoryWikiVault } from "./vault.js";
type UnsafeLocalArtifact = {
syncKey: string;
configuredPath: string;
absolutePath: string;
relativePath: string;
};
const DIRECTORY_TEXT_EXTENSIONS = new Set([".json", ".jsonl", ".md", ".txt", ".yaml", ".yml"]);
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function resolveArtifactKey(absolutePath: string): Promise<string> {
const canonicalPath = await fs.realpath(absolutePath).catch(() => path.resolve(absolutePath));
return process.platform === "win32" ? canonicalPath.toLowerCase() : canonicalPath;
}
function detectFenceLanguage(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".json" || ext === ".jsonl") {
return "json";
}
if (ext === ".yaml" || ext === ".yml") {
return "yaml";
}
if (ext === ".txt") {
return "text";
}
return "markdown";
}
async function listAllowedFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listAllowedFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && DIRECTORY_TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(fullPath);
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function collectUnsafeLocalArtifacts(
configuredPaths: string[],
): Promise<UnsafeLocalArtifact[]> {
const artifacts: UnsafeLocalArtifact[] = [];
for (const configuredPath of configuredPaths) {
const absoluteConfiguredPath = path.resolve(configuredPath);
const stat = await fs.stat(absoluteConfiguredPath).catch(() => null);
if (!stat) {
continue;
}
if (stat.isDirectory()) {
const files = await listAllowedFilesRecursive(absoluteConfiguredPath);
for (const absolutePath of files) {
artifacts.push({
syncKey: await resolveArtifactKey(absolutePath),
configuredPath: absoluteConfiguredPath,
absolutePath,
relativePath: path.relative(absoluteConfiguredPath, absolutePath).replace(/\\/g, "/"),
});
}
continue;
}
if (stat.isFile()) {
artifacts.push({
syncKey: await resolveArtifactKey(absoluteConfiguredPath),
configuredPath: absoluteConfiguredPath,
absolutePath: absoluteConfiguredPath,
relativePath: path.basename(absoluteConfiguredPath),
});
}
}
const deduped = new Map<string, UnsafeLocalArtifact>();
for (const artifact of artifacts) {
deduped.set(artifact.syncKey, artifact);
}
return [...deduped.values()];
}
function resolveUnsafeLocalPagePath(params: { configuredPath: string; absolutePath: string }): {
pageId: string;
pagePath: string;
} {
const configuredBaseSlug = slugifyWikiSegment(path.basename(params.configuredPath));
const configuredHash = createHash("sha1")
.update(path.resolve(params.configuredPath))
.digest("hex")
.slice(0, 8);
const artifactBaseSlug = slugifyWikiSegment(path.basename(params.absolutePath));
const artifactHash = createHash("sha1")
.update(path.resolve(params.absolutePath))
.digest("hex")
.slice(0, 8);
const pageSlug = `${configuredBaseSlug}-${configuredHash}-${artifactBaseSlug}-${artifactHash}`;
return {
pageId: `source.unsafe-local.${pageSlug}`,
pagePath: path.join("sources", `unsafe-local-${pageSlug}.md`).replace(/\\/g, "/"),
};
}
function resolveUnsafeLocalTitle(artifact: UnsafeLocalArtifact): string {
return `Unsafe Local Import: ${artifact.relativePath}`;
}
async function writeUnsafeLocalSourcePage(params: {
config: ResolvedMemoryWikiConfig;
artifact: UnsafeLocalArtifact;
sourceUpdatedAtMs: number;
sourceSize: number;
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const { pageId, pagePath } = resolveUnsafeLocalPagePath({
configuredPath: params.artifact.configuredPath,
absolutePath: params.artifact.absolutePath,
});
const pageAbsPath = path.join(params.config.vault.path, pagePath);
const created = !(await pathExists(pageAbsPath));
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
const title = resolveUnsafeLocalTitle(params.artifact);
const renderFingerprint = createHash("sha1")
.update(
JSON.stringify({
configuredPath: params.artifact.configuredPath,
relativePath: params.artifact.relativePath,
}),
)
.digest("hex");
const shouldSkip = await shouldSkipImportedSourceWrite({
vaultRoot: params.config.vault.path,
syncKey: params.artifact.syncKey,
expectedPagePath: pagePath,
expectedSourcePath: params.artifact.absolutePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,
sourceSize: params.sourceSize,
renderFingerprint,
state: params.state,
});
if (shouldSkip) {
return { pagePath, changed: false, created };
}
const raw = await fs.readFile(params.artifact.absolutePath, "utf8");
const rendered = renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title,
sourceType: "memory-unsafe-local",
provenanceMode: "unsafe-local",
sourcePath: params.artifact.absolutePath,
unsafeLocalConfiguredPath: params.artifact.configuredPath,
unsafeLocalRelativePath: params.artifact.relativePath,
status: "active",
updatedAt,
},
body: [
`# ${title}`,
"",
"## Unsafe Local Source",
`- Configured path: \`${params.artifact.configuredPath}\``,
`- Relative path: \`${params.artifact.relativePath}\``,
`- Updated: ${updatedAt}`,
"",
"## Content",
renderMarkdownFence(raw, detectFenceLanguage(params.artifact.absolutePath)),
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
}
setImportedSourceEntry({
syncKey: params.artifact.syncKey,
state: params.state,
entry: {
group: "unsafe-local",
pagePath,
sourcePath: params.artifact.absolutePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,
sourceSize: params.sourceSize,
renderFingerprint,
},
});
return { pagePath, changed: existing !== rendered, created };
}
export async function syncMemoryWikiUnsafeLocalSources(
config: ResolvedMemoryWikiConfig,
): Promise<BridgeMemoryWikiResult> {
await initializeMemoryWikiVault(config);
if (
config.vaultMode !== "unsafe-local" ||
!config.unsafeLocal.allowPrivateMemoryCoreAccess ||
config.unsafeLocal.paths.length === 0
) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const artifacts = await collectUnsafeLocalArtifacts(config.unsafeLocal.paths);
const state = await readMemoryWikiSourceSyncState(config.vault.path);
const activeKeys = new Set<string>();
const results = await Promise.all(
artifacts.map(async (artifact) => {
const stats = await fs.stat(artifact.absolutePath);
activeKeys.add(artifact.syncKey);
return await writeUnsafeLocalSourcePage({
config,
artifact,
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
state,
});
}),
);
const removedCount = await pruneImportedSourceEntries({
vaultRoot: config.vault.path,
group: "unsafe-local",
activeKeys,
state,
});
await writeMemoryWikiSourceSyncState(config.vault.path, state);
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 || removedCount > 0) {
await appendMemoryWikiLog(config.vault.path, {
type: "ingest",
timestamp: new Date().toISOString(),
details: {
sourceType: "memory-unsafe-local",
configuredPathCount: config.unsafeLocal.paths.length,
artifactCount: artifacts.length,
importedCount,
updatedCount,
skippedCount,
removedCount,
},
});
}
return {
importedCount,
updatedCount,
skippedCount,
removedCount,
artifactCount: artifacts.length,
workspaces: 0,
pagePaths,
};
}

View File

@@ -1,72 +0,0 @@
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

@@ -1,156 +0,0 @@
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";
import { appendMemoryWikiLog } from "./log.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);
if (createdDirectories.length > 0 || createdFiles.length > 0) {
await appendMemoryWikiLog(rootDir, {
type: "init",
timestamp: new Date(options?.nowMs ?? Date.now()).toISOString(),
details: {
createdDirectories: createdDirectories.map((dir) => path.relative(rootDir, dir) || "."),
createdFiles: createdFiles.map((file) => path.relative(rootDir, file)),
},
});
}
return {
rootDir,
created: createdDirectories.length > 0 || createdFiles.length > 0,
createdDirectories,
createdFiles,
};
}

View File

@@ -679,10 +679,6 @@
"types": "./dist/plugin-sdk/memory-core-host-secret.d.ts",
"default": "./dist/plugin-sdk/memory-core-host-secret.js"
},
"./plugin-sdk/memory-core-host-events": {
"types": "./dist/plugin-sdk/memory-core-host-events.d.ts",
"default": "./dist/plugin-sdk/memory-core-host-events.js"
},
"./plugin-sdk/memory-core-host-status": {
"types": "./dist/plugin-sdk/memory-core-host-status.d.ts",
"default": "./dist/plugin-sdk/memory-core-host-status.js"
@@ -699,30 +695,6 @@
"types": "./dist/plugin-sdk/memory-core-host-runtime-files.d.ts",
"default": "./dist/plugin-sdk/memory-core-host-runtime-files.js"
},
"./plugin-sdk/memory-host-core": {
"types": "./dist/plugin-sdk/memory-host-core.d.ts",
"default": "./dist/plugin-sdk/memory-host-core.js"
},
"./plugin-sdk/memory-host-events": {
"types": "./dist/plugin-sdk/memory-host-events.d.ts",
"default": "./dist/plugin-sdk/memory-host-events.js"
},
"./plugin-sdk/memory-host-files": {
"types": "./dist/plugin-sdk/memory-host-files.d.ts",
"default": "./dist/plugin-sdk/memory-host-files.js"
},
"./plugin-sdk/memory-host-markdown": {
"types": "./dist/plugin-sdk/memory-host-markdown.d.ts",
"default": "./dist/plugin-sdk/memory-host-markdown.js"
},
"./plugin-sdk/memory-host-search": {
"types": "./dist/plugin-sdk/memory-host-search.d.ts",
"default": "./dist/plugin-sdk/memory-host-search.js"
},
"./plugin-sdk/memory-host-status": {
"types": "./dist/plugin-sdk/memory-host-status.d.ts",
"default": "./dist/plugin-sdk/memory-host-status.js"
},
"./plugin-sdk/memory-lancedb": {
"types": "./dist/plugin-sdk/memory-lancedb.d.ts",
"default": "./dist/plugin-sdk/memory-lancedb.js"

View File

@@ -159,17 +159,10 @@
"memory-core-host-multimodal",
"memory-core-host-query",
"memory-core-host-secret",
"memory-core-host-events",
"memory-core-host-status",
"memory-core-host-runtime-cli",
"memory-core-host-runtime-core",
"memory-core-host-runtime-files",
"memory-host-core",
"memory-host-events",
"memory-host-files",
"memory-host-markdown",
"memory-host-search",
"memory-host-status",
"memory-lancedb",
"msteams",
"models-provider-runtime",

View File

@@ -1,93 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { MemoryDreamingPhaseName } from "./dreaming.js";
export const MEMORY_HOST_EVENT_LOG_RELATIVE_PATH = path.join("memory", ".dreams", "events.jsonl");
export type MemoryHostRecallRecordedEvent = {
type: "memory.recall.recorded";
timestamp: string;
query: string;
resultCount: number;
results: Array<{
path: string;
startLine: number;
endLine: number;
score: number;
}>;
};
export type MemoryHostPromotionAppliedEvent = {
type: "memory.promotion.applied";
timestamp: string;
memoryPath: string;
applied: number;
candidates: Array<{
key: string;
path: string;
startLine: number;
endLine: number;
score: number;
recallCount: number;
}>;
};
export type MemoryHostDreamCompletedEvent = {
type: "memory.dream.completed";
timestamp: string;
phase: MemoryDreamingPhaseName;
inlinePath?: string;
reportPath?: string;
lineCount: number;
storageMode: "inline" | "separate" | "both";
};
export type MemoryHostEvent =
| MemoryHostRecallRecordedEvent
| MemoryHostPromotionAppliedEvent
| MemoryHostDreamCompletedEvent;
export function resolveMemoryHostEventLogPath(workspaceDir: string): string {
return path.join(workspaceDir, MEMORY_HOST_EVENT_LOG_RELATIVE_PATH);
}
export async function appendMemoryHostEvent(
workspaceDir: string,
event: MemoryHostEvent,
): Promise<void> {
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
await fs.mkdir(path.dirname(eventLogPath), { recursive: true });
await fs.appendFile(eventLogPath, `${JSON.stringify(event)}\n`, "utf8");
}
export async function readMemoryHostEvents(params: {
workspaceDir: string;
limit?: number;
}): Promise<MemoryHostEvent[]> {
const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir);
const raw = await fs.readFile(eventLogPath, "utf8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
if (!raw.trim()) {
return [];
}
const events = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line) as MemoryHostEvent];
} catch {
return [];
}
});
if (!Number.isFinite(params.limit)) {
return events;
}
const limit = Math.max(0, Math.floor(params.limit as number));
return limit === 0 ? [] : events.slice(-limit);
}

View File

@@ -3,4 +3,4 @@
export { listMemoryFiles, normalizeExtraMemoryPaths } from "./host/internal.js";
export { readAgentMemoryFile } from "./host/read-file.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export type { MemorySearchManager, MemorySearchResult } from "./host/types.js";
export type { MemorySearchResult } from "./host/types.js";

View File

@@ -1 +0,0 @@
export * from "../memory-host-sdk/events.js";

View File

@@ -1,8 +1 @@
export * from "../memory-host-sdk/runtime-core.js";
export type {
MemoryCorpusGetResult,
MemoryCorpusSearchResult,
MemoryCorpusSupplement,
MemoryCorpusSupplementRegistration,
} from "../plugins/memory-state.js";
export { listMemoryCorpusSupplements } from "../plugins/memory-state.js";

View File

@@ -46,12 +46,6 @@ export {
withProgress,
withProgressTotals,
} from "./memory-core-host-runtime-cli.js";
export {
appendMemoryHostEvent,
readMemoryHostEvents,
resolveMemoryHostEventLogPath,
} from "./memory-core-host-events.js";
export type { MemoryHostEvent } from "./memory-core-host-events.js";
export {
resolveMemoryCorePluginConfig,
formatMemoryDreamingDay,

View File

@@ -1 +0,0 @@
export * from "./memory-core-host-runtime-core.js";

View File

@@ -1,60 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appendMemoryHostEvent,
readMemoryHostEvents,
resolveMemoryHostEventLogPath,
} from "./memory-host-events.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("memory host event journal helpers", () => {
it("appends and reads typed workspace events", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-events-"));
tempDirs.push(workspaceDir);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: "2026-04-05T12:00:00.000Z",
query: "glacier backup",
resultCount: 1,
results: [
{
path: "memory/2026-04-05.md",
startLine: 1,
endLine: 3,
score: 0.9,
},
],
});
await appendMemoryHostEvent(workspaceDir, {
type: "memory.dream.completed",
timestamp: "2026-04-05T13:00:00.000Z",
phase: "light",
lineCount: 4,
storageMode: "both",
inlinePath: path.join(workspaceDir, "memory", "2026-04-05.md"),
reportPath: path.join(workspaceDir, "memory", "dreaming", "light", "2026-04-05.md"),
});
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
await expect(fs.readFile(eventLogPath, "utf8")).resolves.toContain(
'"type":"memory.recall.recorded"',
);
const events = await readMemoryHostEvents({ workspaceDir });
const tail = await readMemoryHostEvents({ workspaceDir, limit: 1 });
expect(events).toHaveLength(2);
expect(events[0]?.type).toBe("memory.recall.recorded");
expect(events[1]?.type).toBe("memory.dream.completed");
expect(tail).toHaveLength(1);
expect(tail[0]?.type).toBe("memory.dream.completed");
});
});

View File

@@ -1 +0,0 @@
export * from "../memory-host-sdk/events.js";

View File

@@ -1 +0,0 @@
export * from "./memory-core-host-runtime-files.js";

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import { replaceManagedMarkdownBlock, withTrailingNewline } from "./memory-host-markdown.js";
describe("withTrailingNewline", () => {
it("preserves trailing newlines", () => {
expect(withTrailingNewline("hello\n")).toBe("hello\n");
});
it("adds a trailing newline when missing", () => {
expect(withTrailingNewline("hello")).toBe("hello\n");
});
});
describe("replaceManagedMarkdownBlock", () => {
it("appends a managed block when missing", () => {
expect(
replaceManagedMarkdownBlock({
original: "# Title\n",
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- first",
}),
).toBe("# Title\n\n## Generated\n<!-- start -->\n- first\n<!-- end -->\n");
});
it("replaces an existing managed block in place", () => {
expect(
replaceManagedMarkdownBlock({
original:
"# Title\n\n## Generated\n<!-- start -->\n- old\n<!-- end -->\n\n## Notes\nkept\n",
heading: "## Generated",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "- new",
}),
).toBe("# Title\n\n## Generated\n<!-- start -->\n- new\n<!-- end -->\n\n## Notes\nkept\n");
});
it("supports headingless blocks", () => {
expect(
replaceManagedMarkdownBlock({
original: "alpha\n",
startMarker: "<!-- start -->",
endMarker: "<!-- end -->",
body: "beta",
}),
).toBe("alpha\n\n<!-- start -->\nbeta\n<!-- end -->\n");
});
});

View File

@@ -1,34 +0,0 @@
export type ManagedMarkdownBlockParams = {
original: string;
body: string;
startMarker: string;
endMarker: string;
heading?: string;
};
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
export function replaceManagedMarkdownBlock(params: ManagedMarkdownBlockParams): string {
const headingPrefix = params.heading ? `${params.heading}\n` : "";
const managedBlock = `${headingPrefix}${params.startMarker}\n${params.body}\n${params.endMarker}`;
const existingPattern = new RegExp(
`${params.heading ? `${escapeRegex(params.heading)}\\n` : ""}${escapeRegex(params.startMarker)}[\\s\\S]*?${escapeRegex(params.endMarker)}`,
"m",
);
if (existingPattern.test(params.original)) {
return params.original.replace(existingPattern, managedBlock);
}
const trimmed = params.original.trimEnd();
if (trimmed.length === 0) {
return `${managedBlock}\n`;
}
return `${trimmed}\n\n${managedBlock}\n`;
}

View File

@@ -1,5 +0,0 @@
export {
closeActiveMemorySearchManagers,
getActiveMemorySearchManager,
resolveActiveMemoryBackendConfig,
} from "../plugins/memory-runtime.js";

View File

@@ -1,42 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
closeActiveMemorySearchManagers,
getActiveMemorySearchManager,
} from "./memory-host-search.js";
const { closeActiveMemorySearchManagersMock, getActiveMemorySearchManagerMock } = vi.hoisted(
() => ({
closeActiveMemorySearchManagersMock: vi.fn(),
getActiveMemorySearchManagerMock: vi.fn(),
}),
);
vi.mock("./memory-host-search.runtime.js", () => ({
closeActiveMemorySearchManagers: closeActiveMemorySearchManagersMock,
getActiveMemorySearchManager: getActiveMemorySearchManagerMock,
}));
describe("memory-host-search facade", () => {
beforeEach(() => {
closeActiveMemorySearchManagersMock.mockReset();
getActiveMemorySearchManagerMock.mockReset();
});
it("delegates active manager lookup to the lazy runtime module", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
const expected = { manager: null, error: "unavailable" };
getActiveMemorySearchManagerMock.mockResolvedValue(expected);
await expect(getActiveMemorySearchManager({ cfg, agentId: "main" })).resolves.toEqual(expected);
expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
});
it("delegates runtime cleanup to the lazy runtime module", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
await closeActiveMemorySearchManagers(cfg);
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledWith(cfg);
});
});

View File

@@ -1,29 +0,0 @@
import type { OpenClawConfig } from "../config/config.js";
import type { RegisteredMemorySearchManager } from "../plugins/memory-state.js";
type ActiveMemorySearchPurpose = "default" | "status";
export type ActiveMemorySearchManagerResult = {
manager: RegisteredMemorySearchManager | null;
error?: string;
};
type MemoryHostSearchRuntimeModule = typeof import("./memory-host-search.runtime.js");
async function loadMemoryHostSearchRuntime(): Promise<MemoryHostSearchRuntimeModule> {
return await import("./memory-host-search.runtime.js");
}
export async function getActiveMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: ActiveMemorySearchPurpose;
}): Promise<ActiveMemorySearchManagerResult> {
const runtime = await loadMemoryHostSearchRuntime();
return await runtime.getActiveMemorySearchManager(params);
}
export async function closeActiveMemorySearchManagers(cfg?: OpenClawConfig): Promise<void> {
const runtime = await loadMemoryHostSearchRuntime();
await runtime.closeActiveMemorySearchManagers(cfg);
}

View File

@@ -1 +0,0 @@
export * from "./memory-core-host-status.js";

View File

@@ -41,8 +41,6 @@ export type BuildPluginApiParams = {
| "registerCommand"
| "registerContextEngine"
| "registerMemoryPromptSection"
| "registerMemoryPromptSupplement"
| "registerMemoryCorpusSupplement"
| "registerMemoryFlushPlan"
| "registerMemoryRuntime"
| "registerMemoryEmbeddingProvider"
@@ -80,10 +78,6 @@ const noopOnConversationBindingResolved: OpenClawPluginApi["onConversationBindin
const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
const noopRegisterMemoryPromptSupplement: OpenClawPluginApi["registerMemoryPromptSupplement"] =
() => {};
const noopRegisterMemoryCorpusSupplement: OpenClawPluginApi["registerMemoryCorpusSupplement"] =
() => {};
const noopRegisterMemoryFlushPlan: OpenClawPluginApi["registerMemoryFlushPlan"] = () => {};
const noopRegisterMemoryRuntime: OpenClawPluginApi["registerMemoryRuntime"] = () => {};
const noopRegisterMemoryEmbeddingProvider: OpenClawPluginApi["registerMemoryEmbeddingProvider"] =
@@ -135,10 +129,6 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerContextEngine: handlers.registerContextEngine ?? noopRegisterContextEngine,
registerMemoryPromptSection:
handlers.registerMemoryPromptSection ?? noopRegisterMemoryPromptSection,
registerMemoryPromptSupplement:
handlers.registerMemoryPromptSupplement ?? noopRegisterMemoryPromptSupplement,
registerMemoryCorpusSupplement:
handlers.registerMemoryCorpusSupplement ?? noopRegisterMemoryCorpusSupplement,
registerMemoryFlushPlan: handlers.registerMemoryFlushPlan ?? noopRegisterMemoryFlushPlan,
registerMemoryRuntime: handlers.registerMemoryRuntime ?? noopRegisterMemoryRuntime,
registerMemoryEmbeddingProvider:

View File

@@ -8,10 +8,7 @@ import {
import {
buildMemoryPromptSection,
getMemoryRuntime,
listMemoryCorpusSupplements,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -158,12 +155,7 @@ describe("clearPluginLoaderCache", () => {
id: "stale",
create: async () => ({ provider: null }),
});
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get: async () => null,
});
registerMemoryPromptSection(() => ["stale memory section"]);
registerMemoryPromptSupplement("memory-wiki", () => ["stale wiki supplement"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
@@ -182,9 +174,7 @@ describe("clearPluginLoaderCache", () => {
});
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"stale memory section",
"stale wiki supplement",
]);
expect(listMemoryCorpusSupplements()).toHaveLength(1);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md");
expect(getMemoryRuntime()).toBeDefined();
expect(getMemoryEmbeddingProvider("stale")).toBeDefined();
@@ -192,7 +182,6 @@ describe("clearPluginLoaderCache", () => {
clearPluginLoaderCache();
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]);
expect(listMemoryCorpusSupplements()).toEqual([]);
expect(resolveMemoryFlushPlan({})).toBeNull();
expect(getMemoryRuntime()).toBeUndefined();
expect(getMemoryEmbeddingProvider("stale")).toBeUndefined();

View File

@@ -28,10 +28,7 @@ import {
import {
buildMemoryPromptSection,
getMemoryRuntime,
listMemoryCorpusSupplements,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -1384,12 +1381,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
id: "active",
create: async () => ({ provider: null }),
});
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get: async () => null,
});
registerMemoryPromptSection(() => ["active memory section"]);
registerMemoryPromptSupplement("memory-wiki", () => ["active wiki supplement"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
@@ -1456,9 +1448,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
expect(scoped.plugins.find((entry) => entry.id === "snapshot-memory")?.status).toBe("loaded");
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"active memory section",
"active wiki supplement",
]);
expect(listMemoryCorpusSupplements()).toHaveLength(1);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/active.md");
expect(getMemoryRuntime()).toBe(activeRuntime);
expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual(["active"]);
@@ -1478,11 +1468,6 @@ module.exports = { id: "throws-after-import", register() {} };`,
create: async () => ({ provider: null }),
});
api.registerMemoryPromptSection(() => ["stale failure section"]);
api.registerMemoryPromptSupplement(() => ["stale failure supplement"]);
api.registerMemoryCorpusSupplement({
search: async () => [],
get: async () => null,
});
api.registerMemoryFlushPlan(() => ({
softThresholdTokens: 10,
forceFlushTranscriptBytes: 20,
@@ -1519,7 +1504,6 @@ module.exports = { id: "throws-after-import", register() {} };`,
expect(registry.plugins.find((entry) => entry.id === "failing-memory")?.status).toBe("error");
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]);
expect(listMemoryCorpusSupplements()).toEqual([]);
expect(resolveMemoryFlushPlan({})).toBeNull();
expect(getMemoryRuntime()).toBeUndefined();
expect(listMemoryEmbeddingProviders()).toEqual([]);

View File

@@ -39,8 +39,6 @@ import {
getMemoryFlushPlanResolver,
getMemoryPromptSectionBuilder,
getMemoryRuntime,
listMemoryCorpusSupplements,
listMemoryPromptSupplements,
restoreMemoryPluginState,
} from "./memory-state.js";
import { isPathInside, safeStatSync } from "./path-safety.js";
@@ -131,11 +129,9 @@ export class PluginLoadFailureError extends Error {
type CachedPluginState = {
registry: PluginRegistry;
memoryCorpusSupplements: ReturnType<typeof listMemoryCorpusSupplements>;
memoryEmbeddingProviders: ReturnType<typeof listRegisteredMemoryEmbeddingProviders>;
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
memoryPromptSupplements: ReturnType<typeof listMemoryPromptSupplements>;
memoryRuntime: ReturnType<typeof getMemoryRuntime>;
};
@@ -996,9 +992,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cached) {
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: cached.memoryCorpusSupplements,
promptBuilder: cached.memoryPromptBuilder,
promptSupplements: cached.memoryPromptSupplements,
flushPlanResolver: cached.memoryFlushPlanResolver,
runtime: cached.memoryRuntime,
});
@@ -1563,8 +1557,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders();
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
const previousMemoryCorpusSupplements = listMemoryCorpusSupplements();
const previousMemoryPromptSupplements = listMemoryPromptSupplements();
const previousMemoryRuntime = getMemoryRuntime();
try {
@@ -1581,9 +1573,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (!shouldActivate) {
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: previousMemoryCorpusSupplements,
promptBuilder: previousMemoryPromptBuilder,
promptSupplements: previousMemoryPromptSupplements,
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
@@ -1593,9 +1583,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
} catch (err) {
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: previousMemoryCorpusSupplements,
promptBuilder: previousMemoryPromptBuilder,
promptSupplements: previousMemoryPromptSupplements,
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
@@ -1647,12 +1635,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
setCachedPluginRegistry(cacheKey, {
memoryCorpusSupplements: listMemoryCorpusSupplements(),
registry,
memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(),
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
memoryPromptBuilder: getMemoryPromptSectionBuilder(),
memoryPromptSupplements: listMemoryPromptSupplements(),
memoryRuntime: getMemoryRuntime(),
});
}

View File

@@ -6,11 +6,7 @@ import {
getMemoryFlushPlanResolver,
getMemoryPromptSectionBuilder,
getMemoryRuntime,
listMemoryCorpusSupplements,
listMemoryPromptSupplements,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -42,15 +38,12 @@ function createMemoryFlushPlan(relativePath: string) {
function expectClearedMemoryState() {
expect(resolveMemoryFlushPlan({})).toBeNull();
expect(buildMemoryPromptSection({ availableTools: new Set(["memory_search"]) })).toEqual([]);
expect(listMemoryCorpusSupplements()).toEqual([]);
expect(getMemoryRuntime()).toBeUndefined();
}
function createMemoryStateSnapshot() {
return {
corpusSupplements: listMemoryCorpusSupplements(),
promptBuilder: getMemoryPromptSectionBuilder(),
promptSupplements: listMemoryPromptSupplements(),
flushPlanResolver: getMemoryFlushPlanResolver(),
runtime: getMemoryRuntime(),
};
@@ -110,32 +103,6 @@ describe("memory plugin state", () => {
).toEqual(["citations: off"]);
});
it("appends prompt supplements in plugin-id order", () => {
registerMemoryPromptSection(() => ["primary"]);
registerMemoryPromptSupplement("memory-wiki", () => ["wiki"]);
registerMemoryPromptSupplement("alpha-helper", () => ["alpha"]);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"primary",
"alpha",
"wiki",
]);
});
it("stores memory corpus supplements", async () => {
const supplement = {
search: async () => [{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }],
get: async () => null,
};
registerMemoryCorpusSupplement("memory-wiki", supplement);
expect(listMemoryCorpusSupplements()).toHaveLength(1);
await expect(
listMemoryCorpusSupplements()[0]?.supplement.search({ query: "alpha" }),
).resolves.toEqual([{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }]);
});
it("uses the registered flush plan resolver", () => {
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
@@ -170,23 +137,14 @@ describe("memory plugin state", () => {
relativePath: "memory/first.md",
runtime,
});
registerMemoryPromptSupplement("memory-wiki", () => ["wiki supplement"]);
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }],
get: async () => null,
});
const snapshot = createMemoryStateSnapshot();
_resetMemoryPluginState();
expectClearedMemoryState();
restoreMemoryPluginState(snapshot);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"first",
"wiki supplement",
]);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["first"]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/first.md");
expect(listMemoryCorpusSupplements()).toHaveLength(1);
expect(getMemoryRuntime()).toBe(runtime);
});

View File

@@ -1,69 +1,16 @@
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryCitationsMode } from "../config/types.memory.js";
import type { MemorySearchManager } from "../memory-host-sdk/runtime-files.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySyncProgressUpdate,
} from "../memory-host-sdk/engine-storage.js";
export type MemoryPromptSectionBuilder = (params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) => string[];
export type MemoryCorpusSearchResult = {
corpus: string;
path: string;
title?: string;
kind?: string;
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
source?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
};
export type MemoryCorpusGetResult = {
corpus: string;
path: string;
title?: string;
kind?: string;
content: string;
fromLine: number;
lineCount: number;
id?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
};
export type MemoryCorpusSupplement = {
search(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusSearchResult[]>;
get(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusGetResult | null>;
};
export type MemoryCorpusSupplementRegistration = {
pluginId: string;
supplement: MemoryCorpusSupplement;
};
export type MemoryPromptSupplementRegistration = {
pluginId: string;
builder: MemoryPromptSectionBuilder;
};
export type MemoryFlushPlan = {
softThresholdTokens: number;
forceFlushTranscriptBytes: number;
@@ -78,7 +25,18 @@ export type MemoryFlushPlanResolver = (params: {
nowMs?: number;
}) => MemoryFlushPlan | null;
export type RegisteredMemorySearchManager = MemorySearchManager;
export type RegisteredMemorySearchManager = {
status(): MemoryProviderStatus;
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
probeVectorAvailability(): Promise<boolean>;
sync?(params?: {
reason?: string;
force?: boolean;
sessionFiles?: string[];
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void>;
close?(): Promise<void>;
};
export type MemoryRuntimeQmdConfig = {
command?: string;
@@ -110,68 +68,28 @@ export type MemoryPluginRuntime = {
};
type MemoryPluginState = {
corpusSupplements: MemoryCorpusSupplementRegistration[];
promptBuilder?: MemoryPromptSectionBuilder;
promptSupplements: MemoryPromptSupplementRegistration[];
flushPlanResolver?: MemoryFlushPlanResolver;
runtime?: MemoryPluginRuntime;
};
const memoryPluginState: MemoryPluginState = {
corpusSupplements: [],
promptSupplements: [],
};
export function registerMemoryCorpusSupplement(
pluginId: string,
supplement: MemoryCorpusSupplement,
): void {
const next = memoryPluginState.corpusSupplements.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, supplement });
memoryPluginState.corpusSupplements = next;
}
export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] {
return [...memoryPluginState.corpusSupplements];
}
const memoryPluginState: MemoryPluginState = {};
export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void {
memoryPluginState.promptBuilder = builder;
}
export function registerMemoryPromptSupplement(
pluginId: string,
builder: MemoryPromptSectionBuilder,
): void {
const next = memoryPluginState.promptSupplements.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, builder });
memoryPluginState.promptSupplements = next;
}
export function buildMemoryPromptSection(params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}): string[] {
const primary = memoryPluginState.promptBuilder?.(params) ?? [];
const supplements = memoryPluginState.promptSupplements
// Keep supplement order stable even if plugin registration order changes.
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
.flatMap((registration) => registration.builder(params));
return [...primary, ...supplements];
return memoryPluginState.promptBuilder?.(params) ?? [];
}
export function getMemoryPromptSectionBuilder(): MemoryPromptSectionBuilder | undefined {
return memoryPluginState.promptBuilder;
}
export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] {
return [...memoryPluginState.promptSupplements];
}
export function registerMemoryFlushPlanResolver(resolver: MemoryFlushPlanResolver): void {
memoryPluginState.flushPlanResolver = resolver;
}
@@ -200,17 +118,13 @@ export function hasMemoryRuntime(): boolean {
}
export function restoreMemoryPluginState(state: MemoryPluginState): void {
memoryPluginState.corpusSupplements = [...state.corpusSupplements];
memoryPluginState.promptBuilder = state.promptBuilder;
memoryPluginState.promptSupplements = [...state.promptSupplements];
memoryPluginState.flushPlanResolver = state.flushPlanResolver;
memoryPluginState.runtime = state.runtime;
}
export function clearMemoryPluginState(): void {
memoryPluginState.corpusSupplements = [];
memoryPluginState.promptBuilder = undefined;
memoryPluginState.promptSupplements = [];
memoryPluginState.flushPlanResolver = undefined;
memoryPluginState.runtime = undefined;
}

View File

@@ -24,9 +24,7 @@ import {
registerMemoryEmbeddingProvider,
} from "./memory-embedding-providers.js";
import {
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
} from "./memory-state.js";
@@ -1118,12 +1116,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
registerMemoryPromptSection(builder);
},
registerMemoryPromptSupplement: (builder) => {
registerMemoryPromptSupplement(record.id, builder);
},
registerMemoryCorpusSupplement: (supplement) => {
registerMemoryCorpusSupplement(record.id, supplement);
},
registerMemoryFlushPlan: (resolver) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

@@ -2082,14 +2082,6 @@ export type OpenClawPluginApi = {
registerMemoryPromptSection: (
builder: import("./memory-state.js").MemoryPromptSectionBuilder,
) => void;
/** Register an additive memory-adjacent prompt section (non-exclusive). */
registerMemoryPromptSupplement: (
builder: import("./memory-state.js").MemoryPromptSectionBuilder,
) => void;
/** Register an additive memory-adjacent search/read corpus supplement (non-exclusive). */
registerMemoryCorpusSupplement: (
supplement: import("./memory-state.js").MemoryCorpusSupplement,
) => void;
/** Register the pre-compaction flush plan resolver for this memory plugin (exclusive slot). */
registerMemoryFlushPlan: (resolver: import("./memory-state.js").MemoryFlushPlanResolver) => void;
/** Register the active memory runtime adapter for this memory plugin (exclusive slot). */

View File

@@ -33,8 +33,6 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerCommand() {},
registerContextEngine() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryCorpusSupplement() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},

View File

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