agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,104 lines • 57.8 kB
JavaScript
"use strict";
/**
* 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 `<engine>/memories/<resource id>`, 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-<turn>-<index>`, `fact:<key>`, `snap-<turn>`. 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
* `<namespace>#<id>`, `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`.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.scoreFromDistance = exports.memoryBankStore = exports.MemoryBankStore = exports.MAX_PAGE_SIZE = exports.MAX_CARRIED_JSON = void 0;
const requestLimit_js_1 = require("../../memory/store/requestLimit.js");
const index_js_1 = require("../../memory/identity/index.js");
const writeOptions_js_1 = require("../../memory/store/writeOptions.js");
const aiPlatform_js_1 = require("../google/aiPlatform.js");
const ADAPTER = 'memoryBankStore';
/**
* Why this store refuses both write options. A `put` here is `memories.create`
* — the entry IS a long-term memory the moment it lands, with no short-term
* tier for `extraction: 'skip'` to leave it in; and its namespace is the
* immutable `scope` from `scopeFor(identity)`, which a per-write
* `namespaceScope` would silently fork away from every later retrieval.
*/
const MEMORY_BANK_WRITES = 'every write here is itself a long-term memory (there is no extraction step to skip), ' +
'and its scope comes from `scopeFor(identity)` — a per-write namespace would file it ' +
'where no retrieval for that identity looks. Leave the option off for this store; ' +
'scope it through `scopeFor`';
/**
* The metadata keys this adapter owns on a `Memory`.
*
* Prefixed, because the metadata map belongs to whoever owns the bank and this
* library is a guest in it: an unprefixed `id` or `version` collides with the
* next writer, and Memory Bank's metadata filters are exact-key matches, so a
* collision is not a merge — it is one party's filter quietly matching the
* other party's rows.
*/
const META = {
id: 'agentfootprint_id',
version: 'agentfootprint_version',
createdAt: 'agentfootprint_created_at',
updatedAt: 'agentfootprint_updated_at',
tier: 'agentfootprint_tier',
json: 'agentfootprint_json',
/** {@link MemoryEntry.source}, verbatim, as JSON. See {@link MAX_CARRIED_JSON}. */
source: 'agentfootprint_source',
/** {@link MemoryEntry.metadata} the CALLER wrote, verbatim, as JSON. */
meta: 'agentfootprint_metadata',
/** {@link MemoryEntry.decayPolicy}, as JSON — retrieval ranking depends on it. */
decay: 'agentfootprint_decay',
};
/**
* The metadata keys this adapter GENERATES on the way out, which therefore
* cannot also be caller metadata.
*
* They are read-side facts about the row, not stored data: `source` names the
* backend, `resourceName` is the Vertex name, `distance` is the raw retrieval
* distance. Round-tripping an entry this store handed back must not fail, and
* a caller's own value under one of these names must not be silently replaced
* — see {@link splitMetadata} for how those two are told apart.
*/
const GENERATED_METADATA = ['source', 'resourceName', 'distance'];
/**
* What a Vertex resource name for a memory looks like:
* `projects/<p>/locations/<l>/reasoningEngines/<e>/memories/<resource id>`.
*
* Used as the SHAPE half of recognising this adapter's own `resourceName` on the
* way back in — never as a parser. Nothing reads an id out of a resource name
* here (the module header says why); this only answers "could this string be one
* of ours?".
*/
const RESOURCE_NAME = /^projects\/.+\/memories\/[^/]+$/;
/**
* 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.
*/
exports.MAX_CARRIED_JSON = 8_192;
/** The service's own ceiling on a page or a top-k. Larger values are coerced. */
exports.MAX_PAGE_SIZE = 100;
/** What a `list()` page carries when nothing was asked for. */
const DEFAULT_PAGE_SIZE = 20;
/**
* 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.
*/
class MemoryBankStore {
/**
* **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.
*/
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.
*/
ranksBy = 'server-text';
memories;
scope;
scopeFor;
operationTimeoutMs;
pageSize;
defaultTtl;
closed = false;
constructor(options) {
this.scope = (0, aiPlatform_js_1.resolveEngine)(ADAPTER, options);
this.memories = (0, aiPlatform_js_1.buildAiPlatformClient)(ADAPTER, options, this.scope).projects.locations.reasoningEngines.memories;
this.scopeFor = options.scopeFor ?? defaultScopeFor;
this.operationTimeoutMs = options.operationTimeoutMs ?? aiPlatform_js_1.DEFAULT_OPERATION_TIMEOUT_MS;
this.pageSize = clampPage(options.pageSize ?? DEFAULT_PAGE_SIZE);
this.defaultTtl = options.ttl;
}
// ── Reads ───────────────────────────────────────────────────────
/**
* 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.
*/
async get(identity, id) {
this.ensureOpen('get');
const wanted = this.resolveScope(identity);
const memory = await this.fetch(this.nameOf(wanted, id));
if (memory === undefined)
return null;
if (!sameScope(memory.scope, wanted))
return null;
const entry = toEntry(memory);
if (entry === null)
return null;
if (entry.ttl !== undefined && entry.ttl <= Date.now())
return null;
return entry;
}
/**
* 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.
*/
async list(identity, options = {}) {
this.ensureOpen('list');
const requestedLimit = (0, requestLimit_js_1.storeRequestLimit)(options.limit, 'limit');
const scope = this.resolveScope(identity);
const pageSize = clampPage(requestedLimit ?? this.pageSize);
let page;
try {
page = (await this.memories.retrieve({
parent: this.scope.parent,
requestBody: {
scope,
simpleRetrievalParams: {
pageSize,
...(options.cursor !== undefined && { pageToken: options.cursor }),
},
},
}))?.data;
}
catch (err) {
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
}
const now = Date.now();
const entries = [];
for (const row of page?.retrievedMemories ?? []) {
const entry = row.memory === undefined ? null : toEntry(row.memory);
if (entry === null)
continue;
if (entry.ttl !== undefined && entry.ttl <= now)
continue;
if (options.tiers && (entry.tier === undefined || !options.tiers.includes(entry.tier))) {
continue;
}
entries.push(entry);
}
const cursor = page?.nextPageToken;
return typeof cursor === 'string' && cursor !== '' ? { entries, cursor } : { entries };
}
/**
* 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.
*/
async search(identity, query, options = {}) {
this.ensureOpen('search');
const requestedK = (0, requestLimit_js_1.storeRequestLimit)(options.k, 'k');
const text = options.text?.trim();
if (!text) {
throw new Error(`${ADAPTER}.search() needs the query as TEXT, in \`options.text\`.\n` +
` Memory Bank embeds and ranks on Google's side, so the ${query.length}-dimension ` +
`vector this method was handed cannot be sent anywhere — and returning [] would look ` +
`like "no matches" rather than "wrong query form".\n` +
` Fix: store.search(identity, vector, { text: theUserQuestion })\n` +
` Backends that rank locally ignore \`text\`, so passing both is always safe.`);
}
if (options.minScore !== undefined) {
throw new Error(`${ADAPTER}.search() does not accept \`minScore\`.\n` +
` This service reports a DISTANCE (smaller is closer), not a cosine similarity ` +
`(higher is closer). This adapter converts it to 1/(1+distance) so the ORDERING is ` +
`right, but that scale is not a cosine one and your threshold was calibrated for a ` +
`cosine — applying it here would silently keep or drop the wrong memories, with a ` +
`number in the right range that nothing can distinguish from a real score.\n` +
` Fix: drop minScore and use \`k\` to bound the result, or filter yourself on ` +
`\`entry.metadata.distance\`, which is carried through unmodified.`);
}
const scope = this.resolveScope(identity);
const topK = clampPage(requestedK ?? 10);
let page;
try {
page = (await this.memories.retrieve({
parent: this.scope.parent,
requestBody: { scope, similaritySearchParams: { searchQuery: text, topK } },
}))?.data;
}
catch (err) {
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
}
const now = Date.now();
const scored = [];
for (const row of page?.retrievedMemories ?? []) {
const entry = row.memory === undefined ? null : toEntry(row.memory, row);
if (entry === null)
continue;
if (entry.ttl !== undefined && entry.ttl <= now)
continue;
// A tier is this library's metadata, not something the service ranks on,
// so a tier filter is applied to what came back rather than ignored.
if (options.tiers && (entry.tier === undefined || !options.tiers.includes(entry.tier))) {
continue;
}
scored.push({ entry, score: scoreFromDistance(row.distance) });
}
// The service returns its own ranking; re-sort anyway so the port's
// "descending by score" holds even if a future API version reorders.
scored.sort((a, b) => b.score !== a.score ? b.score - a.score : a.entry.id < b.entry.id ? -1 : 1);
return scored.slice(0, topK);
}
// ── Writes ──────────────────────────────────────────────────────
/**
* 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.
*/
async put(identity, entry, options) {
this.ensureOpen('put');
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('MemoryBankStore', undefined, options, MEMORY_BANK_WRITES);
if (entry.ttl !== undefined && entry.ttl <= Date.now())
return;
const scope = this.resolveScope(identity);
const body = toMemory(entry, scope, this.defaultTtl);
const resourceId = resourceIdFor(scope, entry.id);
const name = `${this.scope.parent}/memories/${resourceId}`;
if (await this.overwrite(name, scope, entry.id, body))
return;
let created;
try {
created = await this.memories.create({
parent: this.scope.parent,
memoryId: resourceId,
requestBody: body,
});
}
catch (err) {
if (!(0, aiPlatform_js_1.isAlreadyExists)(err))
throw this.asFailure(err, 'memories.create');
// Another writer created it between our read and our create. The row
// exists now, which is all this wanted — so take the same checked patch
// path rather than reporting a failure for a race that resolved.
if (await this.overwrite(name, scope, entry.id, body))
return;
// Created by someone and gone again before we could read it. Nothing
// this adapter can say landed, so it says so.
throw this.asFailure(err, 'memories.create');
}
await this.wait(created?.data, `creating memory '${entry.id}'`);
}
/**
* 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.
*/
async putMany(identity, entries, options) {
this.ensureOpen('putMany');
for (const entry of entries)
await this.put(identity, entry, options);
}
/**
* 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.
*/
async delete(identity, id) {
this.ensureOpen('delete');
const scope = this.resolveScope(identity);
const name = this.nameOf(scope, id);
const existing = await this.fetch(name);
if (existing === undefined || !sameScope(existing.scope, scope))
return;
let deleted;
try {
deleted = await this.memories.delete({ name });
}
catch (err) {
if ((0, aiPlatform_js_1.isNotFound)(err))
return;
throw this.asFailure(err, 'memories.delete');
}
await this.wait(deleted?.data, `deleting memory '${id}'`);
}
/**
* 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.
*/
async forget(identity) {
this.ensureOpen('forget');
const scope = this.resolveScope(identity);
let cursor;
do {
let page;
try {
page = (await this.memories.retrieve({
parent: this.scope.parent,
requestBody: {
scope,
simpleRetrievalParams: {
pageSize: exports.MAX_PAGE_SIZE,
...(cursor !== undefined && { pageToken: cursor }),
},
},
}))?.data;
}
catch (err) {
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
}
const names = (page?.retrievedMemories ?? [])
.map((row) => row.memory?.name)
.filter((name) => typeof name === 'string' && name !== '');
for (const name of names) {
let deleted;
try {
deleted = await this.memories.delete({ name });
}
catch (err) {
if ((0, aiPlatform_js_1.isNotFound)(err))
continue;
throw this.asFailure(err, 'memories.delete');
}
await this.wait(deleted?.data, `deleting memory '${name}'`);
}
const next = page?.nextPageToken;
// A page that deleted everything it listed and hands back the same
// cursor would loop forever. Erasure walks forward or it stops.
cursor = typeof next === 'string' && next !== '' && next !== cursor ? next : undefined;
} while (cursor !== undefined);
}
// ── The five with no primitive behind them ──────────────────────
/**
* @throws always — this service has no compare-and-set.
* @see the module header for why this refuses where the sibling adapter
* emulates.
*/
putIfVersion(_identity, entry, expectedVersion) {
return Promise.reject(unsupported('putIfVersion', `A \`Memory\` carries no etag or generation number, so there is nothing to compare ` +
`against — the write for '${entry.id}' at version ${expectedVersion} cannot be made ` +
`conditional.\n` +
` Emulating it with a read-then-write would report \`{ applied: true }\` to two ` +
`writers at once, which is a lost update that no log would show. ` +
`Use put() when you know you are the only writer, or keep memories that need ` +
`optimistic concurrency in a store that has it (pgVectorStore, RedisStore, ` +
`sqliteVectorStore).`));
}
/** @throws always — this service has no recognition set. */
seen(_identity, signature) {
return Promise.reject(unsupported('seen', `There is no dedup primitive here, and answering '${signature}' from a per-process Map ` +
`would be worse than refusing: this store is one you reach for BECAUSE it is shared ` +
`across a fleet, and a second container would answer "never seen" for a signature ` +
`the first one recorded.\n` +
` Keep the recognition set in a store that is really shared (RedisStore), or ` +
`dedup on a deterministic entry id and let put() overwrite.`));
}
/** @throws always — the write side of a recognition set this service does not have. */
recordSignature(_identity, signature) {
return Promise.reject(unsupported('recordSignature', `There is nowhere to record '${signature}'. See seen() — the two are one feature and ` +
`neither is emulated in-process.`));
}
/** @throws always — this service has no feedback primitive. */
feedback(_identity, id, _usefulness) {
return Promise.reject(unsupported('feedback', `There is no usefulness signal on a \`Memory\`, so feedback for '${id}' has nowhere ` +
`durable to go. A per-process tally would be lost on the next deploy and invisible ` +
`to every other container, which is the shape of a metric that quietly stops ` +
`meaning anything.`));
}
/** @throws always — the read side of feedback this service does not record. */
getFeedback(_identity, id) {
return Promise.reject(unsupported('getFeedback', `Nothing records feedback here, so there is none to read for '${id}'. Answering ` +
`\`null\` would be indistinguishable from "recorded, but neutral", which callers ` +
`are documented to treat differently.`));
}
// ── Lifecycle ───────────────────────────────────────────────────
/**
* 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() {
this.closed = true;
return Promise.resolve();
}
// ── Internals ───────────────────────────────────────────────────
/** Where this identity's copy of `id` lives. See the module header. */
nameOf(scope, id) {
return `${this.scope.parent}/memories/${resourceIdFor(scope, id)}`;
}
/** One memory by resource name, or `undefined` when there is none. */
async fetch(name) {
try {
return (await this.memories.get({ name }))?.data;
}
catch (err) {
if ((0, aiPlatform_js_1.isNotFound)(err))
return undefined;
throw this.asFailure(err, 'memories.get');
}
}
/**
* 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.
*/
async overwrite(name, scope, id, body) {
const existing = await this.fetch(name);
if (existing === undefined)
return false;
if (!sameScope(existing.scope, scope))
throw foreignMemory(id, name);
let patched;
try {
patched = await this.memories.patch({
name,
// `scope` is immutable and deliberately NOT in the mask: including it
// would make every update a request to change something the service
// refuses to change.
updateMask: 'fact,metadata',
requestBody: { fact: body.fact, metadata: body.metadata },
});
}
catch (err) {
// Deleted between the read and the patch. Nothing was overwritten, so
// the caller falls through to a create — the outcome they asked for.
if ((0, aiPlatform_js_1.isNotFound)(err))
return false;
throw this.asFailure(err, 'memories.patch');
}
// Awaited OUTSIDE the try: an operation refusal is already sanitized and
// already says what to do, and re-wrapping would replace a precise
// diagnosis with a generic one.
await this.wait(patched?.data, `updating memory '${id}'`);
return true;
}
ensureOpen(op) {
if (this.closed)
throw new Error(`${ADAPTER}.${op}() called after close().`);
}
wait(operation, what) {
return (0, aiPlatform_js_1.awaitOperation)(ADAPTER, this.memories.operations, operation, what, this.operationTimeoutMs);
}
/** Keep an already-sanitized refusal; sanitize anything else. */
asFailure(err, operation) {
if ((0, aiPlatform_js_1.isSanitizedGoogleError)(err))
return err;
return (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, operation, err);
}
/** The identity's scope, checked for the two values that would break isolation. */
resolveScope(identity) {
const raw = this.scopeFor(identity);
const entries = Object.entries(raw ?? {}).filter(([key, value]) => typeof value === 'string' && key !== '');
if (entries.length === 0) {
throw new TypeError(`${ADAPTER}: 'scopeFor' produced an empty scope for conversation ` +
`'${identity.conversationId}'.\n` +
` An empty scope is not "no scoping" — it is a real scope that matches every other ` +
`empty-scoped memory in the bank, whoever wrote it. Return at least one key.`);
}
// The service rejects `*` in a scope value, so one is ESCAPED rather than
// passed on — a tenant named with one is a NAME, not a wildcard. Escaped
// and not replaced: mapping `*` onto some other character merges two
// different scopes into one address, which is the isolation bug this
// method exists to prevent (`*` used to become `_`, landing a tenant named
// `*` on top of the no-tenant scope). `%` goes first because it introduces
// the escape; a value that arrived already encoded is escaped again, which
// is unreadable but injective, and unreadable-and-separate beats legible
// and shared.
return Object.fromEntries(entries.map(([key, value]) => [key, value.replace(/%/g, '%25').replace(/\*/g, '%2A')]));
}
}
exports.MemoryBankStore = MemoryBankStore;
/**
* 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 });
*/
function memoryBankStore(options) {
return new MemoryBankStore(options);
}
exports.memoryBankStore = memoryBankStore;
// ─── Mapping ─────────────────────────────────────────────────────────
/**
* The resource id one memory is addressed by: **the scope and the entry id,
* together.**
*
* Keyed on the resolved SCOPE rather than on the raw identity, so the two
* decisions stay one decision: whatever `scopeFor` says two callers share, they
* share here too, and whatever it keeps apart is kept apart here. A `scopeFor`
* widened to `{ tenant, principal }` gives one person one row for `fact:tone`
* across all their conversations — which is the point of widening it — while
* the default tuple gives them one per conversation.
*
* The scope is folded to a fingerprint rather than spelled out: real tenant,
* principal and conversation ids do not fit in 63 characters together, and the
* entry id is the half worth being able to read in the console. The `m` prefix
* is what keeps the composed id starting with a letter, which is what lets an
* ordinary lowercase entry id survive {@link safeResourceId} unchanged and stay
* legible.
*/
function resourceIdFor(scope, id) {
return (0, aiPlatform_js_1.safeResourceId)(`m${(0, aiPlatform_js_1.fingerprint)(canonicalScope(scope))}-${id}`);
}
/**
* A scope as one unambiguous string.
*
* Keys sorted, so the order `scopeFor` happened to return them in cannot change
* an address. Every key and value length-prefixed, so no two different scopes
* can spell themselves the same way — `{ 'a=1,b': '2' }` and
* `{ a: '1', b: '2' }` would otherwise be the same string, and two different
* scopes that share an address are the very bug this composer exists to close.
*/
function canonicalScope(scope) {
return Object.keys(scope)
.sort()
.map((key) => `${key.length}:${key}=${scope[key].length}:${scope[key]}`)
.join(',');
}
/** Somebody else's memory is sitting where this identity's would be. */
function foreignMemory(id, name) {
const err = new Error(`${ADAPTER}: refusing to overwrite '${id}' — the memory at '${name}' carries a different ` +
`scope, so it belongs to another identity.\n` +
` A memory's address is composed from the scope AND the entry id, so this should be ` +
`unreachable. Reaching it means either the scope fingerprint collided, or something ` +
`other than this library wrote a memory under a name of ours.\n` +
` The write is refused rather than applied: the row's scope is immutable, so writing ` +
`would leave one identity's fact under another identity's scope — readable by them, ` +
`invisible to you, and missed by forget(). Report the memory id; do not retry.`);
err.name = 'MemoryScopeConflictError';
return err;
}
/**
* The default scope: the full identity tuple, matching every other store.
*
* Each field goes through the SAME encoder the namespace stores use, so the
* isolation is the same isolation: absence is the marker `_`, and a value that
* spells the marker (or carries the separator) is escaped away from it. Left
* raw, a bank with no tenant and a bank whose tenant is named `_` shared one
* scope — and a Memory Bank scope is exact-matched, so sharing it is sharing
* the memories. Well-behaved ids encode to themselves, so existing banks keep
* the scope they were written under.
*/
function defaultScopeFor(identity) {
return {
tenant: (0, index_js_1.encodeIdentityField)(identity.tenant),
principal: (0, index_js_1.encodeIdentityField)(identity.principal),
conversation: (0, index_js_1.encodeIdentityField)(identity.conversationId),
};
}
/**
* 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.
*/
function scoreFromDistance(distance) {
if (typeof distance !== 'number' || !Number.isFinite(distance))
return 0;
return 1 / (1 + Math.max(0, distance));
}
exports.scoreFromDistance = scoreFromDistance;
/** Do two scopes match the way the service matches them — same keys, same values? */
function sameScope(stored, wanted) {
if (stored === null || stored === undefined)
return false;
const storedKeys = Object.keys(stored);
const wantedKeys = Object.keys(wanted);
if (storedKeys.length !== wantedKeys.length)
return false;
return wantedKeys.every((key) => stored[key] === wanted[key]);
}
/** Clamp a page size or top-k into what the service will actually honour. */
function clampPage(value) {
if (!Number.isFinite(value))
return DEFAULT_PAGE_SIZE;
return Math.max(1, Math.min(exports.MAX_PAGE_SIZE, Math.floor(value)));
}
const str = (value) => ({ stringValue: value });
const num = (value) => ({ doubleValue: value });
/**
* `MemoryEntry` → `Memory`.
*
* The value becomes the `fact`. A string goes through as itself — this is a
* natural-language store and a sentence is what it ranks well. Anything else
* is JSON, flagged so the read side restores the original type, and worth
* knowing about: a JSON blob IS what gets embedded and ranked, so semantic
* retrieval over structured values is close to meaningless. Store a sentence
* when you want it found.
*
* ── What is CARRIED, and why it took a field trial ──────────────────────────
* `source`, the caller's own `metadata` and `decayPolicy` are stored as JSON
* under prefixed keys of ours and restored verbatim on the way back.
*
* They were dropped until 9.30.0, and an independent trial (2026-08-14) is what
* made that visible: an entry written with `source.turn`, `source.messageId`
* and `metadata.classification` came back carrying only this adapter's own
* metadata. That is a contract failure and not merely a gap —
* `MemorySource.identity` is documented as a field "storage adapters MUST
* preserve verbatim on every read/write", and the fields feed audit, causal
* chains, decay and retrieval policy. A store that accepts them, reports
* success and silently forgets them is the exact shape of wrongness this
* library refuses everywhere else.
*
* `embedding` and `embeddingModel` are the ONE pair still dropped, and that is
* a refusal rather than an oversight: there is nowhere to put a vector here and
* nothing that would rank it (see `supportsVectorSearch`), and a stored
* `embeddingModel` with no vector beside it would claim a compatibility that
* does not exist.
*/
function toMemory(entry, scope, defaultTtl) {
const isString = typeof entry.value === 'string';
const carried = splitMetadata(entry);
const metadata = {
[META.id]: str(entry.id),
[META.version]: num(entry.version),
[META.createdAt]: num(entry.createdAt),
[META.updatedAt]: num(entry.updatedAt),
[META.json]: { boolValue: !isString },
...(entry.tier !== undefined && { [META.tier]: str(entry.tier) }),
...(entry.source !== undefined && {
[META.source]: str(carriedJson(entry.source, 'source', entry.id)),
}),
...(carried !== undefined && {
[META.meta]: str(carriedJson(carried, 'metadata', entry.id)),
}),
...(entry.decayPolicy !== undefined && {
[META.decay]: str(carriedJson(entry.decayPolicy, 'decayPolicy', entry.id)),
}),
};
return {
fact: isString ? entry.value : JSON.stringify(entry.value ?? null),
scope: { ...scope },
metadata,
// An entry's own `ttl` is a unix TIMESTAMP; the service takes a DURATION.
// Converted here rather than at the call site so both spellings of "how
// long does this live" resolve in one place.
...(entry.ttl !== undefined
? { ttl: `${Math.max(1, Math.round((entry.ttl - Date.now()) / 1000))}s` }
: defaultTtl !== undefined && { ttl: defaultTtl }),
};
}
/**
* The caller's own metadata, with this adapter's generated keys separated out
* — or `undefined` when there is none to carry.
*
* The three keys in {@link GENERATED_METADATA} are produced on every read, so
* an entry that came out of this store carries them and a `get`-then-`put`
* round trip hands them straight back. Two cases, told apart rather than
* conflated:
*
* • **It is one of ours coming home** — recognised by IDENTITY, not by shape.
* See {@link isGeneratedValue}.
* • **It is the caller's own value** under one of those names — refused by
* name. Storing it would mean the next read silently answered with our
* value instead of theirs, which is the failure mode this whole function
* exists to end.
*/
function splitMetadata(entry) {
const metadata = entry.metadata;
if (metadata === undefined || metadata === null)
return undefined;
// `toEntry` stamps `source` on EVERY read, so its presence — with exactly the
// backend name this store writes — is what distinguishes an entry coming home
// from a caller who happened to pick one of our key names. Computed once for
// the whole bag, because it is a fact about the ENTRY, not about a key.
const ours = metadata['source'] === BACKEND_NAME;
const kept = {};
for (const [key, value] of Object.entries(metadata)) {
if (!GENERATED_METADATA.includes(key)) {
kept[key] = value;
continue;
}
if (isGeneratedValue(key, value, ours))
continue;
throw metadataKeyConflict(entry.id, key, value, ours);
}
return Object.keys(kept).length === 0 ? undefined : kept;
}
/**
* Is this the value THIS adapter put under that key on a read — or the caller's
* own, wearing one of our names?
*
* **Recognition is by identity, not by shape**, and the difference is a bug this
* adapter shipped once. A shape test asks "is `resourceName` a string, is
* `distance` a number?" — which every string and every number passes, so a
* caller's `metadata.resourceName = 'sku-42'` or `metadata.distance = 12` was
* accepted as ours, dropped, and answered on the next read with THIS adapter's
* resourceName sitting where their value had been. That is precisely the
* accepted-and-silently-wrong failure {@link metadataKeyConflict} exists to
* refuse, and a shape test could not tell the two apart even in principle
* (finding 29, 2026-08-14 field trial).
*
* So the test is: `source` must be exactly {@link BACKEND_NAME} — the stamp
* `toEntry` writes on every read and nothing else does — and `resourceName` /
* `distance` count as ours only when that stamp RIDES WITH THEM on the same
* entry, and the value itself still has the right shape (a Vertex resource name;
* a number). Both halves are needed: the stamp says "this entry came from here",
* the shape says "and this field was not overwritten since".
*
* The one ambiguity left is honest and narrow: a caller who takes an entry out
* of this store and writes their OWN number into `metadata.distance` before
* putting it back is indistinguishable from the retrieval distance that came
* with it, and is dropped. Keep such a value under a name of your own.
*/
function isGeneratedValue(key, value, ours) {
if (key === 'source')
return value === BACKEND_NAME;
if (!ours)
return false;
if (key === 'resourceName')
return typeof value === 'string' && RESOURCE_NAME.test(value);
return typeof value === 'number';
}
/**
* A caller's metadata key collides with one this adapter generates on every read.
*
* `ours` says whether the entry carried this store's own `source` stamp, which
* decides WHICH of the two things went wrong — a name collision, or a value
* edited into a round-tripped entry — and therefore what the reader should do.
*/
function metadataKeyConflict(id, key, value, ours) {
const err = new Error(
// The caller's own value, echoed back short: enough to recognise which
// field this is, never enough to paste a whole payload into a log line
// that an error reaches more sinks than the writer expects.
`${ADAPTER}: entry '${id}' carries metadata.${key} = ${shortly(value)}, and this ` +
`adapter GENERATES metadata.${key} on every read.\n` +
` Storing it would mean your value went in and this adapter's came back — a read that ` +
`looks right and is not. The three generated keys are: ` +
`${GENERATED_METADATA.join(', ')} (the backend name, the Vertex resource name, and the ` +
`raw retrieval distance).\n` +
(ours
? ` This entry does carry this store's own metadata.source = '${BACKEND_NAME}', so it ` +
`came from here — but this particular value is not the one this adapter wrote ` +
`(a resourceName must look like 'projects/…/memories/<id>'; a distance must be a ` +
`number). It is refused rather than dropped, because dropping a value somebody ` +
`deliberately set is the same silent wrongness in the other direction.\n`
: '') +
` Fix: rename the key on your entry. Values this store itself produced are recognised ` +
`and dropped instead, so reading an entry and writing it back is always safe.`);
err.name = 'MemoryMetadataConflictError';
return err;
}
/** A value as at most 80 characters of JSON, for a refusal to point with. */
function shortly(value) {
const json = JSON.stringify(value) ?? String(value);
return json.length <= 80 ? json : `${json.slice(0, 77)}…`;
}
/** One carried field as JSON, or a refusal that says which field and how big. */
function carriedJson(value, field, id) {
const json = JSON.stringify(value ?? null);
if (json.length <= exports.MAX_CARRIED_JSON)
return json;
throw new RangeError(`${ADAPTER}: entry '${id}' has a '${field}' of ${json.length} JSON characters, over this ` +
`adapter's ${exports.MAX_CARRIED_JSON}-character bound for a carried field.\n` +
` Memory Bank stores these as metadata STRINGS, and the service's own ceiling on one is ` +
`not measured by this library — so the bound is ours and deliberately conservative.\n` +
` It refuses rather than truncating: provenance that came back shortened would be ` +
`provenance nothing could tell was shortened. Keep large payloads in the entry VALUE, ` +
`which becomes the memory's fact.`);
}
/** What this adapter names itself as the backend, in a returned entry's metadata. */
const BACKEND_NAME = 'vertex-memory-bank';
/**
* `Memory` → `MemoryEntry`, or `null` for a row this store did not write.
*
* Memory Bank generates memories of its own (that is a headline feature), and
* those carry a `fact` and no metadata of ours. They are not entries and are
* skipped rather than dressed up as ones with invented ids and versions — the
* ids would belong to Google, `store.get()` on them would work by accident,
* and a caller could not tell which of their "memories" they had ever written.
*/
function toEntry(memory, retrieved) {
const metadata = memory.metadata ?? {};
const id = metadata[META.id]?.stringValue;
if (typeof id !== 'string' || id === '')
return null;
const fact = typeof memory.fact === 'string' ? memory.fact : '';
const isJson = metadata[META.json]?.boolValue === true;
let value = fact;
if (isJson) {
try {
value = JSON.parse(fact);
}
catch {
// Written as JSON, came back as something else. The bytes are handed
// through as text rather than dropped: a memory that exists must not
// read as one that was never written.
value = fact;
}
}
const createdAt = metadata[META.createdAt]?.doubleValue ?? toMillis(memory.createTime);
const updatedAt = metadata[META.updatedAt]?.doubleValue ?? toMillis(memory.updateTime);
const tier = metadata[META.tier]?.stringValue;
const expireTime = toMillis(memory.expireTime);
const source = carriedBack(metadata[META.source]?.stringValue);
const callerMetadata = carriedBack(metadata[META.meta]?.stringValue);
const decayPolicy = carriedBack(metadata[META.decay]?.stringValue);
return {
id,
value: value,
version: metadata[META.version]?.doubleValue ?? 1,
createdAt,
updatedAt,
lastAccessedAt: Date.now(),
accessCount: 0,
...(expireTime > 0 && { ttl: expireTime }),
...(tier === 'hot' || tier === 'warm' || tier === 'cold' ? { tier } : {}),
...(source !== undefined && { source }),
...(decayPolicy !== undefined && { decayPolicy }),
metadata: {
// The caller's own metadata first, then this adapter's generated facts.
// They cannot collide: a caller value under a generated key is refused at
// write time rather than overwritten here (see splitMetadata).
...callerMetadata,
source: BACKEND_NAME,
...(memory.name !== null && memory.name !== undefined && { resourceName: memory.name }),
// The raw distance, carried through unmodified — the honest number
// beside the converted one, for a caller who knows the metric.
...(typeof retrieved?.distance === 'number' && { distance: retrieved.distance }),
},
};
}
/**
* One carried JSON field on the way back, or `undefined`.
*
* Unparseable JSON answers `undefined` rather than raising: the bytes were
* written by an older release of this adapter or edited in the console, and a
* memory that EXISTS must not read as one that was never written over a
* damaged provenance field. The entry's own value is unaffected.
*/
function carriedBack(json) {
if (typeof json !== 'string' || json === '')
return undefined;
try {
const parsed = JSON.parse(json);
return parsed === null ? undefined : parsed;
}
catch {
return undefined;
}
}
/** An RFC 3339 timestamp as unix milliseconds, or 0 when it is not one. */
function toMillis(value) {
if (typeof value !== 'string')
return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** One refusal shape for the five operations this service has no primitive for. */
function unsupported(operation, why) {
const err = new Error(`${ADAPTER}.${operation}() is not supported by Vertex AI Memory Bank.\n ${why}`);
err.name = 'MemoryOperationUnsupportedError';
return err;
}
//# sourceMappingURL=memoryBank.js.map