π¦ Batched Engine
BatchedEngine (@lighthouse-ai/engine-batched) is the DIY engine: you embed locally, memories buffer as pending, and flush as one batch blob per flushEvery memories on any storage.
import '@lighthouse-ai/store-lighthouse'
import '@lighthouse-ai/embed-local'
import { createStorage, createEmbedder } from '@lighthouse-ai/core'
import { BatchedEngine } from '@lighthouse-ai/engine-batched'
const storage = await createStorage('lh-ipfs-filecoin', { apiKey: process.env.LIGHTHOUSE_API_KEY })
const embedder = await createEmbedder('local') // or null for keyword-only
const memory = new BatchedEngine(storage, { namespace: 'demo', agent: 'support-bot', flushEvery: 10, embedder })
Constructor - new BatchedEngine(storage, opts?)β
| Option | Default | Purpose |
|---|---|---|
namespace | MEMORY_NAMESPACE ?? 'default' | Isolates memories per agent/project; snapshots are namespace-checked |
agent | MEMORY_AGENT ?? 'agent' | Recorded on every memory |
dataDir | MEMORY_DIR ?? ./.memory-sdk | Index + local-fs blob location |
flushEvery | MEMORY_FLUSH_EVERY ?? 10 (min 1) | Batch size before auto-flush |
embedder | null (keyword) | Embedder instance or null for word-match only |
indexStore | FileJsonIndexStore | Custom index backend (e.g. Dynamo) |
flushEvery: 1 uploads on every write (maximum durability). Higher values pack more memories per blob (maximum quota efficiency - critical on Walrus where every blob costs ~63 MB of quota after erasure coding).
remember(content, opts?)β
const res = await memory.remember('Customer ACME is on enterprise.', {
tags: ['customer'],
metadata: { source: 'support-bot' },
})
// pending: { id: '80cc2055-β¦', flushed: false, pendingCount: 1 }
// tripped flushEvery: { id, flushed: true, pendingCount: 0, cid: 'bafβ¦', gatewayUrl: 'https://β¦' }
- Trims content; throws on empty strings.
- Embeds locally if an embedder is set (graceful keyword fallback if the model cannot load -
status()reports which mode is active). - Pending memories are searchable immediately via
recall()/list()but live only on the local machine until flushed. - Returns
RememberResult:{ id, flushed?, pendingCount?, cid?, gatewayUrl? }.
flush()β
const out = await memory.flush()
// { flushedMemories: 3, cid: 'bafβ¦', gatewayUrl: 'https://gatewayβ¦/ipfs/bafβ¦' }
// { flushedMemories: 0 } when nothing was pending
Uploads all pending memories as one mem-batch.<namespace>.<batchId>.json blob, assigns the CID to every new index entry, clears the pending queue, and records the batch CID as lastSnapshotCid (despite the name - see status()). Call it manually before ending important sessions, or rely on auto-flush.
recall(query, opts?)β
const matches = await memory.recall('how do I escalate for ACME?', {
tags: ['customer'], // only memories carrying at least one of these tags
limit: 5, // default 5
})
// [{ id, content, score, semanticScore, keywordScore, tags, cid, gatewayUrl, ... }]
Search runs fully locally over flushed and pending memories - no network round trips.
Scoring with the local embedder (pass embedder: await createEmbedder('local') - BatchedEngine defaults to null, keyword-only; the hosted server defaults to local):
score = 0.7 Γ cosine(query, memory) + 0.3 Γ min(1, keywordScore)
keywordScore = hits / βwords + 0.5 per tag appearing in the query
- Vectors carry their model id (
embeddingModel); recall only compares same-model vectors, so switching models safely falls back to keyword until backfilled. - Omit
embedderor passnullfor word-match only - zero deps, fully offline. Thenscore = keywordScore(uncapped). - Ties break by recency. Empty queries return everything up to
limit.
With the local embedder, every memory that has a vector is a candidate, so recall() always returns up to limit results - even when nothing is related to the query. (Keyword-only mode does drop zero-score matches.) There is no minScore option; filter on score yourself:
const MIN_SCORE = 0.3 // tune on your own data
const matches = (await memory.recall(query, { limit: 10 })).filter((m) => m.score >= MIN_SCORE)
list(opts?)β
await memory.list({ limit: 20 }) // newest first, flushed + pending, score: 0
Returns RecallMatch[] with score: 0, keywordScore: 0 - use it for browsing, not ranking. No tag filter here (filter client-side, or use recall() with tags).
get(idOrCid)β
await memory.get('80cc2055-β¦') // by memory id
await memory.get('bafβ¦') // by batch CID - first record in that batch only
Checks pending first, then the index, then fetches the batch blob from storage. Throws if the id is not found in the referenced batch.
By batch CID, get() and forget() act on one record only - the first index entry stored in that batch. A batch usually holds up to flushEvery memories, so get(batchCid) returns just the first of them and forget(batchCid) removes just that one (the blob is kept because the others still reference it). Use memory ids to target a specific record, and list() to see what a batch contains. On a machine whose local index has never seen the CID, get(batchCid) throws Memory <cid> not found in batch <cid>.
forget(idOrCid)β
await memory.forget('80cc2055-β¦')
// pending: { removed: true, blobDeleted: false, note: 'Removed from pending queueβ¦' }
// flushed: { removed: true, blobDeleted: true|false, note: 'β¦' }
- Pending - dropped locally, never uploaded.
- By batch CID - removes only the first record in that batch, not the whole batch (see
get()). - Flushed - removed from the index. The backing blob is deleted only when no other memory references it (
blobDeleted: true). On S3/local-fs that is a hard delete; on Lighthouse deletion stops renewal - content stays readable until the period expires.
Forgetting a flushed batched memory does not erase it. forget() only edits the local index - it records no tombstone. Until its whole batch blob is deleted:
- The record stays in the blob. If other memories share the batch, the blob is kept and the record's content stays readable to anyone with the CID (batch blobs are plaintext).
- It comes back on
rebuild(). Rebuild re-reads every batch blob, so a forgotten record in a kept blob is restored to the index. - It stays in earlier snapshots. Any
snapshotIndex()CID taken before the forget still contains it, andrebuildLocal()/restoreIndex()from that snapshot restores it. Take a fresh snapshot (and back it up) after forgetting.
Even when the blob is deleted, Lighthouse storage only stops renewal, and copies already fetched through public IPFS gateways may persist. Don't store anything you may need to truly erase - see the encryption warning in the Introduction.
status()β
await memory.status()
// {
// engine: 'batched', storage: 'lh-ipfs-filecoin', namespace: 'demo', agent: 'agent',
// memories: 42, pendingMemories: 3, flushEvery: 10,
// embeddings: 'local:Xenova/all-MiniLM-L6-v2' | 'keyword:off',
// indexPath: '.memory-sdk/demo.index.json', lastSnapshotCid: 'bafβ¦'
// }
lastSnapshotCid is the last batch CIDDespite its name, lastSnapshotCid holds the CID of the most recent batch blob, not an index snapshot. flush() sets it to the batch it just uploaded, and rebuild() sets it to the newest batch it read. snapshotIndex() does not update it - keep the CID that snapshotIndex() returns (or back it up with the pointer service) if you need it for rebuildLocal().
rebuild()β
await memory.rebuild() // { restored: 42, source: 'blobs' }
Re-reads every mem-batch.<namespace>.* blob from storage (sorted by createdAt), deduplicates by id, and replaces the local index. This is the slow path - no pointer needed, only storage credentials - but it lists and fetches every blob, so it slows as history grows. Snapshots are the fast path.
rebuild() discards pending memories. It replaces the local index with only what is in the batch blobs and sets pending: [] - anything you remembered since the last flush is lost. Call flush() first if the index still has pending memories (status().pendingMemories > 0).
snapshotIndex() / rebuildLocal(snapshotCid)β
const snap = await memory.snapshotIndex()
// { cid: 'bafβ¦', entries: 42, gatewayUrl: 'https://β¦' }
await freshMemory.rebuildLocal(snap.cid) // { added: 42, total: 42 }
Pins the entire local index (entries + pending) as one mem-index.<namespace>.json snapshot blob and merges it back elsewhere. Merges skip entries this machine already has; snapshots are namespace-checked (alice won't load into bob).
Snapshots and batch blobs are plaintext - anyone with the CID can read them. Do not store secrets.
Batch blob shapeβ
{
"v": 1,
"kind": "batch",
"namespace": "default",
"batchId": "80cc2055-β¦",
"createdAt": "2026-07-22T14:59:03.427Z",
"records": [
{
"v": 1, "id": "β¦", "namespace": "default", "agent": "support-bot",
"content": "Customer ACME is on enterprise.",
"tags": ["customer"], "metadata": {},
"createdAt": "β¦", "embedding": [0.0123, -0.0456],
"embeddingModel": "local:Xenova/all-MiniLM-L6-v2",
"origin": "batched"
}
]
}
Blobs are content-addressed (same content = same CID). CIDs are CIDv1 raw + sha2-256 and must stay stable.