Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function buildWriteTools(kb: KnowledgeBase, filesChanged: Set<string>, 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
Expand All @@ -115,7 +115,7 @@ export function buildWriteTools(kb: KnowledgeBase, filesChanged: Set<string>, 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(),
Expand Down
39 changes: 32 additions & 7 deletions packages/core/src/okf/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end = i;
break;
}
Expand Down
43 changes: 43 additions & 0 deletions packages/core/test/okf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down