UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

98 lines (97 loc) • 6.42 kB
import { type StoredStrategy, type StrategyStore, type StrategyStoreIncident } from "../../core/strategy-store.js"; /** * File-backed {@link StrategyStore} — the persistence twin of `InMemoryStrategyStore`, so the teacher * mode's cross-session reuse premise actually holds on a local (TOC) deployment where every session is * a fresh process (an in-memory strategy repository there re-learns everything, every time). * * DESIGN STANCE — this is a **cache, not an authority store**: every entry is regenerable (worst case: * ask the teacher again), so losing one never loses correctness. It therefore deliberately skips the * journal/shadow armor of the memory file backend and does NOT fsync-harden beyond the shared * atomic-write primitive, does NOT take a cross-process lock, and accepts a narrow multi-process race * on save-side dedup (two processes can each write one copy of the same normalized entry — the read * side dedups, and a lost max-confidence merge is cache-grade). What it does NOT relax: scope * isolation and loud-refusal discipline, which are held to the same standard as the durable stores. * * Layout: `<root>/<scopeDir>/<id>.json`, one strategy per file, whole-file atomic replace on write. * `scopeDir = slug(scope) + "-" + sha256(scope).slice(0,24)` — the slug is a strict `[a-z0-9-]` * whitelist purely for readability; IDENTITY lives in the 96-bit hash, so no scope value, however * adversarial, can traverse out of the root or collide another scope's directory. Ids are gated to * `[A-Za-z0-9_-]{1,64}` at save (the escalation loop mints UUIDs; the interface is public, so a * hand-rolled id must not be able to name a path). * * `scope` is a NAMESPACE, not an authorization boundary — the store cannot authenticate its caller; * whoever holds the instance can address any scope (same posture as the memory store). Authorization * is the host's obligation. */ export interface FileStrategyStoreOptions { /** Directory to keep strategies under (created `0o700` if absent; a non-directory or unwritable * path is refused loudly at construction — a store that cannot persist must not pretend to). */ root: string; /** Per-scope capacity cap (entries), evicting the lowest-scoring on overflow. Default 100. */ maxPerScope?: number; /** * Disclosure sink for contained store incidents (a corrupt entry quarantined, an eviction that * failed after a successful save, a degraded read). Absent ⇒ `console.warn`, once per op kind — * a contained fault must be loud somewhere, but must never flood. */ onIncident?: (i: StrategyStoreIncident) => void; } export declare class FileStrategyStore implements StrategyStore { private readonly root; private readonly maxPerScope; private readonly onIncident?; /** Ops that already warned on the absent-sink fallback (bounded disclosure — never flood). */ private readonly warnedOps; /** Isolation scope for the host incident sink (sync throw AND async rejection contained). */ private readonly sinkNotifier; /** Scope dirs whose stale temps this instance already swept (once per scope per instance). */ private readonly sweptTemps; constructor(opts: FileStrategyStoreOptions); /** `slug(scope)-sha256(scope)[0..24]` — slug is readability only; the hash is the injective key. */ private scopeDirName; private scopeDirPath; /** The scope dir must be a REAL directory — a symlink here means the store's namespace was tampered * with (a link could point retrieval at another scope's data, or writes out of the root). The * check-then-use window against a same-uid untrusted local process is out of the threat model: such a * process already holds the same rights as the store itself. */ private assertScopeDirSafe; private incident; /** Sweep crashed writers' stale temps (once per scope per instance). Fresh temps are left alone — * they may belong to a live concurrent writer. */ private sweepStaleTemps; /** Load every VALID entry of a scope dir. Corrupt files are quarantined (`.bad` rename) with one * disclosure; entries that violate the read-side invariants (foreign scope / id≠filename) are * skipped with disclosure but NOT quarantined (a copied-in file may be someone's valid data); * normalized duplicates collapse to the best-scoring copy. */ private loadScope; /** Rename a corrupt file to `<name>.bad` — one disclosure now, zero parse cost on every later read * (the suffix no longer matches the entry-file grammar). Never faults the calling operation. */ private quarantine; /** Serialize + write one entry, refusing a record whose SERIALIZED form exceeds the read bound. * The field caps bound RAW string bytes, but JSON escaping expands control characters up to 6x — * without this door a save could succeed and then self-quarantine on the very next read (the * worst possible shape: an accepted write the store itself later refuses to serve). */ private writeEntry; save(s: StoredStrategy): void; private evict; find(scope: string, query: string, limit: number): StoredStrategy[]; /** Bounded candidate view for the RETRIEVAL-adjacent paths (find/hasStrategy/save): an externally * inflated directory must not make a hot operation unbounded. MAINTENANCE passes Infinity — see * {@link prune}. */ private retrievalReadCap; /** * Trims to `maxSize` over an UNCAPPED enumeration: prune is the reconciliation verb, so it must see * every entry file — under the bounded retrieval view, a capacity SHRINK across restarts (or * `maxPerScope: 0`) left files beyond the window untouched, reporting success while the "removed" * strategies sat on disk ready to resurrect under a later, larger capacity. Host-invoked * maintenance accepts the O(all files) cost the retrieval path refuses. */ prune(scope: string, maxSize: number): void; /** Uncapped for the same reason as {@link prune}: this face feeds the seed capacity door, and an * under-count there turns "refuse what cannot fit" into silent eviction of fresh seeds. */ scopeUsage(scope: string): { used: number; capacity: number; }; hasStrategy(scope: string, entry: Pick<StoredStrategy, "problem" | "strategy">): boolean; }