@nightowlsdev/graph
Adapter/StorageA bi-temporal, provenance-anchored knowledge graph plane, push any source in and get resolved entities and relations that remember when a fact was true, when you believed it, and who told you.
What it does
The knowledge-graph plane, complementary to `@nightowlsdev/knowledge` (flat pgvector RAG, "find text like this") and thread memory: it answers "what do we know about X, who told us, when did it stop being true, and what else connects to it". `graphMigration({ dimensions })` plus the additive `graph_0002`–`graph_0005` follow-ups (`GRAPH_MIGRATIONS` is the ordered set a host ejects) create the Postgres schema, the vector dimension MUST match your embedder. `createGraph({ pool, embedder, model, ontology })` assembles the plane from seams that each ship a working default: an ontology (`defineOntology` / `defaultOntology`) classifies entity and edge types; an extractor turns a record into entities and relations (`llmExtractor` for prose, `structuredExtractor` for provider payloads that already carry the values at zero model calls, `ruleExtractor` for patterns, `composeExtractors` to stack them); `resolveEntity` matches each candidate against what is already known; and `planEdgeWrite` writes it bi-temporally, tracking when a fact was true and when it was believed, invalidating a contradicted edge instead of overwriting it. Provenance is never severed, every edge cites the episode that produced it, so `episodesFor` walks a fact back to its source text. Ingestion is two-phase: `ingest`/`sync` persists episodes cheaply, `enrich`/`drain` does the model work later (an atomic claim column lets concurrent drains take disjoint batches). Ingesters live on the `@nightowlsdev/graph/sources` subpath so a host pays only for what it wires, `textSource`, `threadSource`, `postgresTableSource`, and `connectorSource`, which page-loops any connector-plane proxy behind a hard page cap so a broken paginator cannot spin forever. Reads go through `GraphQuery` (`search`, `neighborhood`, `node`, `timeline`, `path`, `findNodes`, `episodesFor`); `graphTools(graph.query)` hands those to an agent as tools whose output is fenced untrusted, and `createGraphHandlers()` mounts the same surface as framework-agnostic Request/Response handlers. `merge` and `split` correct resolution mistakes as audited operations (merge tombstones rather than deletes, so remembered ids keep resolving), and the compaction layer summarizes communities into compiled notes and lints the graph. ⚠ NOT for metrics: `corroborate` never updates an edge's properties, so an edge carrying a NUMBER silently keeps the first value it ever saw while its corroboration count, and so its confidence, rises with every repetition, the reading gets more trusted as it gets more stale, nothing errors, and a chart built on it is wrong in the direction that looks healthy. This plane is for relationships that hold across time; a time series of values belongs in `@nightowlsdev/metrics`. Prototype status; engine-wall clean, `pg` + `zod` only, with `@nightowlsdev/core` as a peer.
Install
pnpm add @nightowlsdev/graphKey exports
- createGraph
- graphMigration / GRAPH_MIGRATIONS
- defineOntology / defaultOntology
- llmExtractor / structuredExtractor / ruleExtractor
- pgGraphStore
- createQuery (GraphQuery: search / neighborhood / node / timeline / path / episodesFor)
- graphTools / rememberTool
- createGraphHandlers
- textSource / threadSource / postgresTableSource / connectorSource (@nightowlsdev/graph/sources)
Usage
import { createGraph, GRAPH_MIGRATIONS, graphTools, defaultOntology } from "@nightowlsdev/graph";
import { textSource, connectorSource } from "@nightowlsdev/graph/sources";
import { defineAgent } from "@nightowlsdev/core";
// 1. Eject the schema (dimensions MUST match your embedder). GRAPH_MIGRATIONS is the ordered
// graph_0001..0005 set — install all of it, never just the first migration.
export const MIGRATIONS = [/* …engine migrations… */ ...GRAPH_MIGRATIONS({ dimensions: 1536 })];
// 2. Build the plane. Every seam has a working default; override what you need.
const graph = createGraph({ pool, embedder, model, ontology: defaultOntology() });
// 3. Register a source, then pull it (or push a record straight in with graph.ingest).
const docs = await graph.registerSource({ orgId, kind: "text", externalId: "handbook", label: "Handbook" });
await graph.sync({ orgId, source: docs, adapter: textSource(items) });
// 4. Ask it things — search / neighborhood / timeline / path, provenance attached.
const facts = await graph.search(orgId, { query: "who owns billing" });
// 5. Or grant the read tools to an agent (output fenced untrusted).
const analyst = defineAgent({ slug: "analyst", skills: graphTools(graph.query), /* … */ });What it provides
graph is a bi-temporal, provenance-anchored knowledge-graph plane. Push any source in — internal tables, chat threads, connector payloads — and it extracts and resolves entities and relations against a host-owned ontology, records when a fact was true AND when you believed it, and never severs the link back to the source episode. It answers 'what do we know about X, who told us, when did it stop being true, and what else connects to it' — the reasoning and navigation counterpart to knowledge's verbatim RAG. createGraph({ pool, embedder, model, ontology }) assembles the whole plane from seams that each ship a working default.
When to use it
- You need to reason over relationships that hold across time — 'Acme uses Postgres', 'Jane reports to Sam' — with automatic invalidation when they stop holding.
- Provenance matters: every fact must trace back to the episode and the source that produced it.
- The swarm should learn from its own conversations (threadSource), or from internal tables at zero model calls (postgresTableSource + structuredExtractor).
- You want bi-temporal as-of queries — 'who owned billing in April?' or 'what did the agent believe on 5 July?'.
When not to
- You are recording a NUMBER that changes over time — corroborate never updates an edge's properties, so a fact holding a number silently keeps the FIRST value while its confidence rises with every repetition. Use @nightowlsdev/metrics.
- You need citation-grade verbatim quoting — the graph keeps distilled entities, not the original text; that is @nightowlsdev/knowledge.
- You want a turnkey, zero-config store — this is prototype status and needs an ontology, an embedder, and a model (or a custom extractor).
Alternatives
- @nightowlsdev/knowledgeYou want flat pgvector RAG — 'find text like this' — and verbatim chunks for citation. Ingest a document into both: knowledge quotes it, graph reasons over it.
- @nightowlsdev/metricsThe shape is a numeric time series — an (org, scope, metric, at, dims) observation plane with an upserting identity and a period-over-period compare().
- core memory.semanticRecallYou only need recall of what was said in the current thread — the graph is cross-source and durable, not per-thread.
Strengths
- Bi-temporal: it tracks world time (validFrom / validTo) and system time (believedAt / expiredAt); nothing is deleted, a contradicted edge is invalidated, and expiredBy points at its successor.
- Structural provenance: every edge cites an episode and every episode a source with a URL, so episodesFor walks a fact back to the source text that taught it.
- Injectable at every seam with a working default — ontology, extractor (llm / structured / rule / compose), resolver, temporal writer, and a provider-agnostic model adapter.
- Two-phase ingest: ingest / sync persists episodes cheaply, enrich / drain does the model work later, and an atomic claim column lets concurrent drains take disjoint batches.
- Trust caps assertions: an untrusted source can at worst land a low-confidence, clearly-attributed, expirable claim; agent-tool output is fenced untrusted.
- Ships the read tools (graphTools), framework-agnostic handlers (createGraphHandlers), audited merge / split corrections (merge tombstones so remembered ids keep resolving), and a compaction 'wiki' layer that compiles notes and lints the graph.
Limits & trade-offs
- Prototype status — extraction precision and recall on a real corpus are unmeasured; three hand-written documents prove the loop runs, not its quality.
- Not for numbers — corroborate never overwrites an edge's value, so a metric stored here goes silently, healthily stale.
- Needs a host-owned ontology, an injected embedder, and a model (or a custom extractor); the vector dimension must match the embedder or the migration is wrong.
- Traversal is bounded on purpose (expandFrom caps at 3 hops, findPaths at 4; community detection is in-memory over the top ~20k nodes) — a hub 3-hop expansion can touch millions of buffers, hence the timeout.
- egress on graph_search is undeclared by default (danger 2, so it asks under 'auto'); the other four reads are plain SQL and always danger 0.
How it works
graphMigration({ dimensions }) plus the additive graph_0002 through graph_0006 follow-ups (GRAPH_MIGRATIONS is the ordered set the CLI ejects) create the Postgres schema. createGraph({ pool, embedder, model, ontology }) assembles the plane. The ontology compiles into the extractor's Zod schema so the model structurally cannot emit an undeclared type; resolveEntity matches each candidate against what is already known; planEdgeWrite writes it bi-temporally, invalidating a contradiction instead of overwriting. Ingestion is two-phase — ingest / sync persist episodes cheaply and enrich / drain run the model later. Reads go through GraphQuery (search, neighborhood, node, timeline, path, findNodes, episodesFor); graphTools(graph.query) hands those to an agent fenced untrusted, and createGraphHandlers() mounts the same surface as framework-agnostic Request/Response handlers.
Examples
Build the plane, sync a source, query it
An internal table paired with structuredExtractor costs ZERO model calls — the provider already knows the values.
import { createGraph, GRAPH_MIGRATIONS, defaultOntology } from "@nightowlsdev/graph";
import { postgresTableSource } from "@nightowlsdev/graph/sources";
// 1. Eject the ORDERED migration set — dimensions MUST match your embedder.
export const MIGRATIONS = [...engineMigrations, ...GRAPH_MIGRATIONS({ dimensions: 1536 })];
// 2. Every seam has a working default; override what you need.
const graph = createGraph({ pool, embedder, model, ontology: defaultOntology() });
// 3. Register a source, then pull it (structuredExtractor keeps this at zero model calls).
const customers = await graph.registerSource({ orgId, kind: "postgres", externalId: "public.customers", label: "Customers", trust: "trusted" });
await graph.sync({
orgId, source: customers,
adapter: postgresTableSource({ pool, table: "public.customers", primaryKey: "id", columns: ["name", "domain"] }),
});
// 4. Ask it things — provenance, confidence, and dates ride along.
const facts = await graph.search(orgId, { query: "who owns the billing service?" });Layered, deterministic-first extraction
Free, exact extractors run before the model, which can ADD to but never overwrite what they found. Pass extractor instead of model.
import { createGraph, composeExtractors, structuredExtractor, ruleExtractor, llmExtractor } from "@nightowlsdev/graph";
const extractor = composeExtractors(
structuredExtractor(mappings), // provider fields into entities. Free, exact. Runs first.
ruleExtractor(patterns), // ticket keys, SKUs, employee ids
llmExtractor({ model, ontology }), // prose only
);
const graph = createGraph({ pool, embedder, ontology, extractor });Grant the read tools, then ask an as-of question
graph_search embeds the query (declare egress); the bi-temporal store answers what was true at a past instant without deleting anything.
import { graphTools } from "@nightowlsdev/graph";
import { defineAgent } from "@nightowlsdev/core";
// graph_search embeds the query — egress:true for a hosted embedder; the other four reads are plain SQL.
const analyst = defineAgent({ slug: "analyst", skills: graphTools(graph.query, { egress: true }) });
// As-of query: who did we believe owned billing at the start of the year?
const then = await graph.search(orgId, { query: "who owned billing?", asOf: new Date("2026-01-01") });Doing the parts it doesn't support
- Numbers that change over timecorroborate never overwrites an edge's value, so a metric stored here goes silently stale while its confidence rises. Use @nightowlsdev/metrics for a numeric series.
- Verbatim citation quotingThe graph keeps distilled entities, not the source text. For citation-grade quoting use @nightowlsdev/knowledge — ingest a document into both planes.
- A React UI over the graphUse @nightowlsdev/graph-react: mount createGraphHandlers() and point httpGraphClient({ baseUrl }) at it, then render <GraphWorkbench> (search, canvas, entity pages, the bi-temporal timeline, and the review queue).
- Deferring the expensive model workCall sync({ enrich: false }) (or ingest with enrich:false) to persist episodes cheaply now, then run graph.drain(...) from a durable worker — the atomic claim column lets concurrent drains take disjoint batches.
- A non-Postgres graph backendAll SQL lives behind the GraphStore interface; test/memory-store.ts is a complete second implementation, which is the proof a Neo4j/Kùzu backend would be a drop-in rather than a rewrite.
Related
- knowledge — The verbatim-RAG sibling; ingest a document into both — one quotes it, the other reasons over it.
- graph-react — The React workbench (search, force-directed canvas, entity pages, timeline, review queue) over createGraphHandlers().
- metrics — Where numeric series belong; the graph's corroborate silently keeps the first value of any number it stores.
- connectors — connectorSource bridges any @nightowlsdev/connectors connector into the graph using the tenant's existing connection.
- ai-studio — The graph workbench composes into the AI Studio operator console alongside agents, skills, and knowledge.