Project

Weave — Notes That Become a Graph, and a Graph That Becomes Memory

Deep dive into Weave, a knowledge-graph application whose canvas lives in the browser and whose agent memory lives behind MCP, built with Rust, Axum, Next.js, React Flow, PostgreSQL/pgvector, Redis, and local ONNX embeddings.

5,536 words28 min read
On this page

A note on this write-up. Written with AI assistance. The architecture, code, and decisions are mine; the prose was drafted with an AI editor and reviewed before publishing.

Welcome to Weave

Most note-taking tools ask you to build the structure yourself. You type a note, then you draw the connections: this person works with that one, this project belongs to that team, this concept came from that paper. The graph is the useful part, and the graph is exactly the part you have to maintain by hand.

Weave tries to remove the drawing. You type a plain-language note and the application extracts the concepts and relationships from it, merges them into an existing knowledge graph, and draws the result on an infinite canvas. Within a session, typing another note does not create a second, disconnected graph. It extends the one you already have: existing concepts are resolved and reused, new ones are added, and every edge keeps a link back to the note that produced it.

That is one half of the project. The other half is a memory service for AI agents. The same idea — notes become entities and relationships, with provenance — is exposed over the Model Context Protocol, so an agent can store what it learns and retrieve a compact, evidence-backed answer later instead of carrying everything in its context window.

The two halves share a graph model and a Rust library, but they are deliberately different products. The canvas is a client-owned graph for a person. The MCP server is a server-owned store for an agent. I will come back to why that split is both the most interesting decision in the project and the one I would revisit.

The Weave canvas after ingesting a note about FBI behavioral profiling. Nodes are concepts and people, edges carry relation labels, and the teal cluster is one detected community.

The canvas after a single note: Bill Tench and Holden Ford, their work, the research team, and the profiling techniques that connect them. The +5 badge is progressive disclosure — the node has more neighbours than it is currently drawing.

What Makes Weave Different?

Plenty of tools can call an LLM and draw a graph. The interesting engineering is in the constraints around that call:

  • Extraction is not always a model call. A note that already states its graph — a Markdown outline, or JSON with nodes and edges — is parsed in-process. No tokens, no variance, identical output on every run. Prose still goes to the model.
  • The model never sees the whole graph. Ingest sends only a bounded, relevant subgraph: lexical and semantic anchors, a two-hop neighbourhood, and hard caps on nodes, edges, and estimated prompt tokens. The prompt cost stays bounded as the graph grows.
  • Every edge is grounded. The model proposes relationships, but the system decides whether each one is EXTRACTED (stated in the note), INFERRED (a deduction), or AMBIGUOUS (not supported by the note), and stores the supporting sentence. The UI draws inferred edges differently from stated ones.
  • The graph is client-owned. It lives in the browser, not in a database behind the API. The backend authenticates, limits, meters, and runs extraction, but never persists graph contents. Views — topic colours, semantic zoom, community grouping — are projections, not mutations.
  • The graph can leave. JSON with provenance, an Obsidian vault, GraphML for graph tools, or Cypher for Neo4j. The export is generated client-side and is the durable copy.
  • The agent memory is server-owned and audited. weave-mcp stores claims rather than loose text: each claim has an evidence span, a modality, a status, and a contradiction link when something newer disagrees with it.
  • The pipeline is inspectable. An admin-only tracer replays every ingest stage, including the exact prompt, the raw response, and the subgraph that was selected. When extraction is wrong, there is something to look at other than the output.

The rest of this write-up is about how those pieces fit together, and where the seams still show.

The Graph Lives in the Browser

The deployment looks conventional: a Next.js frontend on port 3000, a Rust API on 3001, PostgreSQL and Redis behind it. The unusual part is what the API is allowed to know.

TEXT
Browser → Next.js (:3000) ──/api, /auth──▶ weave-api (:3001) ──▶ PostgreSQL, Redis, LLM

Next.js acts as a same-origin gateway. It rewrites /api/* and /auth/* to the Rust service, so the browser only ever talks to one origin. That keeps the HttpOnly, SameSite=Lax session cookie working in development and keeps CORS locked to the frontend origin.

The knowledge graph itself never crosses that boundary except as part of a request. On ingest, the frontend sends the note plus the current graph; the backend selects the relevant subgraph, extracts a delta, and returns it. The delta is applied in the browser. The database has tables for users, roles, sessions, rate-limit policies, usage events, analytics, and audit entries — not for nodes and edges.

That is a deliberate inversion. The browser is the source of truth because a personal knowledge graph is a personal artifact: it changes constantly, it is read far more often than it is written, and the interesting interactions — dragging a node, focusing a neighbourhood, changing the projection — are local. Sending every drag to a server would add latency and a synchronization problem without adding anything the user can see.

The state lives in a Zustand store. The parts that matter are separated by role:

TypeScript
interface GraphState {
  /** Source of truth: the knowledge graph. */
  knowledgeNodes: KnowledgeNode[];
  knowledgeEdges: KnowledgeEdge[];
  /** Visual metadata: persisted node positions, keyed by node id. */
  positions: Record<string, XYPosition>;
  viewConfig: ViewConfig;
  /** Provenance: nodeId -> originating note (or manual creation). */
  nodeSources: Record<string, SourceRef>;
  /** Provenance: edgeId -> originating note (or manual creation). */
  edgeSources: Record<string, SourceRef>;
}

knowledgeNodes and knowledgeEdges are the model. positions and viewConfig are presentation. Keeping them apart is what makes "views are reversible projections" true rather than a slogan: switching from the default view to the topic view changes colours and grouping, and the knowledge arrays are untouched.

What the browser actually stores

A word on persistence, because it is easy to hand-wave. The scene is stored in localStorage, one key per session, plus a small key for the session list and the active session id. Saves are debounced by 300 milliseconds, and the subscription only schedules a save when something worth persisting actually changed:

TypeScript
const persistable =
  next.knowledgeNodes !== prev.knowledgeNodes ||
  next.knowledgeEdges !== prev.knowledgeEdges ||
  next.positions !== prev.positions ||
  next.communityLabels !== prev.communityLabels ||
  next.viewConfig.type !== prev.viewConfig.type ||
  next.viewConfig.semanticZoom !== prev.viewConfig.semanticZoom;

The comparisons are reference checks, not deep equality. Zustand replaces the arrays when they change and leaves them alone when they do not, so a reference check is enough — and far cheaper than walking a graph on every store update. There is also a storage event listener, so two tabs editing the same session can pick up each other's changes, and a save on beforeunload and on tab visibility changes.

The repository also contains a small IndexedDB wrapper, but it is explicitly a stub for a future binary-file tier, not where scenes live. That distinction matters for the trade-offs later: localStorage is simple, synchronous, and shared across tabs, but it is small and local to one browser. The export is not a nice-to-have; it is the only copy that survives the browser.

A Note Becomes a Delta

The ingest path has one decision at the top and two ways to finish.

Weave's ingest pipeline. The browser sends a note and the current graph; the API either parses a structured note or selects a bounded subgraph for the model, then grounds every edge before returning a delta.

The decision is whether the note already states its graph. A note like this one does:

MARKDOWN
# Ada Lovelace
- [[Analytical Engine]] — programmed
- [[Charles Babbage]] — corresponded with

## Relationships
- [[Notes on the Analytical Engine]] — wrote

The shallowest heading level names entities. Deeper headings — ## Relationships — are section labels, so their bullets stay attached to the entity above rather than becoming new entities. A bullet with a [[Target]] — relation pair becomes an edge. A link with no stated relation becomes links to. Aliases ([[Target|shown text]]) and heading anchors ([[Note#section]]) resolve to the target's label. Links inside code fences are ignored, and self-links are dropped.

JSON is accepted too: an object with nodes and edges, or an array of nodes with nested relations.

The parser is deliberately conservative. It runs only when at least 60% of the note's non-empty, non-code lines are headings, bullets, or links, and the result contains at least one edge:

RUST
/// A note must be at least this structured (headings + bullets) to be parsed
/// here; below the threshold it is prose and belongs to the LLM.
const STRUCTURED_RATIO_PERCENT: usize = 60;

/// Relation used when a note links to a concept without saying how.
const MENTION_RELATION: &str = "links to";

That second condition is the important one. An Obsidian note that happens to contain three wikilinks among five paragraphs is mostly prose; parsing it as an outline would silently discard the prose, and the user would be left wondering why half the note produced no graph. The conservative rule sends it to the model instead, where the prose can actually be read.

Structured extraction has a property that is hard to get from a model: it is a pure function of the text. The same note produces the same nodes and the same edges every time, with the source line kept as evidence. No API key, no latency, no cost, and no chance that a prompt change quietly rewrites yesterday's graph.

The model path, and why the delta is only a delta

When the note is prose, the backend selects a relevant subgraph and calls an OpenAI-compatible provider. With no provider configured, a deterministic mock extractor keeps the development loop usable. The model is asked for new nodes and new edges only, and for relations in a short lowercase form:

TEXT
Every important concept becomes a node.
Every relationship becomes an edge with a short lowercase relation label.
Reuse existing node labels EXACTLY as given. Never duplicate an existing concept.
Only return NEW nodes and NEW edges. If the input adds nothing new, return empty arrays.

Merging happens in the browser, in one pure function. Node identity first checks for an existing node with the backend's stable id, then an existing label match (case-insensitive), and only then creates a node — using the backend id when one was provided. Edges are rewritten to the canonical existing label. When a note mentions something already in the graph, the node is reused rather than duplicated — which is the difference between a graph that grows and a graph that accumulates near-duplicates until it is useless.

The merge is pure on purpose: it takes the current nodes and edges, the delta, the existing positions, and an optional layout context, and returns the next state plus positions for the new nodes. No store access, no side effects, no network. That turns identity and deduplication into a pure problem rather than a debugging session.

The LLM Only Sees What It Needs

The naive version of this feature sends the whole graph with every note. It works beautifully for the first fifty concepts and then turns into a cost problem, a latency problem, and a context-window problem at the same time. It also makes extraction worse: a model given a thousand unrelated concepts has more opportunities to attach a new note to the wrong one.

Weave selects a subgraph first. The budgets are explicit constants, not emergent behaviour:

RUST
pub const MAX_ANCHORS: usize = 8;
pub const MAX_NODES: usize = 64;
pub const MAX_EDGES: usize = 256;
pub const MAX_HOPS: usize = 2;
pub const ESTIMATED_TOKEN_CEILING: i64 = 1_500;

Selection runs in three passes.

Anchors. Candidate terms are extracted from the note: proper-noun phrases first, then significant lowercase tokens with stopwords removed. Each existing label is matched against those terms, with exact matches beating word overlap, and word overlap beating a substring hit. If the embedder is semantically meaningful, cosine similarity is computed against each label and contributes a smaller, secondary score:

RUST
fn combined_score(strength: MatchStrength, similarity: Option<f32>) -> f64 {
    let lexical = match strength {
        MatchStrength::Exact => 1.0,
        MatchStrength::WordOverlap => 0.9,
        MatchStrength::Substring => 0.6,
        MatchStrength::None => 0.0,
    };
    let semantic = similarity.unwrap_or(0.0) as f64 * 0.4;
    lexical + semantic
}

The best eight matches become BFS seeds. Matched nodes beyond eight are still included at hop zero; they just do not expand the search.

Expansion. The BFS treats edges as undirected and walks out to two hops. Nodes are ranked by hop distance first, then combined score, then degree, then label. That ordering encodes a useful bias: the note's own concepts matter most, their immediate neighbours matter next, and a well-connected node is more likely to be a useful bridge than an obscure leaf.

Greedy selection under budget. Nodes are taken in rank order until the node cap or the token ceiling is reached. Edges are then ranked by the sum of their endpoints' hop distances and taken until the edge cap or the budget runs out. Because the token estimate is checked while selecting, a graph full of very long labels cannot defeat the node count. There is a test for exactly that, and another for a hub with three hundred leaves — a single high-degree node must not be able to consume the whole context.

The fallback is equally important: if nothing matches, the selection is empty and the note is sent alone. A note about a completely new topic should not be padded with irrelevant context just because the graph happens to be large. An empty subgraph is a valid, honest answer.

The admin tracer makes this visible. It runs the same functions as a real ingest — authentication, permission check, policy resolution, rate limiting, extraction — but observe-only, so a would-be block does not abort the trace. The page shows each stage with its duration, the anchors that were selected, the nodes and edges that were omitted, the exact prompt, and the raw response. Debugging a bad extraction stops being guesswork.

Every Edge Says How It Knows

Asking a model for a confidence score produces a number that looks precise and means very little. Weave does not ask. The model returns relationships; the system grounds each one against the note afterwards, deterministically, at zero extra tokens.

The rules are simple enough to state in a paragraph. Both endpoint labels have to appear in the note. The relation's meaningful words have to appear too. If the relation is generic — "related to", "associated with", "connected to" — it can only ever be inferred. If an endpoint is missing, the edge came from graph context rather than from what the user wrote, so it is flagged as ambiguous.

RUST
let kind = if !source_present || !target_present {
    // An endpoint the note never names came from graph context, not from
    // what the user wrote: flag it for review.
    confidence::AMBIGUOUS
} else if generic {
    confidence::INFERRED
} else if tokens.is_empty() {
    // Relation is pure function words ("is a"): accept only if literal.
    if note_lower.contains(&relation_lower) {
        confidence::EXTRACTED
    } else {
        confidence::INFERRED
    }
} else if matched.len() == tokens.len() {
    confidence::EXTRACTED
} else {
    confidence::INFERRED
};

The evidence sentence comes from the note itself: the first sentence containing a meaningful word from the relation, or failing that, the first sentence containing the source label. It is trimmed to a reviewable length. There is no model in this path, so the class and the evidence are reproducible.

The UI takes the classification seriously. Extracted edges are solid, inferred edges are dashed, and ambiguous edges are amber. The node inspector lists every connection with its class, its relation, and the evidence sentence, and the amber treatment makes an ambiguous edge read as a question rather than a fact. Provenance is stored next to the edge — the originating note and the time — so "why is this here?" has an answer that does not depend on remembering the session.

This is the part of Weave I would defend most strongly. The model is good at reading language and bad at knowing what it knows. Splitting those jobs — model for extraction, deterministic code for grounding — gives you most of the flexibility with none of the false confidence.

The Canvas Is a View

The graph model has no opinion about how it is drawn. The canvas is where that opinion lives, and it can change without touching the knowledge.

Semantic zoom is the clearest example. Zooming does not just scale the camera; it changes the information density. The four levels are overview, category, entity, and detail. At overview the projection keeps only the most important concepts; category lowers that threshold; entity and detail draw the full graph. The underlying knowledge is identical at every level.

Communities are detected with label propagation. Every node starts with its own label, then repeatedly adopts the most common label among its neighbours, with the smallest label breaking ties so the result is deterministic. A few passes are enough for small graphs; the loop is capped at twenty. It is a pure graph algorithm — no LLM, no vectors, and it never mutates the knowledge graph.

TypeScript
const counts = new Map<number, number>();
let best: number | null = null;
let bestCount = 0;
for (const nb of neighbors) {
  const l = labels.get(nb);
  if (l === undefined) continue;
  const c = (counts.get(l) ?? 0) + 1;
  counts.set(l, c);
  if (c > bestCount || (c === bestCount && best !== null && l < best)) {
    best = l;
    bestCount = c;
  }
}

Layout is derived from the same signals. Communities get anchors spread around the graph centroid; members sit on a small ring around their anchor; importance orders the members so hubs land nearest the community anchor. The radii grow logarithmically with the number of communities, so adding a tenth group does not push the first one off the canvas:

TypeScript
const anchorR = nGroups === 1 ? 0 : 150 + 60 * Math.log2(Math.max(nGroups, 2));
const innerR = n === 1 ? 0 : 40 + 110 * (i / (n - 1));

Existing positions only inform the centroid. That is a small detail with a large effect: pressing "re-layout" re-organizes the graph without teleporting it to a different part of the canvas, so the user does not lose their place.

The same philosophy runs through the advisory layer. Structural findings are computed deterministically: hub concepts by degree, bridge nodes by articulation points, isolated concepts, near-duplicate labels, and edges whose confidence is not extracted. The LLM is then asked only to do what a graph algorithm cannot: name groups of existing labels, and propose plausible missing edges between labels that already exist. It receives a bounded digest, not the whole graph, and it is explicitly told never to invent a label. An AI suggestion can therefore be wrong, but it cannot reference a concept that is not there.

Agents can edit the canvas, too

There is a second way into the graph that is easy to miss: Weave implements a Common Ground adapter. Agents send typed operations — node.create, node.update, node.delete, edge.create, edge.update, edge.delete, merge, view.set — over a stream, and the adapter applies them to the Zustand graph, computes inverses, and posts acknowledgements.

The interesting part is the journal. Manual edits are recorded through the same path with a cap_human capability, so a rename or a deletion appears beside agent turns rather than in a separate history. Position-only drags and view changes are not journaled, because they do not change knowledge. The cap_graph capability is bound to a weave-agent; creates and updates apply directly, while node and edge deletes and merges require a one-shot human approval. Undo is a new turn, not a rewrite of history.

This is a different mechanism from the MCP server, and the distinction is deliberate. Common Ground is about two actors editing the same document with a shared, auditable history. MCP is about an agent storing and retrieving memory that the canvas never sees.

The Other Half: Memory for Agents

weave-mcp is a Rust binary built on rmcp. It speaks the Model Context Protocol over stdio, or over Streamable HTTP at /mcp when containerized, and exposes eighteen tools. The tools are the public contract; the storage underneath is PostgreSQL with pgvector, plus hash-keyed files on disk.

weave-mcp: an agent talks to eighteen memory tools, which validate and verify claims before storing them in PostgreSQL/pgvector and the file store. Each agent gets its own database and data directory.

The two graphs are not two views of one store. The canvas graph is client-owned, visual, and personal. The memory graph is server-owned, textual, and programmatic. Each agent runs its own weave-mcp instance with its own database and data directory, so one agent cannot read another's notes. The database is created automatically on first start.

The eighteen tools cover the whole lifecycle. Memory writes and reads: remember, recall_memory, search, list_notes, get_note, delete_note. Graph traversal: get_node, get_related, explain_node, path_between. Claims: get_claim, list_claims, correct_claim. Operations: add_file, reindex_embeddings, forget_entity, prune_notes, memory_stats.

Notes, claims, and the difference between them

A note is the raw unit: full text, a compact LLM summary, tags, and the entities and relations extracted from it. Long documents are chunked into chunk notes. Writes are idempotent — identical content returns the existing note rather than creating a duplicate — and audited.

A claim is the durable memory unit, and it is stricter. Every candidate claim is validated deterministically before it becomes memory. Labels are checked for length, alphanumeric content, and a list of placeholders ("thing", "something", "n/a", "it"). Predicates are normalized and length-checked. The note text is scanned for negation, hedging, and conditional markers to infer modality: asserted, negated, suggested, or conditional. An evidence span and offset are stored alongside the claim.

Contradictions are not resolved by overwriting. If a new claim opposes an existing one — same triple, opposing modality — both are marked contradicted and linked. Cross-note repeats are treated as corroboration and kept separate. Claims also carry a status: active, contradicted, superseded, rejected, or quarantined. The history is the point; silently replacing an old belief with a new one loses the most useful information in a memory system.

Selective verification

The deterministic validator cannot catch everything. A claim can be well-formed and still be unsupported by its evidence, or rest on an ambiguous entity, or use comparative language that the extractor flattened into a fact. Those cases go to a second, narrow LLM call.

The risk policy is explicit. Verification is triggered by an unsupported claim, uncertain modality, comparative language, ambiguous entity resolution, or a contradiction with an existing high-confidence claim. The verifier gets one claim, its evidence, and a small set of candidate entities, and returns strict JSON:

JSON
{"decision":"accept|reject|quarantine","confidence":0..1,
 "reason_code":"supported_by_evidence|unsupported|contradictory_evidence|ambiguous_entity|scoped_negation|uncertain|malformed",
 "canonical_subject_id":"uuid|null","canonical_object_id":"uuid|null",
 "corrected_modality":"asserted|negated|suggested|conditional|null",
 "explanation":"one short sentence"}

It can correct a scoped negation, pick a canonical entity, or decline to decide. "Quarantine" is a first-class outcome, which is the right default for a memory system: a confident wrong answer is worse than an explicit "I am not sure". The exception path is capped at five verifier calls per note, so the risk policy can never turn a normal write into an unbounded bill.

Retrieval that can be measured

recall_memory is the flagship tool. It combines three signals: full-text search, vector similarity from local ONNX embeddings, and graph expansion from the matched entities. The note lists are merged with reciprocal-rank fusion into a compact context block, with claims, evidence, and contradictions attached.

Entity selection uses a separate, explainable score. Each candidate records whether it was mentioned verbatim, matched by keyword, or reached as a neighbour, plus its cosine similarity and its degree. The ranking formula is:

TEXT
0.6 · lexical + 0.4 · similarity − hub_penalty

The lexical base is 0.9 for a mention, 0.6 for a keyword match, and 0.4 for a one-hop neighbour. The hub penalty is min(0.2, 0.01·ln(1+degree)), which stops a handful of very connected entities from dominating every query. Every candidate carries its reasons, so a tool can explain why it was selected rather than presenting a black-box list.

The part I am proudest of is the evaluation corpus. mcp/tests/retrieval_eval.rs measures recall@k and MRR over a fixed set of cases, using pre-computed signals — no database, no network, no embedder. A weight change that makes retrieval worse fails the test suite. The corpus is not a benchmark; it is a ratchet. It prevents the most common form of quiet regression in retrieval systems, where a small "improvement" to one weight makes three other queries worse and nobody notices until users complain.

Embeddings are local. The container build uses BAAI/bge-small-en-v1.5 through fastembed, 384 dimensions, with the model cached on the data volume. The default build uses a deterministic stub embedder so the pipeline works offline; the stub reports that it is not semantic, and the retrieval layer skips the semantic signal entirely rather than ranking on random vectors. That sounds like a small detail. It is the difference between a useful fallback and nonsense.

The Platform Work Nobody Sees

A lot of Weave is not knowledge-graph code. It is the machinery that makes an LLM-backed application safe to leave running: identity, permissions, quotas, metering, and audit.

Authentication is Google OAuth. The flow stores a one-time state value in Redis, verifies the returned ID token's issuer, audience, and nonce, and keys the user on the stable Google sub claim rather than email. Sessions are server-side: the browser holds an opaque 256-bit token in an HttpOnly, SameSite=Lax cookie, and PostgreSQL stores only its SHA-256 hash.

RUST
/// SHA-256 hex digest of the raw token — the value stored in PostgreSQL.
pub fn hash_token(token: &str) -> String {
    hex::encode(Sha256::digest(token.as_bytes()))
}

A database dump therefore does not hand over live sessions. The first account whose email is in BOOTSTRAP_ADMIN_EMAILS is granted admin — but only while no admin exists. After that, admin rights come from the database, so the bootstrap list cannot be used as a permanent back door.

Rate limits resolve from global to role to user override, with the more specific rule winning, and everything is clamped to a global hard ceiling. Policies can be endpoint-specific: graph.ingest, graph.organize, graph.label_community, and graph.search each have their own budget. Counters are atomic Lua scripts in Redis, so there is no read-calculate-write race between concurrent requests. Blocked requests do not consume quota. Token quotas are checked before the model call, and concurrency slots are released even on failure through an RAII guard.

Redis failure behaviour is intentionally asymmetric. Authentication uses PostgreSQL and keeps working. Expensive graph and LLM endpoints fail closed with a 503 rather than silently allowing unlimited model traffic. Cheap health and status endpoints never require Redis at all. When the alternative is an unbounded bill, failing closed is the correct default.

Usage metering is best-effort, and deliberately so: a metering failure must never break the graph operation the user asked for. Each LLM call records provider tokens, latency, endpoint, model, and status in PostgreSQL, and feeds the Redis day and month counters used by the quota check. Analytics events and the audit log capture the product and security side. Privileged changes are written to the audit log in the same transaction as the change, so an admin action cannot exist without its record.

Every API response carries an X-Request-ID, and every error uses the same shape:

JSON
{ "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded", "request_id": "…" } }

None of this is novel. It is the unglamorous layer that turns a demo into something you can expose to other people, and it is a substantial fraction of the backend.

The Backup Is the Export

Because the graph lives in the browser, export is not a feature bolted on at the end. It is the durability story.

The header menu reads the active session and generates the file client-side. Nothing is uploaded, and there is no server route for it. Four formats cover four different jobs:

  • JSON snapshot — the archival form. schema: "weave.graph", an independent schema_version, counts, positions, community labels, and provenance inlined per node and edge.
  • Obsidian vault — a ZIP with a Weave.md index and one note per concept under Nodes/. YAML frontmatter carries the Weave id and kind; [[wikilinks]] point both ways; relation confidence, evidence, and the source note are included.
  • GraphML — for Gephi, yEd, NetworkX, and the rest of the graph-tool ecosystem. Labels and kinds on nodes; relation, confidence, and evidence on edges.
  • Cypher — MERGE statements for Neo4j or Memgraph, one per concept and one per relationship.

The export schema version is independent of the in-browser PersistedScene version. An export outlives migrations: it may be opened years after the app has changed its internal storage shape, so it gets its own contract and its own breaking-change rules.

The writers are pure functions in frontend/src/lib/export.ts, with a dependency-free ZIP writer beside them. npm test runs them under Node's built-in test runner, no test framework required. That is a small decision with a predictable payoff: the code that decides whether a user keeps their data is the easiest code in the project to test, and it does not need a browser to run.

What I Would Do Differently

The two graphs are too easy to confuse. The canvas graph and the MCP memory graph share an extraction pipeline, a name, and a Rust library, but they are not connected. A user who stores notes in the canvas will not find them in their agent's memory, and the explanation — one is client-owned, one is server-owned — is architectural, not obvious. I would either unify them behind one storage model with different projections, or give the memory service a clearly different name. The current split is defensible for isolation and for very different access patterns, but it costs more explanation than it should.

I would move scenes to IndexedDB sooner. localStorage was the fastest way to get a reliable save loop, and the debounce plus cross-tab listener works well. But it is a few megabytes, synchronous, and tied to one browser profile. The IndexedDB wrapper is already there; it is a stub for files, not scenes. For a graph that is meant to grow for years, the primary store should be the one with room, and export should be a backup rather than the only durable copy.

Layout is good at hundreds of nodes and untested at thousands. Label propagation and the community-ring layout are deterministic, readable, and cheap. They are also global: every re-layout considers every node. At a few thousand nodes the canvas becomes a hairball, and semantic zoom helps the reading problem more than the layout problem. The next version would need incremental layout, clustering at the top level, and virtualization — not a different force simulation.

Ambiguous edges should be opt-in. The grounding classifier is honest, and the UI distinguishes ambiguous edges, but they still appear by default. In practice, an ambiguous edge is a question, not a fact. I would apply extracted and inferred edges automatically and put ambiguous ones in a review queue, so the default canvas contains only what the note actually supports. The confidence data is already stored; the missing step is making review the default rather than a suggestion.

Some of the platform layer is overbuilt for a personal tool. Rate-limit policies with role and user overrides, analytics dashboards, audit logs, and a pipeline tracer are all useful, and I learned a lot building them. But they are also a second product's worth of surface area. If the goal were only a personal knowledge graph, I would keep authentication, hard limits, and the tracer, and defer the policy UI.

Closing

Weave started from a simple question: can the graph build itself? The answer is yes, with qualifications. A model can read a note and propose structure, but it should not be trusted to know which parts of that structure are real. The deterministic parser, the bounded subgraph, the grounding classifier, and the evaluation corpus are all ways of drawing that line.

The useful lesson is not "use an LLM" or "don't use an LLM". It is to use the model where language is genuinely ambiguous, and deterministic code everywhere else: parsing, identity, budgets, grounding, storage, export. The model is a component. The architecture is still yours.

Repository

github.com/Sidharth-Singh10/weave

← Back to projects