diff --git a/extensions/memory-wiki/src/apply.ts b/extensions/memory-wiki/src/apply.ts index 64d914b53c4b..e677aa8aa851 100644 --- a/extensions/memory-wiki/src/apply.ts +++ b/extensions/memory-wiki/src/apply.ts @@ -12,6 +12,7 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { parseWikiMarkdown, renderWikiMarkdown, + slugifyWikiPageStem, slugifyWikiSegment, normalizeSourceIds, normalizeWikiClaims, @@ -224,7 +225,8 @@ async function applyCreateSynthesisMutation(params: { mutation: CreateSynthesisMemoryWikiMutation; }): Promise<{ changed: boolean; pagePath: string; pageId: string }> { const slug = slugifyWikiSegment(params.mutation.title); - const pagePath = path.join("syntheses", `${slug}.md`).replace(/\\/g, "/"); + const pageStem = slugifyWikiPageStem(params.mutation.title); + const pagePath = path.join("syntheses", `${pageStem}.md`).replace(/\\/g, "/"); const root = await fsRoot(params.config.vault.path); const existing = await root.readText(pagePath).catch(() => ""); const parsed = parseWikiMarkdown(existing); diff --git a/extensions/memory-wiki/src/ingest.ts b/extensions/memory-wiki/src/ingest.ts index 4f9aea5a788e..030782940b53 100644 --- a/extensions/memory-wiki/src/ingest.ts +++ b/extensions/memory-wiki/src/ingest.ts @@ -9,6 +9,7 @@ import { preserveHumanNotesBlock, renderMarkdownFence, renderWikiMarkdown, + slugifyWikiPageStem, slugifyWikiSegment, } from "./markdown.js"; import { resolveMemoryWikiTimestamp } from "./time.js"; @@ -59,8 +60,9 @@ export async function ingestMemoryWikiSource(params: { const content = assertUtf8Text(buffer, sourcePath); const title = resolveSourceTitle(sourcePath, params.title); const slug = slugifyWikiSegment(title); + const pageStem = slugifyWikiPageStem(title); const pageId = `source.${slug}`; - const pageRelativePath = path.join("sources", `${slug}.md`); + const pageRelativePath = path.join("sources", `${pageStem}.md`); const pagePath = path.join(params.config.vault.path, pageRelativePath); const created = !(await pathExists(pagePath)); const timestamp = resolveMemoryWikiTimestamp(params.nowMs); diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 2a0d12692e46..98fcf79630d8 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -134,6 +134,7 @@ const MAX_WIKI_SAFE_WRITE_FILENAME_COMPONENT_BYTES = Buffer.byteLength(FS_SAFE_PINNED_WRITE_TEMP_SUFFIX) - Buffer.byteLength("."); const WIKI_SEGMENT_HASH_BYTES = 12; +const WIKI_RESERVED_PAGE_STEMS = new Set(["index"]); const HUMAN_START_MARKER = ""; const HUMAN_END_MARKER = ""; @@ -174,6 +175,15 @@ export function slugifyWikiSegment(raw: string): string { return capWikiValueWithHash(slug, MAX_WIKI_SEGMENT_BYTES, "page"); } +export function slugifyWikiPageStem(raw: string): string { + const slug = slugifyWikiSegment(raw); + if (!WIKI_RESERVED_PAGE_STEMS.has(slug)) { + return slug; + } + const suffix = createHash("sha1").update(slug).digest("hex").slice(0, WIKI_SEGMENT_HASH_BYTES); + return `${slug}-${suffix}`; +} + export function createWikiPageFilename(stem: string, extension = ".md"): string { const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`; const maxStemBytes = Math.max( diff --git a/extensions/memory-wiki/src/reserved-index-collision.repro.test.ts b/extensions/memory-wiki/src/reserved-index-collision.repro.test.ts new file mode 100644 index 000000000000..ac3fd2e1adc4 --- /dev/null +++ b/extensions/memory-wiki/src/reserved-index-collision.repro.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyMemoryWikiMutation } from "./apply.js"; +import { ingestMemoryWikiSource } from "./ingest.js"; +import { slugifyWikiPageStem, slugifyWikiSegment } from "./markdown.js"; +import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; +import { createMemoryWikiTestHarness } from "./test-helpers.js"; + +const { createVault, createTempDir } = createMemoryWikiTestHarness(); + +describe("reserved index.md filename collision (repro)", () => { + it("create_synthesis titled Index stays retrievable", async () => { + const { rootDir, config } = await createVault({ prefix: "memory-wiki-reserved-" }); + + const applied = await applyMemoryWikiMutation({ + config, + mutation: { + op: "create_synthesis", + title: "Index", + body: "Durable synthesis body that must survive.", + sourceIds: ["source.alpha"], + }, + }); + + const found = await searchMemoryWiki({ + config, + query: "Durable synthesis body that must survive", + maxResults: 10, + }); + expect(found.some((hit) => hit.path === applied.pagePath)).toBe(true); + + const fetched = await getMemoryWikiPage({ config, lookup: applied.pagePath }); + expect(fetched?.content).toContain("Durable synthesis body that must survive."); + expect(applied.pageId).toBe("synthesis.index"); + expect(await getMemoryWikiPage({ config, lookup: "synthesis.index" })).not.toBeNull(); + + const onDisk = await fs.readFile(path.join(rootDir, applied.pagePath), "utf8"); + expect(onDisk).toContain("Durable synthesis body that must survive."); + }); + + it("ingest of a file titled Index stays retrievable", async () => { + const { config } = await createVault({ prefix: "memory-wiki-reserved-ingest-" }); + const inputDir = await createTempDir("memory-wiki-reserved-input-"); + const inputPath = path.join(inputDir, "notes.md"); + await fs.writeFile(inputPath, "Unique ingest content sentinel ZZZ.", "utf8"); + + const result = await ingestMemoryWikiSource({ + config, + inputPath, + title: "Index", + }); + + const found = await searchMemoryWiki({ + config, + query: "Unique ingest content sentinel ZZZ", + maxResults: 10, + }); + expect(found.some((hit) => hit.path === result.pagePath)).toBe(true); + expect(result.pageId).toBe("source.index"); + expect(await getMemoryWikiPage({ config, lookup: result.pageId })).not.toBeNull(); + }); + + it("disambiguates the compiler-owned index stem but leaves shared slug output stable", () => { + expect(slugifyWikiSegment("Index")).toBe("index"); + + expect(slugifyWikiPageStem("Index")).toMatch(/^index-[0-9a-f]{12}$/); + expect(slugifyWikiPageStem(" INDEX ")).toBe(slugifyWikiPageStem("Index")); + expect(slugifyWikiPageStem("Log")).toBe("log"); + expect(slugifyWikiPageStem("Overview")).toBe("overview"); + }); +});