/** * Code-anchored memory — deterministic anchoring + freshness engine. * (change: add-code-anchored-memory-staleness) * * A persisted memory (an architectural decision or a `remember` note) is bound to * the code it describes by one or more {@link StructuralAnchor}s. At recall time * a freshness verdict is computed against the *current* call graph using only * boolean inputs — symbol existence and content-hash equality — with no tunable * threshold and no weighted score. This is what lets recall be bullet-proof: a * memory whose anchored code moved or died is labeled, never served silently. * * This module is intentionally pure: it operates on a minimal node view and a * {@link GraphFreshnessView} of lookups, so the resolution/verdict logic is unit * tested without touching disk or the edge store. The disk-backed adapter that * supplies the views lives in `anchor-adapter.ts`. */ import type { StructuralAnchor, AnchorVerdict, MemoryFreshness, PendingDecision, StaleRegionComposition } from '../../types/index.js'; /** * Longest span text an anchor may carry (add-memory-anchor-verdicts). A span names * the line or few lines a memory is about; the bound also limits what every later * freshness check searches for, since notes.json is committed repo content. The * `remember` tool schemas state the same number. */ export declare const MAX_SPAN_CHARS = 2000; /** * The anchor's span text when it is usable: a non-empty string within * {@link MAX_SPAN_CHARS}. Stores are repo content, so a span of any other shape is * treated as absent (a plain symbol anchor), never hashed or searched. */ export declare function spanTextOf(anchor: Pick): string | undefined; /** The identity key of a symbol anchor: a span anchor is distinct from its symbol and from other spans. */ export declare function symbolAnchorKey(anchor: Pick): string; /** Stable, reproducible hash of a source span (or whole file). Unnormalized. */ export declare function hashSpan(text: string): string; /** Minimal node view the anchor engine needs to resolve a symbol to an anchor. */ export interface AnchorNode { id: string; name: string; filePath: string; /** Current hash of the node's source span. */ contentHash: string; /** Content-addressed stable id, when the symbol has a derivable one. */ stableId?: string; } /** Current-state lookups used to compute freshness against the live graph. */ export interface GraphFreshnessView { /** Current content hash for a node id, or `undefined` if the node is gone. */ nodeHash(nodeId: string): string | undefined; fileExists(filePath: string): boolean; /** Current whole-file content hash, or `undefined` if the file is gone. */ fileHash(filePath: string): string | undefined; /** * Resolve a symbol by its content-addressed stable id when its `nodeId` no * longer matches (the symbol was moved/renamed-file but otherwise survives). * Returns the relocated node's id + current span hash, or `undefined` when no * unambiguous node carries that stable id. Optional — absent on legacy views. * (change: add-content-addressed-stable-symbol-ids) * * Resolution is unique-only: because `stableId` is name+parameter-shape (a * homonym — a genuinely different symbol with the same name and signature — * shares it), the resolver returns a node only when exactly one carries the id, * never guessing between candidates. A residual false-`fresh` is still possible * if a homonym is the sole survivor AND its span hash happens to equal the * recorded one; the content-hash equality check in `anchorFreshness` is the * guard that makes any *changed* survivor read `drifted` instead. During an * incremental cross-file move the old and new rows can briefly both carry the id * (ambiguous → `undefined` → falls through to `orphaned`); the state self-heals * once the batch finishes and is corrected by the next full analyze. */ resolveStableId?(stableId: string): { nodeId: string; contentHash: string; } | undefined; /** * Confident rename mapping: given an absent node id, the new location label * (e.g. `newFile.ts::newName`) when `structural_diff` mapped it, else undefined. * Optional — when absent, a missing symbol is always `orphaned`. */ renameOf?(nodeId: string): string | undefined; /** * True when the node lies in an explicitly-marked stale region — its * surrounding topology was NOT recomputed by a budget-exceeded incremental * update (fix-transitive-incremental-staleness). A symbol whose own span is * unchanged can still sit in a stale region; this lets the verdict reflect * that the topology around it may have diverged. Optional — absent on legacy * views, where nothing is ever stale-by-region. */ inStaleRegion?(nodeId: string): boolean; /** * True when the FILE is in an explicitly-marked stale region — the file-level * counterpart of {@link inStaleRegion}, used for file-level anchors that carry * no `nodeId` (fix-transitive-incremental-staleness). */ fileInStaleRegion?(filePath: string): boolean; /** Persisted structural context for a stale file's region. */ staleRegionComposition?(filePath: string): StaleRegionComposition | undefined; /** * Current source text of a node's span, CRLF read as LF, and the 1-based file line * it starts on — what a span anchor is re-located in (add-memory-anchor-verdicts). Optional: a * view without it decides a span anchor by the whole-symbol hash, which reports * the span `fresh` only when the whole symbol is unchanged. */ nodeSource?(nodeId: string): { text: string; startLine: number; } | undefined; } /** * CRLF read as LF (add-memory-anchor-verdicts). notes.json is committed, and a * checkout may convert line endings, so a span recorded on one platform must match * on another. Line numbers are unchanged: only `\r` before `\n` is dropped. Every * run of `\r` before a `\n` goes, so normalizing twice equals normalizing once. */ export declare function normalizeLineEndings(text: string): string; /** * The 1-based lines where `span` starts in `body`, overlapping occurrences * included, at most `limit` of them; `startLine` is the line `body` starts on * (add-memory-anchor-verdicts). Exact text only, CRLF read as LF — no similarity. * Knuth–Morris–Pratt, so the cost is linear in body + span whatever the text: the * span is repo content, and `indexOf` turns superlinear on crafted patterns (a * 2,000-character span measured 18 s on a 50 MB body). */ export declare function spanLines(body: string, span: string, limit: number, startLine?: number): number[]; /** * Resolve symbol names to symbol-level anchors deterministically. A name resolves * only when it matches exactly one internal node — optionally narrowed to the * given `preferFiles` first. Ambiguous or unknown names are skipped (no guessing); * callers fall back to file-level anchoring for those. */ export declare function resolveSymbolAnchors(symbolNames: readonly string[], nodes: readonly AnchorNode[], preferFiles?: readonly string[]): StructuralAnchor[]; /** Build a file-level anchor, capturing the file content hash when available. */ export declare function fileAnchor(filePath: string, contentHash?: string): StructuralAnchor; /** * Compute the freshness verdict for a single anchor against the current graph. * Inputs are booleans only — no threshold, no score. */ export declare function anchorFreshness(anchor: StructuralAnchor, view: GraphFreshnessView): AnchorVerdict; /** * What happened to an anchor between recording and now, in the terms an operator * acts on (add-memory-anchor-verdicts). Mapped from the existing verdict, never a * new judgment: * - `silently-carried` — the anchored code is still where it resolves, unchanged * (byte-identical, or an exact span re-located inside the symbol); * - `re-pointed-with-provenance` — carried across a rename/move, `carriedAcross` * says from where; * - `needs-human-reselection` — the anchor is lost or ambiguous (orphaned). * A `drifted` anchor that was not carried has no outcome: it still resolves, and * its content change is what `freshness` already says. */ export type AnchorOutcome = 'silently-carried' | 're-pointed-with-provenance' | 'needs-human-reselection'; /** Why an anchor needs a human to reselect it. */ export type ReselectionReason = /** The span text now occurs more than once in the symbol (see `spanCandidates`). */ 'span-ambiguous' /** The symbol disappeared and more than one symbol could be it (see `possiblyMovedTo`). */ | 'move-ambiguous' /** * The symbol disappeared with no unambiguous successor. Continuity pairs only an * exact body (or a body that differs by the name alone) between two adjacent * analyzed states, so a rename made together with other edits, or across * several unanalyzed states, also lands here. */ | 'no-successor' /** The anchored file no longer exists. */ | 'file-missing'; export declare function anchorOutcome(v: AnchorVerdict): { outcome: AnchorOutcome; reason?: ReselectionReason; } | undefined; /** * A memory's overall verdict is the worst of its anchors' verdicts * (orphaned > drifted > fresh). An unanchored memory is `fresh` — there is * nothing to invalidate; callers report `anchored: false` separately. */ export declare function aggregateFreshness(verdicts: readonly AnchorVerdict[]): MemoryFreshness; /** Compute per-anchor verdicts and the aggregate for a whole memory. */ export declare function memoryFreshness(anchors: readonly StructuralAnchor[], view: GraphFreshnessView): { freshness: MemoryFreshness; verdicts: AnchorVerdict[]; anchored: boolean; }; /** * True when a memory's non-`fresh` aggregate is due ONLY to the explicit stale * region — every drifted anchor is a stale-region downgrade and nothing is * orphaned or genuinely content-drifted (fix-transitive-incremental-staleness). * The anchored code is byte-identical; its surrounding topology simply was not * recomputed by a budget-exceeded incremental update and will self-heal. Callers * use this to label it honestly ("not yet reconciled") instead of asserting the * code changed, and the drift detector uses it to NOT report a false code-drift. */ export declare function isStaleRegionOnly(verdicts: readonly AnchorVerdict[]): boolean; /** A memory reduced to what deterministic contradiction detection needs. */ export interface AnchoredItem { id: string; anchors: readonly StructuralAnchor[]; /** Aggregate freshness — only `fresh` items participate. */ freshness: MemoryFreshness; /** When true (superseded/invalidated) the item is excluded. */ invalidated?: boolean; } /** Two or more authoritative memories that all anchor to the same symbol. */ export interface UnreconciledGroup { /** The shared resolved symbol key (`stableId` ?? `nodeId`). */ symbol: string; /** The symbol's name, when known (for display). */ symbolName?: string; /** The symbol's file, when known (for display). */ filePath?: string; /** Ids of the authoritative memories sharing this symbol (sorted, ≥ 2). */ memberIds: string[]; } /** * Detect `unreconciled` pairs: when two or more authoritative (`fresh`, * non-invalidated) memories resolve to the SAME symbol anchor, they may contradict * and the agent should reconcile or supersede one. This is a pure set intersection * over symbol-level anchors — no LLM decides which wins, and "same file" is too coarse * to count (file-level anchors are ignored). Deterministic and order-independent. */ export declare function findUnreconciled(items: readonly AnchoredItem[]): UnreconciledGroup[]; /** * The anchors to check freshness against for a decision: its explicit structural * anchors when present, otherwise existence-only file-level anchors derived from * `affectedFiles` (legacy decisions recorded before anchoring). Shared by recall, * orient, and the drift detector so they agree on what a decision is bound to. */ export declare function decisionAnchors(d: Pick): StructuralAnchor[]; //# sourceMappingURL=anchor.d.ts.map