From f4e746bdfc8ef0d9c0cfd65294632d1a6edc883f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 9 Jun 2026 02:04:14 +0900 Subject: [PATCH] fix(memory-wiki): render native links relative to generated pages --- extensions/memory-wiki/src/apply.test.ts | 2 +- extensions/memory-wiki/src/compile.test.ts | 127 ++++++++++++++++++--- extensions/memory-wiki/src/compile.ts | 118 +++++++++++++------ extensions/memory-wiki/src/lint.test.ts | 2 +- extensions/memory-wiki/src/markdown.ts | 21 +++- 5 files changed, 212 insertions(+), 58 deletions(-) diff --git a/extensions/memory-wiki/src/apply.test.ts b/extensions/memory-wiki/src/apply.test.ts index a3ded8a8984..35819aac372 100644 --- a/extensions/memory-wiki/src/apply.test.ts +++ b/extensions/memory-wiki/src/apply.test.ts @@ -225,6 +225,6 @@ keep this note expect(parsed.body).toContain(""); await expect( fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"), - ).resolves.toContain("[Alpha](entities/alpha.md)"); + ).resolves.toContain("[Alpha](alpha.md)"); }); }); diff --git a/extensions/memory-wiki/src/compile.test.ts b/extensions/memory-wiki/src/compile.test.ts index 6217ebd299e..6779eefb7ae 100644 --- a/extensions/memory-wiki/src/compile.test.ts +++ b/extensions/memory-wiki/src/compile.test.ts @@ -102,7 +102,7 @@ describe("compileMemoryWikiVault", () => { "- Claims: 1", ); await expect(fs.readFile(path.join(rootDir, "sources", "index.md"), "utf8")).resolves.toContain( - "[Alpha](sources/alpha.md)", + "[Alpha](alpha.md)", ); const agentDigest = JSON.parse( await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), @@ -121,6 +121,50 @@ describe("compileMemoryWikiVault", () => { ).resolves.toContain('"text":"Alpha is the canonical source page."'); }); + it("renders native directory index links relative to each generated index", async () => { + const { rootDir, config } = await createVault({ + rootDir: nextCaseRoot(), + initialize: true, + }); + + await fs.writeFile( + path.join(rootDir, "concepts", "alpha-concept.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "concept", id: "concept.alpha", title: "Alpha Concept" }, + body: "# Alpha Concept\n", + }), + "utf8", + ); + await fs.writeFile( + path.join(rootDir, "entities", "alpha-entity.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "entity", id: "entity.alpha", title: "Alpha Entity" }, + body: "# Alpha Entity\n", + }), + "utf8", + ); + await fs.writeFile( + path.join(rootDir, "syntheses", "alpha-synthesis.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "synthesis", id: "synthesis.alpha", title: "Alpha Synthesis" }, + body: "# Alpha Synthesis\n", + }), + "utf8", + ); + + await compileMemoryWikiVault(config); + + await expect( + fs.readFile(path.join(rootDir, "concepts", "index.md"), "utf8"), + ).resolves.toContain("[Alpha Concept](alpha-concept.md)"); + await expect( + fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"), + ).resolves.toContain("[Alpha Entity](alpha-entity.md)"); + await expect( + fs.readFile(path.join(rootDir, "syntheses", "index.md"), "utf8"), + ).resolves.toContain("[Alpha Synthesis](alpha-synthesis.md)"); + }); + it("bounds concurrent page reads while compiling", async () => { const { rootDir, config } = await createVault({ rootDir: nextCaseRoot(), @@ -248,16 +292,69 @@ describe("compileMemoryWikiVault", () => { "## Related", ); await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain( - "[Alpha](sources/alpha.md)", + "[Alpha](../sources/alpha.md)", ); await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain( - "[Gamma](concepts/gamma.md)", + "[Gamma](../concepts/gamma.md)", ); await expect(fs.readFile(path.join(rootDir, "sources", "alpha.md"), "utf8")).resolves.toContain( - "[Beta](entities/beta.md)", + "[Beta](../entities/beta.md)", ); await expect(fs.readFile(path.join(rootDir, "sources", "alpha.md"), "utf8")).resolves.toContain( - "[Gamma](concepts/gamma.md)", + "[Gamma](../concepts/gamma.md)", + ); + }); + + it("renders native synthesis related and source links relative to the synthesis page", async () => { + const { rootDir, config } = await createVault({ + rootDir: nextCaseRoot(), + initialize: true, + }); + + await fs.writeFile( + path.join(rootDir, "sources", "alpha.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha Source" }, + body: "# Alpha Source\n", + }), + "utf8", + ); + await fs.writeFile( + path.join(rootDir, "concepts", "alpha-concept.md"), + renderWikiMarkdown({ + frontmatter: { + pageType: "concept", + id: "concept.alpha", + title: "Alpha Concept", + sourceIds: ["source.alpha"], + }, + body: "# Alpha Concept\n", + }), + "utf8", + ); + await fs.writeFile( + path.join(rootDir, "syntheses", "alpha-synthesis.md"), + renderWikiMarkdown({ + frontmatter: { + pageType: "synthesis", + id: "synthesis.alpha", + title: "Alpha Synthesis", + sourceIds: ["source.alpha"], + }, + body: "# Alpha Synthesis\n", + }), + "utf8", + ); + + await compileMemoryWikiVault(config); + + const synthesis = await fs.readFile( + path.join(rootDir, "syntheses", "alpha-synthesis.md"), + "utf8", + ); + expect(synthesis).toContain("### Sources\n\n- [Alpha Source](../sources/alpha.md)"); + expect(synthesis).toContain( + "### Related Pages\n\n- [Alpha Concept](../concepts/alpha-concept.md)", ); }); @@ -314,7 +411,7 @@ describe("compileMemoryWikiVault", () => { const firstEntity = await fs.readFile(path.join(rootDir, "entities", "entity-0.md"), "utf8"); const sourcePage = await fs.readFile(path.join(rootDir, "sources", "alpha.md"), "utf8"); - expect(firstEntity).toContain("[Alpha](sources/alpha.md)"); + expect(firstEntity).toContain("[Alpha](../sources/alpha.md)"); expect(firstEntity).not.toContain("### Related Pages"); expect(sourcePage).not.toContain("### Referenced By"); }); @@ -398,16 +495,16 @@ describe("compileMemoryWikiVault", () => { expect(result.pageCounts.report).toBeGreaterThanOrEqual(5); await expect( fs.readFile(path.join(rootDir, "reports", "open-questions.md"), "utf8"), - ).resolves.toContain("[Alpha](entities/alpha.md): What changed after launch?"); + ).resolves.toContain("[Alpha](../entities/alpha.md): What changed after launch?"); await expect( fs.readFile(path.join(rootDir, "reports", "contradictions.md"), "utf8"), - ).resolves.toContain("Conflicts with source.beta: [Alpha](entities/alpha.md)"); + ).resolves.toContain("Conflicts with source.beta: [Alpha](../entities/alpha.md)"); await expect( fs.readFile(path.join(rootDir, "reports", "contradictions.md"), "utf8"), ).resolves.toContain("`claim.alpha.db`"); await expect( fs.readFile(path.join(rootDir, "reports", "low-confidence.md"), "utf8"), - ).resolves.toContain("[Alpha](entities/alpha.md): confidence 0.30"); + ).resolves.toContain("[Alpha](../entities/alpha.md): confidence 0.30"); await expect( fs.readFile(path.join(rootDir, "reports", "low-confidence.md"), "utf8"), ).resolves.toContain("Alpha uses PostgreSQL for production writes."); @@ -419,7 +516,7 @@ describe("compileMemoryWikiVault", () => { ).resolves.toContain("Alpha uses PostgreSQL for production writes."); await expect( fs.readFile(path.join(rootDir, "reports", "stale-pages.md"), "utf8"), - ).resolves.toContain("[Alpha](entities/alpha.md): missing updatedAt"); + ).resolves.toContain("[Alpha](../entities/alpha.md): missing updatedAt"); const agentDigest = JSON.parse( await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), ) as { @@ -521,16 +618,16 @@ describe("compileMemoryWikiVault", () => { await expect( fs.readFile(path.join(rootDir, "reports", "person-agent-directory.md"), "utf8"), - ).resolves.toContain("Microsoft Teams"); + ).resolves.toContain("[Brad Groux](../entities/brad.md)"); await expect( fs.readFile(path.join(rootDir, "reports", "relationship-graph.md"), "utf8"), - ).resolves.toContain("collaborates-with"); + ).resolves.toContain("[Brad Groux](../entities/brad.md) -> Alice"); 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"); + ).resolves.toContain("[Brad Groux](../entities/brad.md)"); const agentDigest = JSON.parse( await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"), @@ -571,7 +668,7 @@ describe("compileMemoryWikiVault", () => { path.join(rootDir, "concepts", "gamma.md"), renderWikiMarkdown({ frontmatter: { pageType: "concept", id: "concept.gamma", title: "Gamma" }, - body: "# Gamma\n\nSee [Beta](entities/beta.md).\n", + body: "# Gamma\n\nSee [Beta](../entities/beta.md).\n", }), "utf8", ); @@ -581,7 +678,7 @@ describe("compileMemoryWikiVault", () => { expect(second.updatedFiles).toStrictEqual([]); await expect(fs.readFile(path.join(rootDir, "entities", "beta.md"), "utf8")).resolves.toContain( - "[Gamma](concepts/gamma.md)", + "[Gamma](../concepts/gamma.md)", ); await expect( fs.readFile(path.join(rootDir, "concepts", "gamma.md"), "utf8"), diff --git a/extensions/memory-wiki/src/compile.ts b/extensions/memory-wiki/src/compile.ts index be79ab4b4d1..c5a19264b09 100644 --- a/extensions/memory-wiki/src/compile.ts +++ b/extensions/memory-wiki/src/compile.ts @@ -65,6 +65,7 @@ type DashboardPageDefinition = { config: ResolvedMemoryWikiConfig; pages: WikiPageSummary[]; now: Date; + sourceRelativeTo: string; }) => string; }; @@ -73,7 +74,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.open-questions", title: "Open Questions", relativePath: "reports/open-questions.md", - buildBody: ({ config, pages }) => { + buildBody: ({ config, pages, sourceRelativeTo }) => { const matches = pages.filter((page) => page.questions.length > 0); if (matches.length === 0) { return "- No open questions right now."; @@ -86,6 +87,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ `- ${formatWikiLink({ renderMode: config.vault.renderMode, relativePath: page.relativePath, + sourceRelativeTo, title: page.title, })}: ${page.questions.join(" | ")}`, ), @@ -96,7 +98,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.contradictions", title: "Contradictions", relativePath: "reports/contradictions.md", - buildBody: ({ config, pages, now }) => { + buildBody: ({ config, pages, now, sourceRelativeTo }) => { const pageClusters = buildPageContradictionClusters(pages); const claimClusters = buildClaimContradictionClusters({ pages, now }); if (pageClusters.length === 0 && claimClusters.length === 0) { @@ -109,13 +111,13 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ if (pageClusters.length > 0) { lines.push("", "### Page Notes"); for (const cluster of pageClusters) { - lines.push(formatPageContradictionClusterLine(config, cluster)); + lines.push(formatPageContradictionClusterLine(config, cluster, sourceRelativeTo)); } } if (claimClusters.length > 0) { lines.push("", "### Claim Clusters"); for (const cluster of claimClusters) { - lines.push(formatClaimContradictionClusterLine(config, cluster)); + lines.push(formatClaimContradictionClusterLine(config, cluster, sourceRelativeTo)); } } return lines.join("\n"); @@ -125,7 +127,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.low-confidence", title: "Low Confidence", relativePath: "reports/low-confidence.md", - buildBody: ({ config, pages, now }) => { + buildBody: ({ config, pages, now, sourceRelativeTo }) => { const pageMatches = pages .filter((page) => typeof page.confidence === "number" && page.confidence < 0.5) .toSorted((left, right) => (left.confidence ?? 1) - (right.confidence ?? 1)); @@ -143,14 +145,14 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ lines.push("", "### Pages"); for (const page of pageMatches) { lines.push( - `- ${formatPageLink(config, page)}: confidence ${(page.confidence ?? 0).toFixed(2)}`, + `- ${formatPageLink(config, page, sourceRelativeTo)}: confidence ${(page.confidence ?? 0).toFixed(2)}`, ); } } if (claimMatches.length > 0) { lines.push("", "### Claims"); for (const claim of claimMatches) { - lines.push(`- ${formatClaimHealthLine(config, claim)}`); + lines.push(`- ${formatClaimHealthLine(config, claim, sourceRelativeTo)}`); } } return lines.join("\n"); @@ -160,7 +162,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.claim-health", title: "Claim Health", relativePath: "reports/claim-health.md", - buildBody: ({ config, pages, now }) => { + buildBody: ({ config, pages, now, sourceRelativeTo }) => { const claimHealth = collectWikiClaimHealth(pages, now); const missingEvidence = claimHealth.filter((claim) => claim.missingEvidence); const contestedClaims = claimHealth.filter((claim) => isClaimHealthContested(claim)); @@ -182,19 +184,19 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ if (missingEvidence.length > 0) { lines.push("", "### Missing Evidence"); for (const claim of missingEvidence) { - lines.push(`- ${formatClaimHealthLine(config, claim)}`); + lines.push(`- ${formatClaimHealthLine(config, claim, sourceRelativeTo)}`); } } if (contestedClaims.length > 0) { lines.push("", "### Contested Claims"); for (const claim of contestedClaims) { - lines.push(`- ${formatClaimHealthLine(config, claim)}`); + lines.push(`- ${formatClaimHealthLine(config, claim, sourceRelativeTo)}`); } } if (staleClaims.length > 0) { lines.push("", "### Stale Claims"); for (const claim of staleClaims) { - lines.push(`- ${formatClaimHealthLine(config, claim)}`); + lines.push(`- ${formatClaimHealthLine(config, claim, sourceRelativeTo)}`); } } return lines.join("\n"); @@ -204,7 +206,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.stale-pages", title: "Stale Pages", relativePath: "reports/stale-pages.md", - buildBody: ({ config, pages, now }) => { + buildBody: ({ config, pages, now, sourceRelativeTo }) => { const matches = pages .filter((page) => page.kind !== "report") .flatMap((page) => { @@ -223,7 +225,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ "", ...matches.map( ({ page, freshness }) => - `- ${formatPageLink(config, page)}: ${formatFreshnessLabel(freshness)}`, + `- ${formatPageLink(config, page, sourceRelativeTo)}: ${formatFreshnessLabel(freshness)}`, ), ].join("\n"); }, @@ -232,7 +234,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.person-agent-directory", title: "Person Agent Directory", relativePath: "reports/person-agent-directory.md", - buildBody: ({ config, pages, now }) => { + buildBody: ({ config, pages, now, sourceRelativeTo }) => { const matches = pages .filter((page) => page.kind !== "report" && isPersonLikePage(page)) .toSorted((left, right) => left.title.localeCompare(right.title)); @@ -242,7 +244,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ const lines = [`- People with routing metadata: ${matches.length}`]; for (const page of matches) { const freshness = assessPageFreshness(page, now); - lines.push(`- ${formatPersonDirectoryLine(config, page, freshness)}`); + lines.push(`- ${formatPersonDirectoryLine(config, page, freshness, sourceRelativeTo)}`); } return lines.join("\n"); }, @@ -251,7 +253,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.relationship-graph", title: "Relationship Graph", relativePath: "reports/relationship-graph.md", - buildBody: ({ config, pages }) => { + buildBody: ({ config, pages, sourceRelativeTo }) => { const relationships = pages .flatMap((page) => page.relationships.map((relationship) => ({ page, relationship }))) .toSorted((left, right) => { @@ -268,7 +270,8 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ `- Structured relationships: ${relationships.length}`, "", ...relationships.map( - ({ page, relationship }) => `- ${formatRelationshipLine(config, page, relationship)}`, + ({ page, relationship }) => + `- ${formatRelationshipLine(config, page, relationship, sourceRelativeTo)}`, ), ].join("\n"); }, @@ -277,7 +280,7 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.provenance-coverage", title: "Provenance Coverage", relativePath: "reports/provenance-coverage.md", - buildBody: ({ config, pages }) => { + buildBody: ({ config, pages, sourceRelativeTo }) => { const evidenceEntries = pages.flatMap((page) => page.claims.flatMap((claim) => claim.evidence.map((evidence) => ({ page, claim, evidence })), @@ -310,7 +313,9 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ if (missingEvidence.length > 0) { lines.push("", "### Missing Evidence"); for (const { page, claim } of missingEvidence) { - lines.push(`- ${formatPageLink(config, page)}: ${formatClaimIdentityForPage(claim)}`); + lines.push( + `- ${formatPageLink(config, page, sourceRelativeTo)}: ${formatClaimIdentityForPage(claim)}`, + ); } } return lines.join("\n"); @@ -320,8 +325,8 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [ id: "report.privacy-review", title: "Privacy Review", relativePath: "reports/privacy-review.md", - buildBody: ({ config, pages }) => { - const entries = collectPrivacyReviewEntries(config, pages); + buildBody: ({ config, pages, sourceRelativeTo }) => { + const entries = collectPrivacyReviewEntries(config, pages, sourceRelativeTo); if (entries.length === 0) { return "- No non-public privacy tiers flagged right now."; } @@ -390,10 +395,15 @@ function buildPageCounts(pages: WikiPageSummary[]): Record }; } -function formatPageLink(config: ResolvedMemoryWikiConfig, page: WikiPageSummary): string { +function formatPageLink( + config: ResolvedMemoryWikiConfig, + page: WikiPageSummary, + sourceRelativeTo?: string, +): string { return formatWikiLink({ renderMode: config.vault.renderMode, relativePath: page.relativePath, + sourceRelativeTo, title: page.title, }); } @@ -440,6 +450,7 @@ function formatPersonDirectoryLine( config: ResolvedMemoryWikiConfig, page: WikiPageSummary, freshness: WikiFreshness, + sourceRelativeTo?: string, ): string { const card = page.personCard; const details = [ @@ -456,17 +467,21 @@ function formatPersonDirectoryLine( formatMaybeDetail("refreshed", page.lastRefreshedAt ?? card?.lastRefreshedAt), formatMaybeDetail("freshness", formatFreshnessLabel(freshness)), ].filter(Boolean); - return `${formatPageLink(config, page)}${details.length > 0 ? `: ${details.join("; ")}` : ""}`; + return `${formatPageLink(config, page, sourceRelativeTo)}${ + details.length > 0 ? `: ${details.join("; ")}` : "" + }`; } function formatRelationshipTarget( config: ResolvedMemoryWikiConfig, relationship: WikiRelationship, + sourceRelativeTo?: string, ) { if (relationship.targetPath && relationship.targetTitle) { return formatWikiLink({ renderMode: config.vault.renderMode, relativePath: relationship.targetPath, + sourceRelativeTo, title: relationship.targetTitle, }); } @@ -477,6 +492,7 @@ function formatRelationshipLine( config: ResolvedMemoryWikiConfig, page: WikiPageSummary, relationship: WikiRelationship, + sourceRelativeTo?: string, ): string { const details = [ relationship.kind ?? "related", @@ -488,9 +504,11 @@ function formatRelationshipLine( relationship.privacyTier ? `privacy ${relationship.privacyTier}` : null, relationship.note, ].filter(Boolean); - return `${formatPageLink(config, page)} -> ${formatRelationshipTarget(config, relationship)}${ - details.length > 0 ? ` (${details.join(", ")})` : "" - }`; + return `${formatPageLink(config, page, sourceRelativeTo)} -> ${formatRelationshipTarget( + config, + relationship, + sourceRelativeTo, + )}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; } function countBy(values: readonly string[]): Map { @@ -536,23 +554,26 @@ function formatEvidencePrivacyDetails(evidence: WikiClaimEvidence): string { function collectPrivacyReviewEntries( config: ResolvedMemoryWikiConfig, pages: WikiPageSummary[], + sourceRelativeTo?: string, ): string[] { const entries: string[] = []; for (const page of pages) { if (isReviewablePrivacyTier(page.privacyTier)) { - entries.push(`- ${formatPageLink(config, page)}: page privacy ${page.privacyTier}`); + entries.push( + `- ${formatPageLink(config, page, sourceRelativeTo)}: page privacy ${page.privacyTier}`, + ); } if (isReviewablePrivacyTier(page.personCard?.privacyTier)) { entries.push( - `- ${formatPageLink(config, page)}: person card privacy ${page.personCard?.privacyTier}`, + `- ${formatPageLink(config, page, sourceRelativeTo)}: person card privacy ${page.personCard?.privacyTier}`, ); } for (const relationship of page.relationships) { if (isReviewablePrivacyTier(relationship.privacyTier)) { entries.push( - `- ${formatPageLink(config, page)}: relationship privacy ${ + `- ${formatPageLink(config, page, sourceRelativeTo)}: relationship privacy ${ relationship.privacyTier - } -> ${formatRelationshipTarget(config, relationship)}`, + } -> ${formatRelationshipTarget(config, relationship, sourceRelativeTo)}`, ); } } @@ -563,7 +584,7 @@ function collectPrivacyReviewEntries( } const detail = formatEvidencePrivacyDetails(evidence); entries.push( - `- ${formatPageLink(config, page)}: evidence privacy ${evidence.privacyTier} on ${formatClaimIdentityForPage(claim)}${detail ? ` (${detail})` : ""}`, + `- ${formatPageLink(config, page, sourceRelativeTo)}: evidence privacy ${evidence.privacyTier} on ${formatClaimIdentityForPage(claim)}${detail ? ` (${detail})` : ""}`, ); } } @@ -579,7 +600,11 @@ function isClaimHealthContested(claim: WikiClaimHealth): boolean { return isClaimContestedStatus(claim.status); } -function formatClaimHealthLine(config: ResolvedMemoryWikiConfig, claim: WikiClaimHealth): string { +function formatClaimHealthLine( + config: ResolvedMemoryWikiConfig, + claim: WikiClaimHealth, + sourceRelativeTo?: string, +): string { const details = [ `status ${claim.status}`, typeof claim.confidence === "number" ? `confidence ${claim.confidence.toFixed(2)}` : null, @@ -589,6 +614,7 @@ function formatClaimHealthLine(config: ResolvedMemoryWikiConfig, claim: WikiClai return `${formatWikiLink({ renderMode: config.vault.renderMode, relativePath: claim.pagePath, + sourceRelativeTo, title: claim.pageTitle, })}: ${formatClaimIdentity(claim)} (${details.join(", ")})`; } @@ -596,11 +622,13 @@ function formatClaimHealthLine(config: ResolvedMemoryWikiConfig, claim: WikiClai function formatPageContradictionClusterLine( config: ResolvedMemoryWikiConfig, cluster: WikiPageContradictionCluster, + sourceRelativeTo?: string, ): string { const pageRefs = cluster.entries.map((entry) => formatWikiLink({ renderMode: config.vault.renderMode, relativePath: entry.pagePath, + sourceRelativeTo, title: entry.pageTitle, }), ); @@ -610,12 +638,14 @@ function formatPageContradictionClusterLine( function formatClaimContradictionClusterLine( config: ResolvedMemoryWikiConfig, cluster: WikiClaimContradictionCluster, + sourceRelativeTo?: string, ): string { const entries = cluster.entries.map( (entry) => `${formatWikiLink({ renderMode: config.vault.renderMode, relativePath: entry.pagePath, + sourceRelativeTo, title: entry.pageTitle, })} -> ${formatClaimIdentity(entry)} (${entry.status}, ${formatFreshnessLabel(entry.freshness)})`, ); @@ -661,6 +691,7 @@ function buildPageLookupKeys(page: WikiPageSummary): Set { function renderWikiPageLinks(params: { config: ResolvedMemoryWikiConfig; pages: WikiPageSummary[]; + sourceRelativeTo?: string; }): string { return params.pages .map( @@ -668,6 +699,7 @@ function renderWikiPageLinks(params: { `- ${formatWikiLink({ renderMode: params.config.vault.renderMode, relativePath: page.relativePath, + sourceRelativeTo: params.sourceRelativeTo, title: page.title, })}`, ) @@ -756,19 +788,31 @@ function buildRelatedBlockBody(params: { if (sourcePages.length > 0) { sections.push( "### Sources", - renderWikiPageLinks({ config: params.config, pages: sourcePages }), + renderWikiPageLinks({ + config: params.config, + pages: sourcePages, + sourceRelativeTo: params.page.relativePath, + }), ); } if (backlinkPages.length > 0) { sections.push( "### Referenced By", - renderWikiPageLinks({ config: params.config, pages: backlinkPages }), + renderWikiPageLinks({ + config: params.config, + pages: backlinkPages, + sourceRelativeTo: params.page.relativePath, + }), ); } if (relatedPages.length > 0) { sections.push( "### Related Pages", - renderWikiPageLinks({ config: params.config, pages: relatedPages }), + renderWikiPageLinks({ + config: params.config, + pages: relatedPages, + sourceRelativeTo: params.page.relativePath, + }), ); } if (sections.length === 0) { @@ -820,6 +864,7 @@ function renderSectionList(params: { config: ResolvedMemoryWikiConfig; pages: WikiPageSummary[]; emptyText: string; + sourceRelativeTo?: string; }): string { if (params.pages.length === 0) { return `- ${params.emptyText}`; @@ -830,6 +875,7 @@ function renderSectionList(params: { `- ${formatWikiLink({ renderMode: params.config.vault.renderMode, relativePath: page.relativePath, + sourceRelativeTo: params.sourceRelativeTo, title: page.title, })}`, ) @@ -892,6 +938,7 @@ async function writeDashboardPage(params: { config: params.config, pages: params.pages, now: params.now, + sourceRelativeTo: params.definition.relativePath, }), }); const preservedUpdatedAt = @@ -1003,6 +1050,7 @@ function buildDirectoryIndexBody(params: { config: params.config, pages: params.pages.filter((page) => page.kind === params.group.kind), emptyText: `No ${normalizeLowercaseStringOrEmpty(params.group.heading)} yet.`, + sourceRelativeTo: `${params.group.dir}/index.md`, }); } diff --git a/extensions/memory-wiki/src/lint.test.ts b/extensions/memory-wiki/src/lint.test.ts index e0368cc5345..cf76c967d20 100644 --- a/extensions/memory-wiki/src/lint.test.ts +++ b/extensions/memory-wiki/src/lint.test.ts @@ -41,7 +41,7 @@ describe("lintMemoryWikiVault", () => { title: "Alpha", sourceIds: ["source.alpha"], }, - body: "# Alpha\n\n[Alpha Source](sources/alpha.md)\n", + body: "# Alpha\n\n[Alpha Source](../sources/alpha.md)\n", }), "utf8", ); diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 60fd8178175..10629d83a70 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -372,7 +372,11 @@ function normalizeWikiRelationships(value: unknown): WikiRelationship[] { }); } -function extractWikiLinks(markdown: string): string[] { +function normalizeMarkdownLinkTarget(sourceRelativePath: string, target: string): string { + return path.posix.normalize(path.posix.join(path.posix.dirname(sourceRelativePath), target)); +} + +function extractWikiLinks(markdown: string, sourceRelativePath: string): string[] { const searchable = markdown.replace(RELATED_BLOCK_PATTERN, ""); const links: string[] = []; for (const match of searchable.matchAll(OBSIDIAN_LINK_PATTERN)) { @@ -388,7 +392,7 @@ function extractWikiLinks(markdown: string): string[] { } const target = rawTarget.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim(); if (target) { - links.push(target); + links.push(normalizeMarkdownLinkTarget(sourceRelativePath, target)); } } return links; @@ -397,12 +401,17 @@ function extractWikiLinks(markdown: string): string[] { export function formatWikiLink(params: { renderMode: "native" | "obsidian"; relativePath: string; + sourceRelativeTo?: string; title: string; }): string { const withoutExtension = params.relativePath.replace(/\.md$/i, ""); - return params.renderMode === "obsidian" - ? `[[${withoutExtension}|${params.title}]]` - : `[${params.title}](${params.relativePath})`; + if (params.renderMode === "obsidian") { + return `[[${withoutExtension}|${params.title}]]`; + } + const linkTarget = params.sourceRelativeTo + ? path.posix.relative(path.posix.dirname(params.sourceRelativeTo), params.relativePath) + : params.relativePath; + return `[${params.title}](${linkTarget})`; } export function renderMarkdownFence(content: string, infoString = "text"): string { @@ -460,7 +469,7 @@ export function toWikiPageSummary(params: { canonicalId: normalizeOptionalString(parsed.frontmatter.canonicalId), aliases: normalizeSingleOrTrimmedStringList(parsed.frontmatter.aliases), sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds), - linkTargets: extractWikiLinks(params.raw), + linkTargets: extractWikiLinks(params.raw, params.relativePath.split(path.sep).join("/")), claims: normalizeWikiClaims(parsed.frontmatter.claims), contradictions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.contradictions), questions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.questions),