@sema-agent/core
Version:
Stateless, task-oriented AI agent core
98 lines (97 loc) • 6.42 kB
TypeScript
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;
}