Files
openclaw/extensions/memory-wiki/src/memory-palace.ts
2026-05-01 17:45:26 +01:00

149 lines
4.7 KiB
TypeScript

import type { ResolvedMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, type WikiPageKind } from "./markdown.js";
import { readQueryableWikiPages } from "./query.js";
const PALACE_KIND_ORDER: WikiPageKind[] = ["synthesis", "entity", "concept", "source", "report"];
const PRIMARY_PALACE_KINDS = new Set<WikiPageKind>(["synthesis", "entity", "concept"]);
const PALACE_KIND_LABELS: Record<WikiPageKind, string> = {
synthesis: "Syntheses",
entity: "Entities",
concept: "Concepts",
source: "Sources",
report: "Reports",
};
type MemoryWikiPalaceItem = {
pagePath: string;
title: string;
kind: WikiPageKind;
id?: string;
updatedAt?: string;
sourceType?: string;
claimCount: number;
questionCount: number;
contradictionCount: number;
claims: string[];
questions: string[];
contradictions: string[];
snippet?: string;
};
type MemoryWikiPalaceCluster = {
key: WikiPageKind;
label: string;
itemCount: number;
claimCount: number;
questionCount: number;
contradictionCount: number;
updatedAt?: string;
items: MemoryWikiPalaceItem[];
};
type MemoryWikiPalaceStatus = {
totalItems: number;
totalClaims: number;
totalQuestions: number;
totalContradictions: number;
clusters: MemoryWikiPalaceCluster[];
};
function normalizeTimestamp(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function extractSnippet(body: string): string | undefined {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (
!line ||
line.startsWith("#") ||
line.startsWith("```") ||
line.startsWith("<!--") ||
line.startsWith("- ") ||
line.startsWith("* ")
) {
continue;
}
return line;
}
return undefined;
}
function comparePalaceItems(left: MemoryWikiPalaceItem, right: MemoryWikiPalaceItem): number {
const leftKey = left.updatedAt ?? "";
const rightKey = right.updatedAt ?? "";
if (rightKey !== leftKey) {
return rightKey.localeCompare(leftKey);
}
if (right.claimCount !== left.claimCount) {
return right.claimCount - left.claimCount;
}
return left.title.localeCompare(right.title);
}
export async function listMemoryWikiPalace(
config: ResolvedMemoryWikiConfig,
): Promise<MemoryWikiPalaceStatus> {
const pages = await readQueryableWikiPages(config.vault.path);
const items = pages
.map((page) => {
const parsed = parseWikiMarkdown(page.raw);
return Object.assign(
{ pagePath: page.relativePath, title: page.title, kind: page.kind },
page.id ? { id: page.id } : {},
normalizeTimestamp(page.updatedAt) ? { updatedAt: normalizeTimestamp(page.updatedAt) } : {},
typeof page.sourceType === `string` && page.sourceType.trim().length > 0
? { sourceType: page.sourceType.trim() }
: {},
{
claimCount: page.claims.length,
questionCount: page.questions.length,
contradictionCount: page.contradictions.length,
claims: page.claims.map((claim) => claim.text).slice(0, 3),
questions: page.questions.slice(0, 3),
contradictions: page.contradictions.slice(0, 3),
},
extractSnippet(parsed.body) ? { snippet: extractSnippet(parsed.body) } : {},
) satisfies MemoryWikiPalaceItem;
})
.filter(
(item) =>
PRIMARY_PALACE_KINDS.has(item.kind) ||
item.claimCount > 0 ||
item.questionCount > 0 ||
item.contradictionCount > 0,
)
.toSorted(comparePalaceItems);
const clusters = PALACE_KIND_ORDER.map((kind) => {
const clusterItems = items.filter((item) => item.kind === kind);
if (clusterItems.length === 0) {
return null;
}
return Object.assign(
{
key: kind,
label: PALACE_KIND_LABELS[kind],
itemCount: clusterItems.length,
claimCount: clusterItems.reduce((sum, item) => sum + item.claimCount, 0),
questionCount: clusterItems.reduce((sum, item) => sum + item.questionCount, 0),
contradictionCount: clusterItems.reduce((sum, item) => sum + item.contradictionCount, 0),
},
clusterItems[0]?.updatedAt ? { updatedAt: clusterItems[0].updatedAt } : {},
{ items: clusterItems },
) satisfies MemoryWikiPalaceCluster;
}).filter((entry): entry is MemoryWikiPalaceCluster => entry !== null);
return {
totalItems: items.length,
totalClaims: items.reduce((sum, item) => sum + item.claimCount, 0),
totalQuestions: items.reduce((sum, item) => sum + item.questionCount, 0),
totalContradictions: items.reduce((sum, item) => sum + item.contradictionCount, 0),
clusters,
};
}