# 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: 1. New `graphstore` package — persistent graph on disk 2. MCP/TUI integration — crawls merge into the store, store seeds the TUI graph view 3. `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 ```json { "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 1. `graphstore` package (store.go + store_test.go) 2. MCP `markGraph` modification (etag collection + merge/save) 3. MCP `mark_backlinks` tool 4. TUI integration 5. `bash pre-commit.sh`