agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
216 lines • 10.6 kB
TypeScript
/**
* hosting/sqliteSessions — conversations in a file, so a restart is not an
* amnesia event.
*
* `memorySessions()` keeps conversations in a `Map` and says what that costs in
* its own docstring: restart the process and every conversation is gone. Until
* now the next step up was "bring a Redis", and the step between those two —
* *one machine, one file, nothing to install* — was a store every consumer had
* to write for themselves. This is that store.
*
* A paused run was in the same position from the other direction. `agent.run()`
* hands back a checkpoint that is documented as JSON you can keep anywhere, and
* "anywhere" was the whole of the offer: the library named a shape and left the
* keeping to you. Both halves land in the same table here, because
* {@link CheckpointEnvelope} was already a union of the two and a session store
* has no business caring which one it is holding.
*
* ── What this is, said plainly ──────────────────────────────────────────────
* **One process on one machine, writing one file.** That is the whole claim,
* and it is worth stating as a ceiling rather than leaving it to be discovered:
*
* • It survives a restart, a crash, a deploy — anything that ends the process
* and leaves the disk alone.
* • It is NOT a distributed store. Two machines do not share a session by
* both opening this file over a network filesystem, and nothing here tries
* to make that work.
* • WAL lets readers and one writer run at once, and **one writer at a time
* is the ceiling** — a second writer waits for the lock up to
* `busyTimeoutMs` and then fails loudly rather than queueing forever.
* Several processes on one box is fine at that scale; a fleet is not what
* this is.
*
* When you outgrow it you swap the one argument to `standingAgent`, which is
* the entire point of the port.
*
* ── Zero dependencies, and the version floor that buys ──────────────────────
* SQLite is *inside Node* — `node:sqlite`, no install, no native build, no
* peer dependency. The price is a version floor this package does not otherwise
* have: the module ships with Node 22.5 and newer. Rather than raise `engines`
* for one optional adapter and break every consumer on Node 20, the module is
* loaded when you actually construct a store, and its absence is refused by
* name with the version you are on and what to do about it — see
* {@link SqliteUnavailableError}. There is deliberately **no fallback to
* memory**: a store that silently forgot everything on restart is
* indistinguishable, from the outside, from a brand-new user.
*
* ── The two laws it inherits rather than re-implements ──────────────────────
* `checkEnvelope` is called on the way out AND on the way in, so an envelope
* whose `format` this runtime does not know is refused by name, and a stored
* session that is PRESENT but unreadable is refused by name too. Only a session
* that was never written hydrates as `undefined`. Validating on the way in as
* well is the cheap half of that promise: a row this store could not read back
* never gets written in the first place.
*/
import type { SessionLifecycle, SessionSweep } from './types.js';
/** One prepared statement, as this adapter calls it. */
export interface SqliteStatementLike {
run(...params: readonly unknown[]): unknown;
get(...params: readonly unknown[]): unknown;
all(...params: readonly unknown[]): unknown[];
}
/** One open database, as this adapter calls it. */
export interface SqliteDatabaseLike {
exec(sql: string): void;
prepare(sql: string): SqliteStatementLike;
close(): void;
}
/**
* The shape of `node:sqlite` this adapter needs — declared locally, the same
* way every other adapter here declares the slice of its backend it uses, so
* nothing takes a hard import on a module that does not exist on every
* supported Node.
*/
export interface SqliteModuleLike {
new (path: string): SqliteDatabaseLike;
}
/** Options for {@link sqliteSessions}. */
export interface SqliteSessionsOptions {
/**
* The database file. Created if it does not exist, along with its parent
* directory — "no infrastructure" would be a thin promise if you still had to
* `mkdir` first.
*
* `':memory:'` is refused: it looks like a file, keeps nothing across a
* restart, and a store that quietly forgets is the exact failure this adapter
* exists to remove. Use `memorySessions()` when that is what you want — it
* says so in its name.
*/
readonly file: string;
/**
* How long a write waits for another writer's lock before failing, in
* milliseconds. Default 5000.
*
* It fails rather than waits forever on purpose: a request hung on a lock
* looks exactly like a slow model, and the two need different fixes.
*/
readonly busyTimeoutMs?: number;
/**
* @internal Test seam only — the `node:sqlite` module, injected. Lets the
* suite exercise the refusal path on a Node that HAS the module, and the
* failure paths without corrupting a real file. Not public API, not
* supported, and not a place to plug in another SQLite driver.
*/
readonly _sqlite?: SqliteModuleLike;
}
/**
* A session store in a file.
*
* It is a {@link SessionLifecycle} plus the three things a real store owns
* beyond the port — closing, forgetting, and telling you what the file actually
* got — because the port deliberately asks for only two methods and leaves the
* rest to whoever implements it.
*/
export interface SqliteSessions extends SessionLifecycle {
/**
* The journalling mode the file **actually has**, read back from SQLite
* rather than assumed from what was asked for.
*
* It is normally `'wal'`. It is something else when the file lives somewhere
* WAL cannot work — a network filesystem is the usual reason — and that is a
* fact worth being able to read: the store still works, with one writer *or*
* one reader at a time instead of both. A silent downgrade is the kind of
* thing that is only ever discovered under load.
*/
readonly journalMode: string;
/** Forget one session. No-op if there is nothing stored for it. */
forget(sessionId: string): Promise<void>;
/**
* How conversations here stop existing: **this store deletes them**, when a
* job of yours asks it to (9.42.0).
*
* Narrowed from the port's optional member to a required one, and to the one
* arm a file-backed store can be — a caller holding a `SqliteSessions` needs
* no feature check, and reaching for a backend policy that does not exist
* here is a compile error rather than a surprise at 3am.
*/
retention(): SessionSweep;
/**
* Close the file. Idempotent — a shutdown hook and an explicit close can
* coexist. Reading or writing afterwards refuses by name rather than
* reopening behind your back.
*/
close(): void;
}
/**
* Raised when `node:sqlite` is not available in the running Node.
*
* The class moved to `lib/sqliteUnavailable.ts` in 8.9.0 so the SQLite vector
* store raises the SAME error for the same missing module — a consumer
* catching it should not have to know which of our stores was being built.
* Constructed with no third argument it produces exactly the message it always
* did, and it is re-exported here under its own name, so nothing that imports
* it from this module changes.
*/
export { SqliteUnavailableError } from '../lib/sqliteUnavailable.js';
/**
* Raised when the file exists but this runtime cannot use it as a session
* store.
*
* The same law {@link UnreadableEnvelopeError} states for one stored value,
* one level up: **an unreadable store and an empty one are different facts, and
* only one of them is safe to answer with a fresh start.** A store that opened
* a corrupt file as an empty database would hand every returning user a blank
* slate and log nothing.
*
* `problem` is the fact to branch on — the three cases need different actions:
*
* - `'cannot-open'` — not a SQLite database, or not readable (permissions, a
* directory, a truncated file).
* - `'not-our-schema'` — a database whose `agent_sessions` table is somebody
* else's table of that name. Point the store at its own file.
* - `'newer-schema'` — written by a newer agentfootprint than this one. Same
* answer the envelope `format` field gives: refuse, never half-read.
*/
export declare class UnreadableSessionFileError extends Error {
readonly code: "ERR_UNREADABLE_SESSION_FILE";
/** The file that was refused. */
readonly file: string;
/** Which of the three cases this is. */
readonly problem: 'cannot-open' | 'not-our-schema' | 'newer-schema';
constructor(file: string, problem: UnreadableSessionFileError['problem'], detail: string);
}
/**
* A session store in one SQLite file — the battery-included place to keep a
* conversation, or a run that stopped to ask a person something, so that a
* restart does not lose it.
*
* @throws SqliteUnavailableError when the running Node has no `node:sqlite`.
* @throws UnreadableSessionFileError when the file exists but cannot be used —
* never answered with an empty store.
*
* @example A standing agent whose conversations survive a restart
* import { standingAgent, nodeHost, sqliteSessions } from 'agentfootprint/hosting';
*
* const handle = await standingAgent({
* agent,
* sessions: sqliteSessions({ file: './sessions.db' }),
* host: nodeHost({ port: 8080 }),
* });
*
* @example Keeping a paused run yourself, without the composer
* const sessions = sqliteSessions({ file: './sessions.db' });
* const out = await agent.run({ message });
* if (isPaused(out)) {
* await sessions.persist(sessionId, toPausedEnvelope({
* checkpoint: out.checkpoint,
* conversation: agent.checkpoint()!,
* pending: { pauseData: out.pauseData },
* }));
* }
* // …a deploy later, in a new process:
* const paused = readPausedRun(await sessions.hydrate(sessionId));
* const answer = await agent.resume(paused.checkpoint, decision);
*/
export declare function sqliteSessions(options: SqliteSessionsOptions): SqliteSessions;
//# sourceMappingURL=sqliteSessions.d.ts.map