Skip to content

Latest commit

 

History

55 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Context Graph

Ask your notes a question. Get the sentence back, with the file and line it sits on.

A Claude Code plugin · Copies the structure you already wrote · No language model · 100% local

Tests No LLM Deterministic Python

Windows macOS Linux

182 documents · 8,524 statements linked · rebuilt in 0.11 s


A Claude Code session on the left, the map on the right. You ask in your own words. The matching statement lights up with the sentences near it. A path traces the chain of relations between two decisions. At compaction, the session is written back into the notes and the map rebuilds.

What it is — your notes already say what links to what: a decision that supersedes another, a fact that backs up a claim, written as a plain link like - supersedes [[ADR 0064]]. Reading that trail today means opening file after file. This plugin copies that structure into one map instead of inferring it, so a question is answered in under a second with the exact sentence, its file and its line number — not a summary, not a guess, not a stack of files to open.

The animation above shows the loop in motion: the matching sentence lights up with its neighbours, which is what comes back as the answer. Every statement becomes a node in the map this way, and every link like the one above becomes an edge between two of them.

Ask this Get this
ask.py "<question>" the statements that match, each with its file and line
ask.py --path "<a>" "<b>" the chain of relations linking two decisions
ask.py --explain "<node>" one node and its neighbours
ask.py --chain "<decision>" what a decision came from and what it led to
ask.py --conflicts values two documents state differently
ask.py --settle "<conflict:pick>" correct the document holding the value you did not pick

Contents


The problem

Once knowledge documents pile up, a single check means reading a whole file — and every one of those reads lands in the model's context before it can answer anything.

Counted on the set of documents this was built against: catching up on one thread of work means reading 289,000 characters; finding the right document means reading a 49,000 character index; and the 1,746 links already written between documents can only be followed by opening the file at the other end.

The information was already written down. What cost anything was getting back to it.

$ python ask.py "tray piece length median"

NODE Piece size: longest edge min 0.05 m, **median 1.19 m**, max 99.44 m.
     [src=knowledge\facts\WHRP Plant - Model Contents and Structure Regions - Facts.md loc=192]
NODE Tray pieces attached to no structure rise from 1,033 (18.2%) to 1,141 (25.8%) - the
     structure areas now cover less of the routing.
     [src=knowledge\decisions\ADR 0086 APOC Drops 20 Structure Areas and Their Trays.md loc=33]
...
[also touched: cable_project Handoff, index, WHRP Plant - Model Contents and Structure
 Regions - Facts, Browser 3D Viewers - Facts and 10 more]

The statement that matched comes first, the statements around it follow, and the documents they sit in are named once at the end. Long answers keep going in the same shape; the middle of this one is cut here to keep the example short.

You get the statement itself, plus the file and line number it sits on. That is usually the end of it; you open the surrounding lines only when you need to confirm. Reading drops from tens of thousands of characters to a few thousand, capped and told when it was cut.


Install

Two commands in Claude Code, then a restart.

1. Add the marketplace

/plugin marketplace add JadeKim042386/context-graph

Nothing to clone — Claude Code fetches it for you.

2. Install the plugin

/plugin install context-graph

This brings the skill and the refresh hooks with it.

3. Restart Claude Code

The hooks and the skill only attach on start.

4. Check that it took

/plugin

Seeing context-graph in the list is enough.

Installing from a local clone instead

Use this when you want to change the code and see it immediately.

git clone https://github.com/JadeKim042386/context-graph.git
/plugin marketplace add <clone-path>
/plugin install context-graph

A marketplace added from a directory reads the files as they are on disk, so an edit shows up on the next restart without any publishing step.

Updating

/plugin marketplace update context-graph

Uninstall

/plugin uninstall context-graph
/plugin marketplace remove context-graph

The map file and the config live outside the knowledge documents, so removing the plugin does not change a single character of them.


Why it copies instead of inferring

The relations between documents are already written down, by hand, by the person who wrote the notes.

## Decision
- Split the rack along its length by equipment connection density alone: 8 or more endpoints in a 5 m window.

## Relations
- supersedes [[earlier decision]]
- relates_to [[related fact]]

Asking a model to infer that relation would be paying to guess at something already stated. Copying it across instead is what makes the map

  • fast — well under a second to rebuild
  • free — zero model calls
  • not wrong — a statement in the map is the statement in the document, verbatim
  • repeatable — build twice, get byte-identical output

Same input, same output

Every file walk and every set iteration is sorted and pinned, so a test can build the map twice and compare it byte for byte. If the output changed between runs, no fix could ever be shown to have improved anything — so this is the one test that has to pass before any other result here can be trusted.


How it works

Statements and the relations you wrote are copied into one map that answers with a value, a connection or a lineage

What becomes a node

Node What it is Location
Document one file file + line 1
Heading any of # to ###### (h1-h3 in HTML), including the body under it file + line
Statement one paragraph or list item under a heading file + line
Name-only node a target that is pointed at but exists in no document none

The values live in the statement nodes. Which is why statement labels are never cut — cut at 400 characters and the figures at the end disappear from the answers.

What becomes a link

- <word> [[target]]   ->  that word is the relation name (supersedes, follows, corrects ...)
- 대체함 [[target]]     ->  a Korean relation word, stored under its English name (supersedes)
   [[target]]          ->  a mention

A relation word may be written in any script. Korean notes keep their lineage instead of losing it to an unnamed mention. The common Korean words are mapped onto the English names the rest of the map uses — 대체함/대체 to supersedes, 관련/참고 to relates_to, 후속 to follows, 정정 to corrects, 포함 to part_of, and a few more — so a path can run through documents written in both languages. This is a fixed table in the source, not a translation service: no model is called, and a word outside the table is kept exactly as it was written. The word still has to be the only thing between the bullet and the link — - this decision supersedes [[X]] is a mention, not a relation.

It reads the shape of the line, not the name of the section. So it works on documents with no ## Relations section. When a name does not match, it retries with punctuation swapped (/, : vs -); if there is still no match, it creates a name-only node and links to that.

HTML follows the same rules

Title becomes the document name, h1-h3 become headings, p and li become statements, a href becomes a link, and each table cell is read on its own. Images, styling and scripts are dropped, but text inside SVG is kept — the point a diagram makes usually lives in that text.

From a question to an answer

A question goes to the map, the answer carries the statement and its file and line, and the map is rebuilt at three points


Point it at your notes

No path is baked into the code, so the one thing you have to do is say where your documents are. Write ~/.claude/context-graph/config.json (or point the KNOWLEDGE_MAP_CONFIG environment variable at a file of your own):

{
  "source_dirs": ["C:/notes", "C:/another/folder"],
  "map_path": "C:/notes-map/graph.json",
  "answer_budget": 8000
}
  • source_dirs — one or more folders holding your .md, .markdown, .html or .htm documents. They are only ever read
  • map_path — where the map goes. Keep it outside those folders, or the map is picked up as a document on the next build. The query tool needs the .json suffix
  • answer_budget — the cap on an answer, in characters. Whole statements are dropped from the back, never cut in the middle, and the count of what was left out is printed

Then build it once:

python build_map.py
nodes 8524 · links 9749 · located 99.8% · relations 28 kinds · components 3 · samples 5/5 verbatim · 0.11s

That line is where you find out whether it is any use on your set of documents — what it means is under Quality check. If the folder is missing or holds no documents, the build says so instead of quietly producing an empty map.

The two tools it leans on

Without it
graphify — runs the queries building and scoring still work; asking does not
obsidian-second-brain — writes a session into your notes everything works except the write-back at compaction

Neither is installed for you.


Next to Semantica

Two columns side by side. What goes in: your own markdown and HTML notes, against PDF, DOCX, XLSX, SQL, web and email plus crawlers, repositories and MCP servers. How the graph is made: the relation line you wrote is copied straight across with no model call, against a source passing through an LLM and embeddings that infer the nodes. What it runs on: one JSON file and the Python standard library, against pluggable vector, graph and triple stores plus an LLM and embeddings. What comes back: the statement word for word with its file and line, against an inference carrying its own audit trail (the PROV-O provenance standard)

Semantica · docs

What this does better

  • Nothing is generated. The answer is the line as it stands in the document; every build re-checks a sample against the file, so a figure can never drift from its source by being paraphrased.
  • No model, no key, no service. The build calls nothing and reaches no network. Semantica infers its graph with an LLM and embeddings, which is a cost and a dependency per document.
  • One JSON file is the whole store. Nothing to keep running between sessions.
  • It refreshes itself. As a plugin it rebuilds at session start, when a subagent ends, and around compaction. Semantica is a library you build an application around.

What Semantica does better

  • It reads what this cannot. PDF, DOCX, XLSX, SQL, crawled pages, mailboxes, repositories. This reads markdown and HTML and nothing else.
  • It matches meaning, not words. Ask here in words the document does not use and nothing comes back; ask in another language and nothing comes back at all.
  • It finds relations nobody wrote. Here the lineage is exactly the - <word> [[target]] lines a person typed. A set of notes without them has no lineage at all.
  • It reasons. Forward chaining, deductive and abductive inference, SPARQL, Datalog. --chain walks three steps along hand-written relations, and that is the whole of it.
  • It knows about time. Validity ranges and point-in-time queries answer what held in March. Nothing here carries a date.
  • It holds at size. A query here takes 15 s at 2,480 documents, and there is no sharding.

Pick this when the notes are already yours, already markdown, and what you want back is the sentence with its line number, for nothing. Pick Semantica when the sources are mixed, the questions are open-ended, and the graph has to answer to an auditor.


Command reference

ask.py "<question>"

Finds the statements that match the question and returns the statement itself with its file and line number.

python ask.py "chunk boundary equipment endpoint density"

ask.py --path "<a>" "<b>"

Shows the shortest chain linking two nodes. Use it to follow the lineage of a decision.

$ python ask.py --path "ADR 0024 endpoint_dist Global Normalization Replaces Per-Chunk Min-Max"                 "ADR 0023 GNN Coordinates from Excel mm + Per-Chunk MinMax Normalization"

Shortest path (1 hops):
  ADR 0024 endpoint_dist Global Normalization Replaces Per-Chunk Min-Max
  --supersedes_partially--> ADR 0023 GNN Coordinates from Excel mm + Per-Chunk MinMax Normalization

Names have to be given in full, and the chain is labelled with the relation words as they were written. Where a decision both names an earlier one in prose and lists it under ## Relations, the named relation is the one that shows — the bare mention is dropped so it cannot hide the lineage.

ask.py --explain "<node>"

Shows one node and its neighbours. Use it the first time you meet an unfamiliar term.

ask.py --chain "<decision>"

Shows what a decision came from and what it led to, three steps out, each with its file and line. The cause is a line you write in the later decision document:

- caused_by [[ADR 0071 Bend Radius Raised for Signal Cable]]
$ python ask.py --chain "ADR 0086 Tier Gap Reworked"

ADR 0086 Tier Gap Reworked  [decisions\ADR 0086 Tier Gap Reworked.md:1]

came from - 2
   caused_by · ADR 0071 Bend Radius Raised for Signal Cable  [decisions\ADR 0071 ....md:1]
     caused_by · ADR 0064 Signal Cable Gets Its Own Tier  [decisions\ADR 0064 ....md:1]

led to - 0
   none.

nearby decisions (not cause) - 1
   supersedes · ADR 0064 Signal Cable Gets Its Own Tier  [decisions\ADR 0064 ....md:1]

caused_by, caused, causes and led_to count as cause. The relations already in use between decisions — supersedes, supersedes_partially, corrects, refines, extends, follows, continues, operationalizes — are listed apart, because naming an earlier decision is not the same as being caused by it. The walk stops on a cycle, so a miswritten pair cannot loop it. Nothing is read out of prose: if nobody wrote the cause down, nothing shows.

ask.py --conflicts

Lists values that two documents state differently, ten at a time, with the file and line for each.

$ python ask.py --conflicts

1 conflict(s) - showing the first 1.

1. bend radius
     1) 0.25 m  <-  facts\North Rack Survey.md:4
     2) 0.3 m  <-  decisions\ADR 0071 Bend Radius Raised for Signal Cable.md:4
     3) not a conflict     4) leave it

The list is written fresh at every refresh, into <map>.conflicts.txt beside the map. It is never mixed into an answer. The check is rule-based, so it misfires, and a warning that is sometimes wrong drags down the trust in every answer next to it.

Put the value names you care about in the config to have them read first:

"watched_names": ["bend radius", "tray width", "tier gap"]

ask.py --settle "1:2 2:3"

Reads conflict number : choice and acts on each pick — keep the value you chose and correct the document holding the other, mark the pair "not a conflict", or leave it.

$ python ask.py --settle "1:2" --dry-run

   (dry run) facts\North Rack Survey.md:4  0.25 m -> 0.3 m
fixed 1 · marked not a conflict 0 · left 0 · skipped 0

--dry-run shows what would change without touching a file.

It edits the document, not the map. The map is rebuilt from the documents, so a fix written onto the map alone would leave the two disagreeing forever. Every edit is appended to <map>.resolutions.log with the file, line, old value and new value, and a document that changed since the map was built is skipped rather than overwritten. A "not a conflict" mark is stored with the values it had at the time, so it releases itself as soon as one of them changes.

build_map.py

Rebuilds the map. This is what the refresh hooks call.

python build_map.py                    # scans the folders named in the config
python build_map.py --source <path> --out <map-file>
python build_map.py --quiet            # no score printed

When it refreshes

Asking does not refresh anything. It runs at three points only.

When What
A session starts or resumes rebuild the map
A delegated task (subagent) ends rebuild the map
Right before the conversation is compacted ask for the session to be written into the knowledge documents
Right after the conversation is compacted rebuild the map

Why compaction is two points — the first hook prints the reminder that the session should be written into your notes, which the document tool then does; the second rebuilds the map so what was just written is on it. At the exact spot where context is squeezed out, the knowledge moves onto the map instead of being lost with the conversation.

It rebuilds everything

It does not pick out only the changed files. At this size there is nothing left to win — a few hundred documents rebuild in a tenth of a second — and a change-picking mechanism has to remember "what changed", which is a second thing that can drift and leave stale content alive.

Before a question it checks whether any document is newer than the map, which costs a couple of milliseconds over a few hundred files, and it only tells you.

[The map is behind 1 document(s) — WHRP Plant... It is refreshed at the next compaction]

It never quietly serves a stale answer.


Quality check

A map can build cleanly and still be half useless. So every build scores itself on five things.

Measured What a low value means
Share of nodes with a location value lookup does not work
Share of nodes that are a whole file the map is just a list of documents
Number of relation kinds there is no lineage
Components and isolated nodes path finding fails
Sampled value lookup the four above can look fine and it still finds nothing

The sampled lookup runs on every build, and its result is the samples 5/5 verbatim part of the score line. It picks statements carrying numbers in a fixed way, opens the file and line each one points at, and checks the statement is still there on that line, word for word. A sample that no longer matches is named on the spot.

A low score comes with the cause.

located 12% — many documents carry headings only, with no body text under them
few relation kinds — the `- <word> [[target]]` shape is almost absent

A low score does not block anything. You get to use it knowing what does not work.

An empty map says why

A map that comes out with nothing in it names the cause, at every refresh point, whether or not the score is being printed.

[the document folder in the config is not there - C:\notes\vualt]
[no .md, .markdown, .html or .htm documents under C:\notes]

A mistyped path and an empty folder used to look identical — both ended at nodes 0. Now the first line appears when the folder named in the config is not on disk, and the second when the folder is there but holds no documents.


Limits

Three of them, stated plainly. The last two are measured.

1. Ask in the language the knowledge documents are written in

The map does not recognise the same meaning in different words. Ask in Korean about English documents and nothing matches at all. Whatever language you ask in, match the documents.

Relation words are the one exception: - 대체함 [[X]] and - supersedes [[X]] end up as the same relation, so lineage still runs across a set of documents written in both languages.

2. Ask narrowly

Question Answer Compared to
tray piece length median statement + line number, capped at answer_budget characters cheaper than opening the files it draws from
correlation clustering local search move objective a truncated list filling the budget, the answer budget in characters more expensive than reading one note

To sweep a whole topic, hand it to a subagent and take the conclusion only. If an answer is truncated, it says so on the spot.

3. Queries slow down as the map grows

Two sizes were measured, one ten times the other.

248 documents 2,480 documents
Rebuild 0.24 s 2.13 s
Query 1.80 s 15.46 s

The query is what gives way first, not the refresh. A rebuild ten times the size is still about two seconds; a question at that size takes a quarter of a minute, which is longer than opening the file would have taken. Between those two points a question stops feeling instant somewhere in the high hundreds of documents. Past that the map has to be split, with the question sent only to the branch it belongs to — which this does not do for you.


Requirements

Python 3.9 or newer. Standard library only — nothing to pip install for the plugin itself
OS Windows · macOS · Linux
Queries graphify, a local CLI. See the two tools it leans on

Nothing in the build or the query reaches the network.


Tests

python -m pytest context-graph/tests -v

All 130 pass. Three of them matter most.

  • Same input, same output — build twice, compare byte for byte
  • Sampled value lookup — compare a statement in the map against that line in the source file
  • Odd documents — cases met in real use, kept as test material: a date read as a relation name, an empty file, and prose continuing after the relations section, which used to drop every statement below it

Troubleshooting

Text comes out garbled, or UnicodeEncodeError

The default console encoding on a Korean Windows box cannot write characters such as an em dash. The scripts pin their output to UTF-8 at startup, so this usually does not happen. If it still does:

PYTHONIOENCODING=utf-8 python ask.py "<question>"

The question returns nothing

Check that you asked in the language of the documents. English documents need an English question.

The answer comes back truncated

The question was too broad. Narrow the words, or hand the topic to a subagent.

The map answers with stale content

Refreshes run only at session start, when a delegated task ends, and after compaction - before compaction the hook prints a reminder and nothing more - and the answer tells you when the map is behind. To bring it up to date right now:

python build_map.py

The score comes out low

The map does not fit that set of documents well. Read the hints that come with the score — usually the body under the headings is empty, or there are no - <word> [[target]] relations.


License

MIT

About

A knowledge map over your markdown and HTML documents — ask a question, get the statement and the file:line it was copied from. Built by copying the structure you already wrote, never inferred by a model.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages