diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 4fb54b04850..20b9302e4c1 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -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); diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 59accbd4580..e9bf3a1f8b5 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -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), }; } diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 2afc4b39777..915ebc118eb 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -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); diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 5e2aac4bb04..a2abe9e3684 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -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 } : {}), }; } } diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index 43c40eb7bca..d01e5135675 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -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."); }); diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 700ee554653..18c7d2a052d 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -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; + sourceCounts: { + native: number; + bridge: number; + bridgeEvents: number; + unsafeLocal: number; + other: number; + }; warnings: MemoryWikiStatusWarning[]; }; @@ -61,14 +68,24 @@ async function pathExists(inputPath: string): Promise { } } -async function collectPageCounts(vaultPath: string): Promise> { - const counts: Record = { +async function collectVaultCounts(vaultPath: string): Promise<{ + pageCounts: Record; + sourceCounts: MemoryWikiStatus["sourceCounts"]; +}> { + const pageCounts: Record = { 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 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) { diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 8d3628eb860..bad94ac7f71 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -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 {