Skip to content

How a page evolved

A page in a shared knowledge base is worth more than its current text. When did we learn this? Who added the webhook section? What did the last edit actually change? Contexo answers those with three commands that all read the same server-side git history, and differ only in what they hand back.

You want to know Reach for
When this page changed, and who touched it ctx history
What one specific edit changed ctx diff
Who first wrote a given section ctx diff --blame
The whole trajectory, every commit + what it did ctx evolution
What my uncommitted local copy would change ctx diff --local

All of them resolve the slug against your local working copy — .contexo/wiki/{concepts,entities,analyses}/<slug>.md and .contexo/raw/sessions/<slug>.md — and then ask the server for the commits. A page a teammate created but you have never pulled will fail to resolve before any request is made. Pull first.

ctx history stripe-subscription
b7e9f204  2026-07-28  dev           ctx push (1 pages)
a4b2c1d3  2026-07-28  dev           ctx push (1 pages)

Columns: 8-character sha, date, author padded to 12 characters, commit subject. ctx push without -m writes ctx push (N pages); an agent pushing through ctx_push writes agent push (N pages), which is a quick way to tell who — or what — was driving.

History is per path, produced by git log -- <path> with no rename following. Rename a page’s slug and the new path starts a fresh timeline while the old path keeps every commit before the rename. --limit defaults to 50.

Those shas are the input to everything below.

With no --from/--to, ctx diff compares the page’s most recent change: HEAD-for-this-path against its parent.

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)

This is not a line diff with markdown in it — the differ splits both sides into frontmatter, preamble and ## sections, and reports each part separately. Reading the output:

  • ~ updated — a scalar frontmatter field changed. - is the old value, + the new.
  • + tags: proration — list-valued frontmatter fields use set semantics. This means one element was added to tags, not that the field appeared. A removed element shows as - tags: ….
  • ~ ## Concepts (2 lines changed) — the section exists on both sides with a different body. The count is the number of +/- lines in that section’s own line diff; the full text is in --json.
  • + ## Webhooks (2 lines added) — a new section.
  • = ## Agent Reasoning — present on both sides, byte-identical. Unchanged sections are listed rather than hidden, so you can see what an edit deliberately left alone.
  • - ## Open Questions (1 lines removed) — gone in the newer version.

A fifth status you’ll meet occasionally is ~> ## New heading (renamed from "## Old heading", 3 lines changed) — the line count is dropped when the body is byte-identical and only the heading moved. An unmatched removed/added pair is treated as a rename when the two bodies overlap by at least 70% (Jaccard over word tokens); below that threshold it stays a genuine delete plus a genuine add.

If either side has unparseable frontmatter the differ gives up on structure and sets parse_fallback: the whole file collapses into a single _preamble entry holding a line diff of both versions. The text output says so in one line — (frontmatter unparseable on at least one side; whole-file line diff below) followed by ~ preamble (N lines changed) — and the diff itself is only in --json.

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)

Blame here is heading-level, not line-level. The server walks up to the 200 most recent commits for the path, oldest first, parsing the ## headings present at each revision and recording the first commit in which each heading appeared. Every section in the diff then gets annotated with that commit.

Consequences worth internalising before you trust the answer:

  • It attributes the heading, not the prose. If somebody rewrote every word under ## Concepts last week, the annotation still names whoever created the heading.
  • Removed sections print no annotation — look at the - ## Open Questions line above. That is the text renderer, not the data: the server still resolves an introduced_by for a removed heading, and --json shows it.
  • Renamed sections prefer the old heading’s blame, falling back to the new one if the old heading was never seen.
  • The walk covers only the 200 most recent commits for the path. A heading introduced before that window is attributed to the oldest commit inside it — an answer that is too recent rather than absent.
  • Blame is best-effort: if it fails, it is silently dropped and you get the plain diff back. It also costs one file read per commit, so it is noticeably slower on long histories.

ctx evolution: the whole arc in one round-trip

Section titled “ctx evolution: the whole arc in one round-trip”

ctx history plus one ctx diff per commit is N+1 requests. ctx evolution is one:

ctx evolution stripe-subscription
Evolution of wiki/concepts/stripe-subscription.md (2 commits, newest first):

b7e9f204  2026-07-28  dev           ctx push (1 pages)
    frontmatter, ~ Concepts, + Webhooks, - Open Questions
a4b2c1d3  2026-07-28  dev           ctx push (1 pages)
    frontmatter, + Concepts, + Agent Reasoning, + Open Questions

Each commit line is followed by a one-line summary of what that commit changed — the same summary format the push preview uses, capped at four parts with a +N more overflow.

--show-diff inlines each commit’s full section diff instead:

ctx evolution stripe-subscription --show-diff
Evolution of wiki/concepts/stripe-subscription.md (2 commits, newest first):

b7e9f204  2026-07-28  dev           ctx push (1 pages)
    diff: 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)

a4b2c1d3  2026-07-28  dev           ctx push (1 pages)
    diff: ..a4b2c1d

    Frontmatter
      + agent: claude
      + author: dev
      + created: 2026-07-20T10:04:00Z
      + parent_sha:
      + reasoning_summary: How trials, proration and webhooks fit together in acme-api.
      + related: [webhook-retries]
      + schema: ctx.page.v1
      + slug: stripe-subscription
      + tags: [stripe billing]
      + type: concept
      + updated: 2026-07-20T10:04:00Z

    Sections
      + ## Concepts (2 lines added)
      + ## Agent Reasoning (3 lines added)
      + ## Open Questions (1 lines added)

Note the oldest entry: its range prints as ..a4b2c1d and everything is an addition, including each frontmatter field individually. That’s not a quirk of the display — the first commit for a path has no prior version, so it is diffed against an empty document. Any evolution whose window reaches back to the page’s first commit therefore ends with one all-+ entry.

--limit defaults to 20 here (not 50). --blame works too, but it annotates each entry independently: N commits means N history walks, so combine it with a small --limit.

Everything above compares two server-side revisions. --local compares your working copy against the server’s current version of the same page — the same computation the pre-push preview runs, without pushing:

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)

It is mutually exclusive with --from, --to and --blame (there is no sha to blame against), and if the page doesn’t exist on the server yet, every section reports as added.

One cosmetic tell: compare the updated values here with the ones in the server-side diff further up. --local renders 2026-07-20 10:04:00 +0000 UTC, the server-side diff renders 2026-07-20T10:04:00Z. The local diff is computed in-process and keeps a real timestamp value; the server-side diff crosses JSON on the way back, which turns it into an RFC 3339 string. Same change, different renderer.

The MCP tools are the same three endpoints with the same defaults: ctx_history, ctx_diff, ctx_evolution. ctx_history returns a formatted text timeline; ctx_diff and ctx_evolution return the raw structured JSON so the model can introspect statuses and headings rather than parse a rendering.

ctx_evolution is the one to prefer when an agent is picking up a topic cold — it is explicitly described to the model as the way to get a page’s whole trajectory without N round-trips. Point an agent at ctx_diff when it is about to edit one specific page and needs to know what the last person changed.

no parent commit for this path; pass ?from explicitly — you asked for the default parent..head on a page that has exactly one commit. Nothing precedes it; the evolution view handles this case for you.

No commits found for wiki/concepts/x.md — the page exists locally but was never pushed. Not an error; the command prints the line and exits 0.

A page that “lost its history” — check for a slug rename. History is per path.

Everything fails offline. All three commands read the server’s copy of the repo. There is no local fallback, and no local git repository to fall back to.