The facts table is a durable structured-memory layer for PilotSwarm agents. It provides a PostgreSQL-backed key/value store that agents can use for session-scoped working memory and cross-session shared knowledge. Facts survive process restarts and are automatically cleaned up when sessions are deleted.
LLM conversations are lossy — context windows are finite, chat history can be truncated, and agent sessions may be replayed on different workers after a crash. Agents need a reliable way to persist:
- user instructions and preferences
- task state, checkpoints, and resumable progress
- identifiers, URLs, configuration values, baselines
- cross-agent handoff state
The facts table provides this as a first-class, always-available tool for every session.
┌────────────────────────────────┐
│ PilotSwarmWorker │
│ ┌──────────────────────────┐ │
│ │ SessionManager │ │
│ │ ┌────────────────────┐ │ │
│ │ │ ManagedSession │ │ │
┌──────────┐ │ │ │ ┌──────────────┐ │ │ │
│ LLM Turn │──calls──► │ │ │ │ fact tools │──┼──┼──┼──► PgFactStore ──► PostgreSQL
│ │ │ │ │ │ store/read/ │ │ │ │ │
│ │ │ │ │ │ delete │ │ │ │ ▼
└──────────┘ │ │ │ └──────────────┘ │ │ │ pilotswarm_facts.facts
│ │ └────────────────────┘ │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
┌──────────────────┐
│ PilotSwarmClient │
│ factStore ──────┼──► PgFactStore (used by client.deleteSession for cleanup)
└──────────────────┘
┌──────────────────┐
│ Sweeper Agent │
│ cleanup_session │──► factStore.deleteSessionFactsForSession (descendant cleanup)
└──────────────────┘
| Component | Role |
|---|---|
PgFactStore |
PostgreSQL-backed implementation of the FactStore interface. Handles connection pooling, schema creation, and all CRUD operations. |
createFactTools() |
Factory that produces three Copilot SDK Tool objects (store_fact, read_facts, delete_fact) wired to a FactStore. |
SessionManager |
Owns the FactStore instance and injects fact tools into every ManagedSession's tool set. |
PilotSwarmWorker |
Creates and initializes the PgFactStore during start(), then passes it to SessionManager and sweeper tools. |
PilotSwarmClient |
Creates its own PgFactStore for client-side cleanup (e.g., deleteSession removes session facts). |
cleanup_session (sweeper) |
Deletes session-scoped facts for the root session and all descendants during cleanup. |
Shared facts whose keys start with intake/ also wake the Facts Manager after
the write commits. Session-scoped intake/* facts and shared non-intake facts do
not enqueue a wake-up; the 6-hour Facts Manager maintenance cron is the fallback
for missed or unavailable reactive wake-ups.
CREATE TABLE IF NOT EXISTS pilotswarm_facts.facts (
id BIGSERIAL PRIMARY KEY,
scope_key TEXT NOT NULL UNIQUE,
key TEXT NOT NULL,
value JSONB NOT NULL,
agent_id TEXT,
session_id TEXT,
shared BOOLEAN NOT NULL DEFAULT FALSE,
transient BOOLEAN NOT NULL DEFAULT FALSE,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (NOT (shared AND transient))
);This block shows the baseline table. Later migrations add columns the rest of
this doc relies on: deleted_at TIMESTAMPTZ and etag BIGINT (migration
0010_soft_delete_etag — soft-delete tombstones and crawl-write protection)
and last_crawled_at (crawl-queue bookkeeping).
| Column | Purpose |
|---|---|
scope_key |
Composite uniqueness key. shared:<key> for shared facts, session:<sessionId>:<key> for session-scoped facts. Ensures one fact per key per scope. |
key |
Human-readable fact identifier (e.g., baseline/tps, infra/server/fqdn). |
value |
JSON-serializable payload stored as JSONB. |
agent_id |
Optional provenance — which agent stored the fact. |
session_id |
The session that owns the fact. NULL is technically possible for shared facts but the storing session's ID is still recorded. |
shared |
true = globally visible across all sessions. false = session-scoped, only visible to the owning session. |
transient |
Mutually exclusive with shared (enforced by CHECK constraint). Session-scoped facts are transient; shared facts are not. |
tags |
Array of string tags for categorized querying (e.g., ["build", "ci"]). Uses GIN index for array containment queries. |
idx_*_facts_key— B-tree onkeyidx_*_facts_tags— GIN ontags(supports@>containment)idx_*_facts_session— B-tree onsession_ididx_*_facts_agent— B-tree onagent_ididx_*_facts_shared— B-tree onsharedidx_*_facts_transient— B-tree ontransient
The schema name defaults to pilotswarm_facts and is configurable via factsSchema on both PilotSwarmWorkerOptions and PilotSwarmClientOptions. Tests use isolated schemas (pilotswarm_facts_it_<timestamp>_<random>) for parallel test execution.
Stores or upserts one fact, or a batch of facts. Session-scoped by default.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
key |
string | yes, unless facts is set |
Fact identifier (e.g., baseline/tps) |
value |
any | yes, unless facts is set |
JSON-serializable value |
tags |
string[] | no | Tags for filtering |
shared |
boolean | no | true for cross-session shared fact (default: false) |
facts |
{ key, value, tags?, shared? }[] |
no | Batch ingestion form. When present, top-level key / value are ignored. |
Behavior:
- Uses
ON CONFLICT (scope_key) DO UPDATE— callingstore_factwith the same key overwrites the previous value. - The session ID and agent ID are automatically populated from the calling context.
- Returns
{ key, shared, scope: "shared" | "session", stored: true }. - Batch form returns
{ stored, facts: [...] }with the same per-fact result shape.
Reads facts visible to the calling session.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
key_pattern |
string | no | Key pattern with % or * wildcards |
tags |
string[] | no | All listed tags must be present |
session_id |
string | no | Provenance filter by source session |
agent_id |
string | no | Provenance filter by source agent |
limit |
number | no | Max rows (default: 50) |
scope |
string | no | accessible (default), shared, session, or descendants |
Scope Semantics:
| Scope | Returns |
|---|---|
accessible |
Caller's own session facts + spawn-tree facts (every other session under the same root: ancestors, descendants, siblings, cousins) + all globally-shared facts |
shared |
Only globally-shared facts |
session |
Only the caller's own session-scoped facts |
descendants |
Same spawn-tree visibility as accessible, kept as an explicit family-tree view |
Visibility Rules:
- Session-scoped facts are visible to every session in the same spawn tree — the caller's session, every ancestor on the way up to the root, and every descendant of that root (i.e. siblings and cousins, not just children/grandchildren). The
session_idparameter is spawn-tree-aware: when a caller passessession_id=<other>and<other>belongs to the same spawn tree, that session's session-scoped facts become visible. Sessions outside the spawn tree remain inaccessible. - When
scope=descendants, the handler resolves the entire spawn tree (root + all descendants of root) and includes those session IDs in the visibility set. It is functionally equivalent toaccessibletoday and kept as an explicit family-tree view. - Lineage / spawn-tree resolution uses the CMS
parent_session_idtree: the worker walks up to the root ancestor, then expands via the same recursive CTE the sweeper uses for cleanup.
Deletes a fact by exact key, or deletes matching keys when explicitly requested.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
key |
string | yes | Fact key to delete |
shared |
boolean | no | true to delete the shared fact; false (default) to delete the caller's session-scoped fact |
pattern |
boolean | no | Must be true to treat key as a pattern. Supports * globs and SQL % wildcards. |
scope |
string | no | Pattern-delete scope: session, shared, or all. all is Facts Manager only. |
Returns: { key, shared, deleted: boolean }.
For pattern deletes, returns { keyPattern, scope, deleted }. Pattern deletes are opt-in so a key like a/b/* is literal unless pattern=true. Ordinary task agents can only delete facts they own; the Facts Manager can use scope="all" for shared and session cleanup.
- Visible to the owning session via
scope=session, and to every session in the same spawn tree viascope=accessible,scope=descendants, orsession_id=<tree-member>. - Not visible outside the spawn tree.
- Automatically deleted when the session is deleted (via
deleteSessionFactsForSession()). - Upsert key:
session:<sessionId>:<key>.
- Visible to all sessions via
scope=accessibleorscope=shared. - Persist indefinitely until explicitly deleted with
delete_fact(key, shared=true). - Upsert key:
shared:<key>. - Not cleaned up by session deletion or sweeper cleanup.
client.deleteSession(sessionId)— callsfactStore.deleteSessionFactsForSession(sessionId)to remove all session-scoped facts.- Sweeper
cleanup_session— iterates throughgetDescendantSessionIds()and callsdeleteSessionFactsForSession()for each descendant, then for the root session. - Shared facts are never automatically deleted.
PilotSwarmWorker.start()
→ createFactStoreForUrl(store, factsSchema)
→ factStore.initialize()
→ sessionManager.setFactStore(factStore)
→ createSweeperTools({ ..., factStore })
SessionManager.getOrCreate()
→ createFactTools({ factStore })
→ inject into CopilotSession tool set alongside system tools, sub-agent tools, and user tools
Facts tools are re-registered on every runTurn() call as part of the standard tool set.
The default.agent.md includes a ## Facts Table section that instructs the LLM to use facts aggressively for durable memory. Key guidance:
- Treat conversational memory as lossy — write important state to facts.
- Session-scoped by default, use
shared=trueonly for cross-spawn-tree or global knowledge. - Read relevant facts before resuming long-running or multi-agent work.
- Respond to user "remember" / "forget" requests via facts tools immediately.
- During multi-agent work, use
read_facts(session_id=<tree-member>)orscope=descendantsto pull facts from peers and descendants in the same spawn tree.
The base facts table above is always present. Two optional, independently
injected providers extend it. They are wired through config only — code that
uses store_fact / read_facts / delete_fact is unchanged whether or not they
are present. See Configuration
for the worker/client knobs, and Deploying a Knowledge Harvester
to run ingestion as a dedicated service over a shared HorizonDB.
| Axis | Provider | Lights up |
|---|---|---|
| Facts capability | EnhancedFactStore (HorizonDB: pgvector + pg_textsearch + pg_durable) |
facts_search, facts_similar, search_skills |
| Graph presence | GraphStore (Apache AGE) |
graph_search_nodes, graph_search_edges, graph_neighbourhood, + graph writes and crawler crawl/reconcile surface |
They are independent: an enhanced fact store with no graph gets search tools
but no graph tools; a base fact store with a graph gets the full graph surface
but no search tools. Graph tools key off graph presence alone
(graphDatabaseUrl set / !!graphStore) — never the facts capability. The
runtime asks isEnhancedFactStore(store) once at boot and threads the answer
through; it never sniffs per turn.
// Capability descriptor the runtime reads to gate enhanced tools.
interface FactsCapabilities { search: boolean; embedder: boolean; }facts_search / facts_similar / search_skills register only when the store
is an EnhancedFactStore and capabilities.search is true. The embedder
only affects semantic ranking and prompt wording — with embedder: false,
search still registers and runs in lexical/hybrid mode.
When a batch embed fails, the durable embedder marks those rows with its internal
retry marker and retries them one by one before selecting fresh batch work. When
a single row still cannot be embedded, it records a numeric internal
last_embed_error on that fact and continues with later rows. A subsequent
content change clears the error, putting the row back into normal batched
embedding. Store users do not need to read this field; it is embedder state.
Known internal codes are:
| Code | Meaning |
|---|---|
1001 |
Input too large for the embedding model. |
1400 |
Provider rejected the row/request as bad input. |
1401 |
Provider authentication failed. |
1403 |
Provider authorization failed. |
1429 |
Provider rate limited the row request. |
1500 |
Provider/server returned a 5xx response. |
1901 |
Provider returned a malformed embedding payload. |
9999 |
Unknown terminal embedding failure. |
On a base store, the framework injects a capped slice of curated skills into the
system prompt every turn. On an enhanced store that push is dropped; instead
the agent gets a dedicated search_skills tool for explicit skill requests and
non-obvious questions or workflows that the current context does not resolve.
Greetings, simple arithmetic, status checks, and follow-ups can be answered
directly when context suffices. Relevant loaded skills are reused; another
search is appropriate for a new knowledge gap or an explicit request. Each
search returns ranked relevant skills, whose full instructions must be loaded
before use, instead of an arbitrary slice on every turn. The agent can also
retrieve its own past intake/* observations via facts_search.
The graph stores ids + structure only — never fact values or ACLs. Nodes
(kind, name, aliases) and edges (free-text predicate, confidence,
observations) carry optional evidence: fact scopeKey pointers that anchor
a node/edge to the facts that justify it. Resolving evidence back to fact values
is a tool-layer composition across the two separate providers:
graph_search_nodes(...) -> node.evidence (scopeKeys) -> read_facts({ scopeKeys })
- Seeds pivot. A query seeded with a fact
scopeKeypivots into the graph via theEVIDENCED_BYanchor; a node-key seed expands directly. - Reinforcement. Re-asserting the same
(fromKey, predicate, toKey)does not duplicate — it bumpsobservationsand combines confidence (noisy-OR) only when new evidence is supplied; same-evidence replays are harmless no-ops.
Every base fact carries a scopeKey and a last_crawled_at stamp. Facts with
last_crawled_at IS NULL (new or edited since the last crawl) are the
crawler work queue:
facts_read_uncrawled({ keyPrefix?, namespace?, limit? })— returns queued facts, each with itsscopeKey+etagreceipt (anddeletedAtwhen the fact is a tombstone).keyPrefixis a literal key prefix;namespaceremains a deprecated alias for the same read filter.facts_set_crawled({ scopeKeys: [{ scopeKey, etag }], crawled?: true })— stamps processed facts as incorporated so they leave the queue. Includeetagto make each entry conditional; omit it only for an intentional stomp. A skipped entry means the fact was already in the target state, changed since your read (etag mismatch), or no longer exists.facts_set_crawled({ keyPrefix, crawled })— flips a whole literal prefix. Usecrawled:falseto requeue a prefix for recrawl;crawled:trueis a coarse flush and skips tombstones, which should be reconciled withgraph_remove_evidenceand marked byscopeKeys.
Active graph harvesting is app-specific (deciding what to crawl and how to extract entities/edges is domain knowledge PilotSwarm cannot supply generically), but the tools are shipped by PilotSwarm:
- An app crawler agent (an agent the operator marks with
crawler: true; legacyharvester: trueremains an alias) is the active crawler — it is granted the crawl queue, graph evidence reconciliation, namespace archive, broad corpus fact access, and app-specific extraction guidance. - The
facts-managerholds the same crawler tools but is dormant — not prompted to crawl, so nothing is harvested out of the box. It additionally gets read-onlygraph_statsto report on graph size / last-crawled time. - A crawler should resolve before it creates:
graph_search_nodesbynameLikefirst, thengraph_upsert_nodewith the source fact'sscopeKeyas evidence — this is what makes reinforcement dedup work. - Graph tools accept the same
namespaceconcept as the crawl/search tools.namespace: "corpus/acme"matches graph nodes/edges stamped exactly withcorpus/acmeand descendants such ascorpus/acme/services. Use the same namespace string forfacts_read_uncrawled({ keyPrefix }),facts_search,graph_search_*,graph_upsert_*,graph_neighbourhood,graph_stats, merge, and delete operations so a crawler can discover or maintain one corpus/domain without enumerating seed nodes first.
Graph topology (which nodes/edges exist, how they connect) is shared across
all readers. Graph evidence arrays are ACL-filtered per caller exactly like
facts: a reader sees only the evidence scopeKeys it could read via read_facts
(its own session, its spawn-tree lineage, and shared facts). An inaccessible fact
seed is treated as unknown — indistinguishable from a non-existent seed, so it is
not an existence oracle.
Graph reads use the same lineage resolver as read_facts: the runtime threads
the caller's AccessContext (readerSessionId + granted lineage sessionIds,
self-excluded and deduped) into every graph read. A reader with no lineage sees
its own session only — never an unrestricted/wildcard scope.
The agent-tuner investigator gets every read tool — base read_facts,
facts_search / facts_similar, search_skills, the graph reads, and
graph_stats — with graph reads resolving unrestricted (it is the privileged
investigator). It is never granted a write, delete, crawl, or mutating control
tool, even if a stale config sets the crawler/legacy harvester flag. The sweeper is unchanged: it
sweeps facts via the existing cascading session delete and never touches graph
nodes.
- Provider-level conformance + ACL/crawl/graph semantics:
packages/horizon-store/test/integration/*(gated onHORIZON_DATABASE_URL). - SDK capability×role tool gating + prompt adaptation (DB-less):
packages/sdk/test/local/enhanced-tool-gating.test.jsandgraph-tools-gating.test.js. - SDK real-provider composition (base PgFactStore + real graph, crawl→harvest→resolve round-trip, gated on
HORIZON_DATABASE_URL):packages/sdk/test/local/enhanced-composition.integration.test.js.
- PostgreSQL only. The
createFactStoreForUrl()factory rejects non-Postgres URLs. SQLite is not supported for facts. - No SQLite fallback. Unlike the CMS and duroxide stores which support SQLite for local development, facts are Postgres-exclusive.
- No hard row cap.
readFactsdefaults to 50 rows per query. Callers can raise thelimitparameter as needed. - No cross-spawn-tree access for private facts. A session cannot read the session-scoped facts of any session outside its own spawn tree. Within a spawn tree (everything under a common root: ancestors, descendants, siblings, cousins), session-scoped facts are visible via
scope=accessible,scope=descendants, orsession_id=<other-tree-member>. Spawn-tree membership is verified via the CMSparent_session_idchain.
From src/index.ts:
export { PgFactStore, createFactStoreForUrl } from "./facts-store.js";
export type { FactStore, FactRecord, StoreFactInput, ReadFactsQuery, DeleteFactInput } from "./facts-store.js";
export { createFactTools } from "./facts-tools.js";
// Enhanced facts + knowledge graph (optional providers)
export { isEnhancedFactStore, resolveFactsTarget, createGraphStoreForUrl } from "./facts-store.js";
export type { EnhancedFactStore, FactsCapabilities, AccessContext } from "./facts-store.js";
export type { GraphStore } from "./graph-store.js";
export { createGraphTools } from "./graph-tools.js";These are available to applications that need direct fact store access outside of
the tool layer. The concrete enhanced/graph providers ship separately in
pilotswarm-horizon-store and are loaded by the worker only when configured.