diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index cd02211..54a62bf 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -103,7 +103,7 @@ export function buildWriteTools(kb: KnowledgeBase, filesChanged: Set, tr }), patch_concept: tool({ description: - "Targeted update of an existing concept: merge frontmatter keys (null deletes a key) and/or replace one top-level '# Section' body section. Prefer this over write_concept for small edits.", + "Targeted update of an existing concept: merge frontmatter keys (null deletes a key) and/or replace one heading body section (any level, H1-H6). Prefer this over write_concept for small edits.", inputSchema: z.object({ path: conceptPath, frontmatter: z @@ -115,7 +115,7 @@ export function buildWriteTools(kb: KnowledgeBase, filesChanged: Set, tr heading: z .string() .min(1) - .describe("Top-level heading name, e.g. 'Schema'. Must be non-empty — to replace the whole body use replace_body instead."), + .describe("Heading name (any level, # through ######), e.g. 'Schema'. Must be non-empty — to replace the whole body use replace_body instead."), content: z.string().describe("New content for that section"), }) .optional(), diff --git a/packages/core/src/okf/bundle.ts b/packages/core/src/okf/bundle.ts index b4f5e14..e937548 100644 --- a/packages/core/src/okf/bundle.ts +++ b/packages/core/src/okf/bundle.ts @@ -123,7 +123,7 @@ export class Bundle { /** * Targeted update: merge frontmatter keys (null deletes a key) and/or - * replace the content under one top-level "# Section" heading. + * replace the content under one heading (any level) "# Section". */ async patchConcept( bundlePath: string, @@ -246,21 +246,46 @@ export class Bundle { } } -/** Replace the content under a top-level heading; append the section if absent. */ +/** Replace the content under a heading of any level; append a top-level + * section if absent; throw on ambiguous (multiple) matches. */ export function replaceSection(body: string, heading: string, content: string): string { const normalized = heading.replace(/^#+\s*/, ""); const lines = body.split("\n"); - const isHeading = (line: string) => /^#\s+/.test(line); - const start = lines.findIndex( - (line) => isHeading(line) && line.replace(/^#\s+/, "").trim() === normalized - ); + const isHeading = (line: string) => /^#{1,6}\s+/.test(line); + const level = (line: string) => line.match(/^#+/)![0].length; + // Title without the leading hashes; an optional ATX closing sequence is + // stripped only when preceded by whitespace (CommonMark §4.2 — "Goals##" + // is the literal title "Goals##"). + const headingTitle = (line: string) => + line.replace(/^#{1,6}\s+/, "").trim().replace(/\s+#+$/, ""); + const matches: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (isHeading(lines[i]) && headingTitle(lines[i]) === normalized) { + matches.push(i); + } + } + if (matches.length > 1) { + throw new Error( + `replace_section: section "${normalized}" found ${matches.length} times — resolve duplicates via replace_body first.` + ); + } + const start = matches.length ? matches[0] : -1; + if (start !== -1) { + // Normalize: strip a leading content line that duplicates the target heading + const cLines = content.split("\n"); + const fi = cLines.findIndex((l) => l.trim().length > 0); + if (fi >= 0 && /^#{1,6}\s+/.test(cLines[fi]) && headingTitle(cLines[fi]) === normalized) { + content = cLines.slice(fi + 1).join("\n"); + } + } if (start === -1) { const suffix = body.trim().length > 0 ? "\n\n" : ""; return `${body.trimEnd()}${suffix}# ${normalized}\n\n${content.trim()}\n`; } + const matchedLevel = level(lines[start]); let end = lines.length; for (let i = start + 1; i < lines.length; i++) { - if (isHeading(lines[i])) { + if (isHeading(lines[i]) && level(lines[i]) <= matchedLevel) { end = i; break; } diff --git a/packages/core/test/okf.test.ts b/packages/core/test/okf.test.ts index 670c90d..300fcdd 100644 --- a/packages/core/test/okf.test.ts +++ b/packages/core/test/okf.test.ts @@ -150,6 +150,49 @@ describe("patch", () => { }); }); +describe("replaceSection (#34)", () => { + it("replaces under a ## heading instead of appending a duplicate section", () => { + const body = "# Title\n\nIntro.\n\n## Goals\n\nold\n\n## Notes\n\nn1\n"; + const out = replaceSection(body, "## Goals", "new goals"); + expect(out).toBe("# Title\n\nIntro.\n\n## Goals\n\nnew goals\n\n## Notes\n\nn1\n"); + }); + + it("replaces the full section, including nested subsections, up to the next same-or-higher heading", () => { + const body = "## Goals\n\na\n\n### Detail\n\nd\n\n## Notes\n\nn\n"; + const out = replaceSection(body, "Goals", "fresh"); + expect(out).toBe("## Goals\n\nfresh\n\n## Notes\n\nn\n"); + }); + + it("matches headings written with an ATX closing hash sequence", () => { + const body = "# Title\n\nintro\n\n## Goals ##\n\nold\n\n## Notes\n\nn\n"; + const out = replaceSection(body, "## Goals", "new goals"); + expect(out).toBe("# Title\n\nintro\n\n## Goals ##\n\nnew goals\n\n## Notes\n\nn\n"); + }); + + it("strips a duplicated heading line at the top of the replacement content", () => { + const body = "# Title\n\n## Gotchas\n\nold\n"; + const out = replaceSection(body, "## Gotchas", "## Gotchas\n\n- fresh gotcha"); + expect(out).toBe("# Title\n\n## Gotchas\n\n- fresh gotcha\n"); + expect(out.split("\n").filter((l) => l.trim() === "## Gotchas").length).toBe(1); + }); + + it("strips a duplicated closing-hash heading line at the top of the replacement content", () => { + const body = "# Title\n\n## Gotchas ##\n\nold\n"; + const out = replaceSection(body, "## Gotchas", "## Gotchas ##\n\n- fresh gotcha"); + expect(out).toBe("# Title\n\n## Gotchas ##\n\n- fresh gotcha\n"); + }); + + it("throws on ambiguous multi-match instead of editing the first hit", () => { + const body = "## A\n\nx\n\n## B\n\nx\n\n## A\n\ny\n"; + expect(() => replaceSection(body, "A", "z")).toThrow(/found 2 times/); + }); + + it("still appends a top-level section when absent", () => { + const out = replaceSection("body only", "Citations", "[1]"); + expect(out).toBe("body only\n\n# Citations\n\n[1]\n"); + }); +}); + describe("search", () => { beforeEach(async () => { await kb.writeConcept(