mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
revert(memory-wiki): back out llm wiki stack
This commit is contained in:
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,8 +62,6 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
registerMemoryCorpusSupplement() {},
|
||||
registerMemoryFlushPlan() {},
|
||||
registerMemoryRuntime() {},
|
||||
registerMemoryEmbeddingProvider() {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
28
package.json
28
package.json
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../memory-host-sdk/events.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./memory-core-host-runtime-core.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../memory-host-sdk/events.js";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./memory-core-host-runtime-files.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
closeActiveMemorySearchManagers,
|
||||
getActiveMemorySearchManager,
|
||||
resolveActiveMemoryBackendConfig,
|
||||
} from "../plugins/memory-runtime.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./memory-core-host-status.js";
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -33,8 +33,6 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
registerMemoryCorpusSupplement() {},
|
||||
registerMemoryFlushPlan() {},
|
||||
registerMemoryRuntime() {},
|
||||
registerMemoryEmbeddingProvider() {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user