Skip to content

Repository files navigation

reference-points

A citation grammar for pointing at the exact block of a chat message — a specific table row, a specific sentence — that either resolves exactly or fails loudly. It never silently resolves to the wrong object.

Why

In a fast back-and-forth, referring to "that second table" or "the sentence about caching" is ambiguous: by the time the reply lands, the referent has moved, and the two sides resolve the phrase to different things. reference-points replaces the prose pointer with a mechanical address (N27-S1.T2.3 = turn 27, section 1, table 2, row 3). One deterministic decomposer both labels a message and resolves an address against it, so a label and its lookup agree by construction — and a bad address is rejected with a named reason instead of a wrong guess.

Quick start

uv sync # install (Python 3.13, dev tools)# label a message with its addresses (--turn N, --glyphs)
uv run python -m reference_points label message.md --turn 27
# the compact address→excerpt overlay instead
uv run python -m reference_points map message.md
# resolve an address against a message → prints the referenced content
uv run python -m reference_points resolve 'N27-S1.T2.3' message.md --turn 27
# the labels above are fragments; with the turn supplied they resolve as printed
uv run python -m reference_points resolve '.P1' message.md --turn 27
# validate an address string on its own
uv run python -m reference_points parse 'N1-S1.P2.4'# print the Section/Entity tree
uv run python -m reference_points decompose message.md
# how many replies a transcript holds — the number the next one is labeled with
uv run python -m reference_points turns session.jsonl

Point it at a whole conversation with --transcript, and the address finds its own turn — no --turn, nothing else to know:

# the viewer: every reply labeled, with the prompts it answered quoted between
uv run python -m reference_points label session.jsonl --transcript
# resolve straight out of it — N does the lookup
uv run python -m reference_points resolve 'N27-S1.P1' session.jsonl --transcript

Any <file> may be - to read from stdin. Without --transcript, --turn N (default 1) is the turn the message came from, and it does two jobs: it makes a turn-less address legal, so the fragments label prints resolve exactly as printed; and it makes an address for a different turn a loud miss rather than a confident wrong answer against whatever this message happens to hold.

Use it from Claude Code

This repo is a Claude Code plugin. There is nothing to install first — the plugin puts the CLI on PATH and runs it out of the checkout through uv, so uv is the only prerequisite.

claude plugin marketplace add locriani/reference-points # or an absolute path to a clone
claude plugin install reference-points@reference-points

Restart Claude Code. Two commands are then on PATH in every Bash call:

S=$(reference-points-session)# this conversation's transcript
reference-points resolve 'N27-S1.T2.3'"$S" -T # the block turn 27 pointed at
reference-points turns "$S"# replies so far

The bundled reference-points skill fires when an address appears in a message, and makes the assistant resolve it rather than remember what a turn said — which is the failure the grammar exists to remove, one level up.

It is a skill and not an MCP server on purpose: both commands are stateless text in, text out, which the existing Bash tool already covers. A server would add a process and a protocol to carry two argv arrays.

The grammar

An address is <N>-<S>.<E>.<L>turN · Section · Entity · Location.

PartMeaningNotes
N<n>turn number — the nth assistant reply in the conversationmandatory in prose; omit it only when the reader already has the turn (see below)
S<n>section (markdown heading, ATX or setext; whole msg = S1 if none)container; nests
E<n>entity, indexed per type within its sectionsee table below
Llocation inside the entityshape depends on entity type

Entities

LetterEntityLocation shapeExample
PParagraphsentence numberP1.4 = 4th sentence
TTablerow, row,col, or ,colT2.3, T2.3,4, T2.,4
LListitem numberL1.2 = 2nd item
CVerbatim block — fenced, indented, or raw HTMLline number (\n-delimited)C1.5 = 5th line
QQuote (blockquote)container — nests inner entitiesQ1.P1.2
SSubsectioncontainer — nestsS2.P1
DDiagram (mermaid/dot fence)line numberD1.3 = 3rd line
RLink reference definition ([key]: url)noneR1.<anything> is rejectedR1, R(/mdit/)

R is the one entity that is addressable but never labeled inline: a definition renders to nothing, so a label would put visible text where the reader saw none — and would stop the line being a definition at all. render_inline leaves those lines byte-identical to the source; render_map carries their addresses.

Match strings

Instead of a numeric location you can address by content:

  • (/scheme/) — the single match in scope. More than one match → AMBIGUOUS.
  • (g/scheme/)every match in scope.

A match can also stand in for an entity's index — naming which entity rather than a spot inside one:

  • S(/Costs/) — the subsection headed Costs. A section matches on its heading as well as its body, because the heading is what a reader names it by.
  • P(/caching/) — the paragraph about caching.
  • T(g/\$/) — every table mentioning a price.

A selector takes no further location. An R selector runs against the whole definition, so (/mdit/) finds it by key and (/github.com/) by URL, following CommonMark label normalization: [MDIT], [ mdit ] and [mdit] are one reference.

How labels are rendered

A section header carries the full prefix and its heading text with no dash between; every other block carries only its fragment. Full paths are for references, fragments are for labels.

Two marker sets. ASCII is the default — labels read as the characters you type. Pass --glyphs for the monochrome set instead:

StyleSection headerBlock
ASCII (default)## N27-S1 Costs.P1
--glyphs## ◆27 §1 Costs·P1

The choice is display-only: parse_ref accepts both forms, so a label resolves in whichever style emitted it.

Because a fragment carries no path, it is looked for in every container at every depth — that is what lets a label printed inside a subsection resolve at all. When nesting prints the same fragment twice, typing it is AMBIGUOUS and both candidates come back with their full addresses; the engine never picks the shallower one for you. A full address names its own scope and is never a search.

Anything under C is stored byte-for-byte and addressed by line or by (/match/) — never split into sentences. A thematic break (---, ***) is not an entity: no content, no address, no label.

A labeled message still renders as markdown. Heading markers are kept, so a heading stays a heading — an ATX line is rebuilt with its #s, a setext heading gets the label prefixed onto its text and keeps its underline. Where a first line means what it means because of its leading characters — an indented code block, a --- thematic break, an HTML block — the label moves to its own line with a blank line after it rather than corrupting the construct.

Rejection is loud

A malformed or unresolvable address never guesses. Every failure names its category, says what happened, and ends in the address to type instead:

REJECTED: missing turn number
-> every address starts with the turn it points into: N1-S1.P1
REJECTED: D1 takes no location
-> use D1 on its own — a diagram has no addressable parts
REJECTED: P1 is not a container; cannot address inside it
-> use P1.2 for a sentence, or P1.(/word/) to match inside it
INVALID: regex (/[/): unterminated character set at position 0
-> a match body is a regex — escape the metacharacters you meant literally, as in (/\$40/)
MISSING: no T2 here
-> in scope, in order: P1, T1, P2
MISSING: no such row 9 in T1
-> T1 has 4 rows, counting the header as row 1
AMBIGUOUS: 2 blocks are labeled .P1
N1-S1.P1 Alpha alpha.
N1-S1.S1.P1 Bravo bravo.
-> use N1-S1.P1 or N1-S1.S1.P1
AMBIGUOUS: 3 matches for (/\$/) in T1
| Free | $0 | 1 |
| Pro | $12 | 5 |
| Team | $40 | 20 |
-> use T1.(g/\$/) for all 3, or narrow the pattern

The four categories let you sort "I typed it wrong" from "it is not there" without reading the prose:

PrefixMeans
REJECTED:the address breaks the grammar, or the shape it landed on
INVALID:the address is fine, its regex is not
MISSING:well-formed, and nothing is there
AMBIGUOUS:well-formed, and more than one thing is there

An AMBIGUOUS lists what it found — up to eight, then a count — because choosing between candidates is impossible without seeing them.

parse_ref raises RefError (always REJECTED) and resolve raises ResolveError carrying one of the four; both take their hint as a required argument, so none can ship without one. The CLI prints the message unchanged and exits 1; a file it cannot read is UNREADABLE:, which is not an address defect.

Development

uv run pytest # tests
uv run ruff check .# lint
uv run ruff format .# format
uv run pyright # typecheck (strict on src)

CI runs those same four commands on every push and pull request. CONTRIBUTING.md has the rest: the TDD rule, the two invariants that hold the engine together, and what a grammar change has to carry. Participation is governed by CODE_OF_CONDUCT.md.

Layout: src/reference_points/ (grammar · decompose · resolve · render · __main__), one test module per source module under tests/. Built with the uv toolchain; uv.lock is committed. Block structure comes from markdown-it-pydecompose maps its token stream onto the entity alphabet rather than scanning markdown itself.

License

MIT

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages