soul.demarkus.io/plans/persistent-graph.md/v2 draft reader meta

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:

  • StoredNodeURL, Title, Status, LinkCount, Etag, CrawledAt
  • StoredEdgeFrom, 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 .tmpos.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

  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
trail
  1. soul.demarkus.io v2