UNPKG

@anthropic-ai/sdk

Version:
1,063 lines (1,010 loc) • 41.2 kB
/** * 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'; import type { Anthropic } from '../../client'; import { AnthropicError } from '../../core/error'; import { loggerFor, type Logger } from '../../internal/utils/log'; import { decodeUTF8 } from '../../internal/utils/bytes'; import { isStatus } from '../../internal/utils/backoff'; import { isPathLegal, FileStoreError, LocalFileStore, type FileStore } from '../../internal/file-store'; import type { BetaManagedAgentsSession } from '../../resources/beta/sessions/sessions'; import type { BetaManagedAgentsMemoryStoreResource } from '../../resources/beta/sessions/resources'; import type { BetaManagedAgentsMemory, BetaManagedAgentsMemoryView, } from '../../resources/beta/memory-stores/memories'; import { DEFAULT_MEMORY_SYNC_INTERVAL_MS, checkMemorySyncInterval } from './sync-interval'; export { DEFAULT_MEMORY_SYNC_INTERVAL_MS, MIN_MEMORY_SYNC_INTERVAL_MS } from './sync-interval'; /** * Whether a locally deleted file may delete its memory on the server: * `"enabled"` sends the delete, `"log_only"` runs the checks but only logs, * `"disabled"` never deletes. */ export type MemoryDeleteMode = 'enabled' | 'log_only' | 'disabled'; /** * 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 = 30_000; /** * 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: string): string { 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 = 30_000; /** * 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; /** * One directory scan: the marker check and the file listing come from the * same read, so a folder wiped mid-sync cannot pass the check and then look * empty. */ type MarkerScan = { files: Record<string, string>; markerOk: boolean; distrustReason: string | null; }; /** * 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: string, cause?: unknown) { super(message); this.name = 'SessionMemoryError'; // in some environments the 'cause' property is already declared // @ts-ignore if (cause !== undefined) this.cause = cause; } } /** One attached store: its {@link FileStore} on disk plus the sync baseline. */ type AttachedStore = { memoryStoreId: string; files: FileStore; readOnly: boolean; /** `{rel → content sha}` as of the last download or successful sync. */ baseline: Map<string, string>; /** `{rel → sha}` the server refused; retried only after the file changes. */ refusedShas: Map<string, string>; /** `{rel → Date.now()}` when first seen missing locally. */ pendingDeletes: Map<string, number>; /** * `{rel → sha}` of content sent whose outcome is unknown — recorded before * the request, dropped after a successful response, else settled by the * next listing. */ sentShas: Map<string, string>; }; /** One sync's remote-delete gate and counters. */ class DeletePass { attempted = 0; capped = 0; suppressed = 0; constructor( readonly mode: MemoryDeleteMode, readonly cap: number, /** Skip the delete wait — set on the session's last sync. */ readonly waiveWindow: boolean, ) {} takeSlot(): boolean { if (this.attempted >= this.cap) { this.capped++; return false; } this.attempted++; return true; } } export interface SessionMemoryStoresOptions { /** Base directory for the `{workdir}/memory/<name>` fallback store location. */ workdir: string; /** * How often (milliseconds) {@link SessionMemoryStores.syncIfDue} actually * syncs. Defaults to {@link DEFAULT_MEMORY_SYNC_INTERVAL_MS} (15s); values * below {@link MIN_MEMORY_SYNC_INTERVAL_MS} (5s) are rejected. */ syncIntervalMs?: number; /** See {@link MemoryDeleteMode}. Defaults to `"enabled"`. */ syncDeletions?: MemoryDeleteMode; } /** * 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 class SessionMemoryStores { readonly #client: Anthropic; readonly #workdir: string; readonly #syncIntervalMs: number; readonly #syncDeletions: MemoryDeleteMode; readonly #log: Logger; #lastSyncAt: number; #finished = false; readonly #stores: AttachedStore[] = []; constructor(client: Anthropic, opts: SessionMemoryStoresOptions) { this.#client = client; this.#workdir = opts.workdir; this.#syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_MEMORY_SYNC_INTERVAL_MS; checkMemorySyncInterval(this.#syncIntervalMs, 'syncIntervalMs'); this.#syncDeletions = opts.syncDeletions ?? 'enabled'; this.#log = loggerFor(client); this.#lastSyncAt = Date.now(); } /** * 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(): string[] { return this.#stores.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(): string[] { return this.#stores.filter((s) => s.readOnly).map((s) => s.files.root().path); } /** * Where one store's files land on disk. * * The store's files land at its `mount_path` — the very location the agent's * system prompt tells it to read. A `mount_path` we cannot use verbatim is * refused rather than quietly relocated: the agent would read an empty * folder at the path it was told about, and write notes somewhere the next * session looks for nothing. */ #storeRoot(resource: BetaManagedAgentsMemoryStoreResource): string { 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(this.#workdir, 'memory', resource.name || resource.memory_store_id); } /** * 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: BetaManagedAgentsSession): Promise<void> { for (const resource of session.resources) { if (resource.type !== 'memory_store') continue; const root = this.#storeRoot(resource); let store: AttachedStore | undefined; 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 this.#stampAndPull(store); this.#log.info('downloaded memories', { count: store.baseline.size, memory_store_id: store.memoryStoreId, dest: store.files.root().path, }); this.#stores.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, ); } } this.#lastSyncAt = Date.now(); } /** * The session's last sync — skips the delete wait, so calling it twice * would undo the protection; it throws instead. */ async finish(): Promise<void> { if (this.#finished) { throw new AnthropicError("finish() was already called: it is the session's last sync and runs once"); } this.#finished = true; await this.syncAll(true); } /** @internal — reconcile every store once; the tests' deterministic driver */ async syncAll(final: boolean): Promise<void> { await Promise.all(this.#stores.map((store) => this.#syncStore(store, final))); this.#lastSyncAt = Date.now(); } async #scanMarker(store: AttachedStore): Promise<MarkerScan> { 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', }; } async #syncStore(store: AttachedStore, final: boolean): Promise<void> { try { const scan = await this.#scanMarker(store); const local = scan.files; if (!scan.markerOk) { if (Object.keys(local).length > 0) { this.#log.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 this.#recover(store, 'the folder or its marker is gone'); return; } const remote = new Map<string, BetaManagedAgentsMemory>(); for await (const [rel, item] of this.#listMemories(store.memoryStoreId)) { remote.set(rel, item); } this.#settleSent(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 this.#recover(store, 'every memory file is gone at once'); return; } const deletes = new DeletePass( this.#syncDeletions, Math.max(DELETE_CAP_FLOOR, Math.min(DELETE_CAP_CEILING, Math.floor(store.baseline.size / 4))), final, ); const pulls: Array<[rel: string, item: BetaManagedAgentsMemory]> = []; const baseline = new Map<string, string>(); 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: string | undefined; if ( localSha === undefined && baseSha !== undefined && remoteItem !== undefined && remoteItem.content_sha256 === baseSha && !store.readOnly ) { sha = await this.#corroboratedDelete(store, rel, remoteItem, baseSha, deletes); } else { sha = await this.#syncPath(store, rel, remoteItem, localSha, pulls); } if (sha !== undefined) baseline.set(rel, sha); } store.baseline = baseline; // The content pass: everything above moved only shas. await this.#pullAll(store, pulls); if (deletes.suppressed > 0) { this.#log.debug('remote deletes are disabled; locally deleted memories stay on the server', { count: deletes.suppressed, memory_store_id: store.memoryStoreId, }); } if (deletes.capped > 0) { this.#log.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) { this.#log.warn('memory sync failed', { memory_store_id: store.memoryStoreId, error: String(e) }); } } /** Sync when `syncIntervalMs` has elapsed since the last one. Never throws. */ async syncIfDue(): Promise<void> { if (Date.now() - this.#lastSyncAt < this.#syncIntervalMs) 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?: AbortSignal): Promise<void> { await Promise.all(this.#stores.map((store) => this.#flushStore(store, signal))); } async #flushStore(store: AttachedStore, signal: AbortSignal | undefined): Promise<void> { const dirty = new Map<string, string>(); const unsent = new Set<string>(); const push = async (): Promise<void> => { if (store.readOnly) return; const scan = await this.#scanMarker(store); if (!scan.markerOk) { this.#log.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<string, BetaManagedAgentsMemory>(); for await (const [rel, item] of this.#listMemories(store.memoryStoreId)) { if (signal?.aborted) return; remote.set(rel, item); } this.#settleSent(store, remote); const uploads: Array< [rel: string, localSha: string | undefined, existing: BetaManagedAgentsMemory | undefined] > = []; 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) { this.#log.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 this.#uploadAll(store, uploads, unsent, signal); }; try { await settledOrAborted(push(), signal); if (signal?.aborted && unsent.size > 0) { this.#log.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) { this.#log.warn('memory flush failed', { memory_store_id: store.memoryStoreId, error: String(e) }); } } /** * 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(): Promise<void> { for (const store of this.#stores) { const root = store.files.root(); try { const scan = await this.#scanMarker(store); if (!scan.markerOk && Object.keys(scan.files).length > 0) { this.#log.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; this.#log.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) { this.#log.info('removed memory store dir', { dest: root.path, memory_store_id: store.memoryStoreId, }); } } } /** Rebuild a destroyed folder from the server; sends no deletes, no uploads. */ async #recover(store: AttachedStore, reason: string): Promise<void> { this.#log.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 this.#stampAndPull(store); } /** * Resolve the uploads whose response never arrived, against a fresh listing. * * A memory holding exactly the sha that was sent is that upload, saved after * all: it enters the baseline, so it reads as synced rather than as a remote * edit. Every record is then dropped — kept past this listing, one would * claim another writer's identical bytes. */ #settleSent(store: AttachedStore, remote: Map<string, BetaManagedAgentsMemory>): void { for (const [rel, sha] of store.sentShas) { if (remote.get(rel)?.content_sha256 === sha) store.baseline.set(rel, sha); } store.sentShas.clear(); } /** * 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 #stampAndPull(store: AttachedStore): Promise<void> { 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 this.#listMemories(store.memoryStoreId, 'full')) { if (await this.#write(store, rel, item.content ?? '')) { store.baseline.set(rel, item.content_sha256); } } } /** * 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 #syncPath( store: AttachedStore, rel: string, remote: BetaManagedAgentsMemory | undefined, localSha: string | undefined, pulls: Array<[rel: string, item: BetaManagedAgentsMemory]>, ): Promise<string | undefined> { 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 this.#removeLocal(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) { this.#log.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) { this.#log.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 this.#upload(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) { this.#log.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) { this.#log.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 this.#upload(store, rel, localSha, remote)) ?? remoteSha; } return remoteSha; } /** * 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 #removeLocal(store: AttachedStore, rel: string, expectSha: string): Promise<string | undefined> { // Re-read: an edit since the scan makes this file the only copy. let freshSha: string | null; 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; this.#log.warn('failed to remove memory deleted remotely', { path: rel, memory_store_id: store.memoryStoreId, error: String(e), }); return expectSha; } return undefined; } /** * 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 #write(store: AttachedStore, rel: string, content: string): Promise<boolean> { try { await store.files.put(rel, content); } catch (e) { if (!(e instanceof FileStoreError) && !isErrno(e)) throw e; this.#log.warn('failed to write memory', { path: rel, memory_store_id: store.memoryStoreId, error: String(e), }); return false; } return true; } /** * 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 #pullAll(store: AttachedStore, pulls: Array<[rel: string, item: BetaManagedAgentsMemory]>) { if (pulls.length === 0) return; const pullOne = async (rel: string, listed: BetaManagedAgentsMemory): Promise<void> => { let item: BetaManagedAgentsMemory; try { item = await this.#client.beta.memoryStores.memories.retrieve(listed.id, { memory_store_id: store.memoryStoreId, view: 'full', }); } catch (e) { if (isStatus(e, 404)) return; this.#log.warn('failed to fetch memory content', { path: rel, memory_store_id: store.memoryStoreId, error: String(e), }); return; } if (await this.#write(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 (): Promise<void> => { for (const [rel, listed] of queue) await pullOne(rel, listed); }; await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, pulls.length) }, worker)); } /** * 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 #uploadAll( store: AttachedStore, uploads: Array< [rel: string, localSha: string | undefined, existing: BetaManagedAgentsMemory | undefined] >, unsent: Set<string>, signal: AbortSignal | undefined, ): Promise<void> { const queue = uploads[Symbol.iterator](); const worker = async (): Promise<void> => { for (const [rel, localSha, existing] of queue) { if (signal?.aborted) return; const sha = await this.#upload(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)); } /** * 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 *#listMemories( memoryStoreId: string, view: BetaManagedAgentsMemoryView = 'basic', ): AsyncGenerator<[rel: string, item: BetaManagedAgentsMemory]> { const limit = view === 'basic' ? LIST_PAGE_SIZE : FULL_LIST_PAGE_SIZE; for await (const item of this.#client.beta.memoryStores.memories.list(memoryStoreId, { view, limit })) { if (item.type !== 'memory') continue; const rel = item.path.replace(/^\/+/, ''); if (rel === MARKER_PATH) { this.#log.warn('the server listed the reserved marker path; skipping', { path: item.path, memory_store_id: memoryStoreId, }); continue; } yield [rel, item]; } } /** * 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 #upload( store: AttachedStore, rel: string, localSha: string | undefined, existing: BetaManagedAgentsMemory | undefined, ): Promise<string | undefined> { 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 this.#client.beta.memoryStores.memories.update(existing.id, { memory_store_id: store.memoryStoreId, content, precondition: { type: 'content_sha256', content_sha256: existing.content_sha256 }, }) : await this.#client.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 this.#upload(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. this.#log.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); this.#log.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 { this.#log.warn('failed to upload memory', { path: rel, memory_store_id: store.memoryStoreId, error: String(e), }); } return undefined; } } /** Send the server delete only after the wait, the cap, and a fresh re-check all clear. */ async #corroboratedDelete( store: AttachedStore, rel: string, remote: BetaManagedAgentsMemory, baseSha: string, deletes: DeletePass, ): Promise<string | undefined> { 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: boolean; let stillAbsent: boolean; 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. this.#log.info('log-only: sync would delete this memory on the server', { path: rel, memory_store_id: store.memoryStoreId, }); return baseSha; } const sha = await this.#deleteRemote(store, rel, remote, baseSha); if (sha === undefined) { store.pendingDeletes.delete(rel); } return sha; } async #deleteRemote( store: AttachedStore, rel: string, remote: BetaManagedAgentsMemory, baseSha: string, ): Promise<string | undefined> { try { await this.#client.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)) { this.#log.warn('memory deleted locally but changed remotely; keeping the remote version', { path: rel, memory_store_id: store.memoryStoreId, }); } else { this.#log.warn('failed to delete memory', { path: rel, memory_store_id: store.memoryStoreId, error: String(e), }); } return baseSha; } this.#log.info('propagated local deletion', { path: rel, memory_store_id: store.memoryStoreId }); return undefined; } } /** * 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: unknown): boolean { return typeof e === 'object' && e !== null && typeof (e as NodeJS.ErrnoException).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: Promise<unknown>, signal: AbortSignal | undefined): Promise<void> { if (!signal) { await p; return; } let onAbort!: () => void; const aborted = new Promise<void>((resolve) => { onAbort = resolve; if (signal.aborted) resolve(); }); signal.addEventListener('abort', onAbort, { once: true }); try { await Promise.race([p, aborted]); } finally { signal.removeEventListener('abort', onAbort); } }