Skip to content
Merged
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
94 changes: 7 additions & 87 deletions connectors/notion/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
notionBlocksToText, buildMarkerBlocks, statusMarkerBlock, entityMarkerBlock, notionBlockPlainText, parseMarkers,
buildChangelogEntryText, isChangelogEntryText,
buildRelationBlocks, parseRelationBlocks,
buildSyncStartText, buildSyncEndText, buildSyncRangeBlocks, findSyncRange, textBlock,

Check warning on line 12 in connectors/notion/tools.js

View workflow job for this annotation

GitHub Actions / verify

'buildSyncEndText' is defined but never used

Check warning on line 12 in connectors/notion/tools.js

View workflow job for this annotation

GitHub Actions / verify

'buildSyncEndText' is defined but never used
buildCheckpointRangeBlocks, findCheckpointRange, buildCheckpointStartText,
} from "./client.js";
import { findLinkCandidates, extractTags } from "./linking.js";
Expand Down Expand Up @@ -535,7 +535,7 @@
// client.js-only commit) instead of advancing to this commit -- this no-op
// comment forces a new deployment so the alias promotion re-runs.)
// ---------------------------------------------------------------------------
export async function doCheckpoint({ action, notes, replacements, append_notes }) {
export async function doCheckpoint({ action, notes }) {
if (action === "save") {
const existing = await findPageByEntityId("checkpoint-latest");
const notesLines = (notes || "").split("\n");
Expand Down Expand Up @@ -574,103 +574,23 @@
const innerBlocks = range.innerBlockIds.map((id) => blockMap.get(id)).filter(Boolean);
const notesContent = notionBlocksToText(innerBlocks);
return notesContent || "(empty checkpoint)";
} else if (action === "update") {
// Targeted edit path -- avoids replaceCheckpointRange's delete-every-
// inner-block-then-recreate behavior, which is wasteful (and racks up
// real Notion API calls) when a session just wants to tweak or extend
// an existing checkpoint rather than replace it wholesale.
const existing = await findPageByEntityId("checkpoint-latest");
if (!existing) {
throw new Error("No checkpoint found to update -- use action: \"save\" first to create one.");
}
if (!replacements?.length && !append_notes) {
throw new Error("action: \"update\" requires at least one of 'replacements' or 'append_notes' -- otherwise there's nothing to update. Use action: \"save\" for a full rewrite, or action: \"load\" to just read the current content.");
}
const blocksData = await notionRequest(`/blocks/${existing.pageId}/children?page_size=100`);
const blocks = blocksData.results || [];
const range = findCheckpointRange(blocks);
if (!range) {
throw new Error("Checkpoint page exists but no checkpoint range was found on it (may exceed the 100-block read window) -- use action: \"save\" to recreate it cleanly.");
}
const blockMap = new Map(blocks.map((b) => [b.id, b]));
const innerBlocks = range.innerBlockIds.map((id) => blockMap.get(id)).filter(Boolean);
const results = [];
const trunc = (s) => s.slice(0, 60) + (s.length > 60 ? "\u2026" : "");

if (replacements?.length) {
// Validate ALL replacements against the pre-write snapshot before
// writing ANY of them (bug fix 2026-09-07 -- previously this loop
// validated and PATCHed each replacement in the same iteration, so a
// bad find later in the list threw AFTER earlier ones had already
// been written to Notion, even though the error claimed "nothing
// further written". That left checkpoints silently half-updated.
// Block IDs don't change across these edits, so it's safe to resolve
// every find against the same original innerBlocks snapshot up front.
const resolved = replacements.map(({ find, replace }) => {
const matches = innerBlocks.filter((b) => notionBlockPlainText(b) === find);
if (matches.length === 0) {
throw new Error(`Update aborted, nothing written \u2014 "${trunc(find)}" was not found among the checkpoint's current lines. Use checkpoint (action: "load") to see current content, or action: "save" for a full rewrite.`);
}
if (matches.length > 1) {
throw new Error(`Update aborted, nothing written \u2014 "${trunc(find)}" matches ${matches.length} lines, but must be unique. Include more surrounding context in "find" to disambiguate.`);
}
return { block: matches[0], find, replace };
});
for (const { block, find, replace } of resolved) {
await notionRequest(`/blocks/${block.id}`, {
method: "PATCH",
body: { paragraph: { rich_text: [{ type: "text", text: { content: replace } }] } },
});
results.push(`Replaced line ("${trunc(find)}" \u2192 "${trunc(replace)}").`);
}
}

if (append_notes) {
const newLines = append_notes.split("\n").filter(Boolean);
const children = newLines.map(textBlock);
if (children.length) {
const afterId = range.innerBlockIds.length ? range.innerBlockIds[range.innerBlockIds.length - 1] : range.startBlockId;
await notionRequest(`/blocks/${existing.pageId}/children`, {
method: "PATCH",
body: { children, after: afterId },
});
results.push(`Appended ${children.length} new line(s).`);
}
}

// Bump the start marker's timestamp either way, so action: "load" and
// the visible marker both reflect that the checkpoint has moved since
// its last full save, even though this path never touched the marker
// block for any other reason.
const updated_at = new Date().toISOString();
await notionRequest(`/blocks/${range.startBlockId}`, {
method: "PATCH",
body: { paragraph: { rich_text: [{ type: "text", text: { content: buildCheckpointStartText(updated_at) } }] } },
});

return `Checkpoint updated successfully (targeted edit, no full rewrite).\n${results.join("\n")}\nURL: ${existing.url}`;
} else {
throw new Error(`Invalid checkpoint action: "${action}" (expected "save", "load", or "update").`);
throw new Error(`Invalid checkpoint action: "${action}" (expected "save" or "load").`);
}
}

export function register(server) {

server.tool(
"checkpoint",
"Save, load, or update a handoff note for the CURRENT session so a fresh session can recover context — NOT a general-purpose notes tool. Uses a fixed global checkpoint entity ('checkpoint-latest'). 'save' fully rewrites the stored note (use for the first save in a session, or a genuine full replacement). 'update' makes a targeted edit instead of a full rewrite — use this for later checkpoints within the same session so each call doesn't delete and recreate every line.",
"Save or load a handoff note for the CURRENT session so a fresh session can recover context — NOT a general-purpose notes tool. Uses a fixed global checkpoint entity ('checkpoint-latest'). 'save' fully rewrites the stored note; 'load' retrieves it. (The 'update' targeted-edit action has been disabled — use 'save' for any change, full rewrite only.)",
{
action: z.enum(["save", "load", "update"]).describe("Action to perform: 'save' to fully (re)write the handoff notes, 'load' to retrieve them, 'update' to make a targeted edit (replacements and/or append_notes) without rewriting the whole checkpoint"),
notes: z.string().optional().describe("Freeform plain-text handoff notes to save (only used for action: 'save' — full rewrite)"),
replacements: z.array(z.object({
find: z.string().describe("Exact plain text of an existing checkpoint line — must match exactly one line"),
replace: z.string().describe("New plain text for that line"),
})).optional().describe("Only used for action: 'update'. Targeted find/replace edits applied to specific existing lines in the checkpoint, instead of rewriting the whole note. Each 'find' must match exactly one current line — fails with nothing written on zero or multiple matches."),
append_notes: z.string().optional().describe("Only used for action: 'update'. Plain-text lines to append after the checkpoint's existing content, without touching anything already there. Combine with 'replacements' in the same call if needed."),
action: z.enum(["save", "load"]).describe("Action to perform: 'save' to fully (re)write the handoff notes, 'load' to retrieve them"),
notes: z.string().optional().describe("Freeform plain-text handoff notes to save (only used for action: 'save' — full rewrite)"),
},
async ({ action, notes, replacements, append_notes }) => {
async ({ action, notes }) => {
try {
const text = await doCheckpoint({ action, notes, replacements, append_notes });
const text = await doCheckpoint({ action, notes });
return { content: [{ type: "text", text }] };
} catch (err) {
return { content: [{ type: "text", text: err.message }], isError: true };
Expand Down
Loading