UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

357 lines • 17.5 kB
"use strict"; /** * artifacts/fileArtifacts — the claim-check store in a directory. * * One machine, one directory, nothing to install: each artifact is one JSON * envelope at `<dir>/<tenant>/<principal>/<conversation>/<ref>.json`, so an * incident can be inspected with `ls` and `cat` — a store you cannot read * with the tools already on the box is a store you debug by guessing. * * ── Scope-partitioned paths, structurally traversal-proof ────────────────── * The path is built from two kinds of segment and both are closed: * • the ref — MINTED (`art_` + base62), and `isArtifactRef` is asserted * before any ref touches a path anyway, because "cannot happen" is a * claim, not a defence; * • the scope tuple — caller strings, so each segment is percent-encoded * before it becomes a directory name: `..`, `/` and `\` arrive as data * and land as literals, never as navigation. That encoding is * `scopePath.ts`'s to own (9.25.0) — the object-store adapters partition * their keys with the identical law, and one traversal defence that lives * in two files is one that eventually differs in two files. * * ── Streaming (9.25.0) ───────────────────────────────────────────────────── * This adapter implements the port's OPTIONAL `putStream`/`getStream`, * because a directory can honestly do it: the bytes go to a sibling * `<ref>.bin` as they arrive and the envelope records `payload: { shape: * 'external' }`. The envelope is written LAST, so a crash mid-upload leaves an * orphan `.bin` nobody can see rather than a ticket pointing at a payload that * never finished. A streamed artifact reads back as a `Uint8Array` through * `get()`, and `delete`/retention remove both files. * * ── What this adapter does NOT know ──────────────────────────────────────── * A directory cannot cheaply know which artifact was READ last, so budget * evictions here are oldest-first (by mint time) rather than the in-memory * adapter's least-recently-used — stated here so nobody discovers it as a * surprise under pressure. TTL expiry behaves identically everywhere. * * Node-only (`node:fs` via lazy require, the sqliteSessions law) — importing * the barrel costs a browser bundle nothing; constructing one where there is * no filesystem refuses by name. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.fileArtifacts = exports.UnreadableArtifactFileError = void 0; const lazyRequire_js_1 = require("../lib/lazyRequire.js"); const minting_js_1 = require("./minting.js"); const naming_js_1 = require("./naming.js"); const payload_js_1 = require("./payload.js"); const retention_js_1 = require("./retention.js"); const scopePath_js_1 = require("./scopePath.js"); const streaming_js_1 = require("./streaming.js"); const types_js_1 = require("./types.js"); /** What one artifact file holds. `format` is bumped only for a change an * older reader could not survive; a newer number is refused, never half-read. */ const FILE_FORMAT = 1; /** * Raised when an artifact file EXISTS but this runtime cannot read it — a * different fact from "no artifact", and only one of them is safe to answer * with `null`. (The sqliteSessions law, per file.) */ class UnreadableArtifactFileError extends Error { code = 'ERR_UNREADABLE_ARTIFACT_FILE'; file; constructor(file, detail) { super(`[artifacts] the artifact file at '${file}' cannot be read: ${detail} ` + `An unreadable artifact and a missing one are different facts — this refuses rather ` + `than reporting "no data" over bytes that exist. Move the file aside to release the ref.`); this.name = 'UnreadableArtifactFileError'; this.file = file; } } exports.UnreadableArtifactFileError = UnreadableArtifactFileError; /** * A directory-backed artifact store — durable across restarts, legible to a * human, one file per artifact. * * @example * const store = fileArtifacts({ directory: './artifacts' }); * const agent = Agent.create({ provider, artifacts: store }); */ function fileArtifacts(options) { const { directory, retention } = options; if (typeof directory !== 'string' || directory.trim() === '') { throw new TypeError(`[artifacts] fileArtifacts({ directory: ${JSON.stringify(directory)} }) names no ` + `directory. Give it a path — it will be created if it does not exist.`); } (0, retention_js_1.assertRetention)('fileArtifacts', retention); const now = options._now ?? Date.now; const fs = (0, lazyRequire_js_1.lazyRequire)('node:fs'); const path = (0, lazyRequire_js_1.lazyRequire)('node:path'); fs.mkdirSync(directory, { recursive: true }); // One RAW tuple field → one inert directory name. The law (encode the // FIELD not the joined namespace; encode dots too, so a literal '..' is a // NAME) is `scopePath.ts`'s — shared with the object-store adapters rather // than restated here. const scopeDir = (scope) => path.join(directory, (0, scopePath_js_1.scopeSegment)(scope.tenant), (0, scopePath_js_1.scopeSegment)(scope.principal), (0, scopePath_js_1.scopeSegment)(scope.conversationId)); const fileFor = (scope, ref) => path.join(scopeDir(scope), `${ref}.json`); /** Where a STREAMED payload's bytes live: beside the envelope. */ const payloadFileFor = (scope, ref) => path.join(scopeDir(scope), `${ref}.bin`); /** Remove an artifact's files — the envelope and, when it was streamed, * the sibling payload. One remover, so no caller can forget half. */ const removeArtifactFiles = (scope, ref) => { fs.rmSync(fileFor(scope, ref), { force: true }); fs.rmSync(payloadFileFor(scope, ref), { force: true }); }; const readEnvelope = (file) => { let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch { return undefined; // never written (or already swept) — the null case. } let parsed; try { parsed = JSON.parse(raw); } catch { throw new UnreadableArtifactFileError(file, 'it is not JSON.'); } const candidate = parsed; if (candidate === null || typeof candidate !== 'object' || typeof candidate.format !== 'number' || candidate.meta === undefined || candidate.payload === undefined) { throw new UnreadableArtifactFileError(file, 'it is not an artifact envelope.'); } if (candidate.format > FILE_FORMAT) { throw new UnreadableArtifactFileError(file, `it was written with format ${candidate.format} and this runtime reads format ` + `${FILE_FORMAT}; roll agentfootprint forward rather than half-read it.`); } return candidate; }; /** Read a LIVE envelope; sweep the file when the calendar says it is gone. */ const liveEnvelope = (scope, ref) => { if (!(0, naming_js_1.isArtifactRef)(ref)) return undefined; const file = fileFor(scope, ref); const envelope = readEnvelope(file); if (envelope === undefined) return undefined; if (envelope.meta.expiresAt !== undefined && envelope.meta.expiresAt <= now()) { removeArtifactFiles(scope, ref); return undefined; } return envelope; }; /** Every live meta in a scope (expired files swept as they are found). */ const scopeMetas = (scope) => { const dir = scopeDir(scope); let names; try { names = fs.readdirSync(dir); } catch { return []; // scope never wrote anything. } const at = now(); const metas = []; for (const name of names) { if (!name.endsWith('.json')) continue; const ref = name.slice(0, -'.json'.length); if (!(0, naming_js_1.isArtifactRef)(ref)) continue; // not ours; leave foreign files alone. const envelope = readEnvelope(path.join(dir, name)); if (envelope === undefined) continue; if (envelope.meta.expiresAt !== undefined && envelope.meta.expiresAt <= at) { removeArtifactFiles(scope, ref); continue; } metas.push(envelope.meta); } return metas; }; return { async put(scope, input) { const at = now(); const { meta } = await (0, minting_js_1.prepareArtifact)(input, (parent) => liveEnvelope(scope, parent) !== undefined, retention, at); const rows = scopeMetas(scope).map((held) => ({ ref: held.ref, kind: held.kind, bytes: held.bytes, ...(held.expiresAt !== undefined && { expiresAt: held.expiresAt }), // A directory has no cheap read-recency; oldest-first is the stated law. lastAccessedAt: held.createdAt, })); const plan = (0, retention_js_1.planRetention)(rows, meta.bytes, retention, at); if (plan.refusal !== undefined) throw new types_js_1.InvalidArtifactError(plan.refusal); for (const swept of plan.swept) removeArtifactFiles(scope, swept.ref); const envelope = { format: FILE_FORMAT, meta, payload: (0, payload_js_1.encodeArtifactData)(input.data), }; fs.mkdirSync(scopeDir(scope), { recursive: true }); fs.writeFileSync(fileFor(scope, meta.ref), JSON.stringify(envelope)); return { meta, swept: plan.swept }; }, // eslint-disable-next-line @typescript-eslint/require-await async head(scope, ref) { return liveEnvelope(scope, ref)?.meta ?? null; }, async get(scope, ref) { const envelope = liveEnvelope(scope, ref); if (envelope === undefined) return null; // A STREAMED payload lives beside the envelope: this adapter is the one // that knows where, so it reads the bytes rather than asking the shared // decoder to reach a file it cannot see. const data = envelope.payload.shape === 'external' ? readPayloadFile(scope, envelope.meta.ref) : (0, payload_js_1.decodeArtifactData)(envelope.payload); if (data === undefined) return null; // the sibling bytes are gone if (envelope.meta.digest !== undefined) { const actual = await (0, payload_js_1.computeArtifactDigest)(data); if (actual !== envelope.meta.digest) { throw new types_js_1.ArtifactIntegrityError(envelope.meta.ref, envelope.meta.digest, actual); } } return { meta: envelope.meta, data }; }, // eslint-disable-next-line @typescript-eslint/require-await async delete(scope, ref) { if (!(0, naming_js_1.isArtifactRef)(ref)) return; removeArtifactFiles(scope, ref); }, // eslint-disable-next-line @typescript-eslint/require-await async list(scope, listOptions) { const all = scopeMetas(scope).sort((a, b) => b.createdAt - a.createdAt || (a.ref < b.ref ? -1 : 1)); const offset = decodeCursor(listOptions?.cursor); const limit = Math.max(1, Math.floor(listOptions?.limit ?? 50)); const page = all.slice(offset, offset + limit); const nextOffset = offset + page.length; return { artifacts: page, ...(nextOffset < all.length && { cursor: String(nextOffset) }), }; }, /** * The streamed put: bytes to `<ref>.bin` as they arrive, envelope LAST. * * The declared `bytes` is what retention plans against, and it is * VERIFIED as the stream ends — a producer that said 10 MB and sent 11 is * refused by name and leaves nothing behind, because a ticket that * misdescribes its own payload is the accepted-and-silently-wrong failure * this whole folder exists to prevent. */ async putStream(scope, input, body) { const at = now(); const declared = (0, streaming_js_1.assertStreamBytes)('fileArtifacts', input.bytes); const { meta } = await (0, minting_js_1.prepareArtifact)({ ...input, data: new Uint8Array(0) }, (parent) => liveEnvelope(scope, parent) !== undefined, retention, at); const stated = { ...meta, bytes: declared }; const rows = scopeMetas(scope).map((held) => ({ ref: held.ref, kind: held.kind, bytes: held.bytes, ...(held.expiresAt !== undefined && { expiresAt: held.expiresAt }), lastAccessedAt: held.createdAt, })); const plan = (0, retention_js_1.planRetention)(rows, declared, retention, at); if (plan.refusal !== undefined) throw new types_js_1.InvalidArtifactError(plan.refusal); for (const swept of plan.swept) removeArtifactFiles(scope, swept.ref); fs.mkdirSync(scopeDir(scope), { recursive: true }); const payloadFile = payloadFileFor(scope, stated.ref); const handle = fs.openSync(payloadFile, 'w'); let written = 0; try { const reader = body.getReader(); try { for (;;) { const { done, value } = await reader.read(); if (done) break; if (value === undefined) continue; written += value.byteLength; if (written > declared) { await reader.cancel(); break; } fs.writeSync(handle, value); } } finally { reader.releaseLock(); } } finally { fs.closeSync(handle); } if (written !== declared) { fs.rmSync(payloadFile, { force: true }); throw new types_js_1.InvalidArtifactError(`fileArtifacts: the streamed payload is ${written} bytes but declared ${declared}. ` + `Nothing was stored: a ticket whose \`bytes\` misdescribes its own payload would ` + `be believed by every later consumer. State the exact byte length.`); } // The envelope LAST: a crash before this line leaves an orphan .bin that // no ref names, never a ticket pointing at an unfinished payload. const envelope = { format: FILE_FORMAT, meta: stated, payload: { shape: 'external', value: `${stated.ref}.bin` }, }; fs.writeFileSync(fileFor(scope, stated.ref), JSON.stringify(envelope)); return { meta: stated, swept: plan.swept }; }, /** The streamed read: the payload's canonical bytes, straight off disk * for a streamed artifact, and as one chunk for an inline one (this * store held it whole — it says so rather than pretending to be lazy). */ // eslint-disable-next-line @typescript-eslint/require-await async getStream(scope, ref) { const envelope = liveEnvelope(scope, ref); if (envelope === undefined) return null; if (envelope.payload.shape === 'external') { const file = payloadFileFor(scope, envelope.meta.ref); if (!fs.existsSync(file)) return null; const stream = (0, lazyRequire_js_1.lazyRequire)('node:stream'); return { meta: envelope.meta, body: stream.Readable.toWeb(fs.createReadStream(file)), }; } return { meta: envelope.meta, body: (0, streaming_js_1.bytesAsStream)((0, payload_js_1.canonicalPayloadBytes)((0, payload_js_1.decodeArtifactData)(envelope.payload))), }; }, }; /** The bytes of a streamed payload, or undefined when the sibling file is * gone (an interrupted write, an external deletion) — which is "no data", * the same answer a missing envelope gives. */ function readPayloadFile(scope, ref) { try { return new Uint8Array(fs.readFileSync(payloadFileFor(scope, ref))); } catch { return undefined; } } } exports.fileArtifacts = fileArtifacts; function decodeCursor(cursor) { if (cursor === undefined) return 0; const parsed = Number(cursor); return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0; } //# sourceMappingURL=fileArtifacts.js.map