@equationalapplications/expo-llm-wiki
v7.1.0
Published
Local-first LLM memory for Expo and React Native. Combines the core semantic search and extraction engine with expo-sqlite and ready-to-use React hooks.
Maintainers
Readme
@equationalapplications/expo-llm-wiki
Local-first LLM memory for Expo and React Native. Combines the core semantic search and extraction engine with expo-sqlite and ready-to-use React hooks.
GitHub · ScopeLab · WikiDemo · Changelog · Issues
Inspired by Andrej Karpathy's LLM Wiki memory spec.
Features
Expo-ready — Pre-configured for React Native + Expo
Built on
expo-sqlite— Stable, well-supported SQLite driverHermes-ready — Secure record ID generation via
expo-crypto; wired automatically at import (no manualcryptopolyfill)Semantic search — Vector embeddings via
embedfunction, with MiniSearch fallbackRetrieval tuning — Per-call overrides for search behavior (pre-filter, hybrid blend, tier weights)
Multi-entity reads — Search across multiple
entity_idnamespaces in one pass withtierWeightsSource provenance —
WikiFact.source_typedistinguishes immutable document facts (immutable_document) from mutable derived/user facts. Immutable document content is protected from librarian/heal rewriting and only changed byforget()or re-ingest.Seeded ontologies — Enforce strict taxonomies or allow emergent graph relationship extraction (
useOntologyManifest,useSetOntologyManifest; Strict, Emergent, or Off; defaults to Off).React hooks —
WikiProvider,useMemoryRead,useOntologyManifest,useSetOntologyManifest,useWikiTraversal, and all other hooks re-exported from@equationalapplications/expo-llm-wikiFull-featured memory — Facts, tasks, events, maintenance jobs (librarian, heal, reembed, prune)
Interoperability: Supports Open Knowledge Format (OKF) v0.1 + v0.2 import and export via the llm-wiki OKF profiles (default
llm-wiki/2, back-compatllm-wiki/1).GraphRAG on React Native —
useWikiTraversal+formatGraphContextgive you the same SQLite-only graph retrieval as the core package; pair with@equationalapplications/schema-org-llm-wikifor the canonical ontology. See root README: GraphRAG.
Installation
npx expo install expo-sqlite expo-crypto
npm install @equationalapplications/expo-llm-wikiexpo-crypto is a peer dependency. The package wires its getRandomValues into the core engine at module load — Hermes and React Native lack the Web crypto global, and wiki writes need a cryptographically secure random source for record IDs. No extra setup is required after install; importing @equationalapplications/expo-llm-wiki (or the /factory subpath) activates it before any createWiki() call.
Semantic Search
Enable vector-based retrieval by providing an embed function:
import { createWiki } from '@equationalapplications/expo-llm-wiki';
import { openDatabaseSync } from 'expo-sqlite';
const db = openDatabaseSync('wiki.db');
const wiki = createWiki(db, {
config: {
// Optimize retrieval for large memory stores
preFilterLimit: 50, // Limit cosine scoring to top-50 keyword matches
hybridWeight: 0.7, // Blend semantic (0.7) + keyword (0.3)
},
llmProvider: {
generateText: async ({ systemPrompt, userPrompt }) => {
// Your LLM call — must return the model output as a string
return 'Model output';
},
maxOutputTokens: 4096, // optional — your model's output ceiling; lets maintenance passes size their LLM calls
embed: async (text: string) => {
// Your embedding service (e.g., OpenAI, Cohere)
// Use an absolute URL — React Native / Expo apps do not have a browser
// origin to resolve relative URLs against on device or simulator.
const response = await fetch('https://your-api.example.com/api/embed', {
method: 'POST',
body: JSON.stringify({ text })
});
const { embedding } = await response.json();
return embedding; // number[]
},
},
onRetrievalFallback: (error) => {
console.warn('Embedding unavailable, using keyword search:', error);
},
});
await wiki.setup();
// Semantic query
const memory = await wiki.read('user-123', 'what activities should I do this weekend?');
// Matches facts like "Saturday hiking trip" even with no lexical overlap
// Per-call overrides
const fasterSearch = await wiki.read('user-123', 'activities', {
maxResults: 5,
preFilterLimit: 20, // Tighter pre-filter for speed
hybridWeight: 0.5, // More keyword weight
});
// Multi-entity with tier weights
const multiMemory = await wiki.read(['tier_wisdom', 'tier_fact', 'tier_working'], 'activities', {
maxResults: 8,
tierWeights: {
tier_wisdom: 2, // boost curated notes 2×
tier_fact: 1, // neutral baseline
tier_working: 0.25, // downrank unvetted context
},
// includeZeroWeightEntities: true — include 0-weight entities as bottom-ranked filler
});
// multiMemory.factScores — Record<factId, weightedScore> | undefined (array entityId only, populated when query is non-empty and at least one fact scored)
// multiMemory.metadata — { query, entityIds, tierWeights }Configuration
All WikiConfig fields are optional:
const wiki = createWiki(db, {
llmProvider: { /* ... */ },
config: {
tablePrefix: 'llm_wiki_', // default: 'llm_wiki_'
maxResults: 10, // default: 10
autoLibrarianThreshold: 20, // default: 20 — events before librarian auto-runs
autoHealThreshold: 100, // default: 100 — events before heal auto-runs
maxChunkLength: 12000, // default: 12000 (char count per ingestDocument chunk)
chunkOverlap: 400, // default: 400 (overlap between chunks in characters)
chunkConcurrency: 1, // default: 1 (parallel LLM calls per ingestDocument)
pruneRetainSoftDeletedFor: 7, // default: 7 (days before hard-deleting soft-deleted facts)
pruneEventsAfter: 30, // default: 30 (days before hard-deleting old events)
orphanAfterDays: 30, // default: 30 (days before runHeal flags sourceless facts; null to disable)
staleInferredAfterDays: 60, // default: 60 (days before runHeal downgrades inferred facts; null to disable)
preFilterLimit: 50, // default: undefined — MiniSearch pre-filter before cosine scan; recommended for >500 facts
hybridWeight: 0.7, // default: undefined — blend semantic (1.0) ↔ keyword (0.0); pure semantic when unset
// Global prompt overrides — librarianSystemPrompt and healSystemPrompt apply to write() auto-runs;
// ingestSystemPrompt applies only to explicit ingestDocument() calls.
// ⚠ Overrides replace the entire default prompt, including the JSON output contract.
// Your prompt must instruct the LLM to return the required JSON shape — see packages/core/README.md#prompt-management--overrides.
prompts: {
ingestSystemPrompt: `Extract core facts from this document: {{documentChunk}}\n\nReturn ONLY valid JSON: { "facts": [{ "title": "string", "body": "string", "tags": ["string"], "confidence": "certain|inferred|tentative" }] }. No markdown.`,
librarianSystemPrompt: `Synthesize these thoughts into insights:\n{{events}}\n\nReturn ONLY valid JSON: { "facts": [{ "title": "string", "body": "string", "tags": ["string"], "confidence": "certain|inferred|tentative" }], "tasks": [{ "description": "string", "priority": 0 }] }. No markdown.`,
healSystemPrompt: `Fix the memory graph based on these candidates: {{healCandidates}}\n\nReturn ONLY valid JSON: { "downgraded": ["factId"], "deleted": ["factId"], "newFacts": [{ "title": "string", "body": "string", "tags": ["string"], "confidence": "certain|inferred|tentative" }] }. No markdown.`,
},
},
});Retrieval Tuning
Optimize read() performance and blend retrieval strategies:
const config = {
// Limit cosine similarity scoring to top-K MiniSearch keyword candidates
preFilterLimit: 50,
// Blend semantic and keyword scores (0.0 = pure keyword, 1.0 = pure semantic)
hybridWeight: 0.7,
// Max results returned per read
maxResults: 10,
};
const wiki = createWiki(db, {
config,
llmProvider: { /* ... */ },
});Hybrid scoring blends:
hybridWeight: 1.0→ pure semantic ranking among the candidates being scored; ifpreFilterLimitis set, semantic scoring is still limited to the top-K MiniSearch matcheshybridWeight: 0.5→ balanced semantic + keyword (50/50 blend)hybridWeight: 0.0→ pure keyword ranking, skipsembed()entirely (no LLM API cost)
Pre-filtering optimization:
When preFilterLimit: 50 is set with 1000 facts, cosine similarity is computed only for the top 50 MiniSearch keyword matches, reducing O(N) scoring to O(50).
Usage
import { createWiki } from '@equationalapplications/expo-llm-wiki';
import { openDatabaseSync } from 'expo-sqlite';
const db = openDatabaseSync('wiki.db');
const wiki = createWiki(db, {
llmProvider: {
generateText: async ({ systemPrompt, userPrompt }) => {
// Your LLM call — must return the model output as a string
return 'Model output';
},
},
});
// Initialize tables (call once on app startup)
await wiki.setup();
// Auto-runs: uses config.prompts for background librarian/heal triggers
await wiki.write('user-123', { event_type: 'observation', summary: '...' });
// Manual executions: runtime promptOverride applies only to this single call.
// Must include the JSON output contract — overrides replace the entire default prompt.
await wiki.runLibrarian('user-123', {
promptOverride: `Strict domain extraction task:\n{{events}}\n\nReturn ONLY valid JSON: { "facts": [{ "title": "string", "body": "string", "tags": ["string"], "confidence": "certain|inferred|tentative" }], "tasks": [{ "description": "string", "priority": 0 }] }. No markdown.`,
});
await wiki.ingestDocument('user-123', {
sourceRef: 'doc-1',
sourceHash: sha256(content),
documentChunk: content,
promptOverride: `Focus strictly on technical APIs: {{documentChunk}}\n\nReturn ONLY valid JSON: { "facts": [{ "title": "string", "body": "string", "tags": ["string"], "confidence": "certain|inferred|tentative" }] }. No markdown.`,
});With React
@equationalapplications/expo-llm-wiki re-exports all hooks and WikiProvider from @equationalapplications/react-llm-wiki:
import { WikiProvider } from '@equationalapplications/expo-llm-wiki';
<WikiProvider wiki={wiki}>
<MyApp />
</WikiProvider>Then use hooks in components:
import { useMemoryRead } from '@equationalapplications/expo-llm-wiki';
export function UserProfile({ userId }: { userId: string }) {
const { data, isPending } = useMemoryRead(userId, 'preferences');
if (isPending) return <Text>Loading...</Text>;
return <Text>{data?.facts.map(f => f.title).join(', ')}</Text>;
}For live background-job status (e.g. a loading spinner while a document ingests or the librarian runs):
import { useEntityStatus } from '@equationalapplications/expo-llm-wiki';
export function EntityLoadingSpinner({ entityId }: { entityId: string }) {
const { ingesting, librarian, heal } = useEntityStatus(entityId);
if (!ingesting && !librarian && !heal) return null;
return <Spinner label={ingesting ? 'Ingesting…' : librarian ? 'Organizing…' : 'Healing…'} />;
}useOntologyManifest(entityId)
Reactive read — fetches on mount and when entityId or wiki changes:
import { useOntologyManifest } from '@equationalapplications/expo-llm-wiki';
const { manifest, mode, isPending, error, refetch } = useOntologyManifest('user-123');
// manifest: OntologyManifest | null
// mode: OntologyMode | null ('strict' | 'emergent' | 'off' when present)Note: manifest and mode are null when the entity has no persisted or seeded manifest (getOntologyManifest returned null). Call refetch() after mutations to refresh.
useSetOntologyManifest()
Mutation — same { execute, isPending, error, lastResult } contract as useWikiWrite:
import { useOntologyManifest, useSetOntologyManifest } from '@equationalapplications/expo-llm-wiki';
export function OntologySettings({ entityId }: { entityId: string }) {
const { manifest, mode, refetch } = useOntologyManifest(entityId);
const { execute, isPending, error } = useSetOntologyManifest();
const handleSave = async () => {
await execute(entityId, {
node_types: [{ type: 'person', description: 'An individual.' }],
edge_types: [{
type: 'reports_to',
source_type: 'person',
target_type: 'person',
description: 'Reporting hierarchy.',
}],
}, { mode: 'strict' });
refetch();
};
// render manifest/mode; wire handleSave to a save button
}Global defaults and seedManifests bootstrap are configured at construction time via createWiki(..., { config: { ontology: ... } }). See the core package README § Per-Entity Seeded Ontology for mode semantics and manifest schema.
useSetOntologyManifest does not automatically refresh useOntologyManifest — call refetch() after a successful execute(), same as useWikiWrite + useMemoryRead.
useWikiTraversal(entityId, options)
Reactive read — fetches on mount and whenever entityId or options change. Walks the knowledge graph N hops outward from a fact (options.sourceId) using edges written by runLibrarian()/ingestDocument()'s Seeded Ontology extraction pass:
import { useWikiTraversal, formatGraphContext } from '@equationalapplications/expo-llm-wiki';
const { nodes, edges, isPending, error, refetch } = useWikiTraversal('user-123', {
sourceId: 'fact_42',
maxDepth: 2,
direction: 'both',
});
const promptContext = formatGraphContext({ nodes, edges });maxDepthis clamped to[1, 3]regardless of input.edgeTypes: [](explicit empty array) matches nothing; omitting it matches all edge types.- Defaults (
maxTraversalNodes,minTraversalConfidence,traversalDirection,excludeSourceTypes) can be set globally viacreateWiki(..., { config: { maxTraversalNodes: 20, ... } })and overridden per-call. formatGraphContext()is a pure function — call it with the hook's{ nodes, edges }to get a dense text block suitable for prompt injection.
Component Lifecycle
flowchart TD
A["<WikiProvider wiki={wiki}>"] --> B["App Components"]
B --> C{"Use Hook?"}
C -->|"useMemoryRead(entityId, query, options?)"| D["[Read Memory]"]
C -->|"useWikiWrite()"| E["[Write Memory]"]
C -->|"useWikiIngest()"| F["[Ingest Document]"]
C -->|"useWikiForget()"| G["[Delete Memory]"]
C -->|"useWikiMaintenance()"| H["[Run Jobs]"]
C -->|"useOntologyManifest(entityId)"| S["[Read Ontology]"]
C -->|"useSetOntologyManifest()"| T["[Update Ontology]"]
D --> I{"entityId, query, wiki,<br/>or ReadOptions changed?"}
I -->|"Yes"| J["Auto-refetch"]
I -->|"No"| K["Return cached data"]
J --> L["Trigger read()"]
L --> M["Embed query<br/>if embed available"]
M --> N["Phase 1: Score facts<br/>Phase 2: Fetch winners"]
N --> O["Update component state"]
O --> P["Re-render with data"]
S --> I2{"entityId or wiki changed?"}
I2 -->|"Yes"| J2["Auto-refetch"]
I2 -->|"No"| K2["Return cached manifest/mode"]
J2 --> L2["Trigger getOntologyManifest()"]
L2 --> O2["Update component state"]
O2 --> P2["Re-render with manifest/mode"]
E --> Q["Execute write()"]
F --> Q
G --> Q
H --> Q
T --> Q
Q --> R["Write completes"]Data flow:
- Wrap app with
<WikiProvider wiki={wiki}>— provides wiki context - Use hooks in components — access memory reactively
- Read operations auto-refetch when
entityId,query,wiki, orReadOptionsvalues change; callrefetch()to refresh manually - Ontology reads auto-refetch when
entityIdorwikichanges; callrefetch()manually after ontology mutations - Write operations (write, ingest, forget, maintenance) do not automatically re-trigger
useMemoryRead; callrefetch()after a write to refresh read results - Ontology writes (
useSetOntologyManifest) do not automatically re-triggeruseOntologyManifestin the same component unlessrefetch()is called afterexecute()succeeds - Re-render with new data flowing back to UI
Retrieval Engine Internals
flowchart TD
A["read(entityId | entityId[], query, options?)"] --> B{hybridWeight = 0?}
B -->|Yes| C["MiniSearch only<br/>(skip embed)"]
B -->|No| D{embed available?}
D -->|No| C
D -->|Yes| F["Embed query"]
F -->|throws| E["onRetrievalFallback<br/>callback"]
E --> C
F -->|succeeds| G{preFilterLimit<br/>active?}
G -->|Yes| H["MiniSearch pre-filter<br/>top K candidates"]
H --> I["Phase 1: Cosine score<br/>top K candidates"]
G -->|No| J["Phase 1: Cosine score<br/>all facts"]
J --> K["Cache vectors<br/>in-memory<br/>(full scan only)"]
K --> L{hybridWeight = 1?}
I --> L
L -->|Yes| M["Pure semantic<br/>ranking"]
L -->|No| N["Hybrid blend:<br/>semantic + keyword<br/>via MiniSearch"]
M --> O["Phase 2: Fetch full rows<br/>top maxResults"]
N --> O
C --> P["MiniSearch ranking"]
P --> O
O --> R["Track access"]
R --> Q["Return MemoryBundle"]The flowchart shows:
- Fast-path when
hybridWeight = 0(pure keyword, no embed cost) - Fallback chain when embed unavailable (MiniSearch silently) or throws (
onRetrievalFallbackcallback, then MiniSearch) - Pre-filtering to limit cosine scoring to top-K keyword matches (O(N) → O(K))
- Two-phase SELECT: phase 1 scores all/filtered facts with minimal columns, phase 2 fetches full rows for winners
- Hybrid scoring to blend semantic and keyword rankings
- Vector caching on full scans only; reads with
preFilterLimitactive skip cache population
Multi-Entity Reads
read() accepts a single entity ID or an array to search across namespaces in one retrieval pass. Pass tierWeights to control per-entity ranking before the final top-K results:
const memory = await wiki.read(
['tier_wisdom', 'tier_fact', 'tier_working'],
'What do I know about this topic?',
{
maxResults: 8,
tierWeights: {
tier_wisdom: 2, // boost curated notes 2×
tier_fact: 1, // neutral
tier_working: 0.25, // downrank unvetted context
},
}
);
// memory.factScores — Record<factId, weightedScore> | undefined
// attached for array-shaped reads when the query is non-empty and at least one fact is scored
// memory.metadata — { query, entityIds, tierWeights }
// tasks capped at min(20 × entityCount, 200); events at min(10 × entityCount, 100)For full details on {{mustache}} prompt templating and the strict distinction between global auto-runs and runtime overrides, see Prompt Management & Overrides in @equationalapplications/core-llm-wiki.
Concurrency
All write APIs are safe to call from concurrent async contexts. Transactions are
serialized internally on the single database connection — you never need to know that
SQLite forbids nested BEGIN, and you never need to throttle callers yourself.
- One connection per database file per process is the supported topology.
- Non-transactional reads are not serialized, so read latency is unaffected.
- Inside a transaction callback, use only the provided
txhandle — never the outer database handle. Using the outer handle deadlocks; opening a nested transaction throws.
Driver errors that escape a transaction surface as WikiTransactionError (re-exported
from @equationalapplications/core-llm-wiki) with a top-level sqliteErrorCode.
Monorepo Ecosystem
| Package | Purpose | | ----- | ----- | | @equationalapplications/core-llm-wiki | Persistent episodic memory | | @equationalapplications/expo-llm-wiki | Persistent episodic memory for Expo/React Native | | @equationalapplications/react-llm-wiki | Persistent episodic memory for Web | | @equationalapplications/prisma-outbox | Sync SQLite outbox events to Prisma | | @equationalapplications/core-llm-tools | Gemini tool schemas and capability injector | | @equationalapplications/core-okf | Zero-dependency Open Knowledge Format (OKF) v0.1 + v0.2 primitives — parse and produce interoperable knowledge bundles. | | @equationalapplications/schema-org-llm-wiki | Curated schema.org warm-agent ontology manifest | | @equationalapplications/schema-software-org | Software-organization executive ontology manifest — 17 node types, 40 edges, warm-agent superset, data-only |
License
MIT
Made with ❤️ by Equational Applications LLC. https://equationalapplications.com/
