@sema-agent/core
Version:
Stateless, task-oriented AI agent core
125 lines (124 loc) • 9.01 kB
TypeScript
import { type Embedder, type MemoryNoteHeader, type MemoryNoteRecord, type MemoryStore, type MemoryVectorMode, type ScoredMemory, type StructuredNoteInput } from "../../core/memory.js";
export declare class FileMemoryStore implements MemoryStore {
private readonly root;
/** Optional injected embedder (design/81 Slice 5). When present, note bodies are embedded (in the background,
* after the durable note write) into a rebuildable sidecar index and `searchScored` ranks by cosine distance
* (portable / in-process, mirrors PgMemoryStore's jsonb mode). Absent ⇒ lexical, byte-identical to before. */
private readonly embedder?;
/** ms budget for a background embed; a slower/stuck endpoint yields no vector (row stays lexical), never
* blocking the durable note write (which already completed). */
private readonly embedTimeoutMs;
/** In-flight background embeds (so tests can flush + `close` can drain). */
private readonly pending;
/**
* RB-146 (2026-07-25, 按面收口): scope state is shared per PHYSICAL directory, like every other file
* backend. This was the last store on this surface still caching replayed state on the INSTANCE: a second
* `FileMemoryStore` over one directory replayed its own view on first touch and then served it forever,
* so a note durably committed through one instance was invisible to the other — the same shape as RB-73
* (session), RB-55 (agent), RB-59 (run) and RB-134 (checkpoint). Refcounted, and the last holder closes
* the logs; the `closed` latch below already existed for the fd-recycle guard.
*/
private get scopes();
private readonly sharedScopes;
private readonly scopeKey;
/** Set by {@link close}: a background embed that resolves AFTER close must NOT writeSync to the (now closed)
* vlog fd — a stray write on a recycled fd could corrupt an unrelated file. */
private closed;
constructor(root: string, opts?: {
embedder?: Embedder;
embedTimeoutMs?: number;
});
/** design/81 Slice 5 rung: `portable` (in-process cosine over the sidecar) when an embedder is injected,
* else `lexical`. File never reaches `native` (no backend index). */
get vectorMode(): MemoryVectorMode;
/** True when an embedder was injected. Exposed so a consumer/test can confirm it was accepted. */
get hasEmbedder(): boolean;
/** Await all in-flight background embeds (tests / `close`). */
flushEmbeds(): Promise<void>;
/** Open (replay) a scope's notes + sidecar vectors once and cache it. */
private scopeState;
/**
* RB-267 — record that `scope` holds live descriptors, and evict the coldest scopes past
* {@link MAX_OPEN_MEMORY_SCOPES}. Eviction releases the handles ONLY (`closeForSwap`, the RB-167 lazy
* reopen); the replayed `ScopeState` stays in the map, so nothing about the authority — the entry
* list, the cursor, the vector cache — is rebuilt or lost, and a later write to an evicted scope
* reopens its log transparently.
*/
private touchOpenLogs;
/** RB-267 — the notes log for a WRITE. Every durable append goes through here so the LRU sees the
* access: a write is also the only thing that can (re)open a descriptor (`AppendLog.append` reopens
* a swap-closed log lazily), so touching here is what keeps "has an open handle ⇒ tracked" true.
* Reads never open anything and deliberately do not touch. */
private writeLog;
/** RB-267 — the sidecar vector log, opened on FIRST WRITE. Only {@link queueEmbed} writes vectors, so
* a store with no embedder never opens this file at all (it used to take a descriptor per scope for
* a file it would never touch). Replay reads the path directly and needs no handle. */
private vectorLog;
/** Queue a background embed of `body` → sidecar vector for `id` (best-effort, timeout-bounded, non-blocking).
* Only when an embedder is injected; a failure/timeout leaves the row lexical (fail-open). The durable note
* write has ALREADY happened before this is called, so this never gates durability. */
private queueEmbed;
/** Rewrite the derived `MEMORY.md` projection atomically (CC markdown parity), STAGED IN THE TARGET'S
* OWN DIRECTORY: `rename(2)` is defined only within one filesystem, so staging beside the target makes
* the two share a mount by construction where a fixed scratch dir elsewhere made it deployment luck (a
* memory tree on its own device took `EXDEV` on every note). EVERY caller is POST-DURABLE, so a failure
* here cannot mean the note was lost: it is {@link MemoryProjectionError}, that phase's one name. */
private writeProjection;
read(scope: string): string | null;
append(scope: string, note: string): string | void;
appendStructured(scope: string, note: StructuredNoteInput): string;
/** Append + fsync the note event BEFORE updating the in-memory list, then refresh the projection. */
private commit;
clear(scope: string): void;
/** design/84 Seam B: read the cached periodic-consolidation cursor (replayed from the last `op:cursor`). */
getConsolidationCursor(scope: string): string | undefined;
/** design/84 Seam B: persist the periodic-consolidation cursor via a durable `op:cursor` append-log event,
* then update the cached state (durable FIRST, like `commit`). */
setConsolidationCursor(scope: string, cursor: string): void;
/**
* Keyword search backing the `recall` tool. design/81 Slice 2b — uses the SHARED synonym/stem-aware
* {@link import("../../core/memory.js").lexicalSearchMatch} (the SAME matcher as
* {@link import("../../core/memory.js").InMemoryMemoryStore.search}, ZERO model/native/embedder dep) so
* "auth token" matches a note saying "credentials" / "bearer", and the `recall` tool returns IDENTICAL
* results across the File and InMemory backends. A bullet matches a query term iff it contains ANY of that
* term's expansions, and must match EVERY query term (AND across terms, OR within a term's synonyms). With
* no synonym/stem hit this is byte-identical to the plain substring filter.
*/
search(scope: string, query: string, limit?: number): string[];
/** Manifest (design/65 §2.2): a header per note, body excluded — in-memory parity. */
listStructuredNotes(scope: string): MemoryNoteHeader[];
getByIds(scope: string, ids: string[]): MemoryNoteRecord[];
/**
* Id-addressable similarity retrieval (design/81 Slice 2 — the consolidation candidate source). The
* `score` is a cosine **DISTANCE** ∈ [0,2], **0 = identical** ({@link ScoredMemory} contract): it is the
* lexical `1 - Jaccard(terms)` ∈ [0,1] ⊂ [0,2] stand-in (byte-identical to
* {@link InMemoryMemoryStore.searchScored} / the lexical {@link import("../pg.js").PgMemoryStore} fallback),
* so the `{0.05, 0.3}` consolidation band needs no retune. An injected embedder is ACCEPTED but vector
* ranking is the DEFERRED Slice 5 — so this method ALWAYS uses the lexical distance for now (an injected
* embedder is harmless, NOT a throw). Excludes the query's own id (a note must not self-match — see below)
* and zero-overlap entries; returns the `limit` nearest, ascending by distance.
*
* **Self-id exclusion:** byte-identical to {@link InMemoryMemoryStore.searchScored} / the lexical
* {@link import("../pg.js").PgMemoryStore} fallback, the SOLE consumer (consolidation) excludes the
* query note's own id (and its sibling this-task ids) by filtering the returned `ScoredMemory[]` on its
* `thisTaskIds` set — keeping the store's ranking pure and the cross-backend contract identical.
*/
searchScored(scope: string, query: string, limit?: number): ScoredMemory[] | Promise<ScoredMemory[]>;
/**
* Consolidation UPDATE (design/81 Slice 2): replace a note's body by id, keeping the id stable. Appends a
* durable `update` event FIRST, then mutates the in-memory replay + refreshes the projection — same
* durable-before-visible order as {@link commit}. An auto-derived description is re-derived from the new
* body (so the manifest doesn't drift); an explicit one is preserved (design/65 [79], in-memory parity).
* Unknown id → no-op (defensive). NB: no `sanitizeUntrustedText` here — the fence lives at the
* inlets/recall, NOT in `update` (design/81 §1; in-memory `update` only trims).
*/
update(scope: string, id: string, text: string): void;
/**
* Consolidation DELETE (design/81 Slice 2): remove a note by id (id never reused afterward). Appends a
* durable `delete` tombstone FIRST, then drops it from the in-memory replay + refreshes the projection.
* Unknown id → no-op.
*/
delete(scope: string, id: string): void;
/** Release all open scope handles (best-effort). */
close(): void;
}