Storage
Everything a host keeps lives in its data folder. Give each host a folder of its own: the stores assume one writer, and the file helpers guard against a half-written file, not against a second process.
What goes where
| Path in the data folder | |
|---|---|
chats/<id>.json | A chat's record: { info, items, seq?, resetSeq?, preambles? } and the host's own extras. |
chats/<id>.log | Its events since the record, one JSON line each: { seq, at, event }. |
chats/<id>.bookmarks.json | Its bookmarks. |
chats/<id>.visuals.json | Its visuals, as { version: 1, visuals }. |
attachments/<id>/ | The files attached to its messages, and the page of each visual as <visual id>.html. |
providers.json | The accounts, as { version: 1, accounts }, without secrets. |
accounts/ | The folders of accounts the host made. |
usage/index.json, prices.json, exchange-rate.json | The usage index, and the prices and rate last fetched. |
outbox/, tasks/, lineage/, notices/ | The coordination stores, when a host uses them. |
An id becomes a file name with encodeURIComponent (recordFileName). A host's extras sit at the top level of a chat's record, next to info and items, which they cannot replace. ChatStoreOptions.isSidecar keeps JSON files of your own in chats/ out of the list of chats.
A record this version cannot read is left as it is and refused with chat-unreadable; creating the chat again does not write over it. A log can rebuild a record that is missing, when it holds a start to rebuild from.
Writing
ChatCore writes a small record at once and waits a moment with a large one, and joins deltas before they are written. save and persisted(chatId) wait for a write. The log is what makes replay possible: after(since) answers the events after a sequence number while the log still holds all of them, and a client gets a whole snapshot otherwise. Compacting folds old events into the record. host.close() writes every record, even of a turn still running, and ends the CLIs even when a write fails; persistAllSync is for an exit that cannot wait.
File helpers
import { mkdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { writeAtomic } from '@adecore/agents/fs';
import { Serializer } from '@adecore/agents/serializer';
export async function writeCounter(dataDir: string): Promise<number> {
await mkdir(dataDir, { recursive: true });
const path = join(dataDir, 'counter.json');
await writeAtomic(path, JSON.stringify({ count: 0 }));
const writes = new Serializer();
const increment = (): Promise<void> =>
writes.run(async () => {
const current = JSON.parse(await readFile(path, 'utf8')) as { count: number };
await writeAtomic(path, JSON.stringify({ count: current.count + 1 }), 0o600, { durable: true });
});
await Promise.all([increment(), increment()]);
await writes.idle();
return (JSON.parse(await readFile(path, 'utf8')) as { count: number }).count;
}The count ends at 2. Serializer.run runs work one at a time, even after one failed, and each caller gets its own result or error; idle() waits for the queue without rethrowing. KeyedSerializer does the same per key.
writeAtomic(target, content, mode = 0o600, { durable? }) writes a temporary file beside the target and renames it over, retrying a busy rename five times, 40 ms apart. durable syncs the file before the rename and its folder after, except on Windows. Create the folder first. writeAtomicSync and replaceSync do the same synchronously, without durable. fileExists is false for any error, so use isNotFound when other errors should surface.
RecordDirectory<T> is a folder of JSON records, checked by a schema on load and written one at a time per id; a record that does not parse stays on disk. keep can rewrite or drop records on load.
Other helpers
runProcess(command, options)runs a short command and answers its output andexitCode(nullafter a signal). A nonzero exit is an answer, not an error;timeoutMssends SIGTERM and rejects. It keeps all output and cleans up no process group, so keep it to commands you trust to end.withTimeout(promise, ms, message)rejects at a deadline without stopping what it waited for, andwait(ms, signal?)resolves early on an abort.WatchSeamsis file watching you can swap:SYSTEM_WATCHusesfs.watch, recursive only where the platform supports it (supportsRecursive).settled(seams, ms, run)runs once after a burst of changes, andPerClientWatcheskeeps a watch per client and path.cleanTitleandreadLinesread titles from a transcript;readLinesstops at the last whole line, so a line still being written is read later.CodedErroris an error with acode,errorTextanddescribeErrorwrite an error for a log, andsetErrorStacksturns stack traces on or off for the whole package.