Skip to content

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>.jsonA chat's record: { info, items, seq?, resetSeq?, preambles? } and the host's own extras.
chats/<id>.logIts events since the record, one JSON line each: { seq, at, event }.
chats/<id>.bookmarks.jsonIts bookmarks.
chats/<id>.visuals.jsonIts visuals, as { version: 1, visuals }.
attachments/<id>/The files attached to its messages, and the page of each visual as <visual id>.html.
providers.jsonThe accounts, as { version: 1, accounts }, without secrets.
accounts/The folders of accounts the host made.
usage/index.json, prices.json, exchange-rate.jsonThe 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 ​

ts
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 and exitCode (null after a signal). A nonzero exit is an answer, not an error; timeoutMs sends 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, and wait(ms, signal?) resolves early on an abort.
  • WatchSeams is file watching you can swap: SYSTEM_WATCH uses fs.watch, recursive only where the platform supports it (supportsRecursive). settled(seams, ms, run) runs once after a burst of changes, and PerClientWatches keeps a watch per client and path.
  • cleanTitle and readLines read titles from a transcript; readLines stops at the last whole line, so a line still being written is read later.
  • CodedError is an error with a code, errorText and describeError write an error for a log, and setErrorStacks turns stack traces on or off for the whole package.

Licenses and third-party notices are listed in each package.