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 <dir>/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/<slug>.md) and ingest (sources/<slug>.md) build a bare
<dir>/<slug>.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 <dir>/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 <vincentkoc@ieee.org>
This commit is contained in:
Yuval Dinodia
2026-07-01 11:35:13 -04:00
committed by GitHub
parent eb87566334
commit 9541f4e8bd
4 changed files with 88 additions and 2 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 = "<!-- openclaw:human:start -->";
const HUMAN_END_MARKER = "<!-- openclaw:human:end -->";
@@ -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(

View File

@@ -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");
});
});