feat(memory-wiki): add agent-facing people wiki metadata

This commit is contained in:
Peter Steinberger
2026-04-29 20:17:27 +01:00
parent ccb8472daf
commit 3059702687
14 changed files with 1358 additions and 27 deletions

View File

@@ -26,7 +26,12 @@ import {
runObsidianOpen,
runObsidianSearch,
} from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import {
getMemoryWikiPage,
searchMemoryWiki,
WIKI_SEARCH_MODES,
type WikiSearchMode,
} from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import type { MemoryWikiImportedSourceSyncResult } from "./source-sync.js";
import {
@@ -81,6 +86,7 @@ type WikiSearchCommandOptions = {
maxResults?: number;
backend?: ResolvedMemoryWikiConfig["search"]["backend"];
corpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
mode?: WikiSearchMode;
};
type WikiGetCommandOptions = {
@@ -567,9 +573,13 @@ export async function runWikiSearch(params: {
maxResults?: number;
searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"];
searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
mode?: WikiSearchMode;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
if (params.mode && !(WIKI_SEARCH_MODES as readonly string[]).includes(params.mode)) {
throw new Error(`wiki search --mode must be one of: ${WIKI_SEARCH_MODES.join(", ")}.`);
}
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const results = await searchMemoryWiki({
config: params.config,
@@ -578,6 +588,7 @@ export async function runWikiSearch(params: {
maxResults: params.maxResults,
searchBackend: params.searchBackend,
searchCorpus: params.searchCorpus,
mode: params.mode,
});
const summary = params.json
? JSON.stringify(results, null, 2)
@@ -586,7 +597,7 @@ export async function runWikiSearch(params: {
: 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}`,
`${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}` : ""}${result.matchedClaimId ? `\nClaim: ${result.matchedClaimId}` : ""}${result.evidenceKinds && result.evidenceKinds.length > 0 ? `\nEvidence: ${result.evidenceKinds.join(", ")}` : ""}\nSnippet: ${result.snippet}`,
)
.join("\n\n");
writeOutput(summary, params.stdout);
@@ -935,7 +946,8 @@ export function registerWikiCli(
.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("--max-results <n>", "Maximum results", (value: string) => Number(value))
.option("--mode <mode>", `Search mode (${WIKI_SEARCH_MODES.join(", ")})`),
)
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiSearchCommandOptions) => {
@@ -946,6 +958,7 @@ export function registerWikiCli(
maxResults: opts.maxResults,
searchBackend: opts.backend,
searchCorpus: opts.corpus,
mode: opts.mode,
json: opts.json,
});
});

View File

@@ -353,6 +353,101 @@ describe("compileMemoryWikiVault", () => {
await expect(fs.access(path.join(rootDir, "reports", "open-questions.md"))).rejects.toThrow();
});
it("writes agent directory, relationship, provenance, and privacy reports", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "entities", "brad.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
entityType: "person",
id: "entity.brad",
title: "Brad Groux",
canonicalId: "maintainer.brad-groux",
aliases: ["brad"],
privacyTier: "local-private",
bestUsedFor: ["Microsoft routing"],
lastRefreshedAt: "2026-04-29T00:00:00.000Z",
personCard: {
handles: ["@bgroux"],
lane: "Microsoft Teams",
askFor: ["Teams and Azure questions"],
privacyTier: "confirm-before-use",
},
relationships: [
{
targetId: "entity.alice",
targetTitle: "Alice",
kind: "collaborates-with",
evidenceKind: "discrawl-stat",
privacyTier: "local-private",
},
],
claims: [
{
id: "claim.brad.teams",
text: "Brad is useful for Microsoft Teams routing.",
status: "supported",
confidence: 0.9,
evidence: [
{
kind: "maintainer-whois",
sourceId: "source.maintainers",
privacyTier: "local-private",
},
],
},
],
},
body: "# Brad Groux\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
await expect(
fs.readFile(path.join(rootDir, "reports", "person-agent-directory.md"), "utf8"),
).resolves.toContain("Microsoft Teams");
await expect(
fs.readFile(path.join(rootDir, "reports", "relationship-graph.md"), "utf8"),
).resolves.toContain("collaborates-with");
await expect(
fs.readFile(path.join(rootDir, "reports", "provenance-coverage.md"), "utf8"),
).resolves.toContain("maintainer-whois: 1");
await expect(
fs.readFile(path.join(rootDir, "reports", "privacy-review.md"), "utf8"),
).resolves.toContain("confirm-before-use");
const agentDigest = JSON.parse(
await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"),
) as {
pages: Array<{
path: string;
canonicalId?: string;
aliases?: string[];
personCard?: { lane?: string };
relationshipCount?: number;
}>;
};
expect(agentDigest.pages).toContainEqual(
expect.objectContaining({
path: "entities/brad.md",
canonicalId: "maintainer.brad-groux",
aliases: ["brad"],
personCard: expect.objectContaining({ lane: "Microsoft Teams" }),
relationshipCount: 1,
}),
);
await expect(
fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "claims.jsonl"), "utf8"),
).resolves.toContain('"evidenceKinds":["maintainer-whois"]');
});
it("ignores generated related links when computing backlinks on repeated compile", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),

View File

@@ -28,8 +28,10 @@ import {
renderWikiMarkdown,
toWikiPageSummary,
type WikiClaim,
type WikiClaimEvidence,
type WikiPageKind,
type WikiPageSummary,
type WikiRelationship,
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
@@ -218,6 +220,106 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [
].join("\n");
},
},
{
id: "report.person-agent-directory",
title: "Person Agent Directory",
relativePath: "reports/person-agent-directory.md",
buildBody: ({ config, pages, now }) => {
const matches = pages
.filter((page) => page.kind !== "report" && isPersonLikePage(page))
.toSorted((left, right) => left.title.localeCompare(right.title));
if (matches.length === 0) {
return "- No person-like entity pages with agent cards yet.";
}
const lines = [`- People with routing metadata: ${matches.length}`];
for (const page of matches) {
const freshness = assessPageFreshness(page, now);
lines.push(`- ${formatPersonDirectoryLine(config, page, freshness)}`);
}
return lines.join("\n");
},
},
{
id: "report.relationship-graph",
title: "Relationship Graph",
relativePath: "reports/relationship-graph.md",
buildBody: ({ config, pages }) => {
const relationships = pages
.flatMap((page) => page.relationships.map((relationship) => ({ page, relationship })))
.toSorted((left, right) => {
const leftTitle = left.relationship.targetTitle ?? left.relationship.targetId ?? "";
const rightTitle = right.relationship.targetTitle ?? right.relationship.targetId ?? "";
return `${left.page.title} ${leftTitle}`.localeCompare(
`${right.page.title} ${rightTitle}`,
);
});
if (relationships.length === 0) {
return "- No structured relationships yet.";
}
return [
`- Structured relationships: ${relationships.length}`,
"",
...relationships.map(
({ page, relationship }) => `- ${formatRelationshipLine(config, page, relationship)}`,
),
].join("\n");
},
},
{
id: "report.provenance-coverage",
title: "Provenance Coverage",
relativePath: "reports/provenance-coverage.md",
buildBody: ({ config, pages }) => {
const evidenceEntries = pages.flatMap((page) =>
page.claims.flatMap((claim) =>
claim.evidence.map((evidence) => ({ page, claim, evidence })),
),
);
const missingEvidence = pages.flatMap((page) =>
page.claims
.filter((claim) => claim.evidence.length === 0)
.map((claim) => ({ page, claim })),
);
if (evidenceEntries.length === 0 && missingEvidence.length === 0) {
return "- No structured claims with provenance coverage yet.";
}
const kindCounts = countBy(
evidenceEntries.map(({ evidence }) => evidence.kind ?? "unspecified"),
);
const sourceCounts = countBy(
evidenceEntries.map(({ evidence }) => evidence.sourceId ?? evidence.path ?? "inline"),
);
const lines = [
`- Evidence entries: ${evidenceEntries.length}`,
`- Claims missing evidence: ${missingEvidence.length}`,
"",
"### Evidence Classes",
...formatCountLines(kindCounts),
"",
"### Top Evidence Sources",
...formatCountLines(sourceCounts).slice(0, 20),
];
if (missingEvidence.length > 0) {
lines.push("", "### Missing Evidence");
for (const { page, claim } of missingEvidence) {
lines.push(`- ${formatPageLink(config, page)}: ${formatClaimIdentityForPage(claim)}`);
}
}
return lines.join("\n");
},
},
{
id: "report.privacy-review",
title: "Privacy Review",
relativePath: "reports/privacy-review.md",
buildBody: ({ config, pages }) => {
const entries = collectPrivacyReviewEntries(config, pages);
if (entries.length === 0) {
return "- No non-public privacy tiers flagged right now.";
}
return [`- Privacy review entries: ${entries.length}`, "", ...entries].join("\n");
},
},
];
export type CompileMemoryWikiResult = {
@@ -294,6 +396,165 @@ function formatFreshnessLabel(freshness: WikiFreshness): string {
throw new Error("Unsupported wiki freshness level");
}
function formatListPreview(values: readonly string[], maxItems = 3): string | null {
if (values.length === 0) {
return null;
}
const shown = values.slice(0, maxItems).join(", ");
return values.length > maxItems ? `${shown}, +${values.length - maxItems}` : shown;
}
function formatMaybeDetail(label: string, value: string | null | undefined): string | null {
return value ? `${label} ${value}` : null;
}
function isPersonLikePage(page: WikiPageSummary): boolean {
const entityType = normalizeLowercaseStringOrEmpty(page.entityType);
const pageType = normalizeLowercaseStringOrEmpty(page.pageType);
return (
Boolean(page.personCard) ||
entityType === "person" ||
entityType === "maintainer" ||
pageType === "person" ||
pageType === "maintainer"
);
}
function formatPersonDirectoryLine(
config: ResolvedMemoryWikiConfig,
page: WikiPageSummary,
freshness: WikiFreshness,
): string {
const card = page.personCard;
const details = [
formatMaybeDetail("id", page.canonicalId ?? card?.canonicalId ?? page.id),
formatMaybeDetail("aliases", formatListPreview(page.aliases)),
formatMaybeDetail("handles", formatListPreview(card?.handles ?? [])),
formatMaybeDetail("lane", card?.lane),
formatMaybeDetail("ask", formatListPreview(card?.askFor ?? [])),
formatMaybeDetail(
"best",
formatListPreview([...page.bestUsedFor, ...(card?.bestUsedFor ?? [])]),
),
formatMaybeDetail("privacy", page.privacyTier ?? card?.privacyTier),
formatMaybeDetail("refreshed", page.lastRefreshedAt ?? card?.lastRefreshedAt),
formatMaybeDetail("freshness", formatFreshnessLabel(freshness)),
].filter(Boolean);
return `${formatPageLink(config, page)}${details.length > 0 ? `: ${details.join("; ")}` : ""}`;
}
function formatRelationshipTarget(
config: ResolvedMemoryWikiConfig,
relationship: WikiRelationship,
) {
if (relationship.targetPath && relationship.targetTitle) {
return formatWikiLink({
renderMode: config.vault.renderMode,
relativePath: relationship.targetPath,
title: relationship.targetTitle,
});
}
return relationship.targetTitle ?? relationship.targetId ?? relationship.targetPath ?? "unknown";
}
function formatRelationshipLine(
config: ResolvedMemoryWikiConfig,
page: WikiPageSummary,
relationship: WikiRelationship,
): string {
const details = [
relationship.kind ?? "related",
typeof relationship.weight === "number" ? `weight ${relationship.weight.toFixed(2)}` : null,
typeof relationship.confidence === "number"
? `confidence ${relationship.confidence.toFixed(2)}`
: null,
relationship.evidenceKind ? `evidence ${relationship.evidenceKind}` : null,
relationship.privacyTier ? `privacy ${relationship.privacyTier}` : null,
relationship.note,
].filter(Boolean);
return `${formatPageLink(config, page)} -> ${formatRelationshipTarget(config, relationship)}${
details.length > 0 ? ` (${details.join(", ")})` : ""
}`;
}
function countBy(values: readonly string[]): Map<string, number> {
const counts = new Map<string, number>();
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return counts;
}
function formatCountLines(counts: Map<string, number>): string[] {
const lines = [...counts]
.toSorted((left, right) => {
if (left[1] !== right[1]) {
return right[1] - left[1];
}
return left[0].localeCompare(right[0]);
})
.map(([label, count]) => `- ${label}: ${count}`);
return lines.length > 0 ? lines : ["- None"];
}
function formatClaimIdentityForPage(claim: Pick<WikiClaim, "id" | "text">): string {
return claim.id ? `\`${claim.id}\`: ${claim.text}` : claim.text;
}
function isReviewablePrivacyTier(value: string | undefined): boolean {
const tier = normalizeLowercaseStringOrEmpty(value);
return tier !== "" && tier !== "public";
}
function formatEvidencePrivacyDetails(evidence: WikiClaimEvidence): string {
return [
evidence.kind ? `kind ${evidence.kind}` : null,
evidence.sourceId ? `source ${evidence.sourceId}` : null,
evidence.path ? `path ${evidence.path}` : null,
evidence.lines ? `lines ${evidence.lines}` : null,
]
.filter(Boolean)
.join(", ");
}
function collectPrivacyReviewEntries(
config: ResolvedMemoryWikiConfig,
pages: WikiPageSummary[],
): string[] {
const entries: string[] = [];
for (const page of pages) {
if (isReviewablePrivacyTier(page.privacyTier)) {
entries.push(`- ${formatPageLink(config, page)}: page privacy ${page.privacyTier}`);
}
if (isReviewablePrivacyTier(page.personCard?.privacyTier)) {
entries.push(
`- ${formatPageLink(config, page)}: person card privacy ${page.personCard?.privacyTier}`,
);
}
for (const relationship of page.relationships) {
if (isReviewablePrivacyTier(relationship.privacyTier)) {
entries.push(
`- ${formatPageLink(config, page)}: relationship privacy ${
relationship.privacyTier
} -> ${formatRelationshipTarget(config, relationship)}`,
);
}
}
for (const claim of page.claims) {
for (const evidence of claim.evidence) {
if (!isReviewablePrivacyTier(evidence.privacyTier)) {
continue;
}
const detail = formatEvidencePrivacyDetails(evidence);
entries.push(
`- ${formatPageLink(config, page)}: evidence privacy ${evidence.privacyTier} on ${formatClaimIdentityForPage(claim)}${detail ? ` (${detail})` : ""}`,
);
}
}
}
return entries;
}
function formatClaimIdentity(claim: WikiClaimHealth): string {
return claim.claimId ? `\`${claim.claimId}\`: ${claim.text}` : claim.text;
}
@@ -740,12 +1001,23 @@ type AgentDigestPage = {
title: string;
kind: WikiPageKind;
path: string;
pageType?: string;
entityType?: string;
canonicalId?: string;
aliases: string[];
sourceIds: string[];
questions: string[];
contradictions: string[];
confidence?: number;
privacyTier?: string;
personCard?: WikiPageSummary["personCard"];
bestUsedFor: string[];
notEnoughFor: string[];
relationshipCount: number;
topRelationships: WikiRelationship[];
freshnessLevel: WikiFreshnessLevel;
lastTouchedAt?: string;
lastRefreshedAt?: string;
claimCount: number;
topClaims: AgentDigestClaim[];
};
@@ -878,13 +1150,24 @@ function buildAgentDigest(params: {
title: page.title,
kind: page.kind,
path: page.relativePath,
aliases: [...page.aliases],
sourceIds: [...page.sourceIds],
questions: [...page.questions],
contradictions: [...page.contradictions],
bestUsedFor: [...page.bestUsedFor],
notEnoughFor: [...page.notEnoughFor],
relationshipCount: page.relationships.length,
topRelationships: page.relationships.slice(0, 5),
},
page.pageType ? { pageType: page.pageType } : {},
page.entityType ? { entityType: page.entityType } : {},
page.canonicalId ? { canonicalId: page.canonicalId } : {},
typeof page.confidence === "number" ? { confidence: page.confidence } : {},
page.privacyTier ? { privacyTier: page.privacyTier } : {},
page.personCard ? { personCard: page.personCard } : {},
{ freshnessLevel: pageFreshness.level },
pageFreshness.lastTouchedAt ? { lastTouchedAt: pageFreshness.lastTouchedAt } : {},
page.lastRefreshedAt ? { lastRefreshedAt: page.lastRefreshedAt } : {},
{
claimCount: page.claims.length,
topClaims: sortClaims(page)
@@ -931,10 +1214,24 @@ function buildClaimsDigestLines(params: { pages: WikiPageSummary[] }): string[]
pageTitle: page.title,
pageKind: page.kind,
pagePath: page.relativePath,
pageType: page.pageType,
entityType: page.entityType,
canonicalId: page.canonicalId,
aliases: page.aliases,
text: claim.text,
status: normalizeClaimStatus(claim.status),
confidence: claim.confidence,
sourceIds: page.sourceIds,
evidenceKinds: [...new Set(claim.evidence.flatMap((entry) => entry.kind ?? []))],
privacyTiers: [
...new Set(
[
page.privacyTier,
page.personCard?.privacyTier,
...claim.evidence.map((entry) => entry.privacyTier),
].flatMap((entry) => entry ?? []),
),
],
evidenceCount: claim.evidence.length,
missingEvidence: claim.evidence.length === 0,
evidence: claim.evidence,

View File

@@ -54,6 +54,7 @@ vi.mock("./obsidian.js", () => ({
vi.mock("./query.js", () => ({
getMemoryWikiPage: vi.fn(),
searchMemoryWiki: vi.fn(),
WIKI_SEARCH_MODES: ["auto", "find-person", "route-question", "source-evidence", "raw-claim"],
}));
vi.mock("./source-sync.js", () => ({
@@ -356,6 +357,42 @@ describe("memory-wiki gateway methods", () => {
);
});
it("forwards wiki.search mode and corpus options over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
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: {
query: "Teams Azure",
maxResults: 3,
corpus: "wiki",
backend: "local",
mode: "route-question",
},
respond,
});
expect(searchMemoryWiki).toHaveBeenCalledWith(
expect.objectContaining({
config,
appConfig: undefined,
query: "Teams Azure",
maxResults: 3,
searchBackend: "local",
searchCorpus: "wiki",
mode: "route-question",
}),
);
expect(respond).toHaveBeenCalledWith(true, expect.anything());
});
it("forwards ingest requests over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();

View File

@@ -19,7 +19,7 @@ import {
runObsidianOpen,
runObsidianSearch,
} from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { getMemoryWikiPage, searchMemoryWiki, WIKI_SEARCH_MODES } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { buildMemoryWikiDoctorReport, resolveMemoryWikiStatus } from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
@@ -277,6 +277,7 @@ export function registerMemoryWikiGatewayMethods(params: {
const maxResults = readNumberParam(requestParams, "maxResults");
const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS);
const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA);
const mode = readEnumParam(requestParams, "mode", WIKI_SEARCH_MODES);
respond(
true,
await searchMemoryWiki({
@@ -286,6 +287,7 @@ export function registerMemoryWikiGatewayMethods(params: {
maxResults,
searchBackend,
searchCorpus,
mode,
}),
);
} catch (error) {

View File

@@ -1,6 +1,11 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { createWikiPageFilename, slugifyWikiSegment } from "./markdown.js";
import {
createWikiPageFilename,
renderWikiMarkdown,
slugifyWikiSegment,
toWikiPageSummary,
} from "./markdown.js";
describe("slugifyWikiSegment", () => {
it("preserves Unicode letters and numbers in wiki slugs", () => {
@@ -40,3 +45,103 @@ describe("slugifyWikiSegment", () => {
expect(createWikiPageFilename(stem)).toBe(fileName);
});
});
describe("toWikiPageSummary", () => {
it("normalizes agent-facing people wiki metadata", () => {
const raw = renderWikiMarkdown({
frontmatter: {
pageType: "entity",
entityType: "person",
id: "entity.brad",
title: "Brad Groux",
canonicalId: "maintainer.brad-groux",
aliases: ["brad", "bgroux"],
privacyTier: "local-private",
bestUsedFor: ["Microsoft ecosystem routing"],
notEnoughFor: ["legal approval"],
lastRefreshedAt: "2026-04-29T00:00:00.000Z",
personCard: {
handles: ["@bgroux"],
socials: ["https://x.example/bgroux"],
email: "brad@example.com",
timezone: "America/Chicago",
lane: "Microsoft Teams",
askFor: ["Teams and Azure questions"],
avoidAskingFor: ["unrelated billing"],
confidence: 0.8,
privacyTier: "confirm-before-use",
lastRefreshedAt: "2026-04-28T00:00:00.000Z",
},
relationships: [
{
targetId: "entity.alice",
targetTitle: "Alice",
kind: "collaborates-with",
weight: 0.7,
confidence: 0.6,
evidenceKind: "discrawl-stat",
privacyTier: "local-private",
},
],
claims: [
{
id: "claim.brad.teams",
text: "Brad is useful for Microsoft Teams routing.",
confidence: 0.9,
evidence: [
{
kind: "maintainer-whois",
sourceId: "source.maintainers",
confidence: 0.8,
privacyTier: "local-private",
},
],
},
],
},
body: "# Brad Groux\n",
});
const summary = toWikiPageSummary({
absolutePath: "/tmp/wiki/entities/brad.md",
relativePath: "entities/brad.md",
raw,
});
expect(summary).toEqual(
expect.objectContaining({
entityType: "person",
canonicalId: "maintainer.brad-groux",
aliases: ["brad", "bgroux"],
privacyTier: "local-private",
bestUsedFor: ["Microsoft ecosystem routing"],
notEnoughFor: ["legal approval"],
lastRefreshedAt: "2026-04-29T00:00:00.000Z",
personCard: expect.objectContaining({
handles: ["@bgroux"],
emails: ["brad@example.com"],
lane: "Microsoft Teams",
privacyTier: "confirm-before-use",
}),
relationships: [
expect.objectContaining({
targetId: "entity.alice",
kind: "collaborates-with",
evidenceKind: "discrawl-stat",
}),
],
claims: [
expect.objectContaining({
id: "claim.brad.teams",
evidence: [
expect.objectContaining({
kind: "maintainer-whois",
privacyTier: "local-private",
}),
],
}),
],
}),
);
});
});

View File

@@ -19,10 +19,13 @@ export type ParsedWikiMarkdown = {
};
export type WikiClaimEvidence = {
kind?: string;
sourceId?: string;
path?: string;
lines?: string;
weight?: number;
confidence?: number;
privacyTier?: string;
note?: string;
updatedAt?: string;
};
@@ -36,6 +39,35 @@ export type WikiClaim = {
updatedAt?: string;
};
export type WikiPersonCard = {
canonicalId?: string;
handles: string[];
socials: string[];
emails: string[];
timezone?: string;
lane?: string;
askFor: string[];
avoidAskingFor: string[];
bestUsedFor: string[];
notEnoughFor: string[];
confidence?: number;
privacyTier?: string;
lastRefreshedAt?: string;
};
export type WikiRelationship = {
targetId?: string;
targetPath?: string;
targetTitle?: string;
kind?: string;
weight?: number;
confidence?: number;
evidenceKind?: string;
privacyTier?: string;
note?: string;
updatedAt?: string;
};
export type WikiPageSummary = {
absolutePath: string;
relativePath: string;
@@ -43,12 +75,20 @@ export type WikiPageSummary = {
title: string;
id?: string;
pageType?: string;
entityType?: string;
canonicalId?: string;
aliases: string[];
sourceIds: string[];
linkTargets: string[];
claims: WikiClaim[];
contradictions: string[];
questions: string[];
confidence?: number;
privacyTier?: string;
personCard?: WikiPersonCard;
relationships: WikiRelationship[];
bestUsedFor: string[];
notEnoughFor: string[];
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
@@ -56,6 +96,7 @@ export type WikiPageSummary = {
bridgeWorkspaceDir?: string;
unsafeLocalConfiguredPath?: string;
unsafeLocalRelativePath?: string;
lastRefreshedAt?: string;
updatedAt?: string;
};
@@ -153,21 +194,37 @@ function normalizeWikiClaimEvidence(value: unknown): WikiClaimEvidence | null {
return null;
}
const record = value as Record<string, unknown>;
const kind = normalizeOptionalString(record.kind);
const sourceId = normalizeOptionalString(record.sourceId);
const evidencePath = normalizeOptionalString(record.path);
const lines = normalizeOptionalString(record.lines);
const note = normalizeOptionalString(record.note);
const updatedAt = normalizeOptionalString(record.updatedAt);
const privacyTier = normalizeOptionalString(record.privacyTier);
const weight =
typeof record.weight === "number" && Number.isFinite(record.weight) ? record.weight : undefined;
if (!sourceId && !evidencePath && !lines && !note && weight === undefined && !updatedAt) {
const confidence = normalizeOptionalNumber(record.confidence);
if (
!kind &&
!sourceId &&
!evidencePath &&
!lines &&
!note &&
weight === undefined &&
confidence === undefined &&
!privacyTier &&
!updatedAt
) {
return null;
}
return {
...(kind ? { kind } : {}),
...(sourceId ? { sourceId } : {}),
...(evidencePath ? { path: evidencePath } : {}),
...(lines ? { lines } : {}),
...(weight !== undefined ? { weight } : {}),
...(confidence !== undefined ? { confidence } : {}),
...(privacyTier ? { privacyTier } : {}),
...(note ? { note } : {}),
...(updatedAt ? { updatedAt } : {}),
};
@@ -213,6 +270,101 @@ export function normalizeWikiClaims(value: unknown): WikiClaim[] {
});
}
function normalizeOptionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeWikiPersonCard(value: unknown): WikiPersonCard | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const card: WikiPersonCard = {
...(normalizeOptionalString(record.canonicalId)
? { canonicalId: normalizeOptionalString(record.canonicalId) }
: {}),
handles: normalizeSingleOrTrimmedStringList(record.handles),
socials: normalizeSingleOrTrimmedStringList(record.socials),
emails: normalizeSingleOrTrimmedStringList(record.emails ?? record.email),
...(normalizeOptionalString(record.timezone)
? { timezone: normalizeOptionalString(record.timezone) }
: {}),
...(normalizeOptionalString(record.lane) ? { lane: normalizeOptionalString(record.lane) } : {}),
askFor: normalizeSingleOrTrimmedStringList(record.askFor),
avoidAskingFor: normalizeSingleOrTrimmedStringList(record.avoidAskingFor),
bestUsedFor: normalizeSingleOrTrimmedStringList(record.bestUsedFor),
notEnoughFor: normalizeSingleOrTrimmedStringList(record.notEnoughFor),
...(normalizeOptionalNumber(record.confidence) !== undefined
? { confidence: normalizeOptionalNumber(record.confidence) }
: {}),
...(normalizeOptionalString(record.privacyTier)
? { privacyTier: normalizeOptionalString(record.privacyTier) }
: {}),
...(normalizeOptionalString(record.lastRefreshedAt)
? { lastRefreshedAt: normalizeOptionalString(record.lastRefreshedAt) }
: {}),
};
const hasAnyValue =
Boolean(
card.canonicalId || card.timezone || card.lane || card.privacyTier || card.lastRefreshedAt,
) ||
typeof card.confidence === "number" ||
card.handles.length > 0 ||
card.socials.length > 0 ||
card.emails.length > 0 ||
card.askFor.length > 0 ||
card.avoidAskingFor.length > 0 ||
card.bestUsedFor.length > 0 ||
card.notEnoughFor.length > 0;
return hasAnyValue ? card : undefined;
}
function normalizeWikiRelationships(value: unknown): WikiRelationship[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const record = entry as Record<string, unknown>;
const relationship: WikiRelationship = {
...(normalizeOptionalString(record.targetId)
? { targetId: normalizeOptionalString(record.targetId) }
: {}),
...(normalizeOptionalString(record.targetPath)
? { targetPath: normalizeOptionalString(record.targetPath) }
: {}),
...(normalizeOptionalString(record.targetTitle)
? { targetTitle: normalizeOptionalString(record.targetTitle) }
: {}),
...(normalizeOptionalString(record.kind)
? { kind: normalizeOptionalString(record.kind) }
: {}),
...(normalizeOptionalNumber(record.weight) !== undefined
? { weight: normalizeOptionalNumber(record.weight) }
: {}),
...(normalizeOptionalNumber(record.confidence) !== undefined
? { confidence: normalizeOptionalNumber(record.confidence) }
: {}),
...(normalizeOptionalString(record.evidenceKind)
? { evidenceKind: normalizeOptionalString(record.evidenceKind) }
: {}),
...(normalizeOptionalString(record.privacyTier)
? { privacyTier: normalizeOptionalString(record.privacyTier) }
: {}),
...(normalizeOptionalString(record.note)
? { note: normalizeOptionalString(record.note) }
: {}),
...(normalizeOptionalString(record.updatedAt)
? { updatedAt: normalizeOptionalString(record.updatedAt) }
: {}),
};
const hasAnyValue = Object.keys(relationship).length > 0;
return hasAnyValue ? [relationship] : [];
});
}
export function extractWikiLinks(markdown: string): string[] {
const searchable = markdown.replace(RELATED_BLOCK_PATTERN, "");
const links: string[] = [];
@@ -297,6 +449,9 @@ export function toWikiPageSummary(params: {
title,
id: normalizeOptionalString(parsed.frontmatter.id),
pageType: normalizeOptionalString(parsed.frontmatter.pageType),
entityType: normalizeOptionalString(parsed.frontmatter.entityType),
canonicalId: normalizeOptionalString(parsed.frontmatter.canonicalId),
aliases: normalizeSingleOrTrimmedStringList(parsed.frontmatter.aliases),
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
linkTargets: extractWikiLinks(params.raw),
claims: normalizeWikiClaims(parsed.frontmatter.claims),
@@ -307,6 +462,11 @@ export function toWikiPageSummary(params: {
Number.isFinite(parsed.frontmatter.confidence)
? parsed.frontmatter.confidence
: undefined,
privacyTier: normalizeOptionalString(parsed.frontmatter.privacyTier),
personCard: normalizeWikiPersonCard(parsed.frontmatter.personCard),
relationships: normalizeWikiRelationships(parsed.frontmatter.relationships),
bestUsedFor: normalizeSingleOrTrimmedStringList(parsed.frontmatter.bestUsedFor),
notEnoughFor: normalizeSingleOrTrimmedStringList(parsed.frontmatter.notEnoughFor),
sourceType: normalizeOptionalString(parsed.frontmatter.sourceType),
provenanceMode: normalizeOptionalString(parsed.frontmatter.provenanceMode),
sourcePath: normalizeOptionalString(parsed.frontmatter.sourcePath),
@@ -316,6 +476,7 @@ export function toWikiPageSummary(params: {
parsed.frontmatter.unsafeLocalConfiguredPath,
),
unsafeLocalRelativePath: normalizeOptionalString(parsed.frontmatter.unsafeLocalRelativePath),
lastRefreshedAt: normalizeOptionalString(parsed.frontmatter.lastRefreshedAt),
updatedAt: normalizeOptionalString(parsed.frontmatter.updatedAt),
};
}

View File

@@ -205,6 +205,114 @@ describe("searchMemoryWiki", () => {
expect(results[0]?.snippet).toContain("Teams");
});
it("supports people-routing search modes and claim evidence drilldown metadata", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "entities", "brad.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
entityType: "person",
id: "entity.brad",
title: "Brad Groux",
canonicalId: "maintainer.brad-groux",
aliases: ["bgroux"],
privacyTier: "local-private",
personCard: {
handles: ["@bgroux"],
lane: "Microsoft Teams",
askFor: ["Teams and Azure rollout questions"],
},
bestUsedFor: ["Microsoft ecosystem routing"],
relationships: [
{
targetId: "entity.alice",
targetTitle: "Alice",
kind: "works-with",
note: "Teams escalation buddy",
},
],
claims: [
{
id: "claim.brad.teams",
text: "Brad is a strong route for Microsoft Teams questions.",
status: "supported",
confidence: 0.88,
evidence: [
{
kind: "maintainer-whois",
sourceId: "source.maintainers",
privacyTier: "local-private",
},
],
},
],
},
body: "# Brad Groux\n\nAgent card summary.\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "maintainers.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.maintainers",
title: "Maintainers Source",
},
body: "# Maintainers Source\n\nmaintainer-whois Teams sample.\n",
}),
"utf8",
);
const personResults = await searchMemoryWiki({
config,
query: "bgroux",
mode: "find-person",
});
expect(personResults[0]).toEqual(
expect.objectContaining({
path: "entities/brad.md",
canonicalId: "maintainer.brad-groux",
aliases: ["bgroux"],
privacyTier: "local-private",
searchMode: "find-person",
}),
);
const routeResults = await searchMemoryWiki({
config,
query: "Teams Azure",
mode: "route-question",
});
expect(routeResults[0]?.path).toBe("entities/brad.md");
const claimResults = await searchMemoryWiki({
config,
query: "strong route Teams",
mode: "raw-claim",
});
expect(claimResults[0]).toEqual(
expect.objectContaining({
path: "entities/brad.md",
matchedClaimId: "claim.brad.teams",
matchedClaimConfidence: 0.88,
evidenceKinds: ["maintainer-whois"],
evidenceSourceIds: ["source.maintainers"],
}),
);
const evidenceResults = await searchMemoryWiki({
config,
query: "maintainer-whois",
mode: "source-evidence",
maxResults: 2,
});
expect(evidenceResults.map((result) => result.path)).toContain("sources/maintainers.md");
});
it("uses body text instead of frontmatter for fallback snippets", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,

View File

@@ -22,14 +22,33 @@ const RELATED_BLOCK_PATTERN =
/<!-- openclaw:wiki:related:start -->[\s\S]*?<!-- openclaw:wiki:related:end -->/g;
const MARKDOWN_FRONTMATTER_PATTERN = /^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/;
export const WIKI_SEARCH_MODES = [
"auto",
"find-person",
"route-question",
"source-evidence",
"raw-claim",
] as const;
export type WikiSearchMode = (typeof WIKI_SEARCH_MODES)[number];
type QueryDigestPage = {
id?: string;
title: string;
kind: WikiPageSummary["kind"];
path: string;
pageType?: string;
entityType?: string;
canonicalId?: string;
aliases?: string[];
sourceIds: string[];
questions: string[];
contradictions: string[];
privacyTier?: string;
personCard?: WikiPageSummary["personCard"];
bestUsedFor?: string[];
notEnoughFor?: string[];
relationshipCount?: number;
};
type QueryDigestClaim = {
@@ -38,10 +57,16 @@ type QueryDigestClaim = {
pageTitle: string;
pageKind: WikiPageSummary["kind"];
pagePath: string;
pageType?: string;
entityType?: string;
canonicalId?: string;
aliases?: string[];
text: string;
status?: string;
confidence?: number;
sourceIds?: string[];
evidenceKinds?: string[];
privacyTiers?: string[];
freshnessLevel?: string;
lastTouchedAt?: string;
};
@@ -68,6 +93,16 @@ export type WikiSearchResult = {
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
searchMode?: WikiSearchMode;
entityType?: string;
canonicalId?: string;
aliases?: string[];
privacyTier?: string;
matchedClaimId?: string;
matchedClaimStatus?: string;
matchedClaimConfidence?: number;
evidenceKinds?: string[];
evidenceSourceIds?: string[];
};
export type WikiGetResult = {
@@ -206,11 +241,50 @@ function buildPageSearchText(page: QueryableWikiPage): string {
page.title,
page.relativePath,
page.id ?? "",
page.pageType ?? "",
page.entityType ?? "",
page.canonicalId ?? "",
page.aliases.join(" "),
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.privacyTier ?? "",
page.bestUsedFor.join(" "),
page.notEnoughFor.join(" "),
page.personCard?.canonicalId ?? "",
page.personCard?.handles.join(" ") ?? "",
page.personCard?.socials.join(" ") ?? "",
page.personCard?.emails.join(" ") ?? "",
page.personCard?.timezone ?? "",
page.personCard?.lane ?? "",
page.personCard?.askFor.join(" ") ?? "",
page.personCard?.avoidAskingFor.join(" ") ?? "",
page.personCard?.bestUsedFor.join(" ") ?? "",
page.personCard?.notEnoughFor.join(" ") ?? "",
page.relationships
.flatMap((relationship) => [
relationship.targetId ?? "",
relationship.targetPath ?? "",
relationship.targetTitle ?? "",
relationship.kind ?? "",
relationship.evidenceKind ?? "",
relationship.note ?? "",
])
.join(" "),
page.claims.map((claim) => claim.text).join(" "),
page.claims.map((claim) => claim.id ?? "").join(" "),
page.claims
.flatMap((claim) =>
claim.evidence.flatMap((evidence) => [
evidence.kind ?? "",
evidence.sourceId ?? "",
evidence.path ?? "",
evidence.lines ?? "",
evidence.note ?? "",
evidence.privacyTier ?? "",
]),
)
.join(" "),
]
.filter(Boolean)
.join("\n");
@@ -247,11 +321,30 @@ function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestCla
page.title,
page.path,
page.id ?? "",
page.pageType ?? "",
page.entityType ?? "",
page.canonicalId ?? "",
page.aliases?.join(" ") ?? "",
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.privacyTier ?? "",
page.bestUsedFor?.join(" ") ?? "",
page.notEnoughFor?.join(" ") ?? "",
page.personCard?.canonicalId ?? "",
page.personCard?.handles.join(" ") ?? "",
page.personCard?.socials.join(" ") ?? "",
page.personCard?.emails.join(" ") ?? "",
page.personCard?.timezone ?? "",
page.personCard?.lane ?? "",
page.personCard?.askFor.join(" ") ?? "",
page.personCard?.avoidAskingFor.join(" ") ?? "",
page.personCard?.bestUsedFor.join(" ") ?? "",
page.personCard?.notEnoughFor.join(" ") ?? "",
claims.map((claim) => claim.text).join(" "),
claims.map((claim) => claim.id ?? "").join(" "),
claims.map((claim) => claim.evidenceKinds?.join(" ") ?? "").join(" "),
claims.map((claim) => claim.privacyTiers?.join(" ") ?? "").join(" "),
]
.filter(Boolean)
.join("\n");
@@ -260,11 +353,13 @@ function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestCla
function isClaimTextOrIdMatch(
claim: Pick<QueryDigestClaim, "id" | "text"> | Pick<WikiClaim, "id" | "text">,
queryLower: string,
queryTokens: readonly string[] = buildQueryTokens(queryLower),
): boolean {
if (normalizeLowercaseStringOrEmpty(claim.text).includes(queryLower)) {
const textLower = normalizeLowercaseStringOrEmpty(claim.text);
if (lineMatchesQuery(textLower, queryLower, [...queryTokens])) {
return true;
}
return normalizeLowercaseStringOrEmpty(claim.id).includes(queryLower);
return lineMatchesQuery(normalizeLowercaseStringOrEmpty(claim.id), queryLower, [...queryTokens]);
}
function scoreClaimMatch(params: {
@@ -274,10 +369,18 @@ function scoreClaimMatch(params: {
status?: string;
freshnessLevel?: string;
queryLower: string;
queryTokens?: readonly string[];
}): number {
let score = 0;
if (normalizeLowercaseStringOrEmpty(params.text).includes(params.queryLower)) {
score += 25;
} else if (
params.queryTokens?.length &&
params.queryTokens.every((token) =>
normalizeLowercaseStringOrEmpty(params.text).includes(token),
)
) {
score += 18;
}
if (normalizeLowercaseStringOrEmpty(params.id).includes(params.queryLower)) {
score += 10;
@@ -313,6 +416,7 @@ function scoreDigestClaimMatch(claim: QueryDigestClaim, queryLower: string): num
status: claim.status,
freshnessLevel: claim.freshnessLevel,
queryLower,
queryTokens: buildQueryTokens(queryLower),
});
}
@@ -348,12 +452,212 @@ function scoreWikiMetadataMatch(params: {
return score;
}
function hasQueryMatch(
value: string | undefined,
queryLower: string,
queryTokens: readonly string[],
) {
const normalized = normalizeLowercaseStringOrEmpty(value);
return lineMatchesQuery(normalized, queryLower, [...queryTokens]);
}
function hasAnyQueryMatch(
values: readonly (string | undefined)[],
queryLower: string,
queryTokens: readonly string[],
) {
return values.some((value) => hasQueryMatch(value, queryLower, queryTokens));
}
function isPersonLikeSummary(
page: Pick<WikiPageSummary, "entityType" | "pageType" | "personCard">,
): boolean {
const entityType = normalizeLowercaseStringOrEmpty(page.entityType);
const pageType = normalizeLowercaseStringOrEmpty(page.pageType);
return (
Boolean(page.personCard) ||
entityType === "person" ||
entityType === "maintainer" ||
pageType === "person" ||
pageType === "maintainer"
);
}
function scorePageSearchModeBoost(params: {
page: QueryableWikiPage;
matchingClaims: readonly WikiClaim[];
queryLower: string;
queryTokens: readonly string[];
mode: WikiSearchMode;
}): number {
const { page, queryLower, queryTokens } = params;
switch (params.mode) {
case "auto":
return 0;
case "find-person": {
let score = isPersonLikeSummary(page) ? 24 : -4;
if (
hasAnyQueryMatch(
[
page.canonicalId,
...page.aliases,
page.personCard?.canonicalId,
...(page.personCard?.handles ?? []),
...(page.personCard?.emails ?? []),
...(page.personCard?.socials ?? []),
],
queryLower,
queryTokens,
)
) {
score += 24;
}
return score;
}
case "route-question": {
let score = isPersonLikeSummary(page) ? 14 : 0;
if (
hasAnyQueryMatch(
[
page.personCard?.lane,
...(page.personCard?.askFor ?? []),
...(page.personCard?.avoidAskingFor ?? []),
...page.bestUsedFor,
...page.notEnoughFor,
...(page.personCard?.bestUsedFor ?? []),
...(page.personCard?.notEnoughFor ?? []),
...page.relationships.flatMap((relationship) => [
relationship.kind,
relationship.targetTitle,
relationship.note,
]),
],
queryLower,
queryTokens,
)
) {
score += 32;
}
score += Math.min(8, page.relationships.length * 2);
return score;
}
case "source-evidence": {
let score = page.kind === "source" ? 22 : 0;
if (
hasAnyQueryMatch(
[
page.sourcePath,
...page.sourceIds,
...page.claims.flatMap((claim) =>
claim.evidence.flatMap((evidence) => [
evidence.kind,
evidence.sourceId,
evidence.path,
evidence.lines,
evidence.note,
]),
),
],
queryLower,
queryTokens,
)
) {
score += 30;
}
return score;
}
case "raw-claim":
return params.matchingClaims.length > 0 ? 42 : 0;
}
}
function scoreDigestSearchModeBoost(params: {
page: QueryDigestPage;
claims: readonly QueryDigestClaim[];
matchingClaims: readonly QueryDigestClaim[];
queryLower: string;
queryTokens: readonly string[];
mode: WikiSearchMode;
}): number {
const { page, queryLower, queryTokens } = params;
switch (params.mode) {
case "auto":
return 0;
case "find-person": {
let score = isPersonLikeSummary(page) ? 24 : -4;
if (
hasAnyQueryMatch(
[
page.canonicalId,
...(page.aliases ?? []),
page.personCard?.canonicalId,
...(page.personCard?.handles ?? []),
...(page.personCard?.emails ?? []),
...(page.personCard?.socials ?? []),
],
queryLower,
queryTokens,
)
) {
score += 24;
}
return score;
}
case "route-question": {
let score = isPersonLikeSummary(page) ? 14 : 0;
if (
hasAnyQueryMatch(
[
page.personCard?.lane,
...(page.personCard?.askFor ?? []),
...(page.personCard?.avoidAskingFor ?? []),
...(page.bestUsedFor ?? []),
...(page.notEnoughFor ?? []),
...(page.personCard?.bestUsedFor ?? []),
...(page.personCard?.notEnoughFor ?? []),
],
queryLower,
queryTokens,
)
) {
score += 32;
}
score += Math.min(8, (page.relationshipCount ?? 0) * 2);
return score;
}
case "source-evidence": {
let score = page.kind === "source" ? 22 : 0;
if (
hasAnyQueryMatch(
[
...page.sourceIds,
...params.claims.flatMap((claim) => [
...(claim.sourceIds ?? []),
...(claim.evidenceKinds ?? []),
...(claim.privacyTiers ?? []),
]),
],
queryLower,
queryTokens,
)
) {
score += 30;
}
return score;
}
case "raw-claim":
return params.matchingClaims.length > 0 ? 42 : 0;
}
}
function buildDigestCandidatePaths(params: {
digest: QueryDigestBundle;
query: string;
maxResults: number;
mode: WikiSearchMode;
}): string[] {
const queryLower = normalizeLowercaseStringOrEmpty(params.query);
const queryTokens = buildQueryTokens(queryLower);
const claimsByPage = new Map<string, QueryDigestClaim[]>();
for (const claim of params.digest.claims) {
const current = claimsByPage.get(claim.pagePath) ?? [];
@@ -380,7 +684,7 @@ function buildDigestCandidatePaths(params: {
queryLower,
});
const matchingClaims = claims
.filter((claim) => isClaimTextOrIdMatch(claim, queryLower))
.filter((claim) => isClaimTextOrIdMatch(claim, queryLower, queryTokens))
.toSorted(
(left, right) =>
scoreDigestClaimMatch(right, queryLower) - scoreDigestClaimMatch(left, queryLower),
@@ -389,6 +693,14 @@ function buildDigestCandidatePaths(params: {
score += scoreDigestClaimMatch(matchingClaims[0], queryLower);
score += Math.min(10, (matchingClaims.length - 1) * 2);
}
score += scoreDigestSearchModeBoost({
page,
claims,
matchingClaims,
queryLower,
queryTokens,
mode: params.mode,
});
return { path: page.path, score };
})
.filter((candidate) => candidate.score > 0)
@@ -402,11 +714,20 @@ function buildDigestCandidatePaths(params: {
.map((candidate) => candidate.path);
}
function isClaimMatch(claim: WikiClaim, queryLower: string): boolean {
return isClaimTextOrIdMatch(claim, queryLower);
function isClaimMatch(
claim: WikiClaim,
queryLower: string,
queryTokens: readonly string[],
): boolean {
return isClaimTextOrIdMatch(claim, queryLower, queryTokens);
}
function rankClaimMatch(page: QueryableWikiPage, claim: WikiClaim, queryLower: string): number {
function rankClaimMatch(
page: QueryableWikiPage,
claim: WikiClaim,
queryLower: string,
queryTokens: readonly string[],
): number {
const freshness = assessClaimFreshness({ page, claim });
return scoreClaimMatch({
text: claim.text,
@@ -415,15 +736,18 @@ function rankClaimMatch(page: QueryableWikiPage, claim: WikiClaim, queryLower: s
status: claim.status,
freshnessLevel: freshness.level,
queryLower,
queryTokens,
});
}
function getMatchingClaims(page: QueryableWikiPage, queryLower: string): WikiClaim[] {
const queryTokens = buildQueryTokens(queryLower);
return page.claims
.filter((claim) => isClaimMatch(claim, queryLower))
.filter((claim) => isClaimMatch(claim, queryLower, queryTokens))
.toSorted(
(left, right) =>
rankClaimMatch(page, right, queryLower) - rankClaimMatch(page, left, queryLower),
rankClaimMatch(page, right, queryLower, queryTokens) -
rankClaimMatch(page, left, queryLower, queryTokens),
);
}
@@ -436,7 +760,7 @@ function buildPageSnippet(page: QueryableWikiPage, query: string): string {
return buildSnippet(page.raw, query);
}
function scorePage(page: QueryableWikiPage, query: string): number {
function scorePage(page: QueryableWikiPage, query: string, mode: WikiSearchMode): number {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const queryTokens = buildQueryTokens(queryLower);
const titleLower = normalizeLowercaseStringOrEmpty(page.title);
@@ -468,9 +792,16 @@ function scorePage(page: QueryableWikiPage, query: string): number {
});
const matchingClaims = getMatchingClaims(page, queryLower);
if (matchingClaims.length > 0) {
score += rankClaimMatch(page, matchingClaims[0], queryLower);
score += rankClaimMatch(page, matchingClaims[0], queryLower, queryTokens);
score += Math.min(10, (matchingClaims.length - 1) * 2);
}
score += scorePageSearchModeBoost({
page,
matchingClaims,
queryLower,
queryTokens,
mode,
});
const bodyOccurrences = rawLower.split(queryLower).length - 1;
score += Math.min(10, bodyOccurrences);
for (const token of queryTokens) {
@@ -585,6 +916,10 @@ function buildWikiProvenanceLabel(
| "bridgeRelativePath"
| "unsafeLocalRelativePath"
| "relativePath"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>,
): string | undefined {
if (page.sourceType === "memory-bridge-events") {
@@ -610,11 +945,24 @@ function buildWikiResultMetadata(
| "bridgeRelativePath"
| "unsafeLocalRelativePath"
| "relativePath"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>,
): Partial<
Pick<
WikiSearchResult,
"id" | "sourceType" | "provenanceMode" | "sourcePath" | "provenanceLabel" | "updatedAt"
| "id"
| "sourceType"
| "provenanceMode"
| "sourcePath"
| "provenanceLabel"
| "updatedAt"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>
> {
const provenanceLabel = buildWikiProvenanceLabel(page);
@@ -625,22 +973,50 @@ function buildWikiResultMetadata(
...(page.sourcePath ? { sourcePath: page.sourcePath } : {}),
...(provenanceLabel ? { provenanceLabel } : {}),
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
...("entityType" in page && page.entityType ? { entityType: page.entityType } : {}),
...("canonicalId" in page && page.canonicalId ? { canonicalId: page.canonicalId } : {}),
...("aliases" in page && page.aliases.length > 0 ? { aliases: [...page.aliases] } : {}),
...("privacyTier" in page && page.privacyTier ? { privacyTier: page.privacyTier } : {}),
};
}
function toWikiSearchResult(page: QueryableWikiPage, query: string): WikiSearchResult {
function buildClaimResultMetadata(claim: WikiClaim | undefined): Partial<WikiSearchResult> {
if (!claim) {
return {};
}
return {
...(claim.id ? { matchedClaimId: claim.id } : {}),
...(claim.status ? { matchedClaimStatus: claim.status } : {}),
...(typeof claim.confidence === "number" ? { matchedClaimConfidence: claim.confidence } : {}),
evidenceKinds: [...new Set(claim.evidence.flatMap((evidence) => evidence.kind ?? []))],
evidenceSourceIds: [...new Set(claim.evidence.flatMap((evidence) => evidence.sourceId ?? []))],
};
}
function toWikiSearchResult(
page: QueryableWikiPage,
query: string,
mode: WikiSearchMode,
): WikiSearchResult {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const matchingClaim = getMatchingClaims(page, queryLower)[0];
return {
corpus: "wiki",
path: page.relativePath,
title: page.title,
kind: page.kind,
score: scorePage(page, query),
score: scorePage(page, query, mode),
snippet: buildPageSnippet(page, query),
searchMode: mode,
...buildWikiResultMetadata(page),
...buildClaimResultMetadata(matchingClaim),
};
}
function toMemoryWikiSearchResult(result: MemorySearchResult): WikiSearchResult {
function toMemoryWikiSearchResult(
result: MemorySearchResult,
mode: WikiSearchMode,
): WikiSearchResult {
return {
corpus: "memory",
path: result.path,
@@ -651,6 +1027,7 @@ function toMemoryWikiSearchResult(result: MemorySearchResult): WikiSearchResult
startLine: result.startLine,
endLine: result.endLine,
memorySource: result.source,
searchMode: mode,
...(result.citation ? { citation: result.citation } : {}),
};
}
@@ -659,6 +1036,7 @@ async function searchWikiCorpus(params: {
rootDir: string;
query: string;
maxResults: number;
mode: WikiSearchMode;
}): Promise<WikiSearchResult[]> {
const digest = await readQueryDigestBundle(params.rootDir);
const candidatePaths = digest
@@ -666,6 +1044,7 @@ async function searchWikiCorpus(params: {
digest,
query: params.query,
maxResults: params.maxResults,
mode: params.mode,
})
: [];
const seenPaths = new Set<string>();
@@ -678,7 +1057,7 @@ async function searchWikiCorpus(params: {
}
const results = candidatePages
.map((page) => toWikiSearchResult(page, params.query))
.map((page) => toWikiSearchResult(page, params.query, params.mode))
.filter((page) => page.score > 0);
if (candidatePaths.length === 0 || results.length >= params.maxResults) {
return results;
@@ -691,7 +1070,7 @@ async function searchWikiCorpus(params: {
return [
...results,
...remainingPages
.map((page) => toWikiSearchResult(page, params.query))
.map((page) => toWikiSearchResult(page, params.query, params.mode))
.filter((page) => page.score > 0),
];
}
@@ -728,16 +1107,19 @@ export async function searchMemoryWiki(params: {
maxResults?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
mode?: WikiSearchMode;
}): Promise<WikiSearchResult[]> {
const effectiveConfig = applySearchOverrides(params.config, params);
await initializeMemoryWikiVault(effectiveConfig);
const maxResults = Math.max(1, params.maxResults ?? 10);
const mode = params.mode ?? "auto";
const wikiResults = shouldSearchWiki(effectiveConfig)
? await searchWikiCorpus({
rootDir: effectiveConfig.vault.path,
query: params.query,
maxResults,
mode,
})
: [];
@@ -750,7 +1132,7 @@ export async function searchMemoryWiki(params: {
: null;
const memoryResults = sharedMemoryManager
? (await sharedMemoryManager.search(params.query, { maxResults })).map((result) =>
toMemoryWikiSearchResult(result),
toMemoryWikiSearchResult(result, mode),
)
: [];

View File

@@ -7,7 +7,7 @@ import {
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { lintMemoryWikiVault } from "./lint.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { getMemoryWikiPage, searchMemoryWiki, WIKI_SEARCH_MODES } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
@@ -17,12 +17,14 @@ 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 WikiSearchModeSchema = Type.Union(WIKI_SEARCH_MODES.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),
mode: Type.Optional(WikiSearchModeSchema),
},
{ additionalProperties: false },
);
@@ -126,6 +128,7 @@ export function createWikiSearchTool(
maxResults?: number;
backend?: ResolvedMemoryWikiConfig["search"]["backend"];
corpus?: ResolvedMemoryWikiConfig["search"]["corpus"];
mode?: (typeof WIKI_SEARCH_MODES)[number];
};
await syncImportedSourcesIfNeeded(config, appConfig);
const results = await searchMemoryWiki({
@@ -137,6 +140,7 @@ export function createWikiSearchTool(
maxResults: params.maxResults,
...(params.backend ? { searchBackend: params.backend } : {}),
...(params.corpus ? { searchCorpus: params.corpus } : {}),
...(params.mode ? { mode: params.mode } : {}),
});
const text =
results.length === 0
@@ -144,7 +148,7 @@ export function createWikiSearchTool(
: 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}`,
`${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}` : ""}${result.matchedClaimId ? `\nClaim: ${result.matchedClaimId}` : ""}${result.evidenceKinds && result.evidenceKinds.length > 0 ? `\nEvidence: ${result.evidenceKinds.join(", ")}` : ""}\nSnippet: ${result.snippet}`,
)
.join("\n\n");
return {