/** * memoryBankStore — the `MemoryStore` port over Vertex AI **Memory Bank**. * * import { memoryBankStore } from 'agentfootprint/memory'; * * const store = memoryBankStore({ * project: 'my-project', * location: 'us-central1', * reasoningEngine: '1234567890', * }); * * ── Read this before you write anything into it ───────────────────────────── * Memory Bank is a **natural-language** memory service, not a key-value store * and not a vector database. A `Memory` is a `fact` string plus an immutable * `scope`; retrieval takes a QUESTION IN WORDS, embeds it on Google's side and * ranks there. Three consequences, each of which has a silent-failure mode * that this adapter turns into something you can see: * * 1. **It never ranks the vectors you wrote.** `supportsVectorSearch` is * `false` and `ranksBy` is `'server-text'`, so the corpus builders * (`indexCorpus`, `indexFolder`, `indexDocuments`) refuse this store by * name instead of embedding a whole corpus, reporting success, and leaving * it unreachable forever. An `embedding` on an entry handed to `put()` is * **not stored** — there is nowhere to put it and nothing that would read * it — and the two declarations above are how this adapter says so before * you spend anything. * * 2. **The retrieval score is a DISTANCE, and smaller is closer.** The port's * `ScoredEntry.score` is a cosine similarity, where HIGHER is closer. * Passed through unconverted, `search()` would return the LEAST relevant * memories first with a confident-looking number in the right range — * which no threshold and no eyeball can separate from a working search. * See {@link MemoryBankStore.search} for exactly what this adapter does * instead, and why it refuses `minScore` rather than reinterpreting it. * * 3. **`scope` is an exact match and immutable once written.** A retrieval * whose scope is a subset of a memory's scope returns NOTHING — not a * superset, not a partial match, nothing. So the scope convention has to * be right before the first write, because it cannot be changed after it * and a bank written under the wrong one is poisoned permanently. See * {@link MemoryBankStoreOptions.scopeFor}. * * ── How a memory is ADDRESSED, and why it is not just the entry id ────────── * A memory's resource name is `/memories/`, and that * resource id is composed from **the resolved scope and the entry id * together** — never the entry id alone. * * The reason is that entry ids in this library are deliberately deterministic * and identity-free: `msg--`, `fact:`, `snap-`. Two * people talking to the same agent produce the SAME entry ids, so an address * built from the id alone is one row for both of them — and since a resource * name addresses a row directly, the second writer's `fact` lands on the first * writer's row while the immutable `scope` stays the first writer's. What comes * back is one tenant reading another tenant's private fact, one tenant's own * write invisible to them, and `forget()` finding nothing to erase. Every * sibling store in this library namespaces by identity (`s3Vectors` keys on * `#`, `pgVector` on `(namespace, id)`, `InMemoryStore` on a map * per namespace); this one does the same thing, keyed on the SCOPE rather than * the raw identity so that a widened `scopeFor` widens sharing exactly as much * as it widens retrieval, and not one row more. * * The composed address is a partition, not the boundary itself. The boundary is * the stored `scope`, which is re-checked on the way back from every read AND * before every overwrite — so even an address collision is refused rather than * written through. * * ── The retrieved NAME is not the name you wrote ──────────────────────────── * A live field trial on the raw service found this and it is worth stating, * because it is exactly the assumption an adapter is tempted to make: `create` * and `list` came back with the caller-chosen memory ids, while SIMILARITY * RETRIEVAL answered with generated numeric resource names for the very same * facts (FINDINGS "Agent Runtime Memory Bank"). Anything reading an entry id * out of `memory.name` would therefore work perfectly on `list()` and hand back * unusable ids from `search()` — ids that no `get()` or `delete()` could find. * * This adapter never does that: `toEntry` reads the id from the metadata this * library wrote, and the resource name is carried only as * `entry.metadata.resourceName`, for looking at. The same trial also confirmed * exact-match scope semantics (a partial `{tenant}` scope retrieved nothing) * and the distance-not-similarity ranking that {@link MemoryBankStore.search} * converts. * * ── Writes are long-running operations ────────────────────────────────────── * `create`, `patch` and `delete` all answer with an Operation rather than the * resource — verified against the installed SDK's own return types. Every * write here waits for `done` before returning, because a `put` that came back * early followed by a `get` is a race whose failure mode is "no data", and * nobody can tell that from a memory that was never written. * * ── What has no primitive here, and is therefore refused ──────────────────── * `putIfVersion`, `seen`, `recordSignature`, `feedback` and `getFeedback` have * no counterpart in this service: a `Memory` carries no etag and there is no * dedup or feedback surface. The sibling AgentCore adapter emulates them * in-process; this one refuses them by name, and the difference is deliberate. * A store you reach for BECAUSE it is shared across a fleet is the worst place * for a per-process shadow: `seen()` would answer `false` in the second * container for a signature the first one recorded, and an emulated * `putIfVersion` across two writers is a lost-update generator that reports * `{ applied: true }` to both. A refusal you read once beats a correctness bug * you never find. * * Pattern: Adapter (GoF) — `MemoryStore` onto `reasoningEngines.memories`, * through the shared REST client in `adapters/google/aiPlatform.ts`. */ import type { MemoryEntry } from '../../memory/entry/index.js'; import type { MemoryIdentity } from '../../memory/identity/index.js'; import type { ListOptions, ListResult, MemoryStore, PutIfVersionResult, ScoredEntry, SearchOptions } from '../../memory/store/types.js'; import { type AiPlatformConnection } from '../google/aiPlatform.js'; /** * The most JSON one carried field may be, in characters. * * Memory Bank metadata values are typed scalars and this column has NOT * measured the service's own ceiling on a string one, so the bound is this * adapter's own and says so where it refuses. It is a refusal rather than a * truncation on purpose: provenance that came back shortened would be * provenance nobody could tell was shortened. */ export declare const MAX_CARRIED_JSON = 8192; /** The scope map an identity resolves to. Keys and values are both strings. */ export type MemoryScope = Readonly>; /** Options for {@link memoryBankStore}. */ export interface MemoryBankStoreOptions extends AiPlatformConnection { /** * Map this library's identity tuple onto Memory Bank's `scope` — **the one * decision that cannot be taken back.** * * The default is the full tuple: * * ``` * { tenant: '', principal: '', conversation: '' } * ``` * * which is the same isolation every other store in this library enforces * (`identityNamespace` composes exactly these three), so agent code behaves * identically whichever column it runs on. That consistency is why it is the * default even though it is the NARROWEST useful choice. * * **What it costs, stated plainly.** Because scope matching is exact, a * memory written under a conversation is retrievable only within that * conversation. If what you want from a memory bank is "remember this person * across their conversations" — which is usually the point — widen it here: * * ```ts * import { encodeIdentityField } from 'agentfootprint/memory'; * * scopeFor: (identity) => ({ * tenant: encodeIdentityField(identity.tenant), * principal: encodeIdentityField(identity.principal), * }) * ``` * * Encode the fields rather than writing `identity.tenant ?? '_'`: a raw `_` * is what "no tenant" spells, so an un-encoded widening hands the anonymous * scope to anyone whose tenant is literally `_`. The encoder returns * ordinary ids byte for byte and only escapes the ones that would collide. * * **And why it is worth getting right the first time.** `Memory.scope` is * immutable. Memories already written keep the scope they were written with, * and a later retrieval under a different convention will not find them — * not with a warning, not with a partial match, but with an empty result * that looks exactly like "this person has told us nothing". Changing the * convention on a live bank means re-writing every memory in it. * * Values may not contain `*`; this adapter replaces any it is handed and * refuses an empty scope outright, because a scope of `{}` is the one value * that matches every other empty-scoped memory in the bank regardless of who * wrote it. */ readonly scopeFor?: (identity: MemoryIdentity) => MemoryScope; /** * How long a memory lives, as a duration string the API accepts (`'86400s'`). * Omit and memories do not expire. * * A `MemoryEntry.ttl` is a unix TIMESTAMP and this is a DURATION; the two * are different quantities and the entry's own is honoured per write, so * this is only the default for entries that name none. */ readonly ttl?: string; /** * How long a write waits for its long-running operation before refusing. * Default {@link DEFAULT_OPERATION_TIMEOUT_MS} (30s). */ readonly operationTimeoutMs?: number; /** * How many rows a `list()` page carries when the caller names no limit. * Default 20. The service's own ceiling is 100 and it silently coerces * anything larger, so this adapter clamps rather than letting a request for * 500 come back as 100 with no explanation. */ readonly pageSize?: number; } /** The service's own ceiling on a page or a top-k. Larger values are coerced. */ export declare const MAX_PAGE_SIZE = 100; /** * A `MemoryStore` over Vertex AI Memory Bank. * * **Status: field-validated on the data plane (2026-08-14).** An independent * trial ran THIS class against a real Memory Bank and every one of these * answered a live request: the honest `supportsVectorSearch: false` / * `ranksBy: 'server-text'` declarations; cross-conversation recall under a * widened `scopeFor`; two identities using the SAME entry id without collision * or disclosure; string and structured values; opaque pagination cursors; tier * filtering; text similarity that found the right marker, excluded the other * identity, returned finite distances and converted them to correctly ORDERED * scores; overwrite advancing value and version; a scoped delete leaving the * other identity intact; `forget()` erasing across the widened scope; and the * five unsupported operations refusing by name rather than pretending. * * The same trial found the one thing that was NOT faithful — `source` and * caller `metadata` were accepted and silently dropped — which 9.30.0 fixes by * carrying them (see `toMemory`). That fix is tested here and has not itself * been re-run in a live project. */ export declare class MemoryBankStore implements MemoryStore { /** * **No.** `search()` exists here, but it is Memory Bank's own retrieval: * Google embeds and ranks on its side, over the `fact` strings this store * wrote, and never over an `embedding` handed to `put()`. Embeddings are not * stored at all. * * Declared because a method's presence could not say that — and because the * sibling column already paid for the lesson once, with a corpus that * indexed, billed, reported success, and was unreachable forever. */ readonly supportsVectorSearch = false; /** * The query form this store takes: **words, not a vector.** `search()` reads * {@link SearchOptions.text} and refuses by name without it. A retriever * built over this store therefore needs no `Embedder`, and wiring one would * be spend on a vector discarded on arrival. */ readonly ranksBy: "server-text"; private readonly memories; private readonly scope; private readonly scopeFor; private readonly operationTimeoutMs; private readonly pageSize; private readonly defaultTtl; private closed; constructor(options: MemoryBankStoreOptions); /** * One memory by id. * * Two independent things keep this from reading somebody else's memory, and * both are deliberate. The **address** carries the scope, so another * identity's row for the same entry id is a different resource name that * simply is not there. And the **stored scope is re-checked on the way * back**, because a resource name addresses a memory directly and a `get` * alone would happily read another tenant's row for anyone who could guess a * name. A memory whose scope is not this identity's answers `null` — the same * `null` a missing one answers, because "exists but not yours" is an oracle * for which ids are real. */ get(identity: MemoryIdentity, id: string): Promise | null>; /** * A page of this identity's memories, in no particular order. * * It rides `retrieve` with `simpleRetrievalParams` rather than `memories.list` * — deliberately. `list` filters with AIP-160 over the resource's own fields, * and whether that filter language can express an exact scope match is not * something this adapter is willing to guess at for the call that decides * which memories a caller can see. `retrieve` takes the scope as a structured * field with semantics the SDK states outright, so the isolation is the * service's rather than a filter string's. * * `tiers` filtering is applied to what comes back, since a tier is this * library's own metadata and not something the service ranks on. */ list(identity: MemoryIdentity, options?: ListOptions): Promise>; /** * Memory Bank's own semantic retrieval — **text in, and the ranking trap * handled rather than passed on.** * * ── It takes WORDS, not the vector ─────────────────────────────────────── * Google embeds and ranks server-side, so the `query` vector this method is * handed cannot be sent anywhere. The query it needs travels in * {@link SearchOptions.text}, and omitting it is refused by name — returning * `[]` would read as "no matches" when it means "wrong query form". * * ── The score, and what this adapter refuses to pretend ────────────────── * The service reports a **distance**, in its own words "smaller values * indicate more similar memories". The port's score is a cosine similarity, * where higher is closer. Two things follow, and both are decisions: * * • The distance is **converted**, never forwarded: `score = 1 / (1 + d)`, * which is strictly decreasing in `d`, so **the ordering is right** — * the closest memory has the highest score, which is the whole point. * The raw distance is carried on `entry.metadata.distance` so nothing is * hidden and a caller who knows the metric can do better. * * • **`minScore` is REFUSED by name**, because that number is calibrated * for a cosine similarity and this scale is not one. Silently applying a * cosine threshold to a converted distance is precisely the failure this * library refuses elsewhere: a number that READS like a similarity, in * the right range, that no threshold and no eyeball can separate from a * real one. The sibling S3 Vectors adapter refuses a non-cosine index * for the same reason; here the metric is Google's and cannot be * changed, so the threshold is what goes rather than the store. * * ── One more thing the service requires ────────────────────────────────── * Similarity search only works if the reasoning engine was configured with a * similarity-search config. Without it the service refuses the call, and * that refusal is passed through with its status — it is a setup fact, not a * bug in the query. * * @throws when `options.text` is absent, or `options.minScore` is present. */ search(identity: MemoryIdentity, query: readonly number[], options?: SearchOptions): Promise[]>; /** * Write one memory, waiting for the service to say it landed. * * ── Read-then-write, and what the read is FOR ──────────────────────────── * This costs one `get` before the write, and that read is not an * optimisation — it is the tenant check. A `patch` names a resource * directly and the service does not ask whose it is, so a patch sent * without looking is a write this adapter cannot promise landed on its own * row. The address already carries the scope (see the module header), so * the row under this name is ours in every ordinary run; the read is what * turns "ordinary" into "checked", and what makes the one case where it is * NOT ours a refusal you can read instead of a fact one tenant wrote into * another tenant's memory. * * What the read finds decides the rest: an existing row of ours is patched, * an absent one is created, and a row that is somebody else's is refused by * name. The `create` race — two writers, neither of whom saw a row — is * caught as `ALREADY EXISTS` and folded back into the same checked patch. */ put(identity: MemoryIdentity, entry: MemoryEntry): Promise; /** * Sequential, not batched: the service has no batch-write operation, and * each write is a long-running operation that has to be waited on * individually. An empty batch is a no-op and costs no round trip, which * callers rely on. */ putMany(identity: MemoryIdentity, entries: readonly MemoryEntry[]): Promise; /** * Remove one memory. * * Scope-checked first, for the reason {@link get} spells out: a resource * name addresses a row directly, and a delete that skipped the check would * let anyone who could guess an id remove another tenant's memory. A memory * that is not this identity's is left alone and reported as nothing to do — * the same answer a missing one gets. */ delete(identity: MemoryIdentity, id: string): Promise; /** * GDPR — every memory for this identity, gone. * * Paginated retrieve-then-delete, and **deliberately not `memories.purge`**, * for two reasons that are both about not being silently wrong on the one * operation where that matters most: * * 1. `PurgeMemoriesRequest.force` defaults to **false**, which the service * documents as "the purge request will be validated but not executed". * A forget built on it and written without that flag would report * success and delete nothing — a compliance failure that looks exactly * like a working erasure. * 2. Purge selects rows with an AIP-160 filter STRING, and whether that * language can express an exact scope match is not verified. A filter * that under-matches leaves data behind; one that over-matches deletes * somebody else's. Neither is a guess worth making here. * * The scoped retrieve has semantics the SDK states outright, so that is what * this uses. It costs one delete per memory, which is the right price. */ forget(identity: MemoryIdentity): Promise; /** * @throws always — this service has no compare-and-set. * @see the module header for why this refuses where the sibling adapter * emulates. */ putIfVersion(_identity: MemoryIdentity, entry: MemoryEntry, expectedVersion: number): Promise; /** @throws always — this service has no recognition set. */ seen(_identity: MemoryIdentity, signature: string): Promise; /** @throws always — the write side of a recognition set this service does not have. */ recordSignature(_identity: MemoryIdentity, signature: string): Promise; /** @throws always — this service has no feedback primitive. */ feedback(_identity: MemoryIdentity, id: string, _usefulness: number): Promise; /** @throws always — the read side of feedback this service does not record. */ getFeedback(_identity: MemoryIdentity, id: string): Promise<{ average: number; count: number; } | null>; /** * Stop using this store. Idempotent and final. Nothing is torn down on * Google's side — the memories outlive this process, which is the point. */ close(): Promise; /** Where this identity's copy of `id` lives. See the module header. */ private nameOf; /** One memory by resource name, or `undefined` when there is none. */ private fetch; /** * Patch the row at `name` if it exists AND is this scope's; answer `false` * when there is nothing there to patch. * * A row that exists under somebody else's scope is REFUSED rather than * written. It should be unreachable — the address carries the scope — so * reaching it means the fingerprint collided or the bank was written by * another tool under a name of ours, and both of those are facts an operator * has to be told rather than have resolved in favour of the last writer. */ private overwrite; private ensureOpen; private wait; /** Keep an already-sanitized refusal; sanitize anything else. */ private asFailure; /** The identity's scope, checked for the two values that would break isolation. */ private resolveScope; } /** * A `MemoryStore` over Vertex AI Memory Bank. * * @example Per-person memory that survives a conversation ending * const store = memoryBankStore({ * project: 'my-project', * location: 'us-central1', * reasoningEngine: '1234567890', * scopeFor: (id) => ({ * tenant: encodeIdentityField(id.tenant), * principal: encodeIdentityField(id.principal), * }), * }); * * const hits = await store.search(identity, [], { text: 'what does she prefer?', k: 5 }); */ export declare function memoryBankStore(options: MemoryBankStoreOptions): MemoryBankStore; /** * Distance → a score whose ORDER is right. * * `1 / (1 + d)` is strictly decreasing on `d >= 0`, lands in `(0, 1]`, and is * exactly 1 at distance 0. It is **not** a cosine similarity and this adapter * never says it is — see {@link MemoryBankStore.search} for why `minScore` is * refused rather than measured against it. * * A row with no distance is a simple retrieval, which does no ranking at all; * `0` is the honest score for "this was not ranked", and it sorts last. */ export declare function scoreFromDistance(distance: number | null | undefined): number;