/** * TheBrainV2 — Zero-dependency semantic memory engine for Lemma. * * Replaces: Ollama (embeddings) + ChromaDB (vector store) * Algorithm: BM25 (Okapi) + Inverted Index + Bloom Filter + Jaccard re-rank * Storage: ~/.lemma-cache/brain/ (NDJSON, no external DB) * Latency: <10ms per query (vs ~200ms with Ollama) * * Compatible with: Claude CLI, Codex, OpenCode, Cursor, Windsurf, Kiro, * Cline, VS Code Copilot — any MCP client. */ import type { Evidence, EvidenceResolver } from '../contracts/evidence.js'; export interface BrainEntry { id: string; query: string; response: string; /** * Derived view of `termFreq` keys. Kept in memory for fast iteration but NOT persisted: * it was byte-for-byte identical to Object.keys(termFreq) and cost 38% of the entry file. * `load()` rebuilds it, so entries written by older versions still work. */ terms: string[]; termFreq: Record; provider: string; timestamp: string; hits: number; charCount: number; /** * Which project this memory came from. Absent on entries written before scoping existed; * those stay searchable from everywhere, because silently hiding a user's existing * memories would be a worse failure than showing one from a neighbouring project. */ projectId?: string; fileHashes?: Record; /** * Whether this memory is a working solution or a documented dead end. Absent means * 'confirmed' for backward compatibility with every entry written before this existed. * Without a 'failed' tag, a prior failed attempt is indistinguishable from a working * fix — the Brain can only ever suggest reuse, never warn "we already tried this." */ outcome?: 'confirmed' | 'failed'; /** * Free-text domain tag (e.g. "auth", "billing") lifted from cognitive_map's current * context at store time. Absent means untagged — those entries stay visible to every * domain-scoped search rather than disappearing, same rationale as projectId above. */ domain?: string; /** * Absolute-path::symbolName -> sha256 of just that symbol's extracted source (via * SymbolSurgicalContext), not the whole file. When a file has an entry here, freshness * checks that file by this instead of fileHashes' whole-file hash — an edit elsewhere in * the same file (a different function, an import, a comment) no longer invalidates a * memory that was only ever about one specific symbol. Absent means whole-file hashing * still applies, so every entry written before this existed keeps working unchanged. */ symbolHashes?: Record; /** * Fase D: sha256 of the same symbols as symbolHashes, but of their normalized * (comment/whitespace-stripped) source — see normalizeForFreshness. Always computed * alongside symbolHashes when a symbol is stored, regardless of whether semanticDiff is * ever used. Lets checkEntryFreshness tell "only a comment/formatting changed" apart from * a real change, but only when the caller opts in via semanticDiff — absent (entries * written before this existed) means that classification isn't possible and a raw-hash * mismatch falls back to stale, same as pre-Fase-D behavior. */ symbolNormalizedHashes?: Record; /** * The tracked paths in clear — the same absolute paths fileHashes/symbolHashes are * keyed by, persisted next to the hashes (AN-03). Before this existed the disk kept * only hashes, so a memory could answer "does this hash still match?" but never say * which file it meant; `filePaths` in the store_memory protocol never reached disk. * Derived from the hash keys, never the reverse: hashes stay the source of truth for * freshness, these are the navigable label. Backfilled on load for older entries. */ filePaths?: string[]; /** Same as filePaths, for symbols: "absPath::symbolName" keys of symbolHashes. */ symbols?: string[]; /** * Sub-claims within `response`, each with its own evidence. Absent means the whole * entry is one claim, judged by fileHashes/symbolHashes above (pre-existing behavior). * When present, a claim whose own evidence goes stale doesn't drag down claims that * still hold — search_memory can tell the caller which parts of the response are * still trustworthy instead of discarding (or blindly trusting) the whole thing. */ claims?: Claim[]; /** * Count of explicit "this memory led me astray" signals via downvote(). Distinct from * hits: hits means "this surfaced and looked relevant enough to reuse," demerits means * "reuse turned out to be wrong." Absent/0 means never downvoted. */ demerits?: number; /** * Secrets/credentials redacted from this entry at store time (scrub-on-store). Absent * means the text arrived clean. Kept so audits can prove PII never persisted, and so a * `[NAME_n]` placeholder in a response is explainable rather than mysterious. */ redactions?: number; /** * Cumulative tokens this memory's reuse has saved, booked by search_memory on every * fresh hit (response length/4, floor 100 — the same heuristic as the savings ledger). * Absent means never reused. This is the per-memory ROI ledger: it survives restarts * (persisted), merges (max, like hits), and imports, so brain_stats can name which * memories actually pay for the Brain. */ tokensSaved?: number; /** * Ids of other entries/claims this memory's conclusion was built on top of (the agent * read memory A, then stored B as a conclusion derived from it). Absent means B's * freshness is judged only by its own fileHashes/symbolHashes, same as before this * existed. When present, checkEntryFreshness walks it transitively (bounded by * MAX_DERIVED_DEPTH): if A goes stale, B is stale too even though B's own evidence * never changed — because B's conclusion was never independently verified against * current state, only inherited. */ derivedFrom?: string[]; /** * Ids folded into this entry by mergeEntries(). Informational only — deliberately NEVER * consulted by freshness: a folded id is tombstoned, and walking it would poison the * keeper the merge just created. Tells the reader "this canonical memory absorbed * these fragments" without inheriting their staleness. */ mergedFrom?: string[]; /** * Normalized record of what wrote this memory, as opposed to `provider` — a free-text * model/provider label that several auto-capture paths have been overloading to mean * "who stored this" ('test_oracle_auto', 'checkpoint', 'git-commit', 'pr-review'). Absent * on entries written before this existed; `entrySource()` back-derives one from `provider` * for those, so stats never report a corpus as entirely unknown just because it predates * the field. Exists so a misbehaving auto-capture path can be identified as a group. */ source?: BrainSource; /** * Git HEAD and branch at store time, when the memory was stored inside a git repo. * Absent otherwise (and on every entry written before this existed). Freshness never * consults these — a hash mismatch is still a hash mismatch — but a *stale* result uses * them to say whether the tracked code actually changed or whether the caller is simply * standing on a different branch than the memory was recorded on. Those two look * identical to a content hash and are completely different situations for the reader. */ gitCommit?: string; gitBranch?: string; /** * Set by refresh(): when this memory's evidence was last re-anchored to current code. * Absent means never refreshed. Ranking treats this as the entry's effective age — a * re-verified memory is young again — while `timestamp` keeps the original creation time * so provenance is never rewritten. */ refreshedAt?: string; /** * Id of the entry that corrects/replaces this one's conclusion. Set via update() with * `supersededBy`. Distinct from downvote (demotes but keeps rankable) and forget (removes * entirely, no successor recorded): a superseded entry is wrong-but-explained — excluded * from search() candidates entirely (AN-22: two contradictory entries must not both surface * as live, and prose alone — "see the newer one" in a response body — never evicted the old * one because nothing read it), while get_memory on its id still resolves and points at the * successor instead of quietly 404ing. */ supersededBy?: string; } /** * Who wrote a memory. 'manual' is an explicit store_memory call; every 'auto-*' value is a * capture path that fires without the agent asking; 'import' came from another machine via * importBundle (so its tracked paths may not exist here at all). */ export type BrainSource = 'manual' | 'auto-test' | 'auto-checkpoint' | 'auto-git' | 'auto-pr-review' | 'import'; /** * Optional store() fields that arrived after its positional signature was already eleven * parameters long. Everything here is additive: omitting the object entirely reproduces * pre-existing behavior byte for byte. */ export interface BrainStoreOptions { /** See BrainEntry.source. Defaults to 'manual'. */ source?: BrainSource; /** * Preserve an origin machine's git context instead of stamping this checkout's. Only * importBundle passes these — a local store always records where it actually happened. */ gitCommit?: string; gitBranch?: string; /** Skip recording git context entirely (importBundle for entries that never had any). */ skipGitContext?: boolean; /** Preserve the original creation time (importBundle). Defaults to now. */ timestamp?: string; /** * Pre-computed evidence bundle (cloud path). When present, the entry's fileHashes / * symbolHashes / symbolNormalizedHashes come straight from here instead of hashing the * local filesystem — a server with no client filesystem stores exactly what the client * measured. When absent, evidence is computed locally as before (LocalFsResolver). */ evidence?: Evidence; } export interface Claim { id: string; text: string; fileHashes?: Record; symbolHashes?: Record; symbolNormalizedHashes?: Record; /** Clear-path counterparts of fileHashes/symbolHashes keys — see BrainEntry.filePaths. */ filePaths?: string[]; symbols?: string[]; } export interface ClaimInput { text: string; filePaths?: string[]; symbols?: SymbolRef[]; } export interface ClaimFreshness { id: string; text: string; fresh: boolean; staleFiles?: string[]; /** Fase D, only set under semanticDiff: symbols whose raw hash changed but only cosmetically. */ cosmeticChanges?: string[]; /** Fail-closed: tracked paths/symbols the resolver could not verify. Never `fresh` when present. */ unverifiedFiles?: string[]; } /** * Result of a single id passed to `TheBrainV2.verifyByIds`. `id` may be an entry id or a * claim id — the caller doesn't need to know which, since both are opaque ids handed back * by a prior search_memory/store_memory call. "unknown" (never "fresh") is what a caller * gets for a purged entry, a renamed symbol's old claim, or an id from another project. */ export interface VerifyMemoryResult { /** * 'fresh' means every tracked artifact still hashes the same. 'untracked' means the memory * tracks nothing at all, so there was nothing to check — never folded into 'fresh', because * a caller reading 'fresh' is being told the memory was validated against current code. */ status: 'fresh' | 'stale' | 'unverified' | 'unknown' | 'untracked'; id: string; /** Files/symbols/upstream memories this id is judged against. 0 on 'untracked'. */ trackedArtifacts?: number; staleFiles?: string[]; /** Only set when `id` resolved to an entry that itself has per-claim tracking. */ claimBreakdown?: ClaimFreshness[]; /** Fase D, only set under semanticDiff: symbols whose raw hash changed but only cosmetically. */ cosmeticChanges?: string[]; /** Fail-closed: tracked paths/symbols the resolver could not verify. Only set on status 'unverified'. */ unverifiedFiles?: string[]; /** See BrainSearchResult.staleCause. Only set on status 'stale'. */ staleCause?: StaleCause; /** Only set alongside staleCause 'branch-changed'. */ storedOnBranch?: string; } export interface SymbolRef { /** Relative or absolute; resolved against process.cwd() the same way filePaths is. */ filePath: string; symbolName: string; } export interface BrainSearchResult { id: string; query: string; response: string; similarity: number; provider: string; timestamp: string; fresh: boolean; /** * True when the entry tracks no files, symbols, upstream memories or claims — so * `fresh` is vacuously true (there was nothing to check), NOT a validation against * current code. Presentational layers must not present such results as verified * cache hits, and the savings/credit ledgers must not count them as reuse. */ untracked?: boolean; staleFiles?: string[]; /** * Only set when !fresh: tracked paths/symbols the resolver could not verify (e.g. a * SuppliedEvidenceResolver that the client left uncovered). Fail-closed — never fresh. */ unverifiedFiles?: string[]; outcome?: 'confirmed' | 'failed'; claims?: ClaimFreshness[]; domain?: string; /** Lifetime tokens this memory's reuse has saved. Absent means never reused. */ tokensSaved?: number; /** Times this memory was the top hit of a counted search. Absent means never. */ hits?: number; /** Which ranking signals fired for this result — popularity, penalties, re-verification. */ whyShown?: string[]; /** Absolute tracked paths / path::symbol keys the freshness verdict was judged against. */ evidence?: string[]; /** Fase D, only set under semanticDiff: symbols whose raw hash changed but only cosmetically. */ cosmeticChanges?: string[]; /** * Only set when !fresh. Distinguishes "the code this was recorded against really changed" * from "you're on a different branch than the memory was recorded on" — the second is * usually not a reason to distrust the memory, but a raw hash mismatch reports both * identically. See attributeStaleness(). */ staleCause?: StaleCause; /** Only set alongside staleCause 'branch-changed': the branch this memory was stored on. */ storedOnBranch?: string; } /** * Why a stale result is stale. 'content-changed' is the ordinary case: the tracked code * differs on the branch it was recorded on. 'branch-changed' means the caller has simply * moved to a different branch since — the memory may well still be accurate on its own * branch, and switching back would make it fresh again with nothing else changing. */ export type StaleCause = 'content-changed' | 'branch-changed'; export interface BrainSearchOptions { /** * Set false for internal lookups (duplicate detection) so they don't register as cache * hits. The dedup path searches with minSimilarity 0, so it "hit" on every store and * inflated both the session hit rate and an arbitrary entry's hit counter. */ countStats?: boolean; /** Prefer memories from this project. See the scoping note in search(). */ projectId?: string; /** Prefer memories tagged with this domain. Same soft-fallback rule as projectId. */ domain?: string; /** * Fase D, opt-in and off by default: when a tracked symbol's raw hash changed, check * whether its normalized (comment/whitespace-stripped) hash also changed before marking * stale. Only a real change still marks stale; a comment/formatting-only edit doesn't. * Requires the entry to have been stored with symbolNormalizedHashes — an older entry * without it still falls back to raw-hash-only (stale), same as with the flag off. */ semanticDiff?: boolean; /** * Evidence seam override. Defaults to LocalFsResolver (local MCP, unchanged behaviour). * Pass a SuppliedEvidenceResolver to verify against client-supplied evidence — a tracked * path the client omitted resolves to null and the result is `unverified`, never fresh. */ resolver?: EvidenceResolver; } export interface BrainStats { totalEntries: number; totalTerms: number; avgDocLength: number; bloomFilterSize: number; cacheHits: number; cacheMisses: number; /** Entries no search has ever returned as a top hit. Dead weight, evicted first. */ neverHit?: number; /** Entries with at least one downvote_memory against them. */ downvoted?: number; /** Entry count per `source` (see entrySource(): legacy entries are back-derived). */ bySource?: Record; /** Distinct projectIds represented, plus how many entries predate project scoping. */ projects?: number; unscopedEntries?: number; /** ISO timestamp of the oldest entry still in the corpus. */ oldestEntry?: string; /** Bytes the persisted corpus occupies on disk. */ corpusBytes?: number; /** Ids deleted via forget()/eviction and still suppressed on merge. */ tombstones?: number; /** * Only under { deep: true }: entries whose tracked evidence no longer matches. This one * re-hashes every tracked file/symbol in the corpus, so it is deliberately opt-in — on a * full 8,000-entry Brain it is thousands of file reads. */ staleEntries?: number; /** Only under { deep: true }: staleEntries as a fraction of entries that track anything. */ staleRatio?: number; /** * Entries tracking no file, symbol, claim evidence or upstream memory at all — the ones * `verify_memory` reports as `untracked` because there is nothing there to check. Cheap * (no hashing), so it is always computed, unlike staleEntries. */ untrackedEntries?: number; /** * Entries whose tracked evidence also carries the clear-path labels (filePaths / * symbols in clear, entry-level or on any claim). AN-03: entries written before * clear-path persistence backfill on load, so over time this converges to * totalEntries - untrackedEntries minus derivedFrom-only entries (which track an * upstream memory, not files, and have no paths to label). A gap beyond that means * an evidence write path that bypasses syncClearEvidencePaths. */ withClearPaths?: number; /** Sum of per-memory lifetime savings across the corpus. Always computed (in-memory). */ totalTokensSaved?: number; /** * Top 5 memories by lifetime savings — the entries that pay for the Brain. Queries * truncated to 80 chars to keep stats output bounded. */ topSavers?: Array<{ id: string; query: string; tokensSaved: number; hits: number; }>; /** * Integrity + rollback posture. Present on every call (cheap: no hashing, one dir * listing) so a silently-degraded Brain never reports itself as healthy. */ integrity?: { ok: boolean; lastIssue: IntegrityIssue | null; snapshots: number; backups: boolean; encrypted: boolean; }; } /** Read-only upkeep pass over the corpus — see upkeep(). Every finding carries its fix. */ export interface BrainUpkeepReport { checkedAt: string; totalEntries: number; revalidated: { checked: number; fresh: string[]; stale: Array<{ id: string; query: string; staleFiles: string[]; action: string; }>; untracked: Array<{ id: string; query: string; action: string; anchorCandidates?: string[]; }>; unverified: Array<{ id: string; query: string; action: string; }>; }; duplicatePairs: Array<{ ids: [string, string]; similarity: number; queries: [string, string]; action: string; }>; mergeCandidates: Array<{ ids: [string, string]; similarity: number; queries: [string, string]; action: string; }>; deadWeight: { neverHit: number; neverHitSample: string[]; downvoted: Array<{ id: string; demerits: number; }>; action: string; }; savings: { totalTokensSaved: number; topSavers: Array<{ id: string; query: string; tokensSaved: number; hits: number; }>; }; deep?: { staleEntries: number; staleRatio: number; action: string; }; } /** Test seam: drop the cached key so a test can rotate env/key-file mid-process. */ export declare function resetBrainKeyCache(): void; /** What the checked loader reports when the corpus file on disk isn't trustworthy. */ export interface IntegrityIssue { at: string; file: string; action: 'fallback-to-backup' | 'fresh-start' | 'sync-skipped' | 'partial-corpus'; detail: string; } /** Names of rollback snapshots on disk, newest last. */ export declare function listSnapshotNames(): string[]; /** * Union two views of the corpus. Never subtracts: an id present in either side survives * unless it is tombstoned, because "absent from my copy" and "deleted" are indistinguishable * from inside one process, and guessing wrong loses a user's memory permanently. * * For an id on both sides, the newer version (by refreshedAt/timestamp) supplies the content * and the counters take the max of both — `hits` and `demerits` are monotonic tallies that * each process accumulated independently, so max is the only merge that doesn't discard * feedback one of them collected. */ export declare function mergeEntryMaps(mine: Map, theirs: Map, tombstones?: Set): Map; export declare function currentGitContext(cwd?: string): { commit?: string; branch?: string; }; /** Test seam: forget the cached git context so a test can change branches mid-run. */ export declare function resetGitContextCache(): void; /** * Why a stale entry is stale. Pure attribution over data already gathered — it never * changes whether something is stale, only how the staleness is explained. */ export declare function attributeStaleness(entry: BrainEntry, git: { commit?: string; branch?: string; }): { staleCause: StaleCause; storedOnBranch?: string; }; /** * Normalized provenance for an entry, back-deriving one for entries written before `source` * existed. Those overloaded the free-text `provider` field with the same information, so * reading it here keeps historical stats meaningful instead of a wall of "unknown". */ export declare function entrySource(entry: BrainEntry): BrainSource | 'unknown'; /** * Stable identity for the project a memory belongs to. * * The git remote is preferred over the path so the same repo cloned twice, or checked out * at a different path on another machine, still shares memories. Falls back to the repo * root path, then to the cwd. */ export declare function deriveProjectId(cwd?: string): string; /** * Tokenize text into stemmed, filtered terms. * Handles: camelCase splitting, snake_case, stop-word removal, n-grams. */ export declare function tokenize(text: string): string[]; /** * Compute term frequencies for a token list. */ export declare function termFrequencies(terms: string[]): Record; export declare class BloomFilter { private bits; constructor(serialized?: string); add(item: string): void; has(item: string): boolean; serialize(): string; } export declare function bm25Score(queryTerms: string[], docTermFreq: Record, docLength: number, avgDocLength: number, idf: Record): number; /** * Jaccard similarity between two term sets. */ export declare function jaccardSimilarity(setA: Set, setB: Set): number; /** * Real repository files named in a memory's body — the raw material for turning an * untracked entry into a verifiable one. Only paths that actually exist under the project * root count: an anchor must stay portable, so references that resolve outside the repo * (or into node_modules / generated output) are ignored, and `file:line` suffixes are * stripped (`src/foo.ts:123` anchors to `src/foo.ts`). False positives self-filter by * existence — a stray "auth.ts" in prose that no such file backs just never resolves. */ export declare function extractRepoPathsFromText(text: string, opts?: { root?: string; max?: number; }): string[]; /** A `derivedFrom` id resolves to either a full entry or one of its claims. */ export type FreshnessNode = { kind: 'entry'; entry: BrainEntry; } | { kind: 'claim'; claim: Claim; }; /** * Looks up a `derivedFrom` id against both entries and claims in one pass. `claimIndex` * is built once per outer call (search/verifyByIds) and threaded through recursion, * never rebuilt per level — rebuilding per level would turn a bounded-depth walk into * O(depth * entries) work for no reason. */ export declare function resolveFreshnessNode(entries: Map, claimIndex: Map, id: string): FreshnessNode | undefined; /** Builds an id -> Claim index across every entry's claims, for resolveFreshnessNode. */ export declare function buildClaimIndex(entries: Map): Map; /** * Bound on how many `derivedFrom` hops checkEntryFreshness will walk. Procedence chains * are meant to be short (a conclusion built on a conclusion, maybe twice); an unbounded * walk would turn every search() result into a full graph traversal. */ export declare const MAX_DERIVED_DEPTH = 3; export interface BlastRadiusHit { id: string; kind: 'entry' | 'claim'; /** entry.query, or the claim's own text when kind === 'claim'. */ query: string; outcome?: 'confirmed' | 'failed'; /** 'direct' = tracks the edited symbol/file itself; 'derived' = depends (via derivedFrom) on something that does. */ via: 'direct' | 'derived'; } export interface BlastRadiusResult { hits: BlastRadiusHit[]; /** Subset of hits with outcome === 'failed' — the escalated case worth a stronger warning. */ failedHits: BlastRadiusHit[]; } /** * Everything in the Brain that would go stale (directly or transitively via derivedFrom) if * the given symbol/file changes right now — computed BEFORE the write happens, so a write * tool can warn instead of only ever reporting staleness the next time someone searches. * Purely advisory: never blocks, never mutates anything, matches the fail-open/additive * invariant every prior phase of proof-carrying context has kept. */ export declare function findBlastRadius(entries: Map, filePath: string, symbolName?: string): BlastRadiusResult; /** Outcome of a freshness check. `unverifiedFiles.length > 0` ⇒ fail-closed: never `fresh`. */ export interface FreshnessOutcome { fresh: boolean; staleFiles: string[]; /** Tracked paths/symbols the resolver could not verify (returned null) — mapped to `unverified`, never `fresh`. */ unverifiedFiles: string[]; cosmeticChanges?: string[]; } /** * True only if every file/symbol this entry was recorded against still hashes the same, * AND (when `derivedFrom` is present and a resolver was passed) every memory this one was * built on top of is itself still fresh, walked up to MAX_DERIVED_DEPTH hops. A file that * also has a tracked symbol is judged by the symbol's hash, not the whole file's — an edit * elsewhere in the same file (a different function, an import, a comment) must not * invalidate a memory that was only ever about one specific symbol. * * `resolver` is the evidence seam: LocalFsResolver (local MCP, returns 'MISSING' on * unreadable files — behaviour unchanged) or SuppliedEvidenceResolver (cloud, returns * `null` for a tracked path the client omitted). A `null` from the resolver is the * fail-closed signal: the outcome carries that path in `unverifiedFiles` and is never * `fresh`. */ export declare function checkEntryFreshness(entry: BrainEntry, resolver: EvidenceResolver, resolveNode?: (id: string) => FreshnessNode | undefined, depth?: number, visited?: Set, semanticDiff?: boolean): FreshnessOutcome; /** * How many artifacts an entry's freshness is actually judged against: tracked files, tracked * symbols, upstream memories, and each claim's own evidence. * * Zero means a freshness check has nothing to compare — and reporting that as `fresh` conflates * "validated against the code" with "there was nothing to validate", which are opposite * guarantees for a caller about to act on the memory. `verify_memory` reports those as * `untracked` instead; see VerifyMemoryResult. */ export declare function trackedArtifactCount(entry: BrainEntry): number; /** Claim-level counterpart to trackedArtifactCount. */ export declare function trackedClaimArtifactCount(claim: Claim): number; /** * Same hash-compare as checkEntryFreshness, scoped to one claim's own evidence — a claim * with no fileHashes/symbolHashes at all is always fresh (nothing tracked to go stale). * `resolver` is the same evidence seam as checkEntryFreshness; a `null` from it fails * closed to `unverified`, never `fresh`. */ export declare function checkClaimFreshness(claim: Claim, resolver: EvidenceResolver, semanticDiff?: boolean): ClaimFreshness; export declare class TheBrainV2 { private entries; private invertedIndex; private bloom; private avgDocLength; private sessionHits; private sessionMisses; private dirty; private flushTimer; /** State of entries.ndjson as of our last read/write — see syncIfChanged(). */ private diskStamp; /** id -> ISO deletion time, for ids that must not come back through a merge. */ private tombstones; /** * Last integrity incident on the corpus files (corrupt main file, undecryptable * envelope, skipped sync). Set by load()/save()/syncIfChanged(), never cleared * except by a clean verified read — health() and brain_stats surface it so a silent * fallback never looks like a healthy Brain. */ private integrityIssue; /** * id -> terms of that entry's own `query` field. Derived state, never persisted: `terms` * covers query+response together, so scoring the query field on its own (see the * QUERY_FIELD_BOOST block in search()) needs it split back out. Memoized because a search * touches every candidate and re-tokenizing a prompt per candidate per search is pure waste. * Dropped whenever the entry changes or leaves the corpus — a stale entry here would boost * a memory for words its prompt no longer contains. */ private queryTermCache; constructor(); private ensureDir; private load; /** Terms of an entry's own `query`, memoized. See queryTermCache. */ private queryTermsOf; /** Recomputes the inverted index from scratch over the current entries. */ private rebuildIndex; /** * Pull in anything another process wrote since we last touched the corpus. * * Called at the top of every read path. Two statSync calls when nothing changed (the * overwhelmingly common case) is far below the cost of the search that follows, and it * turns sibling sessions from a data-loss hazard into a live shared corpus: a memory * stored in one project's session is searchable from another within one tool call. * * Merges rather than reloads, so memories stored locally but not yet flushed survive. */ private syncIfChanged; private recalcAvgDocLength; private scheduleSave; /** * Synchronously persist pending writes, if any. The debounced scheduleSave() above * is correct for a long-lived server but loses data when the process is about to * die: one-shot clients (lemma-call, the Muse skill path) kill the MCP server right * after the tool response, so without this every one-shot store/update/forget is * silently dropped. Installed as a SIGTERM/SIGINT handler by the MCP bootstrap — * never called on the hot path, where the debounce still applies. */ flushSync(): void; private save; /** * Pull in deletions made by other processes. * * Tombstones are shared state, not per-process bookkeeping: a peer that deleted a memory * wrote the tombstone to meta.json, and a process that still holds the entry in memory * would otherwise merge it right back on its next flush — undoing a deliberate deletion * from a session that had nothing to do with it. */ private absorbPeerTombstones; /** * Records an id as deliberately deleted, so a merge with a peer that still holds it * doesn't resurrect it. Without this, forget() would be undone the moment any other * session flushed, and eviction would thrash forever between two processes. */ private tombstone; /** Drops tombstones older than TOMBSTONE_TTL_MS — by then no peer still holds the entry. */ private pruneTombstones; /** Removes an entry from the in-memory corpus and the inverted index. No persistence. */ private dropEntry; /** * Store a query+response pair in the brain. * Returns false if detected as duplicate (>= dupThreshold similarity). */ store(query: string, response: string, provider?: string, dupThreshold?: number, filePaths?: string[], projectId?: string, outcome?: 'confirmed' | 'failed', symbolRefs?: SymbolRef[], claimInputs?: ClaimInput[], domain?: string, derivedFrom?: string[], options?: BrainStoreOptions): { stored: boolean; reason: string; id?: string; duplicate?: BrainSearchResult; conflicts?: Array<{ id: string; query: string; outcome: 'confirmed' | 'failed'; }>; /** Secrets/credentials redacted from query+response before anything was persisted. */ redactions: number; }; /** * Hard cap on entry count. Past this, the inverted index keeps growing and old, unused * entries dilute every future BM25 IDF calculation (more docs sharing a term → lower * signal) without ever being asked for again. Evicting keeps ranking quality from * degrading as the Brain accumulates months of memories. */ private static readonly MAX_ENTRIES; /** How many entries to evict per pass once over MAX_ENTRIES — batched, not one-at-a-time. */ private static readonly EVICT_BATCH; /** * Evict the lowest-value entries once the Brain is over capacity. Value = hits (proven * usefulness) with a mild recency tiebreak — never based on similarity to any one query, * since an entry unrelated to today's search may still be exactly what tomorrow's needs. * Failed-outcome entries are cheap to keep (they're short, deliberate warnings) so they're * evicted last among equally-unused entries rather than first. */ private evictIfOverCapacity; /** * Search brain using BM25 + Jaccard re-ranking. * Returns results with similarity scores normalized to 0-1. */ search(query: string, limit?: number, minSimilarity?: number, options?: BrainSearchOptions): BrainSearchResult[]; /** * Entries belonging to a project, including the unscoped ones written before entries * carried a projectId — same fallback rule search() uses, so counts and results agree. */ /** * search(), with an optional semantic re-rank layered on top. * * BM25 still does the retrieving — this only reorders what it already found, and only * when LEMMA_BRAIN_EMBEDDINGS is set and Ollama answers. With the flag off (the default) * this is exactly `search()` plus one boolean check, so callers can use it unconditionally * and no session pays for a feature it hasn't turned on. See BrainEmbeddings.ts. * * The candidate pool is widened before re-ranking: re-ordering the same `limit` results * BM25 already picked can only shuffle them, never surface the memory BM25 ranked 9th * because it happened to use different words — which is the entire point. */ searchHybrid(query: string, limit?: number, minSimilarity?: number, options?: BrainSearchOptions): Promise; private sidecar; private embeddingSidecar; getEntriesForProject(projectId: string): BrainEntry[]; /** * Opposite-outcome entries tracking any of these symbols. Shared by store() (warn before * writing) and previewImport() (warn before importing): two memories about one function * disagreeing on whether an approach works is a trap for whoever searches next. */ private findOutcomeConflicts; /** * Check if a query is likely a duplicate before storing. * Uses bloom filter for O(1) fast-path. */ checkDuplicate(query: string, response: string, threshold?: number): { isDuplicate: boolean; similarity: number; existing?: BrainSearchResult; }; /** * Record that a specific memory turned out NOT to help — the caller acted on it and it * was wrong, misleading, or irrelevant despite ranking well. Unlike `outcome: 'failed'` * (set at store time, about the underlying approach), this is set after the fact, about * the memory's usefulness as a search result. Lowers future ranking and eviction priority * without deleting the entry — a bad-fit-today memory may still be exactly right for a * differently-phrased query tomorrow. */ /** * One memory, by id, body included. * * The Brain had no deterministic read: `verifyByIds` answers "is it still true" without * returning the text, and `search()` — the only path to the body — is a ranking, so an agent * holding an id could still fail to retrieve the memory it had just written. Editing a * long-lived entry under those conditions means reconstructing its text from whatever a * search happened to surface, which is how the accumulated content in it gets lost. * * Returns a copy: callers must not mutate the corpus by holding a reference. Use update() * to change a memory. */ getEntry(id: string): BrainEntry | undefined; /** * The entry a claim id belongs to, plus the claim itself. Claim ids and entry ids are handed * back side by side by search_memory and verify_memory, so an id pasted into a read is as * likely to be one as the other — resolving only entries would report a live claim as missing. */ getClaim(id: string): { entry: BrainEntry; claim: Claim; } | undefined; downvote(id: string): { ok: boolean; message: string; }; /** * Book tokens saved by reusing a memory, called by search_memory on a fresh hit — the * same event that books the savings ledger, so the two can never disagree about * whether a reuse happened. Unknown ids fail silently (a peer may have forgotten the * entry since the search ranked it); savings attribution must never break a search. */ creditSavings(id: string, tokens: number): void; /** * Archive one entry to the trash, drop it from the corpus, and tombstone the id so no * merge resurrects it. Shared by forget() (one memory) and mergeEntries() (folded * fragments). Returns whether the archive write succeeded — a failure never blocks the * removal itself, it only narrows the restore paths. */ private dropWithArchive; /** * Permanently remove one memory. * * The Brain had no way to delete anything: downvote() only demotes, and eviction only * fires at capacity. A memory that is simply wrong, or that captured something that * should never have been stored, had no exit — the only recourse was to downvote it * repeatedly and wait for the corpus to fill up. This is that exit. * * The id is tombstoned as well as dropped, so a concurrent session's merge can't hand it * straight back. The bloom filter cannot un-add a key, so the deleted query may still * register as a possible duplicate later; that costs one extra dedup search and never * produces a wrong answer, which is the right side of that trade for a probabilistic * filter that is rebuilt on the next clear(). */ forget(id: string): { ok: boolean; message: string; forgotten?: { id: string; query: string; response: string; timestamp: string; hits: number; }; archivePath?: string; }; /** * Fold entries into a keeper. All-or-nothing on validation (unknown keeper, unknown or * repeated fragment, keeper listed as its own fragment all fail before anything * changes), then one snapshot covers the whole operation. */ mergeEntries(keepId: string, foldIds: string[]): { ok: boolean; message: string; kept?: string; folded?: string[]; claimsAdded?: number; }; /** * Persist the current in-memory corpus as a timestamped snapshot. Best-effort and * synchronous like save(): a failed snapshot must never block the operation it * protects. Returns the snapshot name, or null when nothing was written. */ snapshotCorpus(reason: string): string | null; /** Rollback points on disk, newest last: name, size, and modification time. */ listSnapshots(): Array<{ name: string; bytes: number; mtime: string; }>; /** * Replace the corpus with a snapshot. The current state is snapshotted first * (`pre-restore`), so a restore is itself undoable. Deliberately does NOT tombstone * the ids that vanish: a peer session holding unflushed memories would lose them on * its next read, and "restore never destroys" beats "restore is total" — whatever a * peer still holds comes back on its next flush, honestly, through the normal merge. */ restoreSnapshot(name: string): { ok: boolean; message: string; restored?: number; }; /** What the trash holds: one row per archived deletion, newest last. */ listForgotten(): Array<{ id: string; query: string; timestamp: string; hits: number; forgottenAt: string; }>; /** * Bring a forgotten memory back. The archived copy becomes a live entry again under its * original id (so any external reference to the id keeps working), its old tombstone is * lifted, and freshness is re-judged on the next search like any other entry. The * archive line stays as history — the trash is append-only, restore doesn't rewrite it. */ restoreForgotten(id: string): { ok: boolean; message: string; }; /** * Permanently drop archived deletions. Without a filter this empties the whole trash; * with olderThanDays it keeps recent deletions restorable. Tombstones are untouched — * purging the archive removes the restore path, not the deletion itself, so purged ids * still cannot come back through a merge or an import. */ purgeForgotten(olderThanDays?: number): { purged: number; remaining: number; }; /** * Re-verify a memory against the code as it stands now, keeping its identity. * * Staleness used to be terminal: once tracked evidence changed, an entry was stale * forever, even in the very common case where the insight is still true and the code just * moved. The only workaround was to store a near-duplicate — which store()'s own dedup * guard would often refuse — losing the entry's id, its hit count, its demerits, and every * `derivedFrom` edge pointing at it. * * With no arguments this re-hashes whatever the entry already tracks (and each claim's own * evidence), which is the "yes, I checked, this is still correct" path. Passing filePaths * or symbols instead re-points the entry at new evidence, which is the "the code moved" * path. Either way the caller is asserting the memory is currently true — this tool * records that assertion, it cannot verify it, so it is never called automatically. * * `derivedFrom` dependencies are deliberately not re-anchored: a conclusion inherited from * a memory that is itself stale is exactly what Fase B exists to catch, and silently * clearing that would defeat it. Those come back in `stillStale` instead. */ /** * Re-hash an entry's evidence, either in place or against newly named files/symbols. * Shared by refresh() ("I checked, this is still true") and update() ("here is the corrected * text"), which make the same assertion about the same code and so must anchor identically. * Mutates `entry`; returns the labels of what it now tracks. */ private reanchorEvidence; refresh(id: string, opts?: { filePaths?: string[]; symbols?: SymbolRef[]; }): { ok: boolean; message: string; fresh?: boolean; retracked?: string[]; stillStale?: string[]; }; /** * Replace an existing memory's content, keeping its identity. * * The Brain could add and it could delete, but it could not correct: store() mints a new id * per call, so re-storing a corrected version under the same query left the outdated one in * place, and a later search returned an arbitrary slice of every historical version — * including the ones that contradict each other. That is the mechanism behind an * append-only pile of near-duplicate "canonical index" entries: not a convention nobody * followed, a convention the API could not express. refresh() keeps identity but only * re-hashes evidence, and has no way to accept new text. This is that missing operation. * * Kept: id, hits, demerits, creation timestamp, projectId, source, and every `derivedFrom` * edge elsewhere in the corpus that points at this id. Replaced: whatever the patch names. * Evidence is re-anchored exactly as refresh() does — rewriting a memory's content asserts * the same thing refresh() records, that the caller checked it against the code as it is now. */ update(id: string, patch?: { query?: string; response?: string; filePaths?: string[]; symbols?: SymbolRef[]; claims?: ClaimInput[]; outcome?: 'confirmed' | 'failed'; domain?: string; derivedFrom?: string[]; supersededBy?: string; }): { ok: boolean; message: string; id?: string; fresh?: boolean; retracked?: string[]; stillStale?: string[]; charDelta?: number; }; /** * Revalidate ids from a prior search_memory/store_memory result via hash-compare only — * no BM25, no `search()`. `ids` may mix entry ids and claim ids freely; each resolves * independently and unknown ids fail closed to "unknown", never "fresh" (an id may be * unknown because the entry was purged, or because the symbol it named was renamed — * either way there is nothing left to vouch for it). */ verifyByIds(ids: string[], options?: { semanticDiff?: boolean; resolver?: EvidenceResolver; }): VerifyMemoryResult[]; /** * What in the Brain would go stale, directly or transitively, if `filePath`/`symbolName` * changes right now. Called by write tools BEFORE they'd otherwise find out (only on the * next search) — see findBlastRadius for the algorithm and cost. */ findBlastRadius(filePath: string, symbolName?: string): BlastRadiusResult; /** * Near-duplicate entry pairs worth consolidating with brain_merge (see Fase E). Pairs * at or above `hi` are excluded: those should have been refused at store time, so a * surviving one is a `forget one of them` case, not a merge — report those separately * via the same call with lo=hi. Bounded: at most `sample` entries each issue one * search, and at most 20 pairs come back. */ findMergeCandidates(opts?: { sample?: number; lo?: number; hi?: number; }): Array<{ ids: [string, string]; similarity: number; queries: [string, string]; }>; /** * Full upkeep pass. Read-only over the corpus: revalidates the top-N most-reused * memories by hash-compare, lists exact duplicates and merge candidates, dead weight, * and the savings ledger — with a concrete suggested action per finding. */ upkeep(opts?: { topN?: number; sample?: number; deep?: boolean; }): BrainUpkeepReport; /** * Serialize memories to a portable NDJSON bundle: a header line, then one entry per line. * * The Brain is a single file under one user's home directory with no way in or out. That * makes it unshareable with a teammate, unmovable to another machine, and unbackupable * except by copying the directory wholesale (which also copies every other project's * memories). This is the smallest thing that fixes all three. */ exportBundle(opts?: { projectId?: string; domain?: string; includeStale?: boolean; }): { text: string; count: number; skippedStale: number; }; /** * Browse memories by project/domain/outcome without BM25 ranking or a query string — for * "what do you have stored about X" instead of forcing that into a search_memory phrasing, * and for manual triage after brain_upkeep names a batch of ids without a shared query to * search on. Summaries only (no full response body), so listing is cheap even at high limits. */ list(opts?: { projectId?: string; domain?: string; outcome?: 'confirmed' | 'failed'; limit?: number; offset?: number; }): { total: number; items: Array<{ id: string; query: string; domain?: string; projectId?: string; outcome?: 'confirmed' | 'failed'; hits: number; tokensSaved?: number; timestamp: string; refreshedAt?: string; }>; }; /** * Classify one bundle line without touching the corpus. The single planning step behind * both importBundle() (which then applies the plan) and previewImport() (which only * reports it) — one classifier means a dry run can never disagree with the real thing * about what would happen. Scrubbing the freshly-parsed object is safe: it is not * corpus state yet. */ private planImportLine; /** Fold the other side's counters into ours without losing feedback either side collected. */ private absorbImportCounters; /** Split a bundle into candidate lines. No header means raw entries — see below. */ private static splitBundleLines; /** * What importBundle() WOULD do, without writing anything: counts, the first 50 items, * and opposite-outcome conflicts against the live corpus. Run this before importing a * bundle from a teammate or another machine — especially a large one — so a flood of * stale, wrong-project memories is a preview, not a surprise. */ previewImport(text: string, opts?: { markSource?: boolean; }): { ok: boolean; wouldImport: number; wouldUpdate: number; wouldSkip: number; items: Array<{ id: string; query: string; action: 'import' | 'update' | 'skip'; reason: string; }>; truncated: boolean; conflicts: Array<{ id: string; query: string; outcome: 'confirmed' | 'failed'; against: string; }>; message: string; }; /** * Merge a bundle produced by exportBundle into this Brain. * * Import is additive and never destructive: an id already present keeps whichever version * is newer and the higher of both counters (the same rule cross-process merging uses), and * a tombstoned id stays deleted — importing a bundle must not resurrect something the user * deliberately forgot. * * Imported entries keep their origin machine's absolute paths, so most of them will read * as stale here until refresh() re-anchors them. That is the honest outcome: their * evidence genuinely cannot be verified against this checkout, and reporting them as fresh * would be the one failure mode this whole system is built to prevent. */ importBundle(text: string, opts?: { markSource?: boolean; }): { ok: boolean; imported: number; updated: number; skipped: number; message: string; }; /** * Corpus counters, plus health signals that answer the question counts alone can't: * is this Brain getting better or is it accumulating dead weight? * * Everything is computed from memory except `staleEntries`, which re-hashes every tracked * file and symbol in the corpus and is therefore behind `deep` — on a full Brain that is * thousands of file reads and has no business running on a routine stats call. */ getStats(options?: { deep?: boolean; }): BrainStats; /** * Integrity + rollback posture in one cheap call (no hashing, no disk reads beyond a * directory listing). `ok` is false while an unverified read, a backup fallback, or an * undecryptable envelope is the reason this process is serving what it serves. */ health(): { ok: boolean; lastIssue: IntegrityIssue | null; snapshots: number; backups: boolean; encrypted: boolean; }; clear(): void; } export declare function getBrain(): TheBrainV2; //# sourceMappingURL=TheBrainV2.d.ts.map