Maintenance
The jobs
Section titled “The jobs”| Method | What it does | Auto-triggered? |
|---|---|---|
runLibrarian(entityId, { promptOverride? }) |
Turns recent events into facts and tasks | With runPendingMaintenance(), or every autoLibrarianThreshold events (default 20) in 'auto' mode |
runHeal(entityId, { promptOverride? }) |
Resolves contradictions, downgrades stale inferences, removes obsolete facts | With runPendingMaintenance(), or every autoHealThreshold events (default 100) in 'auto' mode |
runReembed(entityId?, { skipExisting?, force? }) |
Computes missing or outdated embeddings | No |
runPrune(entityId, opts) |
Hard-deletes aged soft-deleted rows and old events | No |
runOntologyBackfill(entityId) |
Types untyped facts | No |
Jobs for the same entity are mutually exclusive. A conflicting job throws WikiBusyError, which
is safe to catch and retry later.
Librarian strategies
Section titled “Librarian strategies”config: { librarian: { strategy: 'legacy' } } // default: 'ops''legacy' |
'ops' (default) |
|
|---|---|---|
| Input | The newest events, plus existing facts in the prompt | Events since the last pass only |
| Dedup | Done by the model | An embedding gate, with no LLM call: identical facts are a no-op, novel facts are added, and only similar-but-different candidates go to the model |
| Changes | Rewrites facts | ADD / UPDATE / SUPERSEDE / NOOP, in one batched call. Supersede keeps history. |
| Prompt overrides | prompts.librarianSystemPrompt |
Ignored (ops uses its own schema) |
Gate thresholds (gate: { k: 5, dupThreshold: 0.89, novelThreshold: 0.30 }) depend on your
embedding model. The defaults were calibrated on BGE-small and are provisional for other models.
Without embed, the gate uses keyword search, and every match goes to the model.
Merge imports and the librarian position
Section titled “Merge imports and the librarian position”A merge import re-dates events that would sort behind the destination’s librarian position, so the next pass reads them. Every import, merge: false included, re-stamps events whose date is unusable. Bundles from exportDump() and getMemoryBundle() carry the source’s librarian_watermark; events at or before it were consolidated at the source and keep their dates. If you split a bundle into chunks, copy librarian_watermark onto a chunk only if that chunk also carries the source’s facts; otherwise omit it. A chunk that keeps the watermark but drops the facts makes the importer treat its older events as already read, so they are never extracted and a later prune deletes them. A source running the legacy librarian (librarian.strategy: 'legacy') advances its librarian_watermark past events it never read, because a legacy pass reads only the newest 50 events. The importer trusts the watermark, so remove it from a bundle unless you know the source consolidated everything up to it.
Heal looks at candidates such as orphaned facts (no source for orphanAfterDays, default 30) and
inferred facts older than staleInferredAfterDays (default 60). It asks the model to downgrade,
delete, or replace them. immutable_document facts are never rewritten. Set either threshold
to null to turn that check off.
Reembed
Section titled “Reembed”const r = await wiki.runReembed(); // all entitiesawait wiki.runReembed('user-123', { skipExisting: true }); // only facts with no vector// → { embedded, skipped, failed, deferred, permanentlyFailed }- Run it after you add
embed()or change embedding models. deferredrows are inside an exponential backoff (starting at 60 s, doubling, capped at 24 h). That’s not an error.permanentlyFailedcovers afloat32_overflowfailure or 5 failed attempts. Retry them with{ force: true }, which also skips the backoff window fordeferredrows.- A convergence loop should test
failed, notdeferred. - A
storage_errorcounts infailedbut never enters backoff, because storage is treated as a separate failure domain from the embedding provider. While writes keep failing, every sweep retries every row, so a loop onfailedcan spin. Treat repeatedstorage_errorfailures as an infrastructure problem to fix, not something to wait out.
await wiki.runPrune('user-123', { retainSoftDeletedFor: 7, // days; null to skip retainEventsFor: 30, // days; null to skip vacuum: false, // true rewrites the whole DB file (slow on mobile)});// → { entries, tasks, events }Under 'deferred' maintenance or the 'ops' strategy, prune never deletes events the
librarian hasn’t processed yet. It emits event_retention_held instead. To delete an entity’s
events on request, read or not, use
forget(entityId, { clearAll: true, includeEvents: true }).
Deferred maintenance
Section titled “Deferred maintenance”Since 8.0.0, write() only records the event by default. Nothing runs in the background and no
model is called until you run maintenance, so no facts appear from events until you do. To have
write() start the librarian and heal as background promises, set maintenance: 'auto', but not on
serverless or request-scoped hosts, where those promises can be frozen or killed partway through.
const wiki = new WikiMemory(db, { llmProvider, config: { maintenance: 'deferred', librarian: { strategy: 'ops' } },});
await wiki.write(entityId, { event_type: 'observation', summary, occurred_at });
const report = await wiki.runPendingMaintenance({ tokenBudget: 20_000, deadlineMs: 8_000 });report.stoppedReason.reason; // 'complete' | 'deadline' | 'budget_exhausted' | 'budget_too_small'// Nothing is still running at this point, so it's safe to persist the DB file// (unless you set drainExternalDeletionsOnSetup and deadlineMs cut short the wait for it:// then await wiki.drain()).getPendingMaintenance()shows the backlog per entity without calling a model. It also reportspendingExternalDeletionswhen your vector index still owes removals and this process can act on them (a hook is configured, orforceDeleteIgnoreRankerHookis on). An entity with only owed removals is listed only while at least one of its rows is outside backoff (never attempted, or last attempted longer ago thanmin(2^attempts, 3600)seconds). That doesn’t make a loop overgetPendingMaintenance()end on its own: each pass stops after 5 rejections in a row or one timeout, so the rows it didn’t try keep the entity listed.runPendingMaintenance()also delivers those owed removals first (the'externalDeletions'job). That work makes no model call and doesn’t use the token budget. If you loop until nothing is pending, bound the iterations: a hook that keeps failing keeps removals pending, possibly for many passes.- The budget counts text-generation tokens only, not embedding calls. The counts come from
the provider when you implement
generateTextWithUsage, and are estimated otherwise. - Entities share the budget fairly: each round runs one batch per entity.
- A crash mid-batch loses nothing, because the librarian’s watermark only advances after a commit.
- Use
'deferred'together with'ops'. The legacy strategy only reads the newest 50 events per pass, so a larger backlog would be skipped.
On mobile: the Night Shift pattern
Section titled “On mobile: the Night Shift pattern”Phones are hard on background work: iOS can kill memory-hungry processes, and long LLM passes drain the battery. A pattern that works well is to turn off auto maintenance and run it in the foreground while the device is charging:
import * as Battery from 'expo-battery';import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
const wiki = createWiki(db, { llmProvider, config: { maintenance: 'deferred', librarian: { strategy: 'ops' } },});
export async function runNightShift() { const state = await Battery.getBatteryStateAsync(); if (state !== Battery.BatteryState.CHARGING && state !== Battery.BatteryState.FULL) return;
await activateKeepAwakeAsync(); try { await wiki.runPendingMaintenance({ deadlineMs: 15 * 60_000 }); } finally { deactivateKeepAwake(); }}Curated Journal ships the same idea as its Night Shift mode: a full-screen maintenance session the user starts when the device is plugged in.
Live job status
Section titled “Live job status”wiki.getEntityStatus('user-42'); // { ingesting, librarian, heal }
const unsubscribe = wiki.subscribeEntityStatus('user-42', (s) => render(s));unsubscribe();The callback fires once, synchronously, before subscribeEntityStatus returns (treat it as the
initial value), then again whenever any of the three flags flips.
- Each call may get a fresh object. Compare the three booleans, not object identity.
- A callback that throws is caught and logged. It doesn’t affect other subscribers or the job.
- A subscription covers one entity. There’s no wildcard form.
unsubscribe()is safe to call more than once.
In React, use
useEntityStatus.