Integration
Integrate fortemi-react into an existing React app.
Integrating fortemi-react into an Existing React Application
This guide covers embedding `@fortemi/react` as a component in a larger application. For example, a host application can mount Fortemi as a panel inside its own React tree. The patterns here assume you are a senior React developer who needs direct access to the database, event bus, repositories, and capability pipeline, not just the convenience hooks.
Table of Contents
1. Package Installation 2. Provider Setup 3. Accessing the Context 4. Using the Repository Layer Directly 5. MCP Tool Integration 6. Event Bus Integration 7. Job Queue Integration 8. Capability Module Wiring 9. Attachment Handling 10. Multi-Archive Support 11. Service Worker Setup 12. TypeScript Types 13. Browser Compatibility Notes
1. Package Installation
Both packages use the `workspace:*` protocol in a pnpm monorepo. If you are embedding fortemi inside your own monorepo, add them as workspace dependencies.
pnpm-workspace.yaml (in your repo root):
packages:
- apps/*
- packages/*
- vendor/fortemi-browser/packages/* # path to your fortemi checkout
package.json (your app package):
{
"dependencies": {
"@fortemi/core": "workspace:*",
"@fortemi/react": "workspace:*",
"react": "^19.0.0"
}
}
After adding the entries, run `pnpm install` from the monorepo root. Both packages export their source TypeScript directly — there is no separate build step required for consumers in the same workspace.
If you are consuming published packages rather than a local checkout, replace `workspace:*` with the released version:
{
"dependencies": {
"@fortemi/core": "2026.7.8",
"@fortemi/react": "2026.7.8"
}
}
2. Provider Setup
`FortemiProvider` initializes PGlite, the event bus, the archive manager, the capability manager, and the blob store. It must wrap any component tree that calls fortemi hooks or reads from the context.
Minimal setup
import { Suspense } from 'react'
import { FortemiProvider } from '@fortemi/react'
export function HostApp() {
return (
<Suspense fallback={<DatabaseLoading />}>
<FortemiProvider persistence="opfs" archiveName="host-main">
<YourApplicationContent />
</FortemiProvider>
</Suspense>
)
}
function DatabaseLoading() {
return <div aria-label="Initializing database">Loading knowledge base...</div>
}
Error boundary for init failures
`FortemiProvider` throws synchronously when initialization fails, so an error boundary above the Suspense boundary will catch it:
import { Component, type ReactNode, type ErrorInfo } from 'react'
import { Suspense } from 'react'
import { FortemiProvider } from '@fortemi/react'
interface State { error: Error | null }
class FortemiErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[fortemi] provider init failed:', error, info)
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Knowledge base failed to load: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Retry</button>
</div>
)
}
return this.props.children
}
}
export function HostApp() {
return (
<FortemiErrorBoundary>
<Suspense fallback={<DatabaseLoading />}>
<FortemiProvider persistence="opfs" archiveName="host-main">
<YourApplicationContent />
</FortemiProvider>
</Suspense>
</FortemiErrorBoundary>
)
}
Provider props
| Prop | Type | Required | Description | ||
|---|---|---|---|---|---|
| `persistence` | `'opfs' \ | 'idb' \ | 'memory'` | Yes | Storage backend. Use `opfs` for production, `memory` for tests. |
| `archiveName` | `string` | No (default: `'default'`) | Name of the initial archive to open. Maps to `opfs-ahp://fortemi-{name}` or `idb://fortemi-{name}`. | ||
| `executionMode` | `'main' \ | 'worker'` | No (default: `'main'`) | Runs PGlite on the main thread or in a Web Worker. Use `worker` for large imports, vector indexes, or heavy search workloads. | |
| `createWorker` | `() => Worker` | No | Optional worker factory for custom bundlers or host shells. Defaults to `new Worker(new URL('@fortemi/core/worker/pglite-worker', import.meta.url), { type: 'module' })`. | ||
| `children` | `ReactNode` | Yes | Component subtree that will consume the context. |
While PGlite is initializing, `FortemiProvider` returns `null`. The Suspense boundary above it displays the loading UI during that window.
Worker mode: `executionMode="worker"` keeps database work off the UI thread while preserving the same `db.query`, `db.exec`, and `db.transaction` surface used by hooks and repositories. `opfs`, `idb`, and `memory` persistence are forwarded to the worker-backed database.
React StrictMode note: `FortemiProvider` uses module-level singleton promises keyed by execution mode, persistence, and archive name to prevent double-initialization from StrictMode's deliberate double-mount in development. The PGlite WASM module can only be instantiated once per cached `Response` — a second `WebAssembly.instantiateStreaming()` call against the same cached response will fail. The guard handles this automatically; you do not need to disable StrictMode.
3. Accessing the Context
`useFortemiContext()` returns the full `FortemiContextValue` and is the escape hatch for anything not covered by the high-level hooks.
import { useFortemiContext } from '@fortemi/react'
function DebugPanel() {
const { db, events, archiveManager, capabilityManager, blobStore, providerRegistry } = useFortemiContext()
const handleInspect = async () => {
// Raw query against PGlite — same API surface as the repositories use internally
const result = await db.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM note WHERE deleted_at IS NULL`
)
console.log('Live notes:', result.rows[0].count)
// Archive state
console.log('Current archive:', archiveManager.getCurrentArchiveName())
// Capability states
console.log('Capabilities:', capabilityManager.listAll())
// Runtime inference providers
console.log('Inference providers:', providerRegistry.list().map(provider => provider.id))
}
return <button onClick={handleInspect}>Inspect DB</button>
}
`useFortemiContext()` throws if called outside a `FortemiProvider`. This is intentional — it surfaces misconfiguration immediately rather than producing silent failures later.
Context shape
interface FortemiContextValue {
db: PGlite // The active PGlite instance for the current archive
events: TypedEventBus // Typed pub/sub bus shared across the entire tree
archiveManager: ArchiveManager // Manages multiple databases (one per workspace)
capabilityManager: CapabilityManager // Tracks WASM capability load states
providerRegistry: ProviderRegistry // Runtime inference provider registry and route table
blobStore: BlobStore // Content-addressable binary store (OPFS or IDB)
}
4. Using the Repository Layer Directly
The repository classes provide the canonical data access interface. Use them when the built-in hooks do not meet your needs — for example, when you need to run repository operations outside of React (in a background callback, a message handler, or a non-component module), or when you need methods that hooks do not expose, such as `getRevisions`, `star`, or `pin`.
Decision guide
| Scenario | Recommended approach |
|---|---|
| Display a reactive list of notes in a component | `useNotes()` hook |
| Create a note from a user action | `useCreateNote()` hook |
| Run a query in a background message handler | `NotesRepository` directly |
| Access revision history | `NotesRepository.getRevisions()` directly |
| Build a custom SKOS browser | `SkosRepository` directly |
| Need a single atomic transaction across multiple repos | Repositories directly via `db.transaction()` |
NotesRepository
import { NotesRepository } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'
// In a component:
function useRevisionHistory(noteId: string) {
const { db, events } = useFortemiContext()
const fetchRevisions = async () => {
const repo = new NotesRepository(db, events)
return repo.getRevisions(noteId)
}
// ...
}
Multi-repository transaction
Repositories share the same `db` instance, so you can combine them in a single `db.transaction()` call:
import { NotesRepository, CollectionsRepository } from '@fortemi/core'
async function createNoteInCollection(
db: PGlite,
events: TypedEventBus,
content: string,
collectionId: string,
) {
const notesRepo = new NotesRepository(db, events)
const collectionsRepo = new CollectionsRepository(db)
// NotesRepository.create() runs its own internal transaction.
// Create the note first, then add to collection in a second transaction.
const note = await notesRepo.create({ content, format: 'markdown' })
await collectionsRepo.addNote(collectionId, note.id)
return note
}
Available repositories
| Class | Import | Purpose |
|---|---|---|
| `NotesRepository` | `@fortemi/core` | Create, read, update, delete, star, pin, archive, list, getRevisions |
| `SearchRepository` | `@fortemi/core` | Full-text, semantic, and hybrid search with 12 filter options, phrase search, and faceted results |
| `TagsRepository` | `@fortemi/core` | Tag enumeration and management |
| `CollectionsRepository` | `@fortemi/core` | Collection CRUD, note membership |
| `LinksRepository` | `@fortemi/core` | Semantic and manual links between notes |
| `SkosRepository` | `@fortemi/core` | SKOS concept schemes, concepts, and relations |
| `AttachmentsRepository` | `@fortemi/core` | Binary file attachments per note |
5. MCP Tool Integration
Tool functions are the boundary between the MCP protocol layer and the repository layer. They accept raw, unvalidated input (an `unknown` value), run it through a Zod schema, and delegate to repositories. This makes them suitable for use from MCP bridge handlers, message port listeners, or any code path that receives opaque JSON payloads.
captureKnowledge
import { captureKnowledge } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'
// Called from an MCP bridge handler or a host bridge message
async function handleCaptureMessage(rawPayload: unknown) {
const { db, events } = useFortemiContext() // or extract from a ref
const result = await captureKnowledge(db, rawPayload, events)
// result.action: 'create' | 'bulk_create' | 'from_template'
// result.notes: NoteFull[]
console.log('Created note:', result.notes[0].id)
}
The Zod schema for validation is exported separately for cases where you want to validate without executing:
import { CaptureKnowledgeInputSchema } from '@fortemi/core'
const parsed = CaptureKnowledgeInputSchema.safeParse(rawPayload)
if (!parsed.success) {
return { error: parsed.error.format() }
}
// Now safe to call captureKnowledge(db, parsed.data, events)
manageNote
import { manageNote } from '@fortemi/core'
// Delete a note
const result = await manageNote(db, {
action: 'delete',
note_id: 'note-uuid-here',
}, events)
// Update content and title
const updated = await manageNote(db, {
action: 'update',
note_id: 'note-uuid-here',
title: 'Revised Title',
content: '## Updated content\
\
With new structure.',
}, events)
// updated.note is the full NoteFull with current revision
searchTool
import { searchTool } from '@fortemi/core'
const response = await searchTool(db, {
query: 'semantic memory retrieval',
mode: 'text', // 'text' | 'semantic' | 'hybrid' — semantic requires the capability
limit: 20,
offset: 0,
tags: ['knowledge-management'],
date_from: '2026-01-01', // filter by creation date range
is_starred: true, // only starred notes
format: 'markdown', // filter by note format
include_facets: true, // include tag/collection counts
})
// response.results: SearchResult[]
// response.mode: 'text' | 'semantic' | 'hybrid'
// response.semantic_available: boolean
// response.total: number
// response.facets?: { tags: [...], collections: [...] }
The `useSearch` hook automatically dispatches to hybrid search when semantic capability is enabled — no manual embedding required:
import { useSearch, useSearchHistory, useSearchSuggestions } from '@fortemi/react'
// useSearch automatically uses hybrid when semantic is ready
const { data, search } = useSearch()
await search('machine learning', { include_facets: true })
// data.mode will be 'hybrid' if semantic is enabled, 'text' otherwise
// Search history + suggestions
const { history, addEntry } = useSearchHistory()
const { suggestions, getSuggestions } = useSearchSuggestions(history)
manageAttachments
import { manageAttachments } from '@fortemi/core'
// List attachments for a note
const listResult = await manageAttachments(db, blobStore, {
action: 'list',
note_id: 'note-uuid-here',
})
// listResult.attachments: AttachmentRow[]
// Retrieve the binary content
const blobResult = await manageAttachments(db, blobStore, {
action: 'get_blob',
attachment_id: listResult.attachments![0].id,
})
// blobResult.data_base64: string (base64-encoded bytes)
Tool signatures
| Function | Signature | Notes |
|---|---|---|
| `captureKnowledge` | `(db, rawInput, events?) => Promise<CaptureKnowledgeResult>` | |
| `manageNote` | `(db, rawInput, events?) => Promise<ManageNoteResult>` | |
| `searchTool` | `(db, rawInput) => Promise<SearchResponse>` | |
| `manageAttachments` | `(db, blobStore, rawInput) => Promise<ManageAttachmentsResult>` | Requires blobStore from context |
| `manageCapabilities` | `(rawInput, capabilityManager) => Promise<ManageCapabilitiesResult>` | |
| `manageArchive` | `(rawInput, archiveManager) => Promise<ManageArchiveResult>` | |
| `manageTags` | `(db, rawInput) => Promise<ManageTagsResult>` | |
| `manageCollections` | `(db, rawInput) => Promise<ManageCollectionsResult>` | |
| `manageLinks` | `(db, rawInput) => Promise<ManageLinksResult>` | |
| `getNote` | `(db, rawInput) => Promise<NoteFull>` | |
| `listNotes` | `(db, rawInput) => Promise<PaginatedResult<NoteSummary>>` |
6. Event Bus Integration
`TypedEventBus` is a typed, synchronous pub/sub system shared across the entire fortemi component tree. It is the primary mechanism for cross-component and cross-layer communication. The bus supports exact subscriptions, wildcard prefix subscriptions, and cross-context bridging via `MessagePort`.
Subscribing to specific events
import { useFortemiContext } from '@fortemi/react'
import { useEffect } from 'react'
function HostActivityFeed() {
const { events } = useFortemiContext()
useEffect(() => {
// Exact subscription — typed payload
const onCreated = events.on('note.created', ({ id }) => {
console.log('New note captured:', id)
// Notify a host panel, update a badge count, etc.
})
const onJobCompleted = events.on('job.completed', ({ id, noteId, type }) => {
if (type === 'embedding') {
console.log(`Embeddings ready for ${noteId} — semantic search now active`)
}
})
// Subscriptions return an IDisposable — call dispose() to unsubscribe
return () => {
onCreated.dispose()
onJobCompleted.dispose()
}
}, [events])
return null
}
Wildcard prefix subscriptions
The bus supports `'prefix.*'` patterns that match any event whose name starts with the given prefix:
useEffect(() => {
// Fires on note.created, note.updated, note.deleted, note.restored, note.revised
const allNoteEvents = events.on('note.*', (payload) => {
// payload is typed as unknown for wildcard subscriptions
invalidateNoteCache()
})
const allCapabilityEvents = events.on('capability.*', (payload) => {
refreshCapabilityUI()
})
return () => {
allNoteEvents.dispose()
allCapabilityEvents.dispose()
}
}, [events])
One-shot subscriptions with once()
// Wait for capability to become ready before starting a job
await new Promise<void>((resolve) => {
const sub = events.once('capability.ready', ({ name }) => {
if (name === 'semantic') resolve()
else sub.dispose() // wrong capability — re-register if needed
})
})
Bridging across a MessagePort
When fortemi runs in a Worker or iframe context, the event bus can be bridged to a `MessagePort` so events flow bidirectionally:
// In the host window
const channel = new MessageChannel()
const bridge = events.bridge(channel.port1)
// Send port2 to the worker
worker.postMessage({ type: 'FORTEMI_BRIDGE' }, [channel.port2])
// Clean up when unmounting
bridge.dispose()
Full event map
| Event | Payload | Emitted when |
|---|---|---|
| `note.created` | `{ id: string }` | Note successfully inserted |
| `note.updated` | `{ id: string }` | Note fields or content changed |
| `note.deleted` | `{ id: string }` | Note soft-deleted |
| `note.restored` | `{ id: string }` | Soft-delete reversed |
| `note.revised` | `{ id: string; revisionNumber: number }` | AI or user revision applied |
| `search.reindexed` | `{}` | Full-text search index rebuilt |
| `embedding.ready` | `{ noteId: string }` | Embeddings stored for a note |
| `capability.ready` | `{ name: string }` | Capability transitioned to ready |
| `capability.disabled` | `{ name: string }` | Capability disabled |
| `capability.loading` | `{ name: string; progress?: number }` | Capability loading (with optional 0–100 progress) |
| `job.completed` | `{ id: string; noteId: string; type: string }` | Job queue job succeeded |
| `job.failed` | `{ id: string; noteId: string; type: string; error: string }` | Job queue job exhausted retries |
| `archive.switched` | `{ name: string }` | Active archive changed |
| `migration.applied` | `{ version: number }` | DB migration applied |
7. Job Queue Integration
The job queue runs in the browser on a polling loop. Jobs are stored in the `job_queue` table, dispatched to registered handlers, and retried with exponential backoff. Custom job types can be registered alongside the built-in pipeline.
Starting the built-in pipeline with useJobQueue
The `useJobQueue` hook starts the worker and registers all server-compatible handlers. Mount it once at the top of your application tree:
import { useJobQueue } from '@fortemi/react'
function JobQueueOrchestrator() {
const { jobs, enqueue } = useJobQueue(3000) // poll every 3 seconds
const pendingCount = jobs.filter(j => j.status === 'pending').length
const failedCount = jobs.filter(j => j.status === 'failed').length
return (
<div aria-label="Processing queue">
{pendingCount > 0 && <span>{pendingCount} pending</span>}
{failedCount > 0 && <span className="error">{failedCount} failed</span>}
</div>
)
}
Registering a custom job handler
For job types that belong to your host application rather than to fortemi's core pipeline, construct a `JobQueueWorker` directly and register your handlers alongside or instead of the built-ins:
import {
JobQueueWorker,
titleGenerationHandler,
aiRevisionHandler,
embeddingGenerationHandler,
conceptTaggingHandler,
linkingHandler,
enqueueJob,
} from '@fortemi/core'
import type { PGlite } from '@electric-sql/pglite'
import { useFortemiContext } from '@fortemi/react'
import { useEffect, useRef } from 'react'
// Custom handler signature: (job, db) => Promise<unknown>
async function exportToHostHandler(
job: { note_id: string; id: string },
db: PGlite,
): Promise<unknown> {
const result = await db.query<{ content: string; title: string | null }>(
`SELECT content, title FROM note_revised_current nrc
JOIN note n ON n.id = nrc.note_id
WHERE nrc.note_id = $1`,
[job.note_id],
)
if (result.rows.length === 0) return { skipped: true, reason: 'note not found' }
const { content, title } = result.rows[0]
// ... call your host export API
return { exported: true, title }
}
function CustomJobQueueMount() {
const { db, events, capabilityManager } = useFortemiContext()
const workerRef = useRef<JobQueueWorker | null>(null)
useEffect(() => {
const worker = new JobQueueWorker(db, events, { pollIntervalMs: 5000 }, capabilityManager)
// Built-in pipeline
worker.registerHandler('title_generation', titleGenerationHandler)
worker.registerHandler('ai_revision', aiRevisionHandler)
worker.registerHandler('embedding', embeddingGenerationHandler)
worker.registerHandler('concept_tagging', conceptTaggingHandler)
worker.registerHandler('linking', linkingHandler)
// Your application-specific handlers
worker.registerHandler('host_export', exportToHostHandler)
worker.start()
workerRef.current = worker
return () => {
worker.stop()
workerRef.current = null
}
}, [db, events, capabilityManager])
return null
}
Enqueuing jobs programmatically
import { enqueueJob, JOB_PRIORITIES } from '@fortemi/core'
// Enqueue a built-in type
const jobId = await enqueueJob(db, {
noteId: 'note-uuid-here',
jobType: 'embedding',
})
// Enqueue a custom type with explicit priority (lower = higher priority)
const exportJobId = await enqueueJob(db, {
noteId: 'note-uuid-here',
jobType: 'host_export',
priority: 3,
requiredCapability: null, // no capability gate for this job type
})
Monitoring job status
import { getJobQueueStatus } from '@fortemi/core'
// All recent jobs
const allJobs = await getJobQueueStatus(db)
// Jobs for a specific note
const noteJobs = await getJobQueueStatus(db, noteId)
const failed = noteJobs.filter(j => j.status === 'failed')
Capability-gated jobs
Jobs with a `required_capability` field are held in `pending` state until the corresponding capability is `ready`. The worker checks `capabilityManager.isReady(name)` before dispatching, stores a clear pending message such as `requires capability 'llm' — not ready`, and emits `job.blocked` plus `capability.required` so host apps can prompt the user to enable the missing capability. You can set `requiredCapability` to `null` to bypass gating entirely.
Built-in capability gates:
| Job type | Required capability |
|---|---|
| `embedding` | `semantic` |
| `ai_revision` | `llm` |
| `concept_tagging` | `llm` |
| `title_generation` | none |
| `linking` | none |
8. Capability Module Wiring
Capabilities are optional WASM modules (transformers.js, WebLLM) that augment the job pipeline. None are loaded by default — loading is always initiated explicitly by the host application.
CapabilityManager state machine
unloaded -> loading -> ready
-> error -> loading (retry)
ready -> disabled -> loading (re-enable)
Transitions are enforced at runtime. Calling `enable()` from `ready` or `loading` is a no-op (idempotent). Calling `disable()` from anything other than `ready` throws.
registerSemanticCapability with transformers.js
The semantic capability requires an `EmbedFunction` — a function that accepts an array of text strings and returns an array of float arrays (one embedding vector per input).
In production, you load this from a Web Worker to avoid blocking the main thread:
import { registerSemanticCapability } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'
// embedding-worker.ts (runs in a Web Worker)
// import { pipeline } from '@huggingface/transformers'
// const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
// self.onmessage = async (e) => {
// const output = await extractor(e.data.texts, { pooling: 'mean', normalize: true })
// self.postMessage({ embeddings: output.tolist() })
// }
function SemanticCapabilityLoader() {
const { capabilityManager, events } = useFortemiContext()
const enableSemantic = async () => {
// Create a worker that wraps the transformers.js pipeline
const worker = new Worker(new URL('./embedding-worker.ts', import.meta.url), {
type: 'module',
})
// Define the EmbedFunction that delegates to the worker
const embedFn = (texts: string[]): Promise<number[][]> =>
new Promise((resolve, reject) => {
const handler = (e: MessageEvent) => {
worker.removeEventListener('message', handler)
if (e.data.error) reject(new Error(e.data.error))
else resolve(e.data.embeddings as number[][])
}
worker.addEventListener('message', handler)
worker.postMessage({ texts })
})
// Wire the function and register the loader
registerSemanticCapability(
capabilityManager,
embedFn,
(pct) => {
capabilityManager.reportProgress('semantic', pct)
},
)
// Trigger the loader — transitions: unloaded -> loading -> ready
await capabilityManager.enable('semantic')
}
return <button onClick={enableSemantic}>Enable Semantic Search</button>
}
Off-main-thread embedding transport
With `executionMode="worker"` the PGlite database and HNSW query run off the main thread, but a main-thread `EmbedFunction` closure still blocks the UI: the embedding model load janks the first paint of search, and every per-query embed blocks input. `registerSemanticCapabilityWorker` moves the embed function itself behind a `Worker`/`MessagePort`, so semantic search runs off-thread end-to-end. Core posts `{ texts }` to the port and awaits `number[][]`; the host owns the worker, the model, and the model params.
Worker side — wire your model to the message protocol with `handleEmbedRequests`:
// queryEmbed.worker.ts
import { handleEmbedRequests, type EmbedTransportPort } from '@fortemi/core'
import { pipeline } from '@huggingface/transformers'
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
handleEmbedRequests(self as unknown as EmbedTransportPort, async (texts) => {
const out: number[][] = []
for (const text of texts) {
// Match the build-time corpus embeddings exactly: fp32, mean pooling, normalized, 384-d.
const r = await extractor(text, { pooling: 'mean', normalize: true })
out.push(Array.from(r.data as Float32Array))
}
return out
})
Main thread — register the worker as the semantic capability:
import { registerSemanticCapabilityWorker } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'
function SemanticWorkerLoader() {
const { capabilityManager } = useFortemiContext()
const enableSemantic = async () => {
const worker = new Worker(new URL('./queryEmbed.worker.ts', import.meta.url), { type: 'module' })
registerSemanticCapabilityWorker(capabilityManager, worker)
await capabilityManager.enable('semantic') // no main-thread model load, no main-thread inference
}
return <button onClick={enableSemantic}>Enable Semantic Search (off-thread)</button>
}
`registerSemanticCapabilityWorker` accepts an `EmbedWorkerOptions` third argument (`{ timeoutMs }`, default 30000; `0` disables). The transport's message listener is attached when `enable('semantic')` runs and removed by `unregisterSemanticCapability()` (i.e. on `disable`). Disposing the worker (`worker.terminate()`) remains the host's responsibility.
The React `useEmbeddingWorker(transport, options?)` hook is a thin wrapper that wires the same transport directly into the core embed function and tears it down on unmount:
import { useMemo, useEffect } from 'react'
import { useEmbeddingWorker } from '@fortemi/react'
function SearchProvider() {
const worker = useMemo(
() => new Worker(new URL('./queryEmbed.worker.ts', import.meta.url), { type: 'module' }),
[],
)
const { status, connect } = useEmbeddingWorker(worker)
useEffect(() => connect(), [connect])
// status: 'idle' | 'connected'
}
Both `createWorkerEmbedFunction(port, options?)` (the lower-level primitive) and `handleEmbedRequests(port, embed)` are exported for hosts that want to own the wiring directly. The existing main-thread `registerSemanticCapability(manager, embedFn)` path is unchanged — this transport is additive and opt-in.
Runtime inference provider composition
Deployments that need browser-local models, local OpenAI-compatible servers, remote services, or host-managed inference can configure them once through `FortemiProvider`. The runtime registers every configured provider, adapts any available `window.fortemiBridge.inference` provider, optionally discovers local servers, and wires the `semantic` and `llm` capability loaders to the routed registry.
import { FortemiProvider } from '@fortemi/react'
export function App() {
return (
<FortemiProvider
persistence="indexeddb"
inference={{
providers: [
{
kind: 'openai-compatible',
config: {
id: 'remote-small',
name: 'Small remote service',
baseURL: 'https://router.example.com/v1',
defaultModel: 'fast-chat',
defaultEmbeddingModel: 'text-embedding-small',
profile: {
privacyTier: 'external',
costTier: 'low',
dataClasses: ['public'],
},
},
},
{
kind: 'openai-compatible',
config: {
id: 'local-ollama',
name: 'Ollama',
baseURL: 'http://localhost:11434/v1',
tier: 'local-server',
defaultEmbeddingModel: 'nomic-embed-text',
profile: {
privacyTier: 'local',
costTier: 'free',
embeddingDimensions: [768],
dataClasses: ['public', 'private', 'sensitive'],
},
},
},
],
routes: {
'embedding.query': {
providerIds: ['local-ollama', 'remote-small'],
model: 'nomic-embed-text',
requirements: {
maxCostTier: 'low',
},
},
'embedding.large-document': {
providerIds: ['host:research-embedder'],
model: 'large-domain-embedder',
fallback: false,
requirements: {
minEmbeddingDimensions: 1536,
dataClass: 'private',
},
},
'chat.revision': {
providerIds: ['remote-small'],
model: 'fast-chat',
},
},
embeddingTaskSelection: {
largeDocumentChars: 24_000,
largeDocumentChunks: 16,
},
includeBridgeProviders: true,
discoverLocal: false,
}}
>
<Workspace />
</FortemiProvider>
)
}
Routes are task hints, not data-contract claims. They only choose an inference provider/model for runtime work such as query embeddings, document embeddings, revisions, tagging, and linking. Static AIWG index validation, Knowledge Shard profiles, and live Fortemi persistence remain separate planes.
The built-in embedding job selects `embedding.large-document` automatically when combined note and extracted attachment text crosses the exported size thresholds. Hosts that need different breakpoints can call `selectEmbeddingTask()` in custom ingestion or embedding pipelines and pass the selected task through `EmbedFunctionOptions`.
Use `useInferenceRouting()` when a React host UI needs to list providers, switch the active provider, edit routes, or surface validation state:
import { useInferenceRouting } from '@fortemi/react'
function InferencePanel() {
const {
providers,
activeProvider,
routeIssues,
setActiveProvider,
setRoute,
probeRoute,
} = useInferenceRouting({
tasks: ['embedding.query', 'embedding.document', 'embedding.large-document', 'chat.revision'],
})
async function useLargeDocumentEmbedder() {
setRoute('embedding.large-document', {
providerIds: ['remote-large', 'local-ollama'],
model: 'text-embedding-3-large',
requirements: { minEmbeddingDimensions: 1536 },
})
await probeRoute('embedding.large-document')
}
return (
<select
value={activeProvider?.id ?? ''}
onChange={(event) => setActiveProvider(event.target.value)}
>
{providers.map(provider => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
)
}
For lower-level integrations, use `useFortemiContext().providerRegistry` directly:
const { providerRegistry } = useFortemiContext()
providerRegistry.setRoute('embedding.document', {
providerIds: ['remote-large', 'local-ollama'],
model: 'text-embedding-3-large',
})
const vectors = await providerRegistry.embed({
texts: ['Long policy document'],
task: 'embedding.document',
})
Use `previewRoute()` or `probeRoute()` to verify task routing without sending note text, prompts, or vectors:
const selection = providerRegistry.previewRoute('embedding.document', 'embeddings')
const health = await providerRegistry.probeRoute('embedding.document', 'embeddings')
console.log(selection.providerName, selection.model, health.probe.status)
Use `validateRoutes()` for a cheaper configuration check when a deployment first loads or after an admin edits route settings:
const issues = providerRegistry
.validateRoutes()
.flatMap(route => route.issues)
if (issues.length) {
console.warn('[fortemi] inference route configuration issues:', issues)
}
Runtime config fragments can be composed before passing them to `FortemiProvider`. This is useful when a host has a shared provider pack, an environment-specific local discovery policy, and a workspace-specific routing profile:
import {
defineInferenceRuntime,
defineOpenAICompatibleProvider,
mergeInferenceRuntimeConfigs,
} from '@fortemi/core'
const sharedServices = defineInferenceRuntime({
providers: [
defineOpenAICompatibleProvider({
id: 'remote-small',
name: 'Small remote service',
baseURL: 'https://router.example.com/v1',
defaultModel: 'fast-chat',
}),
],
})
const localWorkstation = defineInferenceRuntime({
discoverLocal: { timeoutMs: 1500 },
includeBridgeProviders: true,
})
const workspaceRoutes = defineInferenceRuntime({
routes: {
'embedding.query': { providerIds: ['local:ollama', 'remote-small'] },
'embedding.large-document': {
providerIds: ['remote-large'],
fallback: false,
requirements: { minEmbeddingDimensions: 1536 },
},
},
})
const inference = mergeInferenceRuntimeConfigs(sharedServices, localWorkstation, workspaceRoutes)
Provider packs are merged by configured provider ID, so an environment-specific fragment can replace a shared `remote-small` definition without creating a duplicate registry entry.
Provider route policy is intentionally small: ordered provider IDs, optional tier filtering, an optional model override, and fallback on/off. That keeps deployment configuration composable while still supporting cascades such as small local model first, larger service model second, or a strict domain embedder for selected document classes. In the standalone app, route settings expose this as an ordered primary/fallback provider chain per task. When `providerIds` is present, fallback stays inside that explicit chain; it does not spill to every registered provider. For embeddings and non-streaming chat, fallback also retries the next eligible provider after a provider error. Streaming resolves the route up front and uses one provider for the stream.
Routes can also declare fail-closed requirements for privacy tier, cost ceiling, minimum context length, minimum embedding dimensions, allowed data class, or maximum input size. A provider that does not satisfy those requirements is skipped; if none match, the request fails instead of silently crossing a configured privacy or quality boundary. These provider profiles are runtime inference metadata and are unrelated to Knowledge Shard `core-v1`, `full-v1`, or `record-v1` profiles.
The registry emits `provider.route.configured` and `provider.route.cleared` for host UI synchronization, plus `provider.route.selected`, `provider.route.completed`, and `provider.route.failed` for observability. Payloads contain provider ID/name, tier, capability, task, optional model, route-match status, attempt/fallback counts, latency, and error metadata only; prompts, note bodies, embeddings, generated text, and keys are not emitted.
registerLlmCapability with WebLLM
The LLM capability performs WebGPU detection and selects a model tier before loading. You provide the `LlmCompleteFn` — a function that accepts a prompt string and returns a completion string:
import {
registerLlmCapability,
detectGpuCapabilities,
estimateVramTier,
selectLlmModel,
} from '@fortemi/core'
function LlmCapabilityLoader() {
const { capabilityManager } = useFortemiContext()
const [loadProgress, setLoadProgress] = useState('')
const enableLlm = async () => {
// Inspect GPU first if you need to display model selection to the user
const gpuCaps = await detectGpuCapabilities()
if (!gpuCaps.webgpuAvailable) {
alert('WebGPU is required for local LLM inference.')
return
}
const tier = estimateVramTier(gpuCaps)
const model = selectLlmModel(tier, gpuCaps.supportsF16)
console.log(`Selected model for ${tier} VRAM tier: ${model}`)
// Create a worker that wraps @mlc-ai/web-llm
const worker = new Worker(new URL('./llm-worker.ts', import.meta.url), {
type: 'module',
})
const completeFn = (
prompt: string,
options?: { maxTokens?: number; temperature?: number },
): Promise<string> =>
new Promise((resolve, reject) => {
const handler = (e: MessageEvent) => {
worker.removeEventListener('message', handler)
if (e.data.error) reject(new Error(e.data.error))
else resolve(e.data.completion as string)
}
worker.addEventListener('message', handler)
worker.postMessage({ prompt, options })
})
registerLlmCapability(capabilityManager, completeFn, {
modelOverride: model, // pass the pre-selected model
onProgress: (pct, text) => {
setLoadProgress(`${text} (${pct}%)`)
capabilityManager.reportProgress('llm', pct)
},
})
await capabilityManager.enable('llm')
setLoadProgress('')
}
return (
<div>
<button onClick={enableLlm}>Enable Local LLM</button>
{loadProgress && <p aria-live="polite">{loadProgress}</p>}
</div>
)
}
Reacting to capability state changes
useEffect(() => {
const onReady = events.on('capability.ready', ({ name }) => {
if (name === 'semantic') setSemanticReady(true)
if (name === 'llm') setLlmReady(true)
})
const onLoading = events.on('capability.loading', ({ name, progress }) => {
if (name === 'semantic' && progress !== undefined) {
setSemanticProgress(progress)
}
})
return () => {
onReady.dispose()
onLoading.dispose()
}
}, [events])
Checking capability state without the event bus
const { capabilityManager } = useFortemiContext()
// Point-in-time check
const isSemanticReady = capabilityManager.isReady('semantic')
const llmState = capabilityManager.getState('llm') // 'unloaded' | 'loading' | 'ready' | 'error' | 'disabled'
// All capabilities
const all = capabilityManager.listAll()
// [{ name: 'semantic', state: 'ready' }, { name: 'llm', state: 'unloaded' }, ...]
9. Attachment Handling
Attachments are stored as binary blobs in the `BlobStore` (OPFS or IDB), with metadata in the `attachment` table. The `manageAttachments` tool handles the base64 encoding boundary so that data can be transported over JSON (MCP bridge, `postMessage`, etc.). Search, shard export, static AIWG index, and embedding-set surfaces use extracted text plus attachment references; they do not inline raw binary bytes or `data_base64`.
When `manageAttachments` attaches or removes non-empty `extracted_text`, it automatically queues fresh `embedding` and `concept_tagging` jobs for the note so semantic and hybrid search stay aligned with full-text search.
Attaching a file from a file input
import { manageAttachments } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'
function AttachFileButton({ noteId }: { noteId: string }) {
const { db, blobStore } = useFortemiContext()
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const arrayBuffer = await file.arrayBuffer()
const uint8 = new Uint8Array(arrayBuffer)
// Encode to base64 for transport through the tool boundary
let binary = ''
for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i])
const data_base64 = btoa(binary)
const result = await manageAttachments(db, blobStore, {
action: 'attach',
note_id: noteId,
data_base64,
filename: file.name,
mime_type: file.type || 'application/octet-stream',
extracted_text: await extractTextForSearch(file),
display_name: file.name,
})
console.log('Attached:', result.attachment?.id, 'size:', result.size_bytes, 'bytes')
}
return <input type="file" onChange={handleFileChange} />
}
Retrieving a blob and rendering it
async function downloadAttachment(
db: PGlite,
blobStore: BlobStore,
attachmentId: string,
filename: string,
) {
const result = await manageAttachments(db, blobStore, {
action: 'get_blob',
attachment_id: attachmentId,
})
if (!result.data_base64) throw new Error('No blob returned')
// Decode base64 back to bytes
const binaryStr = atob(result.data_base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
// Create a download
const blob = new Blob([bytes])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
Direct BlobStore access
If you need to bypass the tool boundary for performance reasons (bulk reads, streaming), access the `BlobStore` from context directly:
const { blobStore } = useFortemiContext()
// Write
await blobStore.write(hash, uint8Data)
// Read (returns null if the hash is not found)
const data = await blobStore.read(hash)
// Check existence before reading
const exists = await blobStore.exists(hash)
The `hash` used by the attachment system is computed from the file content using `computeHash` from `@fortemi/core`. Do not fabricate hash values — always let the `AttachmentsRepository` or `manageAttachments` tool manage them.
Shard and AIWG index exports represent an attached binary as:
{
"extracted_text": "OCR or parser text used for search and embeddings",
"attachment": {
"id": "attachment-id",
"path": "file.pdf",
"mime": "application/pdf",
"checksum": "sha256:...",
"bytes": 12345
}
}
Use `get_blob` only when the user or host needs the original file bytes.
10. Multi-Archive Support
Each archive is a separate PGlite database instance with its own storage path. Archives allow you to partition knowledge by workspace, project, or user. The `ArchiveManager` handles creation, switching, and listing.
Creating and switching archives
import { useFortemiContext } from '@fortemi/react'
function WorkspaceSwitcher() {
const { archiveManager, events } = useFortemiContext()
const switchWorkspace = async (name: string) => {
// Closes the current DB and opens (or creates) the named archive
// Runs migrations automatically on the new database
await archiveManager.switchTo(name)
// events.emit('archive.switched', { name }) is called internally
}
const createWorkspace = async (name: string) => {
try {
await archiveManager.create(name) // throws if name already exists
} catch (err) {
console.error('Archive already exists:', err)
}
}
const workspaces = archiveManager.listArchives()
return (
<ul>
{workspaces.map((archive) => (
<li key={archive.name}>
<button onClick={() => switchWorkspace(archive.name)}>
{archive.name}
</button>
</li>
))}
<li>
<button onClick={() => createWorkspace('research-2026')}>
New workspace
</button>
</li>
</ul>
)
}
Reacting to archive switches
When the active archive switches, the `db` reference in the context is replaced. React components that depend on `db` will re-render because the context value has changed. However, repositories instantiated in callbacks or effects need to be re-created. The safest pattern is to key components on the archive name:
function NotesPanel() {
const { archiveManager } = useFortemiContext()
const archiveName = archiveManager.getCurrentArchiveName()
// Re-mount the entire notes list when the archive changes
return <NotesList key={archiveName} />
}
Alternatively, subscribe to `archive.switched` and flush any local state:
useEffect(() => {
const sub = events.on('archive.switched', ({ name }) => {
setNotes([])
setCurrentArchive(name)
// trigger re-fetch
})
return () => sub.dispose()
}, [events])
Using manageArchive tool
import { manageArchive } from '@fortemi/core'
// List archives via the tool boundary
const result = await manageArchive({ action: 'list' }, archiveManager)
// result.archives: ArchiveInfo[]
// Create via tool boundary
await manageArchive({ action: 'create', name: 'fieldwork-notes' }, archiveManager)
// Switch via tool boundary
await manageArchive({ action: 'switch', name: 'fieldwork-notes' }, archiveManager)
11. Knowledge Shard Import/Export
`importShard` accepts optional progress and batching controls for large corpora:
await importShard(db, bytes, {
conflictStrategy: 'skip',
batchSize: 250,
onProgress: ({ phase, done, total }) => {
console.log(`${phase}: ${done}/${total}`)
},
})
The importer reports phases such as `unpack`, `validate`, `notes`, `embeddings`, `embedding_set_members`, and `index`. It yields between batches so hosts can repaint progress UI even when not using worker mode.
`exportShard` can scope embedding payloads to specific sets:
const summariesOnly = await exportShard(db, {
includeEmbeddings: true,
embeddingSetIds: ['summary-set-id'],
})
Use this for the recommended large-corpus pattern: ship a small summary embedding set as the default semantic experience, then import a larger content/deep-search set on demand. HNSW and vector search can only rank vectors that are already loaded and indexed; for very large corpora, use text or summary search to select candidates before loading/reranking deeper content vectors.
Warming shards before import (prefetch)
When the user opts into a larger set, two costs land behind one click: download the shard bytes, then import them (decode + HNSW build). The import has to be on the click (it is heavy and shown with progress — correct). The download does not: `prefetchShard` warms the bytes in the background on idle so the click is purely the index build.
fortemi-react is server-free — shards are static assets (generated at build time and served as static files, or bundled and handed in as bytes). `prefetchShard` warms those static bytes; it assumes no API/server.
import { prefetchShard, fromPrefetched, importShard, isShardPrefetched } from '@fortemi/core'
// Build emits /shards/research.shard and its SHA-256 (e.g. into a constant).
const RESEARCH_SHARD = '/shards/research.shard'
// Warm on idle (avoidable download moves to background).
requestIdleCallback(() => {
void prefetchShard(RESEARCH_SHARD, { expectedSha256: RESEARCH_SHARD_SHA256 })
})
// On the user's click — warm bytes already in hand, so this is just the index build.
async function openResearch(db) {
const bytes = isShardPrefetched(RESEARCH_SHARD)
? fromPrefetched(RESEARCH_SHARD)
: (await prefetchShard(RESEARCH_SHARD)).bytes
await importShard(db, bytes, { onProgress: ({ phase, done, total }) => {/* repaint */} })
}
`prefetchShard(url, options?)` accepts:
| Option | Type | Description | |
|---|---|---|---|
| `bytes` | `ArrayBuffer \ | Uint8Array` | Warm directly from a bundled/build-time asset instead of fetching `url`. |
| `verify` | `boolean` | Compute the SHA-256 of the warmed bytes. | |
| `expectedSha256` | `string` | Verify the whole-archive SHA-256 against a build-time-known hash; mismatch throws and stores nothing. | |
| `useCacheStorage` | `boolean` | Also persist to the Cache Storage API so warmth survives a reload (feature-detected; no-op when unavailable). | |
| `cacheName` | `string` | Cache Storage cache name (default `'fortemi-shards'`). | |
| `fetchImpl` / `signal` | — | Inject a custom `fetch` / forward an `AbortSignal`. |
Integrity is two-layer: `expectedSha256` verifies the whole archive at warm time; `importShard` still validates the per-file checksums inside the manifest at import time. Both share core's `sha256Hex`. Companions: `isShardPrefetched(url)`, `getPrefetchedSha256(url)`, `clearPrefetchedShard(url?)` (in-memory eviction).
The React `useShardPrefetch()` hook wraps `prefetchShard` with per-url warming flags, and `useImportShard().importFromUrl(url, strategy?, prefetchOptions?)` imports from a (warm or freshly-fetched) static URL:
import { useShardPrefetch, useImportShard } from '@fortemi/react'
function ResearchLoader() {
const { prefetch, isPrefetched } = useShardPrefetch()
const { importFromUrl, progress } = useImportShard()
useEffect(() => {
const id = requestIdleCallback(() =>
void prefetch('/shards/research.shard', { expectedSha256: RESEARCH_SHARD_SHA256 }),
)
return () => cancelIdleCallback(id)
}, [prefetch])
// Click runs only the index build when the bytes are already warm.
return (
<button onClick={() => importFromUrl('/shards/research.shard')}>
{isPrefetched('/shards/research.shard') ? 'Open research set' : 'Warming…'}
</button>
)
}
In-place query — the static-file backend (`openShard` / `useShard`)
A shard has a second access mode: instead of importing it into PGlite, open it and query the component files in place — with no PGlite at all. This is the lightest, server-free tier — ideal for a read-only browse/search UI that only upgrades to a database when a visitor opts into semantic.
import { openShard } from '@fortemi/core'
// Source: a packed tar.gz (Uint8Array/Blob) or a static base URL of unpacked files.
const reader = await openShard({ baseUrl: '/shards/notes' })
const { items, total } = await reader.listNotes({ limit: 20 })
const result = await reader.search('founder breakfast', { rank: true, snippets: true })
const note = await reader.getNoteFull('note-id') // note + links + concepts + provenance, lazy
| Reader method | Description |
|---|---|
| `listNotes(options?)` | Paginated browse (soft-deleted excluded; archived included by default). |
| `getNote(id)` / `getNoteFull(id)` | A single note, or the note plus its links + SKOS concepts + provenance. |
| `search(query, options?)` | Full-text + facet search with AND multi-word semantics matching the PGlite text path, plus optional `rank`/`snippets`. A per-query match cache means paging the same query re-scans nothing (`fetchedClusters: 0`). |
| `linksOf(id)` / `conceptsOf(id)` / `relationsOf(conceptId)` / `provenanceOf(id)` | Lazy relationship, SKOS, and W3C PROV resolution from component files. |
| `semantic(query, k?)` | Opt-in (see below); returns `[]` when no provider is configured. |
Clustered layout (large corpora). Emit notes as addressable cluster files so the reader fetches only what a query needs (the shard analog of the AIWG chunk parts):
const clustered = await exportShard(db, { clusterNotesSize: 1000 }) // notes/000000.jsonl, …
`importShard` and `openShard` both consume the clustered layout transparently; a monolithic shard stays valid (the `layout` field is simply absent).
Semantic over static files — pluggable, three tradeoff points. The reader is text/facets-only unless you pass a `StaticSemanticProvider`:
1. none — omit `semantic` (text/facets only). Lightest. 2. cosine-small — `createCosineSemanticProvider({ embedQuery, vectorsFile })`: brute-force cosine over a small static `vectors.jsonl`. Zero prebuild; small corpora only. 3. ANN-full — implement `StaticSemanticProvider` yourself over a prebuilt ANN snapshot (HNSW / flat-IVF served as a static asset), loading it in `prepare()`. The interface is the extension point; no ANN engine is bundled.
import { openShard, createCosineSemanticProvider } from '@fortemi/core'
const reader = await openShard('/shards/notes.shard', {
semantic: createCosineSemanticProvider({ embedQuery: (q) => embed(q) }), // vectors.jsonl alongside
})
const hits = await reader.semantic('founders meeting', 10)
The React `useShard(source, options?)` hook mirrors `useAiwgIndex()` — it manages the async-opened reader and exposes `search`/`listNotes`/`getNote`/`getNoteFull`/`linksOf`/`conceptsOf`/`relationsOf`/`provenanceOf`/`semantic` plus `manifest`/`loading`/`error`. Memoize `source` to avoid re-opening on every render.
import { useMemo } from 'react'
import { useShard } from '@fortemi/react'
function NotesBrowser() {
const source = useMemo(() => ({ baseUrl: '/shards/notes' }), [])
const { search, loading } = useShard(source)
// const result = await search('founder breakfast', { rank: true })
return loading ? <Spinner /> : <SearchUI onSearch={search} />
}
Backend seam — uniform tool-intent dispatch + capability negotiation (`selectBackend`)
The PGlite database backend, the static-file shard reader, the writable canonical record tier, and the remote server tier expose the same operations through a single `DataBackend` interface, so UI code can run against any of them without branching on storage. `selectBackend` negotiates which one to use from the capabilities a caller needs:
import {
createPGliteBackend,
createRecordBackend,
createRecordStore,
createRemoteBackend,
createShardBackend,
selectBackend,
openShard,
} from '@fortemi/core'
// Heavy, writable, full corpus.
const pglite = createPGliteBackend(db) // read+write+merge, semantic 'ann-full' when embeddings exist
// Instant, read-only, fetched on demand.
const shard = createShardBackend(await openShard({ baseUrl: '/shards/notes' }))
// Instant AND writable, no PGlite: the canonical record tier (bounded scan, no vectors).
const records = createRecordBackend(await createRecordStore())
// Network-backed, multi-user server tier.
const remote = createRemoteBackend({ baseUrl: 'https://fortemi.example', headers: () => getSessionHeaders() })
// Ask for what the view needs; get the lightest backend that provides it.
const { backend, missing } = selectBackend({ read: true }, [pglite, shard, remote])
// → backend.id === 'static-file' (instant beats index-build), missing === []
const writable = selectBackend({ read: true, write: true }, [pglite, shard])
// → writable.backend.id === 'pglite' (only it can write among these two candidates)
const lightWritable = selectBackend({ read: true, write: true }, [pglite, records])
// → lightWritable.backend.id === 'canonical-records' (writable AND instant; PGlite only wins when semantic/FTS is requested)
const serverTier = selectBackend({ read: true, write: true, multiUser: true, semantic: 'server' }, [pglite, shard, remote])
// → serverTier.backend.id === 'remote-server'
const results = await backend!.search('founder breakfast') // same call shape on either backend
Every backend implements `listNotes` / `getNote` / `search`; readable backends also expose `getNoteFull`, `linksOf`, `conceptsOf`, and `provenanceOf`. `semantic` and `manageNote` are present only when the backend advertises them (`capabilities.write` gates `manageNote`, `capabilities.semantic !== 'none'` gates `semantic`). `createRemoteBackend(config)` proxies the same surface over HTTP for the Fortemi server tier (`read`/`write`/`multiUser`, `merge: false`, `semantic: 'server'`, `startupCost: 'network'`). Pass session credentials via `headers` or `authToken`; do not put secrets in source.
The React `useRemote(config)` hook wraps `createRemoteBackend` and exposes the same remote operations with `loading`/`error` state.
The remote wire contract is not the local database schema. In particular, `provenanceGraphOf(id)` exposes the server revision/activity graph, and composed notes store it in `provenanceGraph`. Remote `provenanceOf` rejects as unsupported rather than manufacturing local edges. Note absence is distinct from HTTP, transport and enrichment errors, which are surfaced as `RemoteBackendError`. Remote search uses `q`, explicit fts/semantic/hybrid modes and AND-tag filters. Nonzero offset and source filtering reject as unsupported. Timestamps come from bounded detail enrichment, not the search response; this is not an atomic snapshot. Read `degraded`/`effectiveMode` on the result before interpreting rank. `semanticWithReport` preserves fallback; array-returning `semantic` rejects it. `manageNote` maps create/update/star/archive/delete/restore intents to actual REST operations. Unsupported fields reject instead of being silently dropped. See the API reference for exact inputs and acknowledgements. Source fixtures do not replace live published-package qualification or prove provider availability; capability flags alone remain insufficient evidence.
`selectBackend` prefers a fully-satisfying backend with the lightest `startupCost`; when none fully satisfy, it returns the fewest-missing candidate so the caller can degrade deliberately via `selection.missing`.
12. Service Worker Setup
The service worker registers REST route shapes under `/api/v1/*`. The current route handlers return 503 until standalone-mode database wiring injects a live PGlite connection, so direct tool functions remain the primary integration point for code running inside the app.
Registering the service worker
import { registerServiceWorker } from '@fortemi/core'
// Call this once at application startup, before mounting the React tree
const result = await registerServiceWorker('/sw.js')
if (!result.registered) {
console.warn('Service Worker registration failed:', result.error)
// Fall back to direct function call integration
} else {
console.log('SW active — REST API available at /api/v1/*')
}
`registerServiceWorker` waits for the service worker to reach the `activated` state before resolving, so subsequent `fetch` calls against `/api/v1/*` will be intercepted immediately.
Route structure
The SW handles the following routes. All routes accept and return `application/json`.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/v1/notes` | List notes |
| `POST` | `/api/v1/notes` | Create a note |
| `GET` | `/api/v1/notes/:id` | Fetch a single note |
| `PUT` | `/api/v1/notes/:id` | Update a note |
| `DELETE` | `/api/v1/notes/:id` | Soft-delete a note |
| `POST` | `/api/v1/notes/:id/restore` | Restore a soft-deleted note |
| `POST` | `/api/v1/notes/:id/star` | Star or unstar a note |
| `POST` | `/api/v1/notes/:id/archive` | Archive or unarchive a note |
| `GET` | `/api/v1/search` | Full-text search |
Custom route handler
If you need to add routes for your host application, use `createRoutes` and `matchRoute` from `@fortemi/core`:
import { createRoutes, matchRoute, type RouteHandler } from '@fortemi/core'
// In your sw.ts
const fortemiRoutes = createRoutes()
// Add your own routes
const appRoutes: RouteHandler[] = [
{
method: 'POST',
pattern: /^\/api\/v1\/host\/export\/?$/,
handler: async (request) => {
const body = await request.json()
// handle export
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
})
},
},
]
const allRoutes = [...fortemiRoutes, ...appRoutes]
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
if (!url.pathname.startsWith('/api/')) return
const match = matchRoute(allRoutes, event.request, url)
if (!match) return
event.respondWith(
match.handler(event.request, [], url.searchParams)
)
})
12. TypeScript Types
Key types for consumers. All are exported from `@fortemi/core` or `@fortemi/react`.
Context and provider
import type {
FortemiContextValue, // { db, events, archiveManager, capabilityManager, blobStore }
FortemiProviderProps, // { persistence, archiveName?, children }
} from '@fortemi/react'
import type { PersistenceMode } from '@fortemi/core' // 'opfs' | 'idb' | 'memory'
Notes
import type {
NoteSummary, // Lightweight list item (no content body)
NoteFull, // Full note with original, current revision, and tags
NoteCreateInput, // { content, title?, format?, source?, visibility?, tags?, archive_id? }
NoteUpdateInput, // { title?, content?, format?, visibility? }
NoteListOptions, // Filtering and pagination options for list()
NoteRevision, // Single revision record with content and ai_metadata
PaginatedResult, // { items: T[], total, limit, offset }
} from '@fortemi/core'
Search
import type {
SearchResult, // { id, title, snippet, rank, created_at, updated_at, tags }
SearchResponse, // { results, total, query, mode, semantic_available, limit, offset }
SearchOptions, // { limit?, offset?, tags?, collection_id? }
} from '@fortemi/core'
Event bus
import type {
EventMap, // Full typed map of all event names to their payload shapes
IDisposable, // { dispose(): void } — returned by on() and once()
} from '@fortemi/core'
Job queue
import type {
JobType, // 'title_generation' | 'ai_revision' | 'embedding' | 'concept_tagging' | 'linking'
JobStatus, // Full job row including status, retry_count, error, result
EnqueueJobInput, // { noteId, jobType, priority?, requiredCapability? }
JobQueueOptions, // { pollIntervalMs?, maxRetries?, backoffBaseMs?, backoffMaxMs? }
} from '@fortemi/core'
Capabilities
import type {
CapabilityName, // 'semantic' | 'llm' | 'audio' | 'vision' | 'pdf'
CapabilityState, // 'unloaded' | 'loading' | 'ready' | 'error' | 'disabled'
GpuCapabilities, // { webgpuAvailable, vendor, architecture, maxBufferSizeBytes, supportsF16 }
VramTier, // 'low' | 'medium' | 'high' | 'unknown'
EmbedFunction, // (texts: string[]) => Promise<number[][]>
LlmCompleteFn, // (prompt: string, options?) => Promise<string>
LlmCapabilityOptions, // { modelOverride?, onProgress? }
} from '@fortemi/core'
Attachments
import type {
AttachmentRow, // DB row: { id, note_id, filename, mime_type, size_bytes, ... }
AttachInput, // { noteId, data: Uint8Array, filename, mimeType?, displayName? }
ManageAttachmentsInput, // Tool input shape
ManageAttachmentsResult, // Tool result shape
BlobStore, // { write, read, remove, exists }
} from '@fortemi/core'
Archive and collection
import type {
ArchiveInfo, // { name: string, createdAt: string }
CollectionRow, // Collection DB row
CollectionCreateInput, // Input for collection creation
LinkRow, // Link between notes
SkosScheme, // SKOS concept scheme
SkosConcept, // Individual SKOS concept
SkosRelation, // Relation between concepts
} from '@fortemi/core'
13. Browser Compatibility Notes
Storage backend selection
`createBlobStore()` and `createPGliteInstance()` select their backends automatically based on what the browser supports:
| Backend | Availability | PGlite dataDir | BlobStore class |
|---|---|---|---|
| OPFS (Origin Private File System) | Chrome 86+, Edge 86+, Safari 15.2+ | `opfs-ahp://fortemi-{name}` | `OpfsBlobStore` |
| IndexedDB | All modern browsers including Firefox | `idb://fortemi-{name}` | `IdbBlobStore` |
| Memory | Everywhere, no persistence | `undefined` | `MemoryBlobStore` |
When using `persistence: 'opfs'` on a browser that does not support OPFS, PGlite will throw during initialization. `FortemiProvider` will catch this and re-throw, so your error boundary will surface it. Prefer `'idb'` as the production default unless you have confirmed OPFS support in your target environment.
Custom storage backends
`ArchiveManager` can also be constructed with a `StorageBackendFactory` when an embedding application needs to own the physical storage layer:
import { ArchiveManager, type StorageBackendFactory } from '@fortemi/core'
const factory: StorageBackendFactory = {
async open({ archiveName }) {
return openDesktopVaultBackend(archiveName)
},
}
const manager = new ArchiveManager(factory)
const db = await manager.open('workspace')
Custom backends must preserve the `DatabaseClient` contract: `query()`, `exec()`, and `transaction()` should behave like the default PGlite client and return `{ rows }` query results. The repository layer assumes PostgreSQL-compatible SQL semantics.
Dual-backend desktop topologies should use an explicit coordinator policy such as `primary-only`, `read-through-secondary`, or `explicit-replication`. Repositories should still see one active database client, and writes must be serialized through one writer per physical backend to preserve ADR-003.
Secure provider bridge
Keyed remote inference providers must be configured through a host bridge, not browser-readable storage. The shared bridge contract is exported from `@fortemi/core`:
import type { FortemiBridge } from '@fortemi/core'
declare global {
interface Window {
fortemiBridge?: FortemiBridge
}
}
Hosts expose `window.fortemiBridge.capabilities()` and `window.fortemiBridge.secrets`. `hasFortemiSecureSecrets()` returns `true` only when the bridge reports `secureSecrets: true` and the secret store is available. Browser-only apps must fail closed for OpenAI/OpenRouter-style API key providers when this check fails.
The standalone app ships provider presets for browser-local ML, OpenAI, OpenRouter, Groq, Mistral, Together, Fireworks, DeepInfra, xAI, Ollama, LM Studio, llama.cpp, vLLM, and Jan. Remote keyed presets are locked unless a bridge is present. API keys are written only through the bridge secret API and are not serialized into localStorage, IndexedDB, PGlite, Cache Storage, notes, or Knowledge Shards.
The intended bridge hosts are:
- Fortemi server bridge for workstation integration and server-side secret storage.
- A small standalone privacy router that stores keys in OS secure storage and brokers provider calls.
- Local no-key OpenAI-compatible servers such as Ollama or LM Studio, which do not require the bridge when intentionally unauthenticated.
AIWG graph projection and view
AIWG index exports can be searched and projected into a `CommunityGraph`:
import { aiwgFortemiIndexToCommunityGraph } from '@fortemi/core/aiwg-index'
import { GraphView, useAiwgIndex } from '@fortemi/react'
const graph = aiwgFortemiIndexToCommunityGraph(index, {
communityFacet: 'role',
relationshipWeights: { co_attended: 2 },
})
React hosts can call `useAiwgIndex().toCommunityGraph()` and pass the result to `GraphView`:
const aiwg = useAiwgIndex(index)
const graph = aiwg.toCommunityGraph({ communityTagPrefix: 'community:' })
return <GraphView graph={graph} layout={{ algorithm: 'community' }} onSelectNode={openRecord} />
`GraphView` is browser-only and renders SVG with zoom/pan controls, node selection callbacks, community coloring, degree-based node sizing, and filters for nodes, communities, and edge kinds.
No-bundler static documentation hosts can use the lightweight subpath directly:
Package consumers should prefer `@fortemi/core/aiwg-index` for static search and review surfaces. The subpath contains only the static index helpers, the framework-agnostic controller, and their types; it does not import React, PGlite, workers, shards, or browser storage.
When a build step needs to convert an AIWG export into a Knowledge Shard, import the converter from the separate build-oriented subpath:
import { convertAiwgIndexToKnowledgeShard } from '@fortemi/core/aiwg-index-shard'
That path intentionally carries shard dependencies and emits the `core-v1` profile. Keep it out of static documentation bundles; the top-level `@fortemi/core` entry remains available to full-runtime consumers.
Use `loadIndex()` for small single-file exports. Use `loadChunkedIndex()` for large static indexes when first load and memory must stay bounded. Plain browse calls fetch only the intersecting part files; filtered and ranked searches scan parts for exact results while retaining only the configured part cache.
WebGPU for local LLM
WebGPU is required for the `llm` capability. The `detectGpuCapabilities()` function handles the detection and surfaces the result clearly:
- Chrome 113+ on Windows, macOS, Linux (with `--enable-unsafe-webgpu` flag on Linux)
- Safari 18+ (macOS Sequoia, iOS 18)
- Firefox 141+ with `dom.webgpu.enabled` set to `true` in `about:config`
Linux note: Chrome on Linux requires launching with `--enable-unsafe-webgpu` for hardware-accelerated WebGPU. Without the flag, `detectGpuCapabilities()` will return a SwiftShader (software rasterizer) adapter, which `estimateVramTier()` will classify as `low` VRAM tier and `selectLlmModel()` will map to the smallest available model (`Qwen3-0.6B`). The `supportsF16` field will be `false` for SwiftShader, so the `q4f32_1` quantization variant is selected automatically.
If your host deployment targets Linux workstations, document the Chrome launch flag requirement for users who want local LLM inference.
Cross-origin isolation
PGlite with OPFS and WebGPU both require `crossOriginIsolated` to be `true`. Your server must send the following headers for the pages that host fortemi:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Verify isolation at runtime before initializing:
if (!crossOriginIsolated) {
console.error(
'fortemi requires cross-origin isolation. ' +
'Ensure COOP: same-origin and COEP: require-corp headers are set.'
)
}
PGlite WASM initialization
PGlite loads a WASM module on first initialization. On a cold load (no HTTP cache), this fetch is approximately 6–8 MB. Subsequent loads are served from the browser cache.
PGlite 0.4.x requires the `database: 'postgres'` option to be explicitly set — this is handled internally by `createPGliteInstance()`. Do not call `PGlite.create()` directly without this option, as it will fail with a connection error.
The `vector` extension for pgvector is loaded with every instance and enabled via `CREATE EXTENSION IF NOT EXISTS vector` immediately after creation. Embedding dimensions in the default pipeline are 384 (all-MiniLM-L6-v2).