/** * Strip the owner prefix off a stored mem key → the public id the caller wrote. A no-op for an * ownerless mem row, a doc/blob row, or a code/file path (none carry the separator). Applied at every * consumer-facing return so a recall/get/recentMemory/promotion id round-trips back through write/get. * @param {string} key @returns {string} */ export function memId(key: string): string; /** Memory kinds stored in the stemmed `mem` table; everything else rides `docs`. */ export const MEM_KINDS: Set; export class Store { /** * @param {string} dbPath path to the SQLite file, or ":memory:" * @param {{ owner?: string|null, session?: string|null }} [scope] this instance's identity (§4.4): * `owner` = the actor (NULL = unscoped → sees all owners); `session` = the run (NULL = durable → * sees all sessions). Drives both the write-time scope of new memory and the recall read filter. */ constructor(dbPath: string, scope?: { owner?: string | null; session?: string | null; }); /** @type {string|null} */ owner: string | null; /** @type {string|null} */ session: string | null; /** @type {any} */ db: any; /** * One-time migration: written-memory vectors used to share `file_embeddings`, keyed by memKey / direct-doc * id — which in the global tier collides with a same-named file path. Move any such legacy row into the * dedicated `mem_embeddings` table. A row is written-memory iff its key names a `mem` row or a direct * `docs` row (file rows live in `file_index`, never `mem`/direct-`docs`). Runs BEFORE migrateOwnerKeyedMem * so the re-key then finds the vectors in their new home. Idempotent (a re-run finds nothing to move) and a * no-op on a fresh db. An already-collided legacy row (id == file path) resolves toward the mem side; the * now-vectorless file re-embeds on the next embeddings-on index() (the backfill, no longer maskable). * @returns {void} */ migrateMemEmbeddings(): void; /** * One-time W4 migration (multis M4): re-key existing owner-tagged `fact`/`episode` rows from the bare * `id` to the owner-qualified `owner\x1Fid` so they share the new write scheme (else a post-upgrade * `remember` of the same id would create a SECOND, duplicate row at the encoded key instead of * superseding the legacy one). Ownerless/global rows (`owner IS NULL`) are left untouched — their key * is `id` under both schemes (byte-identical legacy, no migration). Pre-W4 a collision was impossible, * so each id had at most ONE owner → re-keying every path-keyed sidecar (incl. the `recall_log` demand * history) by exact match is unambiguous. Idempotent: a path already carrying the separator (a fresh * W4 write, or a prior run) is skipped. A no-op scan on a single-tenant / fresh db (no owner rows). * FTS5 tolerates `UPDATE` of its UNINDEXED `path` column (MATCH survives) — verified before shipping. * @returns {void} */ migrateOwnerKeyedMem(): void; /** * Clear all FILE-sourced data, preserving written memory and the audit log (slice 9 validation * fix; used by `index({ force: true })`). A force pass re-reads every file from disk — so file * rows, chunks, edges, git metadata, and file embeddings are all re-derivable and dropped. What * is NOT re-derivable is never touched: written memory (`mem`, `mem_text`, direct `docs` rows, * their embeddings — no file behind them) and `recall_log` (append-only demand history). This is * the "survives every index() pass" contract (§3.2) extended to its last gap: `force: true` used * to call `reset()` and silently destroy every fact. Written embeddings survive because the * subquery scopes the delete to `file_index` keys — written rows are never in `file_index`. */ clearIndexed(): void; /** * The destructive file-row clear WITHOUT its own transaction, so it can run INSIDE another one. A * force/self-heal rebuild folds this into {@link applyChanges}'s transaction (`clearFile: true`) so the * index is cleared and re-populated atomically — a concurrent reader (e.g. an MCP warm-index firing in * the background) sees the old complete index or the new one, never the empty window between them. File * rows only: written memory (`mem*`, direct `docs`, `mem_embeddings`) is never re-derivable and survives. * @returns {void} */ _clearIndexedRows(): void; /** Drop and recreate everything (the ≤0.1.0 self-heal rebuild — such a db predates the write path, so nothing unrecoverable exists). */ reset(): void; /** * The previously-indexed state, for incremental diffing. * @returns {Map} */ loadIndex(): Map; /** * Apply an incremental change set atomically: drop deleted files, (re)insert changed * ones, and refresh mtimes for files whose content was unchanged. * @param {Changes} changes * @param {number} indexedAt epoch millis to stamp on touched rows * @param {boolean} [recordEdits=false] log per-chunk body changes to `chunk_edits` (slice 5a). Off * for a cold/`force` build (mass insert isn't editing); on for incremental passes — see index(). * @param {number} [stamp=0] index-format stamp ({@link indexStamp}) written on every inserted node, * so a later pass can tell which chunks a DIFFERENT-version writer produced. 0 = the reserved * "rebuild me" sentinel; a real pass always passes a live stamp. * @param {boolean} [clearFile=false] clear ALL prior file rows as the first step of THIS transaction * (the force/self-heal rebuild). Folding the clear in here — rather than a separate `clearIndexed()` * before the seconds-long chunk loop — makes the rebuild atomic: a concurrent reader never sees the * empty index between the clear and the re-population (the warm-index-vs-recall race). */ applyChanges({ upserts, touch, deletes }: Changes, indexedAt: number, recordEdits?: boolean, stamp?: number, clearFile?: boolean): void; /** * Write one directly-authored memory — a fact/episode/doc with no file behind it (slice 7, §3.2). * Upsert by `id` (the row's `path`/key): replaces any prior **direct** row with the same id, and * **never** clobbers an indexed (`source='file'`) row. Always `source='direct'`, so the incremental * index sweep — whose `deletes` come only from `file_index` keys — structurally can't touch it. * Stored **whole** (no chunking); the FTS body is identifier-split like any other (`indexBody`). * `meta` (RT-3 #3) is an opaque JSON string stored verbatim in the sealed `mem_meta` table and * never indexed; `null`/omitted clears any prior meta (the row reflects the latest write, like * `mem_text`). `scope`/`expiresAt` (multis M3 R2/R5) tag a DIRECT `doc` row in the `doc_scope` * sidecar — both NULL = global/forever; ignored for `fact`/`episode` (those scope via `mem_scope`). * `owner` (multis M4) is the per-call fact/episode owner: when present it OVERRIDES the instance owner * (so one instance fences memory per tenant), when omitted (`undefined`) it falls back to the instance * owner (legacy); `null` writes the shared tier deliberately. Ignored for direct `doc`/blob. * @param {{ id: string, text: string, kind: string, format: string, provenance: string|null, occurredAt: number|null, meta?: string|null, embedding?: Float32Array, scope?: string|null, expiresAt?: number|null, createdAt?: number|null, owner?: string|null }} m */ writeMemory(m: { id: string; text: string; kind: string; format: string; provenance: string | null; occurredAt: number | null; meta?: string | null; embedding?: Float32Array; scope?: string | null; expiresAt?: number | null; createdAt?: number | null; owner?: string | null; }): void; /** * Set the per-upload lifecycle (scope/expiry/created_at) of a direct doc/blob row (multis M3 R2/R5 + * recentMemory). Refresh by delete-then-insert. A row is now written UNCONDITIONALLY — even an unscoped, * never-expiring upload gets one — because `created_at` must exist for every direct doc to order * recentMemory. A `scope=NULL, expires_at=NULL` row is byte-identical to the old "no row" under the * recall/get LEFT JOIN (still global/forever), so the fence is unchanged; only `created_at` is new. * `createdAt` is the caller's write clock (threaded from the facade, like `writeStash`), refreshed on * every re-write so a re-ingest reads as more recent. * @param {string} id @param {string|null} scope @param {number|null} expiresAt @param {number|null} createdAt */ setDocScope(id: string, scope: string | null, expiresAt: number | null, createdAt?: number | null): void; /** * Store an uploaded file BYTE-EXACT (multis M3 R3) — the non-chunkable ingest path. The bytes go to * the `blobs` BLOB column verbatim (POC-proven round-trip; TEXT would mangle non-UTF8); a direct * `docs` row carries ONLY the filename as its FTS body, so recall finds the file by NAME without ever * parsing or chunking the bytes. `get(id)` returns the original bytes. Upsert by `id` (drops any prior * direct row + blob for that id first). Scope/expiry ride the same `doc_scope` sidecar as docs; `meta` * is the sealed `mem_meta` passthrough (small tags only). Never embedded (bytes aren't meaning-searchable). * @param {{ id: string, bytes: Uint8Array, filename: string, format: string, meta?: string|null, scope?: string|null, expiresAt?: number|null, createdAt?: number|null }} b */ writeBlob(b: { id: string; bytes: Uint8Array; filename: string; format: string; meta?: string | null; scope?: string | null; expiresAt?: number | null; createdAt?: number | null; }): void; /** * Park a payload in the keyed agent-context store (R-C4 restorable compression). Unlike written * memory, a stash is NEVER indexed — it goes into no FTS table, so recall can't surface it on any * kind — and NEVER pruned; it is addressable only by exact `id` (via {@link getItem}) and lives * until an explicit {@link forgetMemory}. The durable half of "drop the payload, keep a handle": * the agent clears a large tool result from its window and rehydrates it by id on demand. Upsert * by `id`. Deletion is {@link evictStash} (NOT `forgetMemory`, which is memory-only). * @param {{ id: string, text: string, createdAt: number }} s */ writeStash(s: { id: string; text: string; createdAt: number; }): void; /** * Peek a stashed payload (R-I3 handle / lazy-load): a lightweight head+tail preview of a parked blob * WITHOUT rehydrating it. Returns the `head` (first {@link STASH_HEAD} chars), the `tail` (last * {@link STASH_TAIL} chars — empty unless the middle is actually elided), the true byte `bytes`, and * the parked-at timestamp — all computed in SQL via first-N / last-N `substr` + `length(CAST(text AS * BLOB))`. The win is the BOUNDED RESULT: only ~head+tail bytes cross back to the caller, so the * payload stays out of its context/token budget (the point of a lazy-load handle). It is *not* a * DB-time win — SQLite reads the full column to slice it, so peek's compute scales with payload size. * Head+tail because a payload's conclusion (exit code, failing frame, closing structure) lives at the * end. `bytes` is the OCTET length, not `length(text)` (chars — wrong for multibyte). `truncated` * flags that the preview omits a middle span; the full body is one {@link getItem} away. Stash-only — * recall owns ranked retrieval over memory. Null for an unknown id. * @param {string} id * @returns {{ id: string, bytes: number, head: string, tail: string, createdAt: number, truncated: boolean } | null} */ peekStash(id: string): { id: string; bytes: number; head: string; tail: string; createdAt: number; truncated: boolean; } | null; /** * Evict parked stash payloads (R-C4 housekeeping) — the stash deleter, split from {@link forgetMemory} * so a bulk age/size sweep can NEVER reach durable written memory: **only the `stash` table is touched.** * Exactly one selector per call: `{ id }` (one payload), `{ olderThan }` (epoch-ms floor — drop anything * parked before it, via `created_at <`), or `{ maxCount }` (keep the newest N by `created_at`, evict the * rest). Also cleans any `fetch` recall_log rows a {@link getItem} left on an evicted id (parity with the * old forget cascade). Returns rows removed. (Ties on `created_at` under `maxCount` keep an arbitrary * member of the tie — the *count* held is exact, which is all a janitor needs.) * @param {{ id?: string, olderThan?: number, maxCount?: number }} sel * @returns {number} */ evictStash(sel: { id?: string; olderThan?: number; maxCount?: number; }): number; /** * Forget directly-written memory — by `id`, or by query (`kind` and/or `provenance`) for bulk human * invalidation (§3.2). **Only ever removes `source='direct'` rows**, so an indexed file is never * touched. Cleans the row's raw text, embedding + recall-log alongside it. Returns rows removed. * @param {{ id?: string, idPrefix?: string, kind?: string, provenance?: string, ownerFenced?: boolean, owner?: string | null }} sel * `idPrefix` matches a base id and all `#` rows under it — the clean-re-ingest handle * for a multi-segment document ({@link LiteCtx#ingest}); still direct-rows-only. * `ownerFenced` (multis M4) routes a tenant-scoped delete on the MEMORY axis only — see * {@link forgetMemoryByOwner}. * @returns {number} */ forgetMemory(sel: { id?: string; idPrefix?: string; kind?: string; provenance?: string; ownerFenced?: boolean; owner?: string | null; }): number; /** * Tenant-fenced memory forget (multis M4) — the delete-side mirror of the {@link recallMemory} owner * fence. Deletes `fact`+`episode` rows for exactly ONE owner, plus their sidecars (`mem_text`/ * `mem_meta`/`mem_scope`/embeddings/`recall_log`). **MEM-AXIS ONLY**: never touches `docs` (a tenant's * ingested docs fence on the separate `doc_scope` axis), the stash, or another owner's rows. * * **A tenant forget is the STRICTER `s.owner = @owner`, NOT the read fence's `owner IS NULL OR owner = * @owner`.** A tenant reads its rows ∪ the shared tier, but must DELETE only its own — a tenant forget * that also matched `owner IS NULL` would wipe the shared/global memory for everyone. A GLOBAL forget * (`owner === null`) deletes ONLY the shared tier via `s.owner IS NULL` — a LEFT JOIN, matching both a * present `owner=NULL` row (every mem row now carries one for `created_at`, multis M4 R3) and any legacy * row with no `mem_scope` entry. The predicate keys on the owner VALUE, not row existence, so it is * unchanged by the unconditional write. "Delete every owner" is intentionally unexpressible here — that * is {@link reset}'s job. * * **Feature B (0.27.0) — a fenced delete-BY-KEY.** With `precise` (`{ id }` / `{ idPrefix }`) this * deletes exactly one tenant's row(s) by id, tenant-fenced — the delete-side mirror of the W4 * `(scope, id)` upsert. The fence is STRUCTURAL: it matches the owner-qualified physical key * `memKey(owner, id)`, so a foreign tenant's row (a different owner prefix — or the bare id for the * GLOBAL tier) is unmatchable, not policy-filtered → 0 removed, exactly like a cross-tenant `get`. * The redundant `mem_scope.owner` fence (`scopePred`) stays as defense-in-depth (path prefix and * column must agree by construction). Without `precise`, the whole-tenant wipe (unchanged). * * @param {string | null} owner a tenant `mem_scope.owner`, or `null` for the GLOBAL/shared tier * @param {string} [kind] optional narrow to one kind (e.g. just `episode`); omitted → both * @param {{ id?: string, idPrefix?: string }} [precise] Feature B: a fenced delete-by-key (id, or an * id + its `#segment` rows); omitted → the whole-tenant wipe * @returns {number} */ forgetMemoryByOwner(owner: string | null, kind?: string, precise?: { id?: string; idPrefix?: string; }): number; /** * Reclaim expired direct doc/blob rows (multis M3 R5) — the retention sweep's mechanism (the consumer * owns the schedule; litectx owns the delete). Drops every row whose `doc_scope.expires_at <= now` * across `docs` + `doc_scope` + `blobs` + `mem_text`/`mem_meta` + embeddings + `recall_log`, so a * single store leaves NO orphaned bytes. Recall/get already EXCLUDE expired rows live (so a row is * invisible the instant it expires, before any purge); `purge` is what actually frees the storage. * Touches only rows with a non-NULL `expires_at` that has passed — `null`-expiry (keep-forever) and * file-indexed rows are never reached. Returns rows removed. * @param {number} now epoch ms — the cutoff; rows with `expires_at <= now` are reclaimed * @returns {number} */ purge(now: number): number; /** * Append recall hits to the audit log (slice 7, §3.2) — the genuine access log §4's base-level tier * will later score. v1 records, does not rank. One row per hit. `action` tags the signal type: * 'recall' = ranked retrieval (real demand); 'fetch' = a get(id) body read (tagged weak signal — * excluded from demand reads, see the schema comment). * @param {{ path: string, kind: string, chunk?: ChunkRef | null }[]} hits * @param {number} ts epoch ms * @param {string} [action='recall'] */ logRecall(hits: { path: string; kind: string; chunk?: ChunkRef | null; }[], ts: number, action?: string): void; /** @returns {number} total stored items — indexed documents + written memory (both FTS tables) */ count(): number; /** * Count `fact`/`episode` rows for a tenant (multis M4 O1) — owner-fenced with the EXACT predicate * {@link recentMemoryMem}/`search` use (`mem_scope.owner`, session-aware), so it counts only what the * tenant can see (its own ∪ shared). A cheap `count(*)`; no per-row expiry on this axis. * @param {{ kinds: string[], memOwner?: string|null, memSeeAll?: boolean }} filter `kinds` ⊆ {fact, episode} * @returns {number} */ countMem({ kinds, memOwner, memSeeAll }: { kinds: string[]; memOwner?: string | null; memSeeAll?: boolean; }): number; /** * Count direct `doc` rows for a scope (multis M4 O1) — scope-fenced + expiry-aware with the EXACT * predicate {@link recentMemory}/`search` use for docs (`doc_scope.scope`, R5 expiry), so an expired * upload or another tenant's doc is never counted. `code`/file rows are out of scope (this is the * direct-`doc` axis only). A cheap `count(*)`. * @param {{ scope?: string|null, seeAll?: boolean, now?: number|null }} filter same shape as `search`'s * @returns {number} */ countDoc({ scope, seeAll, now }: { scope?: string | null; seeAll?: boolean; now?: number | null; }): number; /** @returns {number} number of symbol/section chunks across all files */ nodeCount(): number; /** * How many times `path` has been recalled (slice 7 audit log, §3.2). Feeds HITL review: an * agent-asserted fact whose count crosses the review threshold is a promotion candidate. * Counts `action='recall'` rows only — a fetch is not demand (the fetch-toll, slice 9). * @param {string} path * @returns {number} */ recallCount(path: string): number; /** * HITL review candidates (§3.2): agent-asserted facts whose recall-hit count has crossed * `threshold` — the set a human is asked to validate (→ re-`remember` as `by:'human'`) or * invalidate (→ `forget`). A plain query over provenance + the recall log. Acting on a candidate * removes it from the set (promotion flips provenance off `'agent'`; forget deletes the row), so no * separate "reviewed" flag is needed. The count gates REVIEW, not ranking — not a feedback loop. * * Tenant-fenced (multis M4): the same per-call owner fence as recall (`filter.memOwner`/`memSeeAll`, * defaulting to the instance owner) so one shared instance's review queue never mixes tenants — a * customer's facts can't surface in another customer's (or the owner's) review set. * @param {number} [threshold=5] * @param {{ memOwner?: string|null, memSeeAll?: boolean }} [filter] per-call owner fence (multis M4) * @returns {{ path: string, hits: number }[]} */ reviewCandidates(threshold?: number, filter?: { memOwner?: string | null; memSeeAll?: boolean; }): { path: string; hits: number; }[]; /** * Episode promotion candidates (slice 5b, §14 #4 view #4): agent-written `episode`s recalled at * least `threshold` times whose `occurred_at` falls in the rolling active window `[since, now]` * (older episodes have decayed out of the active set). Mirrors {@link reviewCandidates} exactly — * same `recall_log` demand join, `'recall'`-only, same `{ path, hits }` shape — with two deltas: * `kind='episode'` (not `'fact'`) and the `occurred_at >= since` window gate. The count gates * DISTILLATION, never ranking (promotion changes an episode's downstream kind/trust, never its * recall score — §14 #4). Threshold runs higher than facts' review (10 vs 5): episodes are noisier * and more numerous. * * Tenant-fenced (multis M4): same per-call owner fence as {@link reviewCandidates} — one shared * instance's promotion queue never mixes tenants' episodes. * @param {{ threshold: number, since: number, memOwner?: string|null, memSeeAll?: boolean }} opts * `threshold` = min recall hits; `since` = `occurred_at` floor (epoch ms) — the rolling-window * cutoff; `memOwner`/`memSeeAll` = per-call owner fence (defaults to the instance owner). * @returns {{ path: string, hits: number }[]} */ promotionCandidates({ threshold, since, memOwner, memSeeAll }: { threshold: number; since: number; memOwner?: string | null; memSeeAll?: boolean; }): { path: string; hits: number; }[]; /** * Drop episodes older than `before` (slice 5b ephemerality, §14 #4 view #4) — the agent scratchpad * is bounded by a rolling window: an episode that mattered was distilled into a durable `fact` * (never pruned), so deleting the raw episode past the window loses nothing earned. Cascades to * `mem_text` / embeddings / `recall_log` like {@link forgetMemory}. Called on each episode write * (self-bounding — only episode writes grow the set, so that is where it's trimmed; no cron). Only * ever touches `kind='episode'` rows. Returns rows removed. * @param {number} before `occurred_at` floor (epoch ms); episodes strictly older are deleted * @returns {number} */ pruneStaleEpisodes(before: number): number; /** * "What was I working on" (slice 5a, §14 #4 view #3): the chunks litectx witnessed edited most * recently, newest first, within `[since, now]`. Grouped per chunk (path + symbol): `lastEditedAt` * is the most recent edit, `edits` the number of distinct index passes (sessions) that changed it. * `edits` counts DISTINCT timestamps, not rows: a file's anonymous chunks (null symbol) collapse to * one per-file row, and counting passes — not chunks — keeps that row's count honest (one busy pass * = 1, not "however many nameless chunks moved"). Pure recency order (`edits` only breaks ties) — NO * activation, NO recall coupling: reads `chunk_edits`, never the ranking path (edit→recall re-rank * ships at zero, §14 #4). * @param {{ since: number, limit: number }} opts `since` epoch ms (window floor); `limit` row cap * @returns {{ id: string, symbol: string|null, kind: string, lastEditedAt: number, edits: number }[]} */ recentActivity({ since, limit }: { since: number; limit: number; }): { id: string; symbol: string | null; kind: string; lastEditedAt: number; edits: number; }[]; /** * Recent direct `doc` rows, newest first (multis M3 — recall's empty-FTS-match recency sibling). The * "no query, just the latest uploads for this scope" view: it returns direct docs (incl. blobs by * filename) ordered by write recency (`doc_scope.created_at` DESC), NOT by BM25. Scope-fenced + expiry- * aware with the EXACT predicate `search()` uses for docs (same `doc_scope` LEFT JOIN), so it can never * surface another tenant's upload or an expired row. `code`/file rows (`source='file'`) and * `fact`/`episode` (the mem table / owner-session axis) are out of scope by construction. * * A direct doc written before the `created_at` column existed has no value to sort on (NULL) and sorts * last under DESC — still returned, just undated. `path` (the id) feeds {@link getItem}. * @param {{ scope?: string|null, seeAll?: boolean, now?: number|null, limit: number }} filter * resolved R2 scope + R5 expiry (same shape as `search`'s filter). `seeAll` defaults to "`scope == null`". * @returns {{ path: string, kind: string, format: string, createdAt: number|null }[]} */ recentMemory({ scope, seeAll, now, limit }: { scope?: string | null; seeAll?: boolean; now?: number | null; limit: number; }): { path: string; kind: string; format: string; createdAt: number | null; }[]; /** * Recent `fact`/`episode` rows for a tenant, newest first (multis M4 R3) — the MEMORY-axis sibling of * {@link recentMemory} (the doc-axis recency view). The "no query, just my latest memory for this * tenant" read: `/memory` listing durable facts, or the conversation window pulling recent episodes — * neither of which `recall` (needs query terms) can answer. Owner-fenced with the EXACT predicate the * memory-axis `search()`/`knnCandidates` use (`mem_scope.owner` via {@link _memFilter}: tenant ∪ shared, * session-aware), so it can never surface another tenant's memory. Ordered by **`occurred_at` for * episodes, `created_at` for facts** (`COALESCE(m.occurred_at, s.created_at)`): an episode's semantic * time (possibly backdated) wins where present, else the write time — the spec's ordering. * * No expiry predicate: the memory axis carries no per-row TTL (unlike the doc axis's `expires_at`); * episode staleness is handled upstream by the rolling-window {@link pruneStaleEpisodes} (`episodeWindowDays`, * default 30 — pruned rows are deleted, so absent here by construction). A row written before `created_at` existed sorts last (NULL). * Logs NO recall — recency is not query-demand (mirrors the doc-axis verb). `path` (the id) feeds * {@link getItem}; the facade attaches verbatim body + opaque meta (where the caller parks `role`, etc). * @param {{ kinds: string[], memOwner?: string|null, memSeeAll?: boolean, limit: number }} filter * `kinds` ⊆ {fact, episode} (validated by the facade); owner fence same shape as `search`'s. * @returns {{ path: string, kind: string, format: string, occurredAt: number|null, createdAt: number|null }[]} */ recentMemoryMem({ kinds, memOwner, memSeeAll, limit }: { kinds: string[]; memOwner?: string | null; memSeeAll?: boolean; limit: number; }): { path: string; kind: string; format: string; occurredAt: number | null; createdAt: number | null; }[]; /** * Exhaustive, scope-fenced, `rowid`-ordered page of ONE memory kind (bareagent RLM `scan` — enumerate). * The structural opposite of {@link search}/{@link knnCandidates}: no query, no rank, no embedder — an * ordered table read. Same owner/session fence as every other mem read (via {@link _memFilter}), so a * scoped instance still sees only its own ∪ shared, never another tenant's rows. Order by `rowid` * (insertion order, the same stable key `nodesForPath` uses) is identical across calls on an unchanged * store, so a consumer's multi-pass union over `offset` is gapless + dup-free. Pairs with * {@link countMem} for the kind's scoped `total` (so a walk can stop exactly at the last page). * @param {{ kind: string, memOwner?: string|null, memSeeAll?: boolean, offset: number, limit: number }} o * `kind` ∈ {fact, episode}; `memOwner`/`memSeeAll` the resolved fence; `offset`/`limit` the page window * @returns {{ path: string, kind: string, format: string, occurredAt: number|null }[]} */ enumerateMem({ kind, memOwner, memSeeAll, offset, limit }: { kind: string; memOwner?: string | null; memSeeAll?: boolean; offset: number; limit: number; }): { path: string; kind: string; format: string; occurredAt: number | null; }[]; /** * One stored item's full record by id — any id (slice 9): a written-memory id or an indexed * file's repo-relative path. Written rows carry their raw text (`mem_text`); file rows carry * `text: null` here — the caller reads the file from disk (the index is not a file cache). * Lookup order: `mem` (facts/episodes) → direct `docs` rows → file rows, so on the pathological * collision of a written id with a file path the written row wins (it has no other home; ids are * namespaced by convention). A pre-slice-9 written row with no `mem_text` falls back to its * stored FTS body — degraded (path tokens folded in) but preserved. * * A **blob** row (multis M3 R3) returns its original BYTES in `bytes` with `text: null` — the bytes, * never the filename, are the deliverable. Everything else returns `bytes: null`. When `now` (epoch * ms) is passed, an **expired** direct row (R5) returns `null` — fetch honors expiry exactly like recall. * * `scope` fences the **direct handle** the same way `recall({scope})` fences discovery, on BOTH * per-tenant axes (multis M3 R2 doc + M4 memory): a `doc`/blob fences on `doc_scope.scope`, a * `fact`/`episode` on `mem_scope.owner`. When set, a row tagged with a *different* tenant returns `null` * (a NULL/global row stays visible to every tenant; a file row has neither sidecar, so it is unaffected). * This is what makes "one customer never sees another's" hold for a *known/guessed* id, not only for * search — closing the by-id leak on the memory axis too. A bare `getItem(id)` (no scope) is unchanged * (the legacy by-id model). `globalOnly` (multis M3 fail-closed) fetches the shared tier ONLY: a row with * a non-null tenant (`doc_scope.scope` or `mem_scope.owner`) returns null even though `scope` is null. * It is how the facade serves a {@link GLOBAL} `get` — distinct from a bare `getItem(id)` (scope null, * globalOnly false), which stays unfenced. * @param {string} id * @param {number} [now] epoch ms; when set, a row whose `expires_at <= now` returns null (R5 — doc axis only) * @param {string|null} [scope] when set, a row tagged with a non-null tenant ≠ `scope` returns null (R2 doc / M4 memory) * @param {boolean} [globalOnly] when true, a row tagged with any non-null tenant returns null (GLOBAL-only fetch) * @returns {{ path: string, kind: string, format: string, source: string, provenance: string|null, occurred_at: number|null, text: string|null, bytes: Buffer|null, meta: string|null } | null} */ getItem(id: string, now?: number, scope?: string | null, globalOnly?: boolean): { path: string; kind: string; format: string; source: string; provenance: string | null; occurred_at: number | null; text: string | null; bytes: Buffer | null; meta: string | null; } | null; /** * Chunks for one file, in id order (insertion order). * @param {string} path * @returns {{ symbol: string|null, node_type: string, start_line: number, end_line: number }[]} */ nodesForPath(path: string): { symbol: string | null; node_type: string; start_line: number; end_line: number; }[]; /** * The stored body of the chunk at (path, startLine, endLine) — the exact text that was indexed and * ranked, powering `recall({ body: true })` for a localized file hit. Reads the index, not the * current disk, so it is drift-free and matches what scored. `null` if no such chunk row exists. * @param {string} path * @param {number} startLine 0-based, inclusive (matches {@link ChunkRef}) * @param {number} endLine 0-based, inclusive * @returns {string | null} */ chunkBodyAt(path: string, startLine: number, endLine: number): string | null; /** * The content hash recorded for an indexed file — the arbiter of whether the index still describes * what is on disk. `null` if the path was never indexed. * @param {string} path * @returns {string | null} */ fileHash(path: string): string | null; /** * The index-format stamp STORED IN THIS DB, via SQLite's built-in `PRAGMA user_version` — a free i32 * in the file header, so this needs no table and no migration. Compare it against `indexStamp()` (the * *library's* current stamp, from `indexer.js`) to decide whether this index still describes the * litectx that will read it. Named `storedStamp`, not `indexStamp`, precisely so the two can never be * mistaken for each other at a call site — they mean opposite things and appear on the same line. * * `0` on any db that predates the stamp (or was never indexed), which never matches a real stamp and * therefore reads as "rebuild me" — exactly right for the pre-stamp indexes already in the wild. * @returns {number} */ storedStamp(): number; /** * Record the index-format stamp. Called only after a pass that covered the WHOLE index — a scoped * pass cannot vouch for the files it never looked at. * @param {number} stamp */ setStoredStamp(stamp: number): void; /** * Indexed files carrying at least one node whose per-row `stamp` differs from the current library * stamp — i.e. chunks written by a DIFFERENT-version writer (an old global CLI, a stale node_modules * copy). These must be re-chunked even when their content is byte-identical, because the boundaries, * not the bytes, are stale. Complements {@link storedStamp}: that catches a version bump of the * repo-local litectx (whole-index rebuild); this catches a foreign writer poisoning a subset. * @param {number} stamp the current {@link indexStamp} * @returns {string[]} distinct file paths needing a re-chunk */ staleStampedFiles(stamp: number): string[]; /** * Indexed files that have NO stored vector — a file indexed while embeddings were off (the library * default) has none, and a later embeddings-on pass skips it as "content unchanged", leaving semantic * recall silently dead on it. The index() embeddings tier reads these back and backfills them. * @returns {string[]} distinct file paths lacking a `file_embeddings` row */ vectorlessFiles(): string[]; /** * Write (or replace) file embedding vectors, WITHOUT re-chunking — the bytes and boundaries are already * current, only the vector was missing. Used by the embeddings backfill; the normal path writes vectors * through {@link applyChanges}. All rows land in ONE transaction (the backfill would otherwise issue N * autocommit writes). File vectors only → `file_embeddings`. * @param {[string, Float32Array][]} pairs (path, vec) tuples * @returns {void} */ putEmbeddings(pairs: [string, Float32Array][]): void; /** * Every node defining symbol `name` (over-count: a name defined in N files returns N rows). The * def's `body` powers callee/complexity analysis (impact, slice 5); `format` routes the parser. * @param {string} name * @returns {{ path: string, format: string, start_line: number, end_line: number, body: string }[]} */ symbolDefs(name: string): { path: string; format: string; start_line: number; end_line: number; body: string; }[]; /** * The set of all symbol names defined anywhere in the index — used to resolve a call's callee to * an intra-repo definition (a callee not in this set is external: stdlib/3rd-party, dropped). * @returns {Set} */ allSymbolNames(): Set; /** * Describe one graph node by id (the substrate accessor). `getNode` returns STRUCTURE; `getItem`/ * `get` return the body. Kind-agnostic: an indexed file resolves to a file node carrying its chunks * (the symbols inside) plus exact per-type edge counts; a written-memory id resolves to a zero-chunk, * zero-edge node. Edge counts cover the persisted `import` graph only — call relationships are * impact()'s on-demand job and are never persisted as edges. Returns null for an unknown id. * @param {string} id an indexed file's repo-relative path, or a written-memory id * @returns {GraphNode | null} */ getNode(id: string): GraphNode | null; /** * Walk the persisted edge graph from `id` (the substrate navigator). BFS over edges of `edge` type * ('import' is the only persisted type today — `call` relationships are impact()'s on-demand job). * `dir` picks direction: "out" = what `id` imports, "in" = what imports `id`, "both" = the * neighbourhood. `hops` is the BFS depth, hard-capped at 3 (navigation, not ranking — multi-hop is * legitimate; the cap stops a walk returning half the repo, and `truncated` flags when it bit). * Deduped, nearest-hop-wins, never includes the seed. `edge` is a generic type so future non-code * edges (e.g. `derived_from`) slot in unchanged once a producer emits them. * @param {string} id * @param {{ edge?: string, dir?: "out"|"in"|"both", hops?: number }} [opts] * @returns {{ items: RelatedNode[], truncated: boolean }} */ related(id: string, opts?: { edge?: string; dir?: "out" | "in" | "both"; hops?: number; }): { items: RelatedNode[]; truncated: boolean; }; /** * Ranked search over the FTS index, scoped to a single kind. Kinds never share a ranking * (§5) — the caller runs one `search` per kind and keeps the lists separate, so high-volume * prose can never out-rank code. The `kind = ?` filter rides the UNINDEXED `kind` column. * * Ranking is BM25 plus optional 1-hop import-spreading (`spreadWeight > 0`): the v1 signal * model. Each candidate's score is its own normalised BM25 PLUS `spreadWeight ×` the best * normalised BM25 among its import-neighbours in the pool — an ADDITIVE boost, so a file that * imports/is-imported-by a strong hit is lifted, but a strong hit with weak neighbours is never * taxed (the convex blend `(1-w)·own + w·spread` demoted well-ranked files whose neighbours * were mediocre; additive holds-or-beats it on every bench repo with fewer regressions). Spreading * re-ranks a wider pool than `limit` and is a no-op for kinds without edges (`doc`): order unchanged. * `filter` (multis M3 R2/R5) narrows direct doc/blob rows via the `doc_scope` sidecar. It is a * **resolved** read filter (the strict-scope policy lives in the facade, not here — see * `LiteCtx._resolveReadScope`); this method only executes the three modes it encodes: * - `seeAll: true` → no scope predicate (every row, incl. all tenants) — the single-tenant / * admin / legacy-`null` default. This is the fail-OPEN mode the facade gates behind strictScope. * - `seeAll: false, scope: null` → the shared/global tier ONLY (`ds.scope IS NULL`) — the GLOBAL view. * - `seeAll: false, scope: "user:42"` → `scope ∪ NULL-global` (own uploads + the global kb, never * another tenant). This is the R2 union. * `now` (epoch ms) drops rows whose `expires_at <= now`. All are no-ops on file-indexed rows (no * `doc_scope` row → always global/forever) and ignored entirely by `fact`/`episode` (the mem branch, * scoped by instance owner/session). `seeAll` defaults to "`scope == null`" so a direct caller passing * only `{scope}` (or `{}`) keeps the pre-strict behaviour byte-identical. * @param {string} match an FTS5 MATCH expression * @param {string} kind the memory kind to scope to ("code" | "doc" | ...) * @param {number} [limit=10] * @param {number} [spreadWeight=0] 0 = pure BM25; ~0.4 = v1 default (set by the caller) * For `fact`/`episode` the doc fields above are inert; the per-call owner fence rides * `filter.memOwner`/`filter.memSeeAll` instead (multis M4 — see {@link _memFilter}). * @param {{ scope?: string|null, seeAll?: boolean, now?: number|null, memOwner?: string|null, memSeeAll?: boolean }} [filter] resolved R2 scope + R5 expiry (doc axis) + per-call owner fence (mem axis) * @returns {Hit[]} */ /** * Resolve the per-call memory-axis owner fence (multis M4) from a search/ladder `filter`, falling back * to this instance's `owner` when the caller passes none — so an instance-owned read (the legacy * single-tenant path) is byte-identical. Returns the two bound params the mem predicate uses: * `memSeeAll` (1 = every owner; 0 = fenced) and `memOwner` (the reader's owner, or NULL = the shared * tier only when not seeing all). The facade encodes the strict-scope policy; this only executes it. * @param {{ memOwner?: string|null, memSeeAll?: boolean }} filter * @returns {{ memSeeAll: 0|1, memOwner: string|null }} */ _memFilter(filter: { memOwner?: string | null; memSeeAll?: boolean; }): { memSeeAll: 0 | 1; memOwner: string | null; }; search(match: any, kind: any, limit?: number, spreadWeight?: number, filter?: {}): Hit[]; /** * Attach the best-matching chunk pointer to each hit, in place (chunk-granular recall). File-level * ranking is untouched — the benches gate on hit order and this never reorders; it localizes WHICH * function/section inside an already-ranked file carried the query terms (a function pointer beats * a file pointer). Scoring is structural, no weights: both sides identifier-split the same way * (`splitIdent`, the indexing convention) and score = distinct query terms present in the chunk. * The one non-obvious rule: **the winner may not strictly contain another scoring chunk.** Chunks * nest (file/preamble ⊃ class ⊃ method ⊃ arrow), so a container's term set is a superset of its * children's and would *always* out-count them — a class chunk that wins only by aggregating a * method's match is the file-pointer problem again at class scale. A container still wins when the * match genuinely lives in container-level code (no scoring descendant). Ties: named beats * anonymous, then smaller span, then first-in-file; an anonymous winner (arrow/lambda) is labeled * with its nearest named container. Runs only over the final returned hits (≤ n per kind), never * the pool. `chunk: null` when nothing localizes: written memory has no nodes rows (the row IS * the unit), and a match carried only by path/filename tokens names no chunk. * @param {Hit[]} hits * @param {string[]} terms identifier-split query keywords (`keywords(query)`) * @returns {Hit[]} */ attachChunks(hits: Hit[], terms: string[]): Hit[]; /** * Attach file-level git activity metadata (gitsig) to a result set, in place. One lookup for the * whole set; a path with no stored row gets `git: null` (uncommitted / no git). Grounding only — * never reorders the hits. * @param {Hit[]} hits * @returns {Hit[]} */ attachGit(hits: Hit[]): Hit[]; /** * Attach written-memory grounding columns to a result set, in place (slice 5c, §15): `provenance` * (human/agent VALIDATION status), `use` (recall-demand count — 'recall' rows only, the fetch-toll), * and `occurredAt` (episode timestamp). The written-memory analog of {@link attachGit}: metadata the * caller reads to DECIDE, never a ranking input. Ranking stays pure relevance — the trust/use * tie-break was bench-falsified (it can't safely reorder, and forcing trust/popularity buries fresh * or better-matching answers; §14 #4 / §15 5c). Only `mem`-table rows (facts/episodes) match; file * and doc-from-disk hits are left untouched (a file is not a claim awaiting validation). One batched * query (mem LEFT JOIN recall_log) over the hit paths. * @param {Hit[]} hits * @returns {Hit[]} */ attachMemMeta(hits: Hit[]): Hit[]; /** * Batched lookup of the sealed opaque `meta` (RT-3 #3) for a set of written-memory paths — the raw * JSON strings as stored, for the facade to parse and attach to recall hits / `get`. Returns a Map * keyed by path; a path with no metadata is simply absent. Reads the `mem_meta` passthrough table * only — never an FTS/ranking surface. * @param {string[]} paths * @returns {Map} */ metaFor(paths: string[]): Map; /** @returns {number} number of import edges (slice 4 — for tests/introspection) */ edgeCount(): number; /** * Stored embedding vectors for the given paths (slice 6). Reads only the requested rows — at * search time that's the BM25-gated pool, never the whole corpus — so cosine stays O(pool). * Reconstructs each BLOB into its own Float32Array (copied, so it never aliases SQLite's buffer). * * **Kind-scoped since the vector-table split:** file vectors live in `file_embeddings`, written-memory * vectors in `mem_embeddings`. A single `_rankKind` call ranks ONE kind, so its candidate paths are all * one kind. `code` reads only file vectors, `fact`/`episode` only mem vectors — so a global-tier mem key * that equals a file path can no longer return the wrong kind's vector. `doc` spans both (file `.md` + * written docs). A missing `kind` reads both (legacy/introspection callers). * @param {string[]} paths * @param {string} [kind] the recall kind these paths belong to — selects the vector table(s) * @returns {Map} */ getEmbeddings(paths: string[], kind?: string): Map; /** * Read `(path, vec)` rows from ONE embeddings table into `m` (keyed by path). `table` is ALWAYS a * hardcoded whitelist literal chosen by the callers here (`file_embeddings`/`mem_embeddings`), never * caller input, so the `${table}` interpolation is injection-safe. * @param {string} table @param {string[]} paths @param {Map} m * @returns {void} */ _readVecs(table: string, paths: string[], m: Map): void; /** * Per-candidate vectors for DOC recall, resolving the file/written-doc table split. `doc` is the one * recall kind whose rows live in BOTH vector tables — a file `.md` in `file_embeddings`, a written doc * in `mem_embeddings` — and the two CAN share a path (a `remember(kind:'doc')` whose id equals an * indexed `.md`). A path-keyed read of both tables (as {@link getEmbeddings} does for a null kind) then * lets one table's vector silently overwrite the other's. Here each candidate is routed to the table its * OWN `source` names ('file' → file vectors, 'direct' → mem vectors), and vectors are returned * POSITIONALLY (aligned to `cands`) so a file-doc and a written-doc that share a path each keep their * own vector. A missing vector (or a candidate lacking `source`, which a docs-sourced hit never is) * yields `undefined` at that position — `cosine()` treats it as 0. * @param {{ path: string, source?: string }[]} cands * @returns {(Float32Array | undefined)[]} */ docCandidateVectors(cands: { path: string; source?: string; }[]): (Float32Array | undefined)[]; /** @returns {number} number of stored file embeddings (slice 6 — for tests/introspection) */ embeddingCount(): number; /** * Semantic nominees for written-kind recall — the KNN side of the slice-11 union. Every stored * vector for `kind` (mem-table rows that have one) is scored by cosine against the query vector; * the top `k` not already in the lexical pool come back as Hit-shaped rows with `score: 0` (they * have no lexical score — the caller's fusion ranks them on semantics alone). Written kinds only: * non-mem kinds return `[]` (code/doc queries virtually always share an identifier with their * answer, and their corpora are where a full scan would start to cost). **No admission * threshold by design** — POC-swept (poc/knn-union-poc.mjs): true-paraphrase cosines run low * (T=0.25 already halves para MRR), and the k-cap + fusion keep weak nominees down. The one * exception is exactly zero: no measured similarity is no evidence, so orthogonal vectors never * nominate (real model vectors are dense — this excludes nothing in practice). Linear scan * by design: written memory is dozens-to-hundreds at lite scale (`sqlite-vec` is the named * escalation if a corpus ever justifies it). Rows written while the tier was off have no vector * and simply never nominate. * @param {string} kind the memory kind ("fact" | "episode"); anything else → [] * @param {Float32Array} qvec the embedded query * @param {number} k max nominees * @param {Set} exclude paths already in the lexical pool (never nominated twice) * @param {{ memOwner?: string|null, memSeeAll?: boolean }} [filter] per-call owner fence (multis M4), * defaulting to the instance owner — the KNN nominees must obey the SAME tenant fence as the lexical * pool, or cosine could float another tenant's memory past the BM25 gate. * @returns {Hit[]} */ knnCandidates(kind: string, qvec: Float32Array, k: number, exclude: Set, filter?: { memOwner?: string | null; memSeeAll?: boolean; }): Hit[]; close(): void; } export type DocRow = { /** * repo-relative file path */ path: string; /** * "code" | "doc" */ kind: string; /** * source/doc format tag: "ts" | "js" | "py" | "md" | ... */ format: string; /** * file contents */ body: string; }; /** * `imports` are raw specifiers from the chunker; `edges` are those resolved to intra-repo dst paths * (edges.js); `git` is file-level activity metadata (gitsig.js); `embedding` is the file's float32 * vector when the embeddings tier is on (slice 6), absent otherwise. */ export type Upsert = DocRow & { hash: string; mtime: number; size: number; nodes?: import("./chunker.js").Chunk[]; imports?: string[]; edges?: string[]; git?: import("./gitsig.js").GitSig; embedding?: Float32Array; }; export type Changes = { /** * files to (re)index */ upserts: Upsert[]; /** * unchanged content, advanced mtime */ touch: { path: string; mtime: number; }[]; /** * paths to drop from the index */ deletes: string[]; }; export type ChunkRef = { /** * function/class name, or the md heading; null for anonymous/file chunks */ symbol: string | null; /** * tree-sitter node type, "section" (md), or "file" (fallback chunk) */ nodeType: string; /** * 0-based, inclusive */ startLine: number; /** * 0-based, inclusive */ endLine: number; }; export type Hit = { path: string; kind: string; format: string; /** * higher = more relevant */ score: number; /** * raw query↔hit semantic similarity in [-1,1] (fact/episode, embeddings * mode only; absent in BM25-only mode). The KNN cosine litectx already * computes for ranking, surfaced verbatim — NOT re-normalized into `score`. * An UNBLESSED signal: it separates related from unrelated in aggregate but * has no reliable per-query threshold (R-S8), so the caller owns any cut. */ cosine?: number | undefined; /** * file-level git activity (grounding, not scored) */ git?: import("./gitsig.js").GitSig | null | undefined; /** * the best-matching chunk inside the hit (function pointer > * file pointer); null when nothing localizes — written memory has no * chunks (the row IS the unit), and a path-only match names none */ chunk?: ChunkRef | null | undefined; /** * present ONLY when recall is called with `{ body: true }` (RT-3 * inline-body): the hit's content inlined. VERBATIM stored text for written * memory; the localized chunk's indexed body for a file hit; the whole file * (read fresh from disk) when nothing localized; null when the file is gone * or the id is unknown. Off by default — recall returns pointers, not payloads. */ body?: string | null | undefined; /** * written memory only (slice 5c): "human" | "agent" — the * VALIDATION status (signed-off vs the agent's own assertion), NOT a quality * signal and NEVER scored: an agent fact may be perfectly true, awaiting HITL. * Surfaced for the caller to decide; absent on indexed files (not a claim). */ provenance?: string | null | undefined; /** * written memory only (slice 5c): recall-demand count ('recall' rows only — * fetches excluded, the fetch-toll). Surfaced, NEVER ranked — a fresh effective * memory has use 0, so ranking on it would be a popularity prior (§14 #4). */ use?: number | undefined; /** * written memory only (slice 5c): episode timestamp (epoch ms); * null for facts; absent on indexed files. */ occurredAt?: number | null | undefined; /** * written memory only (RT-3 #3): the opaque caller * metadata supplied to `remember`, parsed back from its sealed JSON store * and returned VERBATIM. Absent when the memory carries none and on every * indexed file (a file has no caller metadata). Never tokenized/ranked. */ meta?: Record | undefined; }; /** * A graph node's STRUCTURE (what `getNode` returns) — distinct from its body (`get`). Kind-agnostic: * an indexed file carries its chunks + import-edge counts; written memory is a zero-chunk, zero-edge node. */ export type GraphNode = { /** * repo-relative path (file) or written-memory id */ id: string; kind: string; format: string; source: "file" | "direct"; /** * written memory only: "human" | "agent" */ provenance?: string | null | undefined; /** * file activity (grounding, not scored); null for written memory */ git: import("./gitsig.js").GitSig | null; /** * the symbols inside a file node; [] for written memory */ chunks: ChunkRef[]; /** * persisted `import`-edge counts (EXACT; calls are impact()'s job) */ edges: { imports: number; importedBy: number; }; }; /** * One neighbour returned by `related` — a node reached by walking persisted edges from the seed. */ export type RelatedNode = { id: string; /** * null when the neighbour isn't an indexed node (e.g. an import to a file outside scope) */ kind: string | null; format: string | null; /** * BFS distance from the seed (nearest-hop-wins) */ hops: number; /** * "out" = the seed imports it; "in" = it imports the seed */ via: "out" | "in"; };