Skip to content

ctx_diff

Returns the structured diff between two versions of a page. Not a line diff — a section diff.

git diff is the wrong shape for prose. It reports that lines 14–17 changed, which tells an agent nothing about whether the paragraph it is about to rewrite is the one somebody just corrected. Contexo’s differ splits each version into frontmatter, an optional preamble, and its ## sections, then reports changes per field and per section. The output an agent gets back is closer to “the Webhooks section is new, Concepts was reworded, Agent Reasoning is untouched” — which is a fact it can act on.

Before rewriting an existing page, and whenever the answer to “is my understanding current” depends on a specific pair of shas. With no from / to it shows the most recent change to the page, which is the common case. With blame: true it answers “who wrote this section?”.

ParameterTypeRequiredDescription
blamebooleannoAnnotate each section with the commit that originally introduced its heading (best-effort, adds latency on long histories)
fromstringnoOld sha (default: parent of `to`)
slugstringyesPage slug, e.g. 'stripe-subscription'
tostringnoNew sha (default: HEAD-for-this-path)
typeconcept | entity | source | analysisnoOnly needed when the same slug exists under multiple types

Defaults resolve on the server: to becomes HEAD-for-this-path (the most recent commit touching this page, not the repo HEAD), and from becomes the parent commit of to for this path. Both walk the page’s own history, so unrelated pushes by teammates never shift the range.

{
"method": "tools/call",
"params": {
"name": "ctx_diff",
"arguments": { "slug": "stripe-subscription" }
}
}

The response is a one-line framing followed by the raw SectionDiff JSON, so the agent can introspect the structure rather than scrape text:

{
"content": [
{
"type": "text",
"text": "Diff for wiki/concepts/stripe-subscription.md (a4b2c1d..b7e9f20):\n\n{ …SectionDiff… }"
}
]
}

The SectionDiff itself:

{
"from_sha": "a4b2c1d3…",
"to_sha": "b7e9f204…",
"frontmatter": {
"changed": [
{ "field": "updated", "from": "2026-07-20T10:04:00Z", "to": "2026-07-24T16:31:00Z" }
],
"added": [{ "field": "tags", "to": "proration" }]
},
"sections": [
{
"heading": "## Concepts",
"status": "modified",
"from": "Trials are created with trial_end.",
"to": "Trials are created with trial_end, always UTC.",
"line_diff": "- Trials are created with trial_end.\n+ Trials are created with trial_end, always UTC."
},
{
"heading": "## Webhooks",
"status": "added",
"to": "invoice.paid arrives before customer.subscription.updated.\nRetries are idempotent by event id."
},
{ "heading": "## Agent Reasoning", "status": "unchanged" },
{
"heading": "## Open Questions",
"status": "removed",
"from": "Do we prorate mid-cycle downgrades?"
}
]
}

The CLI renders that same struct as a summary — useful for reading the shape of it:

ctx diff stripe-subscription
diff: stripe-subscription   a4b2c1d..b7e9f20

Frontmatter
  ~ updated
      - 2026-07-20T10:04:00Z
      + 2026-07-24T16:31:00Z
  + tags: proration

Sections
  ~ ## Concepts (2 lines changed)
  + ## Webhooks (2 lines added)
  = ## Agent Reasoning
  - ## Open Questions (1 lines removed)

Frontmatter is diffed per field, not as text. Scalars appear in changed with from and to. List-valued fields (tags, related, sources) use set semantics: order is ignored, and each element that appeared or vanished becomes its own entry in added / removed carrying the field name — which is why a single new tag renders as + tags: proration rather than a whole-list rewrite. Fields are compared by value, with time.Time compared as instants.

A section is a ## heading at column 0 and everything under it, up to the next such heading. ### and deeper are not section boundaries: editing an h3 shows up as a modification of its parent h2. Anything before the first ## is the preamble, reported as a pseudo-section with the heading _preamble. Trailing blank lines inside a section are stripped before comparison, so reformatting the whitespace between sections is not a change.

Sections pair by heading, disambiguated by occurrence index when a page repeats a heading (the first ## Notes on the left pairs with the first ## Notes on the right). Each pair lands in one of five statuses:

Status Meaning Fields populated
unchanged present on both sides, byte-identical body
added only in to to
removed only in from from
modified body differs from, to, line_diff
renamed heading changed, body largely survived old_heading, from, to, line_diff when the body also changed

unchanged sections are reported explicitly rather than omitted — that is what lets an agent see which parts of a page it has no business touching.

Renames are detected, not assumed. After pairing, the differ compares each unmatched removal against each unmatched addition using word-token Jaccard similarity and folds the pair into one renamed entry when it clears 0.7. So ## Decision## Final Decision with the body intact is one rename, not a delete plus an add.

Ordering follows the new version. Sections come back in to order; anything that only exists in from is appended at the end, which is why removals cluster at the bottom of the output.

line_diff is LCS-based and covers the whole section: - for deleted lines, + for inserted, two spaces for context, no hunk headers. The (N lines changed) counts in the text rendering are simply the number of +/- markers.

blame: true annotates each section with the commit that introduced its heading:

ctx diff stripe-subscription --blame
diff: stripe-subscription   a4b2c1d..b7e9f20

Frontmatter
  ~ updated
      - 2026-07-20T10:04:00Z
      + 2026-07-24T16:31:00Z
  + tags: proration

Sections
  ~ ## Concepts (2 lines changed) — introduced by dev at a4b2c1d
  + ## Webhooks (2 lines added) — introduced by dev at b7e9f20
  = ## Agent Reasoning — introduced by dev at a4b2c1d
  - ## Open Questions (1 lines removed)

In JSON that is an introduced_by object on the section:

{
"heading": "## Webhooks",
"status": "added",
"to": "…",
"introduced_by": {
"sha": "b7e9f204…",
"author": "dev",
"email": "dev@example.com",
"time": "2026-07-28T09:12:44Z",
"message": "ctx push (1 pages)"
}
}

Three things worth knowing:

  • It is computed by walking the page’s history oldest→newest — up to 200 commits — reading each revision and recording the first commit each heading appeared in. That is a read per commit, which is why the schema calls it best-effort and warns about latency on long histories.
  • It is genuinely best-effort: if the walk fails, blame is dropped silently and you still get the diff. Revisions that fail to read are skipped rather than aborting the annotation.
  • A renamed section is blamed under its old heading when that heading is known, since that is where the content actually started. Removed sections carry introduced_by in the JSON but the text renderer omits it — hence no annotation on the - ## Open Questions line above.

There is no local mode over MCP. ctx_diff always asks the server, so both sides of the comparison are committed versions. Two adjacent things cover the local case:

  • ctx diff --local compares your working copy against the server’s HEAD for that page — what a push would change.
  • Reading ctx://wiki/<slug> prepends a DRIFT_NOTICE block when the page moved on the server since your last pull, so an agent sees divergence without asking. See Drift and conflicts.
ctx diff stripe-subscription --local
diff: stripe-subscription   a4b2c1d..local

Frontmatter
  ~ updated
      - 2026-07-20 10:04:00 +0000 UTC
      + 2026-07-24 16:31:00 +0000 UTC
  + tags: proration

Sections
  ~ ## Concepts (2 lines changed)
  + ## Webhooks (2 lines added)
  = ## Agent Reasoning
  - ## Open Questions (1 lines removed)

Note the updated values in that local run: computed on your machine, timestamps render as Go time values, whereas anything that comes back from the server has been through JSON and reads as RFC 3339. Same field, same page — the formatting difference is just where the diff was computed.

ctx_diff: sync: page diff (400): {"error":"no parent commit for this path; pass ?from explicitly"} — the page has exactly one commit, so there is no parent to default from to. Pass an explicit from, or use ctx_evolution, whose first entry diffs against an empty document instead of failing.

ctx_diff: sync: page diff (400): {"error":"unknown sha: …"} — a supplied from / to does not resolve in that repo.

ctx_diff: sync: page diff (404): {"error":"path not present in repo"} — the slug resolved against a local file, but the path has no commits at all in this repo. This is what a never-pushed page returns, because to defaults to HEAD-for-this-path and there is none.

ctx_diff: sync: page diff (404): {"error":"path not present at either sha"} — both shas resolved, but neither commit’s tree contains the page. Only reachable with an explicit from / to; if the path is missing on just one side, that side is treated as an empty document instead and you get clean adds or removes.

ctx_diff: slug "…" not found under .contexo/ … and ctx_diff: server not configured … — same resolution and configuration rules as ctx_history.