UNPKG

@anthropic-ai/sdk

Version:
990 lines • 52.9 kB
var _SessionMemoryStores_instances, _SessionMemoryStores_client, _SessionMemoryStores_workdir, _SessionMemoryStores_syncIntervalMs, _SessionMemoryStores_syncDeletions, _SessionMemoryStores_log, _SessionMemoryStores_lastSyncAt, _SessionMemoryStores_finished, _SessionMemoryStores_stores, _SessionMemoryStores_storeRoot, _SessionMemoryStores_scanMarker, _SessionMemoryStores_syncStore, _SessionMemoryStores_flushStore, _SessionMemoryStores_recover, _SessionMemoryStores_settleSent, _SessionMemoryStores_stampAndPull, _SessionMemoryStores_syncPath, _SessionMemoryStores_removeLocal, _SessionMemoryStores_write, _SessionMemoryStores_pullAll, _SessionMemoryStores_uploadAll, _SessionMemoryStores_listMemories, _SessionMemoryStores_upload, _SessionMemoryStores_corroboratedDelete, _SessionMemoryStores_deleteRemote;
import { __classPrivateFieldGet, __classPrivateFieldSet } from "../../internal/tslib.mjs";
/**
 * Session-level memory-store download and sync.
 *
 * A session may have several memory stores attached. This module resolves
 * where each store's folder goes on disk, opens a {@link LocalFileStore}
 * there, and reconciles each folder with its remote store — the merge rules
 * live on {@link SessionMemoryStores}.
 *
 * Node-only (it sits on the filesystem-backed FileStore); like `skills.ts`,
 * it is reachable through the shimmed `node.ts` entry point.
 */
import { crypto, path } from "../../internal/node.mjs";
import { AnthropicError } from "../../core/error.mjs";
import { loggerFor } from "../../internal/utils/log.mjs";
import { decodeUTF8 } from "../../internal/utils/bytes.mjs";
import { isStatus } from "../../internal/utils/backoff.mjs";
import { isPathLegal, FileStoreError, LocalFileStore } from "../../internal/file-store.mjs";
import { DEFAULT_MEMORY_SYNC_INTERVAL_MS, checkMemorySyncInterval } from "./sync-interval.mjs";
export { DEFAULT_MEMORY_SYNC_INTERVAL_MS, MIN_MEMORY_SYNC_INTERVAL_MS } from "./sync-interval.mjs";
/**
 * Time bound the worker puts on each teardown pass — the final
 * {@link SessionMemoryStores.finish}, then {@link SessionMemoryStores.flushWrites} —
 * so a slow server cannot stall teardown.
 */
export const MEMORY_FLUSH_TIMEOUT_MS = 30000;
/**
 * Marker file stamped into every store folder; a sync trusts the folder only
 * when it matches. Never itself syncs.
 */
export const MARKER_PATH = '.anthropic-memory-store';
const MARKER_VERSION = 1;
function markerSha(memoryStoreId) {
    return crypto
        .createHash('sha256')
        .update(`version ${MARKER_VERSION}\n${memoryStoreId}`, 'utf-8')
        .digest('hex');
}
/** How long a file must stay missing locally before its server delete goes out. */
export const DELETE_CORROBORATION_MS = 30000;
/**
 * Page sizes for memory listings — the API's maximum per view: `basic` pages
 * carry up to 100 items, `full` pages are capped by the server.
 */
const LIST_PAGE_SIZE = 100;
const FULL_LIST_PAGE_SIZE = 20;
/**
 * How many single-memory content fetches may be in flight at once during one
 * store's pull pass. A sync rarely pulls more than a handful of memories, so
 * a higher cap buys nothing in the common case.
 */
const FETCH_CONCURRENCY = 16;
/**
 * How many uploads one store's flush keeps in flight. At ~0.3s per upload,
 * 32 clears the server's 2000-memories-per-store cap inside
 * {@link MEMORY_FLUSH_TIMEOUT_MS}.
 */
export const UPLOAD_CONCURRENCY = 32;
/**
 * Per-sync remote-delete cap bounds. The floor lets a small store's
 * deletes clear in one pass; the ceiling caps damage on large ones.
 */
const DELETE_CAP_FLOOR = 8;
const DELETE_CAP_CEILING = 50;
/**
 * A session's memory stores could not be mounted.
 *
 * Thrown by {@link SessionMemoryStores.download} when a store cannot be
 * materialised on disk, and by the environment worker when a work item for a
 * session that has memory stores carried no sessions token to reach them with.
 */
export class SessionMemoryError extends AnthropicError {
    constructor(message, cause) {
        super(message);
        this.name = 'SessionMemoryError';
        // in some environments the 'cause' property is already declared
        // @ts-ignore
        if (cause !== undefined)
            this.cause = cause;
    }
}
/** One sync's remote-delete gate and counters. */
class DeletePass {
    constructor(mode, cap, 
    /** Skip the delete wait — set on the session's last sync. */
    waiveWindow) {
        this.mode = mode;
        this.cap = cap;
        this.waiveWindow = waiveWindow;
        this.attempted = 0;
        this.capped = 0;
        this.suppressed = 0;
    }
    takeSlot() {
        if (this.attempted >= this.cap) {
            this.capped++;
            return false;
        }
        this.attempted++;
        return true;
    }
}
/**
 * The memory stores attached to one session, materialised on disk.
 *
 * {@link SessionMemoryStores.download} opens a {@link LocalFileStore} at each
 * attached store's directory (its `mount_path`, or a workdir fallback — see
 * {@link SessionMemoryStores.download}), pulls its memories, and records each
 * one's `content_sha256` as the sync baseline. Each sync
 * ({@link SessionMemoryStores.syncIfDue} on the worker's cadence,
 * {@link SessionMemoryStores.finish} once at the end) reconciles disk against
 * server, per store and per path:
 *
 * - a memory changed only remotely is written to disk;
 * - a file changed only locally is uploaded — an update with a
 *   `content_sha256` precondition, or a create for a new file;
 * - a file changed on both sides logs a warning and takes the server version;
 * - a file the server refuses (too large, invalid content) is skipped —
 *   warned once and retried only after the file changes; other files keep
 *   syncing;
 * - an upload whose response never arrives may still have been saved, so the
 *   sha of the bytes sent is remembered until the next sync has listed the
 *   server: a memory holding exactly that sha is this sync's own upload, not
 *   a remote edit, and a newer local edit goes out over it;
 * - a file deleted locally is deleted on the server after a delay and a
 *   re-check — never on the first sync that notices, and only up to a
 *   per-sync cap. `syncDeletions` gates it;
 * - a memory deleted on the server is deleted on disk — unless the local
 *   file holds un-pushed edits: a writable store re-creates the memory
 *   from the file, a read-only one keeps the file unsynced;
 * - a store attached read-only pulls but never pushes.
 *
 * A download pulls the whole store, so it lists with content included. The
 * recurring syncs instead run two phases: a content-free listing (paths and
 * shas) drives the merge decisions, then only the memories actually being
 * written to disk are fetched, a bounded number at a time. A sync that finds
 * nothing changed moves no content at all.
 *
 * A file whose write to disk failed is never in the baseline, so its absence
 * reads as a failed download — it is pulled again, never deleted. A write
 * never re-creates a store folder that vanished mid-sync: it fails, and the
 * next sync's scan finds whatever is at the path by then — nothing
 * (re-downloaded) or someone else's files (left alone) — under the rules
 * below.
 *
 * A store folder that loses its {@link MARKER_PATH} marker, is emptied,
 * or vanishes is re-downloaded rather than treated as a mass local
 * delete; a folder whose marker names another store is left as found —
 * nothing pushed, nothing deleted.
 *
 * Two things about the store's directory make
 * {@link SessionMemoryStores.download} refuse the session outright, with
 * {@link SessionMemoryError}: a `mount_path` that is not a clean absolute
 * path, and a directory already sitting at that path.
 *
 * {@link SessionMemoryStores.download} throws on the first store it cannot
 * materialise. The syncs never throw: mid-session, one bad store or one bad
 * file is logged and the rest continue. Instances are not safe for concurrent
 * use. The worker builds one on its token-scoped sub-client (the memory
 * endpoints reject the environment key): `syncIfDue` after each tool call,
 * `finish` once at a clean end, a bounded {@link SessionMemoryStores.flushWrites}
 * in every teardown, `dispose` last.
 */
export var SessionMemoryStores = /* @__PURE__ */ (() => {
    /**
     * The memory stores attached to one session, materialised on disk.
     *
     * {@link SessionMemoryStores.download} opens a {@link LocalFileStore} at each
     * attached store's directory (its `mount_path`, or a workdir fallback — see
     * {@link SessionMemoryStores.download}), pulls its memories, and records each
     * one's `content_sha256` as the sync baseline. Each sync
     * ({@link SessionMemoryStores.syncIfDue} on the worker's cadence,
     * {@link SessionMemoryStores.finish} once at the end) reconciles disk against
     * server, per store and per path:
     *
     * - a memory changed only remotely is written to disk;
     * - a file changed only locally is uploaded — an update with a
     *   `content_sha256` precondition, or a create for a new file;
     * - a file changed on both sides logs a warning and takes the server version;
     * - a file the server refuses (too large, invalid content) is skipped —
     *   warned once and retried only after the file changes; other files keep
     *   syncing;
     * - an upload whose response never arrives may still have been saved, so the
     *   sha of the bytes sent is remembered until the next sync has listed the
     *   server: a memory holding exactly that sha is this sync's own upload, not
     *   a remote edit, and a newer local edit goes out over it;
     * - a file deleted locally is deleted on the server after a delay and a
     *   re-check — never on the first sync that notices, and only up to a
     *   per-sync cap. `syncDeletions` gates it;
     * - a memory deleted on the server is deleted on disk — unless the local
     *   file holds un-pushed edits: a writable store re-creates the memory
     *   from the file, a read-only one keeps the file unsynced;
     * - a store attached read-only pulls but never pushes.
     *
     * A download pulls the whole store, so it lists with content included. The
     * recurring syncs instead run two phases: a content-free listing (paths and
     * shas) drives the merge decisions, then only the memories actually being
     * written to disk are fetched, a bounded number at a time. A sync that finds
     * nothing changed moves no content at all.
     *
     * A file whose write to disk failed is never in the baseline, so its absence
     * reads as a failed download — it is pulled again, never deleted. A write
     * never re-creates a store folder that vanished mid-sync: it fails, and the
     * next sync's scan finds whatever is at the path by then — nothing
     * (re-downloaded) or someone else's files (left alone) — under the rules
     * below.
     *
     * A store folder that loses its {@link MARKER_PATH} marker, is emptied,
     * or vanishes is re-downloaded rather than treated as a mass local
     * delete; a folder whose marker names another store is left as found —
     * nothing pushed, nothing deleted.
     *
     * Two things about the store's directory make
     * {@link SessionMemoryStores.download} refuse the session outright, with
     * {@link SessionMemoryError}: a `mount_path` that is not a clean absolute
     * path, and a directory already sitting at that path.
     *
     * {@link SessionMemoryStores.download} throws on the first store it cannot
     * materialise. The syncs never throw: mid-session, one bad store or one bad
     * file is logged and the rest continue. Instances are not safe for concurrent
     * use. The worker builds one on its token-scoped sub-client (the memory
     * endpoints reject the environment key): `syncIfDue` after each tool call,
     * `finish` once at a clean end, a bounded {@link SessionMemoryStores.flushWrites}
     * in every teardown, `dispose` last.
     */
    class SessionMemoryStores {
        constructor(client, opts) {
            _SessionMemoryStores_instances.add(this);
            _SessionMemoryStores_client.set(this, void 0);
            _SessionMemoryStores_workdir.set(this, void 0);
            _SessionMemoryStores_syncIntervalMs.set(this, void 0);
            _SessionMemoryStores_syncDeletions.set(this, void 0);
            _SessionMemoryStores_log.set(this, void 0);
            _SessionMemoryStores_lastSyncAt.set(this, void 0);
            _SessionMemoryStores_finished.set(this, false);
            _SessionMemoryStores_stores.set(this, []);
            __classPrivateFieldSet(this, _SessionMemoryStores_client, client, "f");
            __classPrivateFieldSet(this, _SessionMemoryStores_workdir, opts.workdir, "f");
            __classPrivateFieldSet(this, _SessionMemoryStores_syncIntervalMs, opts.syncIntervalMs ?? DEFAULT_MEMORY_SYNC_INTERVAL_MS, "f");
            checkMemorySyncInterval(__classPrivateFieldGet(this, _SessionMemoryStores_syncIntervalMs, "f"), 'syncIntervalMs');
            __classPrivateFieldSet(this, _SessionMemoryStores_syncDeletions, opts.syncDeletions ?? 'enabled', "f");
            __classPrivateFieldSet(this, _SessionMemoryStores_log, loggerFor(client), "f");
            __classPrivateFieldSet(this, _SessionMemoryStores_lastSyncAt, Date.now(), "f");
        }
        /**
         * Every attached store's root directory.
         *
         * The worker lists these as the file tools' allowed roots so a store
         * mounted outside the workdir stays reachable.
         */
        get roots() {
            return __classPrivateFieldGet(this, _SessionMemoryStores_stores, "f").map((s) => s.files.root().path);
        }
        /**
         * Root directories of stores attached read-only.
         *
         * The file tools consult this to refuse writes into read-only stores.
         */
        get readOnlyRoots() {
            return __classPrivateFieldGet(this, _SessionMemoryStores_stores, "f").filter((s) => s.readOnly).map((s) => s.files.root().path);
        }
        /**
         * Download every attached store's memories to disk.
         *
         * `session` arrives already fetched — one snapshot shared with the skills
         * download, so the two cannot disagree about the resources.
         */
        async download(session) {
            for (const resource of session.resources) {
                if (resource.type !== 'memory_store')
                    continue;
                const root = __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_storeRoot).call(this, resource);
                let store;
                try {
                    store = {
                        memoryStoreId: resource.memory_store_id,
                        // utf8: a binary file is refused at put/get, not mid-sync.
                        files: await LocalFileStore.open(root, { utf8: true }),
                        readOnly: resource.access === 'read_only',
                        baseline: new Map(),
                        refusedShas: new Map(),
                        pendingDeletes: new Map(),
                        sentShas: new Map(),
                    };
                    // A root `open` did not create is a dead run's leftovers; the first
                    // sync would upload them into the customer's store.
                    if (!store.files.root().removedOnDispose) {
                        // The configured path, not root().path — that one is resolved and
                        // would name a symlinked mount's target.
                        throw new SessionMemoryError(`something already exists at the memory store's path: ${root} ` +
                            `(memory_store_id=${resource.memory_store_id}); ` +
                            'it must not exist when the session starts');
                    }
                    try {
                        await store.files.createRoot();
                    }
                    catch (e) {
                        if (!isErrno(e))
                            throw e;
                        // An unmountable root fails the item, not just one file.
                        throw new SessionMemoryError(`cannot create the memory store's folder: ${root} ` +
                            `(memory_store_id=${resource.memory_store_id}): ${e}; ` +
                            'the worker host must make this mount path writable', e);
                    }
                    await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_stampAndPull).call(this, store);
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").info('downloaded memories', {
                        count: store.baseline.size,
                        memory_store_id: store.memoryStoreId,
                        dest: store.files.root().path,
                    });
                    __classPrivateFieldGet(this, _SessionMemoryStores_stores, "f").push(store);
                }
                catch (e) {
                    // A half-downloaded folder self-destructs; `dispose` leaves a refused
                    // pre-existing directory exactly as found.
                    if (store)
                        await store.files.dispose().catch(() => { });
                    // Every store must land: a session missing a folder its system prompt
                    // names runs with amnesia and syncs nothing back.
                    if (e instanceof SessionMemoryError)
                        throw e;
                    throw new SessionMemoryError(`failed to download memory store memory_store_id=${resource.memory_store_id}: ${e}`, e);
                }
            }
            __classPrivateFieldSet(this, _SessionMemoryStores_lastSyncAt, Date.now(), "f");
        }
        /**
         * The session's last sync — skips the delete wait, so calling it twice
         * would undo the protection; it throws instead.
         */
        async finish() {
            if (__classPrivateFieldGet(this, _SessionMemoryStores_finished, "f")) {
                throw new AnthropicError("finish() was already called: it is the session's last sync and runs once");
            }
            __classPrivateFieldSet(this, _SessionMemoryStores_finished, true, "f");
            await this.syncAll(true);
        }
        /** @internal — reconcile every store once; the tests' deterministic driver */
        async syncAll(final) {
            await Promise.all(__classPrivateFieldGet(this, _SessionMemoryStores_stores, "f").map((store) => __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_syncStore).call(this, store, final)));
            __classPrivateFieldSet(this, _SessionMemoryStores_lastSyncAt, Date.now(), "f");
        }
        /** Sync when `syncIntervalMs` has elapsed since the last one. Never throws. */
        async syncIfDue() {
            if (Date.now() - __classPrivateFieldGet(this, _SessionMemoryStores_lastSyncAt, "f") < __classPrivateFieldGet(this, _SessionMemoryStores_syncIntervalMs, "f"))
                return;
            await this.syncAll(false);
        }
        /**
         * Upload new and changed files; send no deletes and pull nothing.
         *
         * The push-only rescue pass for a session ending on an error or
         * cancel — best-effort, bounded by the caller: once `signal` aborts no
         * further upload starts, each store cut off part-way logs how many
         * changed files it had not finished uploading, and this resolves without
         * waiting for requests already in flight. Each store uploads up to
         * {@link UPLOAD_CONCURRENCY} files at a time. Skips read-only stores,
         * refused files, files the server already holds, and folders that fail
         * the marker check. Never throws.
         */
        async flushWrites(signal) {
            await Promise.all(__classPrivateFieldGet(this, _SessionMemoryStores_stores, "f").map((store) => __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_flushStore).call(this, store, signal)));
        }
        /**
         * Remove every store directory that {@link SessionMemoryStores.download}
         * created. Pre-existing directories are left alone — that is
         * {@link FileStore.dispose}'s own rule. A folder that fails the marker
         * check is kept too — sync left it as found, so must dispose.
         */
        async dispose() {
            for (const store of __classPrivateFieldGet(this, _SessionMemoryStores_stores, "f")) {
                const root = store.files.root();
                try {
                    const scan = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_scanMarker).call(this, store);
                    if (!scan.markerOk && Object.keys(scan.files).length > 0) {
                        __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`${scan.distrustReason}; leaving the memory store folder on disk`, {
                            root: root.path,
                            memory_store_id: store.memoryStoreId,
                        });
                        continue;
                    }
                    await store.files.dispose();
                }
                catch (e) {
                    if (!(e instanceof FileStoreError) && !isErrno(e))
                        throw e;
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to remove the memory store folder', {
                        root: store.files.root().path,
                        memory_store_id: store.memoryStoreId,
                        error: String(e),
                    });
                    continue;
                }
                if (root.removedOnDispose) {
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").info('removed memory store dir', {
                        dest: root.path,
                        memory_store_id: store.memoryStoreId,
                    });
                }
            }
        }
    }
    _SessionMemoryStores_client = new WeakMap(), _SessionMemoryStores_workdir = new WeakMap(), _SessionMemoryStores_syncIntervalMs = new WeakMap(), _SessionMemoryStores_syncDeletions = new WeakMap(), _SessionMemoryStores_log = new WeakMap(), _SessionMemoryStores_lastSyncAt = new WeakMap(), _SessionMemoryStores_finished = new WeakMap(), _SessionMemoryStores_stores = new WeakMap(), _SessionMemoryStores_instances = new WeakSet(), _SessionMemoryStores_storeRoot = function _SessionMemoryStores_storeRoot(resource) {
        if (resource.mount_path) {
            if (!isPathLegal(resource.mount_path)) {
                throw new SessionMemoryError(`memory store mount_path is not a clean absolute path: ${JSON.stringify(resource.mount_path)} ` +
                    `(memory_store_id=${resource.memory_store_id})`);
            }
            return resource.mount_path;
        }
        // No mount_path at all: nothing points the agent anywhere, so the workdir
        // is as good a home as any.
        return path.join(__classPrivateFieldGet(this, _SessionMemoryStores_workdir, "f"), 'memory', resource.name || resource.memory_store_id);
    }, _SessionMemoryStores_scanMarker = async function _SessionMemoryStores_scanMarker(store) {
        const local = await store.files.hashtree();
        const marker = local[MARKER_PATH];
        delete local[MARKER_PATH];
        if (marker === markerSha(store.memoryStoreId)) {
            return { files: local, markerOk: true, distrustReason: null };
        }
        return {
            files: local,
            markerOk: false,
            distrustReason: marker !== undefined ? 'the marker file does not match this store' : 'the marker file is gone',
        };
    }, _SessionMemoryStores_syncStore = async function _SessionMemoryStores_syncStore(store, final) {
        try {
            const scan = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_scanMarker).call(this, store);
            const local = scan.files;
            if (!scan.markerOk) {
                if (Object.keys(local).length > 0) {
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`${scan.distrustReason}; leaving the memory store folder as found and not syncing`, {
                        root: store.files.root().path,
                        memory_store_id: store.memoryStoreId,
                    });
                    return;
                }
                await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_recover).call(this, store, 'the folder or its marker is gone');
                return;
            }
            const remote = new Map();
            for await (const [rel, item] of __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_listMemories).call(this, store.memoryStoreId)) {
                remote.set(rel, item);
            }
            __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_settleSent).call(this, store, remote);
            // A lone file vanishing is an ordinary deletion; two or more at
            // once with nothing left is a wiped folder — counted after settling,
            // so an upload that landed unheard counts.
            if (Object.keys(local).length === 0 && store.baseline.size > 1) {
                await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_recover).call(this, store, 'every memory file is gone at once');
                return;
            }
            const deletes = new DeletePass(__classPrivateFieldGet(this, _SessionMemoryStores_syncDeletions, "f"), Math.max(DELETE_CAP_FLOOR, Math.min(DELETE_CAP_CEILING, Math.floor(store.baseline.size / 4))), final);
            const pulls = [];
            const baseline = new Map();
            const paths = [...new Set([...remote.keys(), ...Object.keys(local), ...store.baseline.keys()])].sort();
            for (const rel of paths) {
                const remoteItem = remote.get(rel);
                const localSha = local[rel];
                const baseSha = store.baseline.get(rel);
                let sha;
                if (localSha === undefined &&
                    baseSha !== undefined &&
                    remoteItem !== undefined &&
                    remoteItem.content_sha256 === baseSha &&
                    !store.readOnly) {
                    sha = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_corroboratedDelete).call(this, store, rel, remoteItem, baseSha, deletes);
                }
                else {
                    sha = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_syncPath).call(this, store, rel, remoteItem, localSha, pulls);
                }
                if (sha !== undefined)
                    baseline.set(rel, sha);
            }
            store.baseline = baseline;
            // The content pass: everything above moved only shas.
            await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_pullAll).call(this, store, pulls);
            if (deletes.suppressed > 0) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").debug('remote deletes are disabled; locally deleted memories stay on the server', {
                    count: deletes.suppressed,
                    memory_store_id: store.memoryStoreId,
                });
            }
            if (deletes.capped > 0) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`delete cap reached: ${deletes.mode === 'log_only' ? 'would send' : 'sent'} ` +
                    `${deletes.attempted} deletes, held ${deletes.capped} for later syncs`, { memory_store_id: store.memoryStoreId });
            }
        }
        catch (e) {
            __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory sync failed', { memory_store_id: store.memoryStoreId, error: String(e) });
        }
    }, _SessionMemoryStores_flushStore = async function _SessionMemoryStores_flushStore(store, signal) {
        const dirty = new Map();
        const unsent = new Set();
        const push = async () => {
            if (store.readOnly)
                return;
            const scan = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_scanMarker).call(this, store);
            if (!scan.markerOk) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`${scan.distrustReason}; not uploading anything from the memory store folder`, {
                    root: store.files.root().path,
                    memory_store_id: store.memoryStoreId,
                });
                return;
            }
            for (const [rel, sha] of Object.entries(scan.files)) {
                // A path with an unsettled send counts even at its baseline sha: the
                // send may have landed, leaving the server ahead of the file.
                const changed = sha !== store.baseline.get(rel) || store.sentShas.has(rel);
                if (changed && store.refusedShas.get(rel) !== sha) {
                    dirty.set(rel, sha);
                    unsent.add(rel);
                }
            }
            if (dirty.size === 0 || signal?.aborted)
                return;
            const remote = new Map();
            for await (const [rel, item] of __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_listMemories).call(this, store.memoryStoreId)) {
                if (signal?.aborted)
                    return;
                remote.set(rel, item);
            }
            __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_settleSent).call(this, store, remote);
            const uploads = [];
            for (const rel of [...dirty.keys()].sort()) {
                const localSha = dirty.get(rel);
                const baseSha = store.baseline.get(rel);
                const existing = remote.get(rel);
                if (localSha === baseSha) {
                    // Listed only for its unsettled send, which never landed: the file
                    // holds nothing the server lacks.
                    unsent.delete(rel);
                    continue;
                }
                if (existing !== undefined && existing.content_sha256 === localSha) {
                    store.baseline.set(rel, existing.content_sha256);
                    unsent.delete(rel);
                    continue;
                }
                if (existing !== undefined && existing.content_sha256 !== baseSha) {
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory changed both locally and remotely; the flush leaves the remote version', {
                        path: rel,
                        memory_store_id: store.memoryStoreId,
                    });
                    unsent.delete(rel);
                    continue;
                }
                uploads.push([rel, localSha, existing]);
            }
            await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_uploadAll).call(this, store, uploads, unsent, signal);
        };
        try {
            await settledOrAborted(push(), signal);
            if (signal?.aborted && unsent.size > 0) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`memory flush cut off part-way; ${unsent.size} of ${dirty.size} changed files had not finished uploading`, { memory_store_id: store.memoryStoreId });
            }
        }
        catch (e) {
            __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory flush failed', { memory_store_id: store.memoryStoreId, error: String(e) });
        }
    }, _SessionMemoryStores_recover = 
    /** Rebuild a destroyed folder from the server; sends no deletes, no uploads. */
    async function _SessionMemoryStores_recover(store, reason) {
        __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn(`${reason}; re-downloading the memory store folder instead of syncing`, {
            root: store.files.root().path,
            memory_store_id: store.memoryStoreId,
        });
        await store.files.createRoot();
        await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_stampAndPull).call(this, store);
    }, _SessionMemoryStores_settleSent = function _SessionMemoryStores_settleSent(store, remote) {
        for (const [rel, sha] of store.sentShas) {
            if (remote.get(rel)?.content_sha256 === sha)
                store.baseline.set(rel, sha);
        }
        store.sentShas.clear();
    }, _SessionMemoryStores_stampAndPull = 
    /**
     * Write the marker, then pull every remote memory. Baseline is cleared
     * first so a failed write never leaves an entry whose file is not on disk.
     * Every memory is needed here, so the listing carries the content — pages
     * cost far fewer round-trips than a request per memory.
     */
    async function _SessionMemoryStores_stampAndPull(store) {
        store.baseline = new Map();
        store.sentShas.clear();
        store.pendingDeletes.clear();
        await store.files.put(MARKER_PATH, `version ${MARKER_VERSION}\n${store.memoryStoreId}`);
        for await (const [rel, item] of __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_listMemories).call(this, store.memoryStoreId, 'full')) {
            if (await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_write).call(this, store, rel, item.content ?? '')) {
                store.baseline.set(rel, item.content_sha256);
            }
        }
    }, _SessionMemoryStores_syncPath = 
    /**
     * Reconcile one path. Returns the sha to record in the baseline, or
     * `undefined` to drop the path from it.
     *
     * `pulls` is an output: when the remote version should be written to disk,
     * this appends `[rel, remote]` to it instead of writing — `rel` is the
     * file to write, `remote` the listed memory whose content `#pullAll` will
     * fetch and write there.
     */
    async function _SessionMemoryStores_syncPath(store, rel, remote, localSha, pulls) {
        const baseSha = store.baseline.get(rel);
        if (localSha !== undefined) {
            store.pendingDeletes.delete(rel);
        }
        if (!remote) {
            if (localSha === undefined) {
                store.pendingDeletes.delete(rel);
                return undefined;
            }
            if (baseSha !== undefined) {
                if (localSha === baseSha) {
                    const fresh = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_removeLocal).call(this, store, rel, baseSha);
                    if (fresh === undefined)
                        return undefined;
                    if (fresh === baseSha)
                        return baseSha;
                    localSha = fresh;
                }
                // The file holds an un-pushed edit — the only copy; falling
                // through re-creates or keeps it.
                if (store.readOnly) {
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory deleted remotely but edited locally; keeping the file, ' +
                        'which a read-only store cannot push', { path: rel, memory_store_id: store.memoryStoreId });
                }
                else if (store.refusedShas.get(rel) !== localSha) {
                    __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").info('memory deleted remotely but edited locally; re-creating it from the file', {
                        path: rel,
                        memory_store_id: store.memoryStoreId,
                    });
                }
            }
            if (store.readOnly)
                return undefined;
            if (store.refusedShas.get(rel) === localSha)
                return undefined;
            return await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_upload).call(this, store, rel, localSha, undefined);
        }
        const remoteSha = remote.content_sha256;
        const remoteChanged = remoteSha !== baseSha;
        const locallyEdited = localSha !== undefined && localSha !== baseSha && localSha !== remoteSha;
        // Read-only stores never push, so their local edits don't count.
        const localChanged = !store.readOnly && locallyEdited;
        if (localSha === undefined && baseSha !== undefined) {
            // Only successful writes enter the baseline, so this file was verifiably
            // on disk and is now gone: a real local deletion. The unchanged-remote
            // case went to #corroboratedDelete.
            if (remoteChanged) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory deleted locally but changed remotely; restoring the remote version', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                });
                store.pendingDeletes.delete(rel);
                pulls.push([rel, remote]);
            }
            return baseSha;
        }
        if (remoteChanged) {
            // The file already holds the remote bytes — adopt without a fetch.
            if (localSha === remoteSha)
                return remoteSha;
            // locallyEdited, not localChanged: warn on read-only overwrites too.
            if (locallyEdited) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory changed both locally and remotely; keeping the remote version', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                });
            }
            pulls.push([rel, remote]);
            return baseSha;
        }
        if (localChanged) {
            if (store.refusedShas.get(rel) === localSha)
                return remoteSha;
            return (await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_upload).call(this, store, rel, localSha, remote)) ?? remoteSha;
        }
        return remoteSha;
    }, _SessionMemoryStores_removeLocal = 
    /**
     * Remove the file for a memory the server no longer has, if it still holds
     * `expectSha`. Returns `undefined` when the file is gone from disk,
     * `expectSha` when it must stay in the baseline (I/O error), or the file's
     * fresh sha when it was edited since the scan.
     */
    async function _SessionMemoryStores_removeLocal(store, rel, expectSha) {
        // Re-read: an edit since the scan makes this file the only copy.
        let freshSha;
        try {
            freshSha = await store.files.hashFile(rel);
        }
        catch (e) {
            if (!(e instanceof FileStoreError) && !isErrno(e))
                throw e;
            return expectSha;
        }
        if (freshSha === null)
            return undefined;
        if (freshSha !== expectSha)
            return freshSha;
        try {
            await store.files.remove(rel);
        }
        catch (e) {
            if (!(e instanceof FileStoreError) && !isErrno(e))
                throw e;
            __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to remove memory deleted remotely', {
                path: rel,
                memory_store_id: store.memoryStoreId,
                error: String(e),
            });
            return expectSha;
        }
        return undefined;
    }, _SessionMemoryStores_write = 
    /**
     * Write a memory's content to disk; `false` (and a warning) on failure.
     *
     * A `..` component in the wire path reaches here as {@link FileStoreError} —
     * that is the escape guard.
     */
    async function _SessionMemoryStores_write(store, rel, content) {
        try {
            await store.files.put(rel, content);
        }
        catch (e) {
            if (!(e instanceof FileStoreError) && !isErrno(e))
                throw e;
            __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to write memory', {
                path: rel,
                memory_store_id: store.memoryStoreId,
                error: String(e),
            });
            return false;
        }
        return true;
    }, _SessionMemoryStores_pullAll = 
    /**
     * Fetch and write the given memories, {@link FETCH_CONCURRENCY} at a time.
     *
     * The sync's content pass: the listing carried no content, so each memory
     * is fetched individually and written as it arrives. On success the path's
     * baseline advances; on a failed fetch or write the old entry stays and the
     * next sync retries. A 404 means the memory was deleted after the listing —
     * the next sync reconciles it.
     */
    async function _SessionMemoryStores_pullAll(store, pulls) {
        if (pulls.length === 0)
            return;
        const pullOne = async (rel, listed) => {
            let item;
            try {
                item = await __classPrivateFieldGet(this, _SessionMemoryStores_client, "f").beta.memoryStores.memories.retrieve(listed.id, {
                    memory_store_id: store.memoryStoreId,
                    view: 'full',
                });
            }
            catch (e) {
                if (isStatus(e, 404))
                    return;
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to fetch memory content', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                    error: String(e),
                });
                return;
            }
            if (await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_write).call(this, store, rel, item.content ?? '')) {
                store.baseline.set(rel, item.content_sha256);
            }
        };
        // A fixed pool of workers drains the queue; the write stays inside the
        // worker so a slow disk cannot let fetched bodies pile up beyond the bound.
        const queue = pulls[Symbol.iterator]();
        const worker = async () => {
            for (const [rel, listed] of queue)
                await pullOne(rel, listed);
        };
        await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, pulls.length) }, worker));
    }, _SessionMemoryStores_uploadAll = 
    /**
     * Upload the given files, {@link UPLOAD_CONCURRENCY} at a time, taking each
     * path off `unsent` as its upload returns. No upload starts once `signal`
     * aborts; the ones already in flight run to completion.
     */
    async function _SessionMemoryStores_uploadAll(store, uploads, unsent, signal) {
        const queue = uploads[Symbol.iterator]();
        const worker = async () => {
            for (const [rel, localSha, existing] of queue) {
                if (signal?.aborted)
                    return;
                const sha = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_upload).call(this, store, rel, localSha, existing);
                unsent.delete(rel);
                if (sha !== undefined)
                    store.baseline.set(rel, sha);
            }
        };
        await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, uploads.length) }, worker));
    }, _SessionMemoryStores_listMemories = 
    /**
     * The store's memories keyed by relative path (the wire path's leading `/`
     * stripped — `#upload` re-prefixes it) — `basic` view (shas, no content) at
     * {@link LIST_PAGE_SIZE} per page unless the caller needs `full` pages.
     * `memory_prefix` rollups and the reserved marker path are skipped.
     */
    async function* _SessionMemoryStores_listMemories(memoryStoreId, view = 'basic') {
        const limit = view === 'basic' ? LIST_PAGE_SIZE : FULL_LIST_PAGE_SIZE;
        for await (const item of __classPrivateFieldGet(this, _SessionMemoryStores_client, "f").beta.memoryStores.memories.list(memoryStoreId, { view, limit })) {
            if (item.type !== 'memory')
                continue;
            const rel = item.path.replace(/^\/+/, '');
            if (rel === MARKER_PATH) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('the server listed the reserved marker path; skipping', {
                    path: item.path,
                    memory_store_id: memoryStoreId,
                });
                continue;
            }
            yield [rel, item];
        }
    }, _SessionMemoryStores_upload = 
    /**
     * Push one local file; `undefined` keeps the old baseline so the next pass retries.
     *
     * A refusal the server would repeat (400/413, the utf-8 gate) enters
     * `refusedShas`: warned once, retried only after the file changes.
     */
    async function _SessionMemoryStores_upload(store, rel, localSha, existing) {
        try {
            const data = await store.files.get(rel);
            if (data === null)
                return undefined;
            const content = decodeUTF8(data);
            // Hashed as sent: decoding may have dropped a byte-order mark.
            store.sentShas.set(rel, crypto.createHash('sha256').update(content, 'utf-8').digest('hex'));
            const item = existing ?
                await __classPrivateFieldGet(this, _SessionMemoryStores_client, "f").beta.memoryStores.memories.update(existing.id, {
                    memory_store_id: store.memoryStoreId,
                    content,
                    precondition: { type: 'content_sha256', content_sha256: existing.content_sha256 },
                })
                : await __classPrivateFieldGet(this, _SessionMemoryStores_client, "f").beta.memoryStores.memories.create(store.memoryStoreId, {
                    path: '/' + rel,
                    content,
                });
            store.refusedShas.delete(rel);
            store.sentShas.delete(rel);
            return item.content_sha256;
        }
        catch (e) {
            if (existing && isStatus(e, 404)) {
                // Deleted remotely since the listing, so this file is now the only copy.
                return await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_upload).call(this, store, rel, localSha, undefined);
            }
            const permanent = e instanceof FileStoreError || isStatus(e, 400) || isStatus(e, 413);
            if (existing && isStatus(e, 409)) {
                // The precondition lost a race: the remote moved under us, so the push
                // is dropped. The local file is now stale — the next sync sees
                // remoteChanged and pulls the winner over it. A client retry of an
                // attempt that was saved ends here too, so the sent sha stays for the
                // next listing to tell the two apart.
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory changed both locally and remotely; the upload was refused and the local edit loses', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                });
            }
            else if (permanent && localSha !== undefined) {
                store.refusedShas.set(rel, localSha);
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('the server rejected this memory file, so it stays un-synced until its content changes', { path: rel, memory_store_id: store.memoryStoreId, rejection: String(e) });
            }
            else {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to upload memory', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                    error: String(e),
                });
            }
            return undefined;
        }
    }, _SessionMemoryStores_corroboratedDelete = 
    /** Send the server delete only after the wait, the cap, and a fresh re-check all clear. */
    async function _SessionMemoryStores_corroboratedDelete(store, rel, remote, baseSha, deletes) {
        if (deletes.mode === 'disabled') {
            deletes.suppressed++;
            return baseSha;
        }
        let firstAbsent = store.pendingDeletes.get(rel);
        if (firstAbsent === undefined) {
            firstAbsent = Date.now();
            store.pendingDeletes.set(rel, firstAbsent);
        }
        if (!deletes.waiveWindow && Date.now() - firstAbsent < DELETE_CORROBORATION_MS) {
            return baseSha;
        }
        let markerOk;
        let stillAbsent;
        try {
            // Re-check: the folder may have been wiped mid-sync.
            markerOk = (await store.files.hashFile(MARKER_PATH)) === markerSha(store.memoryStoreId);
            stillAbsent = (await store.files.hashFile(rel)) === null;
        }
        catch (e) {
            if (!(e instanceof FileStoreError) && !isErrno(e))
                throw e;
            markerOk = stillAbsent = false;
        }
        if (!markerOk)
            return baseSha;
        if (!stillAbsent) {
            store.pendingDeletes.delete(rel);
            return baseSha;
        }
        if (!deletes.takeSlot())
            return baseSha;
        if (deletes.mode === 'log_only') {
            // Repeats each sync — the log is the dry run.
            __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").info('log-only: sync would delete this memory on the server', {
                path: rel,
                memory_store_id: store.memoryStoreId,
            });
            return baseSha;
        }
        const sha = await __classPrivateFieldGet(this, _SessionMemoryStores_instances, "m", _SessionMemoryStores_deleteRemote).call(this, store, rel, remote, baseSha);
        if (sha === undefined) {
            store.pendingDeletes.delete(rel);
        }
        return sha;
    }, _SessionMemoryStores_deleteRemote = async function _SessionMemoryStores_deleteRemote(store, rel, remote, baseSha) {
        try {
            await __classPrivateFieldGet(this, _SessionMemoryStores_client, "f").beta.memoryStores.memories.delete(remote.id, {
                memory_store_id: store.memoryStoreId,
                expected_content_sha256: baseSha,
            });
        }
        catch (e) {
            if (isStatus(e, 404))
                return undefined; // already gone remotely too
            if (isStatus(e, 409) || isStatus(e, 412)) {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('memory deleted locally but changed remotely; keeping the remote version', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                });
            }
            else {
                __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").warn('failed to delete memory', {
                    path: rel,
                    memory_store_id: store.memoryStoreId,
                    error: String(e),
                });
            }
            return baseSha;
        }
        __classPrivateFieldGet(this, _SessionMemoryStores_log, "f").info('propagated local deletion', { path: rel, memory_store_id: store.memoryStoreId });
        return undefined;
    };
    return SessionMemoryStores;
})();
/**
 * True for a thrown value shaped like a Node filesystem error. Shape-checked,
 * not `instanceof Error` — fs errors can come from another realm.
 */
function isErrno(e) {
    return typeof e === 'object' && e !== null && typeof e.code === 'string';
}
/**
 * Resolve when `p` settles, or as soon as `signal` aborts. A rejection from
 * `p` before the abort propagates; one after it is dropped.
 */
async function settledOrAborted(p, signal) {
    if (!signal) {
        await p;
        return;
    }
    let onAbort;
    const aborted = new Promise((resolve) => {
        onAbort = resolve;
        if (signal.aborted)
            resolve();
    });
    signal.addEventListener('abort', onAbort, { once: true });
    try {
        await Promise.race([p, aborted]);
    }
    finally {
        signal.removeEventListener('abort', onAbort);
    }
}
//# sourceMappingURL=memories.mjs.map