Retain conversation history with SQLite
Goal: retain an agent's exchanges after process exit and inspect their size without printing private messages. SQLite ships with Node.js 24; no separate system installation or SQLite package is needed. It is not enabled automatically: Touba uses RAM unless configured otherwise.
1. Prepare the project
Follow the SDK installation and prerequisites in the
native calls tutorial, create its
native-calculator.tb program and compile it. Keep its contracts and commands;
only the provider changes here. The same settings work with
decisionInterface: "structured".
Add .private-state/ to the project's .gitignore. This directory contains
private data, not resources to publish.
2. Configure a persistent provider
Replace the provider module with:
import { mkdirSync, appendFileSync } from "node:fs";
import { createOpenAiDecisionProvider } from "@touba/sdk";
import { createSqliteDecisionHistoryStore } from "@touba/sdk/history/sqlite";
mkdirSync(".private-state", { recursive: true, mode: 0o700 });
const store = createSqliteDecisionHistoryStore({
filename: ".private-state/conversations.sqlite",
maxBytes: 32 * 1024 * 1024,
ttlMs: 24 * 60 * 60 * 1000,
});
export const decideAgentCapability = createOpenAiDecisionProvider({
model: "gpt-4.1-mini",
decisionInterface: "native",
maxOutputTokens: 1000,
history: {
store,
namespace: "calculator-local",
maxMessages: 128,
maxBytes: 262144,
maxRetainedBytes: 8 * 1024 * 1024,
onEvent(event) {
appendFileSync(".private-state/history-events.jsonl",
JSON.stringify(event) + "\n", { mode: 0o600 });
},
},
});
Check permissions of an existing directory: mkdirSync does not fix them.
Events contain counters and a hashed session key, not messages. The event file
belongs to the application; configure log rotation for long-running use.
3. Run, then inspect after the process exits
Run the native tutorial's touba run command with the key in your environment.
This contacts the actual provider and may incur charges. The two Double
calls should remain distinct operations.
Create this reader, then run node inspect-history.mjs in the same directory:
import { readFileSync } from "node:fs";
import { createSqliteDecisionHistoryStore } from "@touba/sdk/history/sqlite";
const events = readFileSync(".private-state/history-events.jsonl", "utf8")
.trim().split("\n").map(line => JSON.parse(line));
const sessionKey = events.at(-1).sessionKey;
const store = createSqliteDecisionHistoryStore({
filename: ".private-state/conversations.sqlite",
});
try {
const record = store.read(sessionKey);
if (!record) throw new Error("History expired or absent.");
console.log({
retainedExchanges: record.turns.length,
omittedFromContext: record.contextStart,
pendingCall: record.pending !== null,
closed: record.closed,
});
} finally {
store.close();
}
This second process reads a retained snapshot without network access. A completed
calculation should have closed: true and no pending call. read does not
renew the TTL. Detailed contents are private; avoid printing them in logs.
4. Understand resume
Running the strategy again creates a new run and invocation, with separate history. The database does not replay a previous run. An actual approval resume uses the checkpoint, the same store, namespace, model and interface; the runtime preserves invocation identity. See the human-wait guide.
After a crash, DecisionHistoryBusy may indicate a persistent reservation.
Verify that the old worker has stopped and reconcile effects before calling
store.recover(sessionKey, { workerStopped: true }). This is neither human
approval nor permission to repeat an effect.
5. Check the limits
- Sent context and retained exchanges have separate limits.
- Compaction increases
contextStartwithout reducingturns.length. - TTL and
store.delete(sessionKey)can remove an inactive conversation. - A full store refuses; it does not automatically delete older exchanges.
- Identity and namespaces come from the host, never from the model.
Continue with the context and compaction tutorial for token limits.