feat(memory-wiki): surface imported source provenance

This commit is contained in:
Vincent Koc
2026-04-05 21:43:29 +01:00
parent c11e7a7420
commit cd564bf5a5
7 changed files with 319 additions and 23 deletions

View File

@@ -260,7 +260,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}` : ""}\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}` : ""}\nSnippet: ${result.snippet}`,
)
.join("\n\n");
writeOutput(summary, params.stdout);

View File

@@ -22,8 +22,20 @@ export type WikiPageSummary = {
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;
@@ -156,14 +168,8 @@ export function toWikiPageSummary(params: {
relativePath: params.relativePath.split(path.sep).join("/"),
kind,
title,
id:
typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()
? parsed.frontmatter.id.trim()
: undefined,
pageType:
typeof parsed.frontmatter.pageType === "string" && parsed.frontmatter.pageType.trim()
? parsed.frontmatter.pageType.trim()
: undefined,
id: normalizeOptionalString(parsed.frontmatter.id),
pageType: normalizeOptionalString(parsed.frontmatter.pageType),
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
linkTargets: extractWikiLinks(params.raw),
contradictions: normalizeStringList(parsed.frontmatter.contradictions),
@@ -173,5 +179,15 @@ export function toWikiPageSummary(params: {
Number.isFinite(parsed.frontmatter.confidence)
? parsed.frontmatter.confidence
: undefined,
sourceType: normalizeOptionalString(parsed.frontmatter.sourceType),
provenanceMode: normalizeOptionalString(parsed.frontmatter.provenanceMode),
sourcePath: normalizeOptionalString(parsed.frontmatter.sourcePath),
bridgeRelativePath: normalizeOptionalString(parsed.frontmatter.bridgeRelativePath),
bridgeWorkspaceDir: normalizeOptionalString(parsed.frontmatter.bridgeWorkspaceDir),
unsafeLocalConfiguredPath: normalizeOptionalString(
parsed.frontmatter.unsafeLocalConfiguredPath,
),
unsafeLocalRelativePath: normalizeOptionalString(parsed.frontmatter.unsafeLocalRelativePath),
updatedAt: normalizeOptionalString(parsed.frontmatter.updatedAt),
};
}

View File

@@ -88,6 +88,44 @@ describe("searchMemoryWiki", () => {
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);
@@ -212,6 +250,49 @@ describe("getMemoryWikiPage", () => {
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);

View File

@@ -22,6 +22,11 @@ export type WikiSearchResult = {
endLine?: number;
citation?: string;
memorySource?: MemorySearchResult["source"];
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
};
export type WikiGetResult = {
@@ -33,6 +38,11 @@ export type WikiGetResult = {
fromLine: number;
lineCount: number;
id?: string;
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
};
export type QueryableWikiPage = WikiPageSummary & {
@@ -164,6 +174,28 @@ function buildMemorySearchTitle(resultPath: string): string {
return basename.length > 0 ? basename : resultPath;
}
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",
@@ -173,6 +205,11 @@ function toWikiSearchResult(page: QueryableWikiPage, query: string): WikiSearchR
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 } : {}),
};
}
@@ -269,6 +306,13 @@ export async function getMemoryWikiPage(params: {
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 } : {}),
};
}
}

View File

@@ -1,5 +1,9 @@
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,
@@ -28,6 +32,13 @@ describe("resolveMemoryWikiStatus", () => {
"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 () => {
@@ -45,6 +56,83 @@ describe("resolveMemoryWikiStatus", () => {
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", () => {
@@ -79,11 +167,21 @@ describe("renderMemoryWikiStatus", () => {
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.");
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { inferWikiPageKind, type WikiPageKind } from "./markdown.js";
import { inferWikiPageKind, toWikiPageSummary, type WikiPageKind } from "./markdown.js";
import { probeObsidianCli } from "./obsidian.js";
export type MemoryWikiStatusWarning = {
@@ -32,6 +32,13 @@ export type MemoryWikiStatus = {
pathCount: number;
};
pageCounts: Record<WikiPageKind, number>;
sourceCounts: {
native: number;
bridge: number;
bridgeEvents: number;
unsafeLocal: number;
other: number;
};
warnings: MemoryWikiStatusWarning[];
};
@@ -61,14 +68,24 @@ async function pathExists(inputPath: string): Promise<boolean> {
}
}
async function collectPageCounts(vaultPath: string): Promise<Record<WikiPageKind, number>> {
const counts: Record<WikiPageKind, number> = {
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
@@ -80,11 +97,40 @@ async function collectPageCounts(vaultPath: string): Promise<Record<WikiPageKind
}
const kind = inferWikiPageKind(path.join(dir, entry.name));
if (kind) {
counts[kind] += 1;
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 counts;
return { pageCounts, sourceCounts };
}
function buildWarnings(params: {
@@ -153,14 +199,23 @@ export async function resolveMemoryWikiStatus(
const exists = deps?.pathExists ?? pathExists;
const vaultExists = await exists(config.vault.path);
const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand });
const pageCounts = vaultExists
? await collectPageCounts(config.vault.path)
const counts = vaultExists
? await collectVaultCounts(config.vault.path)
: {
entity: 0,
concept: 0,
source: 0,
synthesis: 0,
report: 0,
pageCounts: {
entity: 0,
concept: 0,
source: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
};
return {
@@ -179,7 +234,8 @@ export async function resolveMemoryWikiStatus(
allowPrivateMemoryCoreAccess: config.unsafeLocal.allowPrivateMemoryCoreAccess,
pathCount: config.unsafeLocal.paths.length,
},
pageCounts,
pageCounts: counts.pageCounts,
sourceCounts: counts.sourceCounts,
warnings: buildWarnings({ config, vaultExists, obsidianCommand: obsidianProbe.command }),
};
}
@@ -217,6 +273,7 @@ export function renderMemoryWikiStatus(status: MemoryWikiStatus): string {
`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) {

View File

@@ -92,7 +92,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}` : ""}\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}` : ""}\nSnippet: ${result.snippet}`,
)
.join("\n\n");
return {