A Claude Code plugin · Copies the structure you already wrote · No language model · 100% local
182 documents · 8,524 statements linked · rebuilt in 0.11 s
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 |
- The problem
- Install
- Why it copies instead of inferring
- How it works
- Point it at your notes
- Next to Semantica
- Command reference
- When it refreshes
- Quality check
- Limits
- Requirements
- Tests
- Troubleshooting
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.
Two commands in Claude Code, then a restart.
/plugin marketplace add JadeKim042386/context-graph
Nothing to clone — Claude Code fetches it for you.
/plugin install context-graph
This brings the skill and the refresh hooks with it.
The hooks and the skill only attach on start.
/plugin
Seeing context-graph in the list is enough.
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.
/plugin marketplace update context-graph
/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.
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
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.
| 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.
- <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.
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.
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,.htmlor.htmdocuments. They are only ever readmap_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.jsonsuffixanswer_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.pynodes 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.
| 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.
- 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.
- 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.
--chainwalks 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.
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"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.
Shows one node and its neighbours. Use it the first time you meet an unfamiliar term.
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.
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"]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.
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 printedAsking 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 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.
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.
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.
Three of them, stated plainly. The last two are measured.
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.
| 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.
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.
| 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.
python -m pytest context-graph/tests -vAll 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
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>"Check that you asked in the language of the documents. English documents need an English question.
The question was too broad. Narrow the words, or hand the topic to a subagent.
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.pyThe 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.
MIT



