Plan: Persistent Graph Store + Backlinks
Context
The graph crawler (client/internal/graph/) works but is entirely in-memory — every crawl starts from scratch. Cross-session graph knowledge is lost. Phase 4 of the roadmap calls for making the graph persistent as the foundation for backlinks, graph-as-content export, and agent discovery.
Scope
Three deliverables in one increment:
- New
graphstore package — persistent graph on disk
- MCP/TUI integration — crawls merge into the store, store seeds the TUI graph view
mark_backlinks MCP tool — first consumer of the persistent graph
New Package: client/internal/graphstore/
store.go
Types:
StoredNode — URL, Title, Status, LinkCount, Etag, CrawledAt
StoredEdge — From, To
document — JSON envelope with Version (schema version = 1), Nodes, Edges
Store — in-memory state with path, sync.RWMutex, nodes map[string]*StoredNode, edges []StoredEdge, edgeSet map[StoredEdge]struct{}
Functions:
DefaultPath() — ~/.mark/graph.json
Load(path) — file-not-exist returns empty store; JSON unmarshal
Save() — atomic write: marshal JSON → write .tmp → os.Rename
Merge(g *graph.Graph, etags map[string]string) int — upsert nodes, dedup edges
Backlinks(url) []string — reverse edge lookup, sorted
GetNode(url) *StoredNode — read-locked lookup
ToGraph() *graph.Graph — reconstruct in-memory graph from stored state
Tests
Table-driven with t.TempDir(): LoadEmpty, SaveLoad round-trip, MergeUpdatesNode, MergeAddsEdges, Backlinks, BacklinksNone, ToGraph.
MCP Changes
Modify markGraph handler
- Collect etags in
FetchFunc closure (mutex-protected)
- After
graph.Crawl(): load store → merge → save (non-fatal on error)
New mark_backlinks tool
- Input:
url (required, bare path supported)
- Load store, reverse edge lookup, format as markdown list with titles
- Empty result hints to run
mark_graph first
TUI Changes
- Add
graphStore *graphstore.Store to model struct
- Load on startup (non-fatal)
- Merge + save in
crawlResult handler (on update loop, not in goroutine)
- Seed graph view from store when entering with no active crawl data
On-Disk Format
{
"version": 1,
"nodes": [{"url": "...", "title": "...", "status": "ok", "link_count": 3, "etag": "...", "crawled_at": "..."}],
"edges": [{"from": "...", "to": "..."}]
}
What Stays Unchanged
client/internal/graph/ — all types and Crawl() untouched
- No server changes, no protocol changes, no new verbs
Implementation Order
graphstore package (store.go + store_test.go)
- MCP
markGraph modification (etag collection + merge/save)
- MCP
mark_backlinks tool
- TUI integration
bash pre-commit.sh