@equationalapplications/core-llm-wiki 7.7.7 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,82 @@ For programmatic pipelines — importing pre-classified data, building a GraphRA
67
67
 
68
68
  This is the GraphRAG seed path: load a corpus, walk it.
69
69
 
70
+ ## Temporal facts
71
+
72
+ Facts carry an optional validity window. Nothing changes until you use it: facts without validity data are always current.
73
+
74
+ ```typescript
75
+ // The user tells you they moved; the move happened on March 1.
76
+ await wiki.supersede('user-123', seattleFactId, { title: 'Lives in San Francisco', body: 'Moved in March.' }, {
77
+ validFrom: Date.parse('2026-03-01'),
78
+ });
79
+
80
+ await wiki.read('user-123', 'where do I live'); // current: San Francisco
81
+ await wiki.read('user-123', 'where do I live', { asOf: Date.parse('2026-02-01') }); // then: Seattle
82
+ await wiki.history('user-123', seattleFactId); // [Seattle, San Francisco]
83
+
84
+ // Record when an event really happened (defaults to ingestion time):
85
+ await wiki.write('user-123', { event_type: 'observation', summary: 'Moved to SF', occurred_at: Date.parse('2026-03-01') });
86
+ ```
87
+
88
+ - Validity is half-open: `[valid_from, valid_to)`. At the switch instant only the new fact matches.
89
+ - `asOf` answers "what do we now know was true at T". Learning late about a past change still answers correctly for the past.
90
+ - `supersede()` refuses `immutable_document` facts (re-ingest the document instead), cycles, facts owned by another entity, and facts already superseded. It throws `WikiSupersedeError` with a `reason`.
91
+ - `traverseGraph({ asOf })` only walks edges whose two facts are both live at that instant.
92
+ - History is kept. Set `config.pruneSupersededAfter` (days) to let `runPrune` retire expired facts.
93
+ - `getMemoryBundle()` and `exportDump()` return every non-deleted fact, including history.
94
+
95
+ ## Librarian strategies
96
+
97
+ `config.librarian.strategy` selects how events become facts. The default, `'legacy'`, is unchanged from 7.x.
98
+
99
+ ```typescript
100
+ new WikiMemory(db, {
101
+ llmProvider,
102
+ config: { librarian: { strategy: 'ops', gate: { k: 5, dupThreshold: 0.89, novelThreshold: 0.30 } } },
103
+ });
104
+ ```
105
+
106
+ `'ops'` works in three steps:
107
+
108
+ 1. **Extract.** Candidate facts come from the events since the last pass. Existing facts are not sent.
109
+ 2. **Gate, with no LLM call.** Each candidate is compared with its closest current facts. An identical fact is a no-op, and a candidate with no close match is added. Only candidates that are similar but different continue to step 3. Similar is not the same as identical: "lives in Seattle" and "moved to San Francisco" score as similar, so they go to the model.
110
+ 3. **Resolve, in one batched call.** The model returns `ADD`, `UPDATE`, `SUPERSEDE` or `NOOP` for each remaining candidate. `SUPERSEDE` uses `supersede()`, so history is kept and `read({ asOf })` still sees the old fact.
111
+
112
+ - The thresholds depend on your embedding model. The defaults are provisional.
113
+ - Without `embed`, the gate uses keyword search. Every keyword match then goes to the resolve step.
114
+ - Under `'ops'`, `runLibrarian`'s `promptOverride` and `prompts.librarianSystemPrompt` are ignored: the ops prompts use a different schema.
115
+ - A model answer that can't be used never loses a fact. It is added, or stored as a draft, and a diagnostic is emitted.
116
+
117
+ ## Deferred maintenance
118
+
119
+ By default, `write()` starts the librarian and heal in the background when thresholds are crossed. On serverless or request-scoped hosts those background promises can be frozen or killed. Set `maintenance: 'deferred'` to decide when maintenance runs:
120
+
121
+ ```typescript
122
+ const wiki = new WikiMemory(db, {
123
+ llmProvider,
124
+ config: { maintenance: 'deferred', librarian: { strategy: 'ops' } },
125
+ });
126
+
127
+ await wiki.write(entityId, { event_type: 'observation', summary, occurred_at });
128
+
129
+ const report = await wiki.runPendingMaintenance({ tokenBudget: 20_000, deadlineMs: 8_000 });
130
+ // report.stoppedReason: complete | deadline | budget_exhausted | budget_too_small
131
+ // Nothing is still running here, so it is safe to persist the database.
132
+ ```
133
+
134
+ - `getPendingMaintenance()` shows the backlog per entity without calling a model.
135
+ - The budget covers text-generation tokens only; embedding calls are not counted. Figures are provider-reported when `generateTextWithUsage` exists, otherwise estimated at characters ÷ 4.
136
+ - Entities share the budget fairly: each round runs one batch per entity.
137
+ - `budget_too_small` names the first job and entity whose smallest step needed more than the whole budget; other entities may have hit the same limit in that run.
138
+ - A crash mid-batch loses nothing. The librarian's progress marker only advances after a batch commits.
139
+ - In `'auto'` mode, call `drain()` before the app is suspended or closed. `autoLibrarianTokenThreshold` also triggers the librarian on the size of the pending text.
140
+ - Use `'deferred'` with the ops strategy. The legacy strategy reads only the newest 50 events per pass and marks them processed, so a backlog larger than that is skipped.
141
+ - Under `'deferred'` or the ops strategy, `runPrune` never deletes events the librarian has not processed.
142
+ - `JobManager` locks only work within one process. Guarantee a single writer per database file across processes.
143
+
144
+ See the [SynapseTree integration guide](../../docs/synapsetree-integration.md) for the end-to-end pattern on tenant-aware hosts (S3-backed SQLite, request lifecycle, `entity_id` scheme, MCP surface, costs). Benchmarks live at [docs/benchmarks.md](../../docs/benchmarks.md).
145
+
70
146
  ## Installation
71
147
 
72
148
  ```bash
@@ -124,6 +200,15 @@ const wikiMemory = new WikiMemory(db, {
124
200
  // read() returns MiniSearch results, onRetrievalFallback called (embed threw)
125
201
  ```
126
202
 
203
+ ### Token budgets
204
+
205
+ ```typescript
206
+ const bundle = await wiki.read('user-123', 'what should I cook?', { tokenBudget: 800 });
207
+ const prompt = formatContext(bundle, { compact: true });
208
+ ```
209
+
210
+ `tokenBudget` keeps the most relevant facts per token that fit within about 800 tokens. Tokens are estimated as characters ÷ 4. The top fact is always included, truncated if it alone is too big. Tasks and events are unaffected. `traverseGraph` accepts the same option and keeps nodes in breadth-first order. A `read_budget` diagnostic reports how many facts were packed.
211
+
127
212
  ## Configuration
128
213
 
129
214
  All `WikiConfig` fields are optional:
@@ -1169,6 +1254,15 @@ const { nodesWritten, edgesWritten, superseded } = await wikiMemory.upsertGraph(
1169
1254
 
1170
1255
  `upsertGraph` is "the tail of `ingestDocument` with the middle (LLM extraction) step removed" — it accepts caller-supplied nodes (`{ id, type, title, body? }`) and edges (`{ type, sourceId, targetId, id? }`) and writes them under the same `(sourceRef, sourceHash)` semantics. If a *different* live `sourceRef` already holds the same `sourceHash`, it throws `WikiSourceRefHashCollision`; re-writing the identical `(sourceRef, sourceHash)` is a no-op returning zero counts. The adapter parameter is required so writes participate in the caller's transaction.
1171
1256
 
1257
+ Core never sees your transaction commit, so it can't index the new nodes itself. Until you tell it, they won't show up in keyword or hybrid `read()` results on the same instance. Call `syncSearchIndex` **after** the commit, not inside the transaction:
1258
+
1259
+ ```typescript
1260
+ await db.withTransactionAsync((tx) => wikiMemory.upsertGraph('entity-123', params, tx));
1261
+ await wikiMemory.syncSearchIndex('entity-123'); // or syncSearchIndex() for every entity written
1262
+ ```
1263
+
1264
+ It is cheap on an entity that is already current, runs in order with core's own index syncs, and never rejects for a valid `entityId` (an empty or non-string id throws `TypeError`). It does not compute embeddings; those still come from the maintenance sweep.
1265
+
1172
1266
  Entry IDs occupy one globally shared namespace within a database. An ID belongs
1173
1267
  to its existing entity while its row exists, including after soft deletion.
1174
1268
  Same-entity updates and resurrection retain their existing behavior; physically