From 9541f4e8bd3fd60d4fbafdf8f9f049b8f9ca504f Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:35:13 -0400 Subject: [PATCH] fix(memory-wiki): disambiguate the reserved index page stem for synthesis and ingest (#94326) * fix(memory-wiki): disambiguate the reserved index page stem for synthesis and ingest A create_synthesis or source ingest whose title slugifies to index was written to the auto-generated /index.md. That file is the only compiler-owned page filename: compile writes it per page group, and compile/query/status exclude it from page scanning. So the stored fact was both unretrievable and clobbered by the generated directory-index on the next compile. The collision only happens at the direct page-stem boundary: synthesis (syntheses/.md) and ingest (sources/.md) build a bare /.md path. The bridge, unsafe-local, and OKF callers compose slugifyWikiSegment output into prefixed and hashed identities that can never equal a bare index.md, and OKF already guards its own write path via OKF_RESERVED_FILENAMES. Add a dedicated slugifyWikiPageStem helper that escapes the reserved index stem and route only the two direct page writers through it, leaving slugifyWikiSegment output stable for the composed callers so their persisted page ids and paths do not churn on upgrade. Only index is reserved: the compiler never owns /log.md in these directories (the wiki log lives at .openclaw-wiki/log.jsonl), so a Log title keeps its existing direct path. Additive and deterministic, no migration. * fix(memory-wiki): preserve reserved-page ids --------- Co-authored-by: Vincent Koc --- extensions/memory-wiki/src/apply.ts | 4 +- extensions/memory-wiki/src/ingest.ts | 4 +- extensions/memory-wiki/src/markdown.ts | 10 +++ .../reserved-index-collision.repro.test.ts | 72 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 extensions/memory-wiki/src/reserved-index-collision.repro.test.ts 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"); + }); +});