agentfootprint 9.26.0 → 9.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/google/aiPlatform.js +438 -0
- package/dist/adapters/google/aiPlatform.js.map +1 -0
- package/dist/adapters/hosting/googleAgentEngine.js +372 -0
- package/dist/adapters/hosting/googleAgentEngine.js.map +1 -0
- package/dist/adapters/identity/google.js +275 -0
- package/dist/adapters/identity/google.js.map +1 -0
- package/dist/adapters/memory/agentcore.js +19 -0
- package/dist/adapters/memory/agentcore.js.map +1 -1
- package/dist/adapters/memory/memoryBank.js +823 -0
- package/dist/adapters/memory/memoryBank.js.map +1 -0
- package/dist/core/agent/stages/routeTurn.js +13 -1
- package/dist/core/agent/stages/routeTurn.js.map +1 -1
- package/dist/esm/adapters/google/aiPlatform.d.ts +453 -0
- package/dist/esm/adapters/google/aiPlatform.js +424 -0
- package/dist/esm/adapters/google/aiPlatform.js.map +1 -0
- package/dist/esm/adapters/hosting/googleAgentEngine.d.ts +156 -0
- package/dist/esm/adapters/hosting/googleAgentEngine.js +368 -0
- package/dist/esm/adapters/hosting/googleAgentEngine.js.map +1 -0
- package/dist/esm/adapters/identity/google.d.ts +179 -0
- package/dist/esm/adapters/identity/google.js +271 -0
- package/dist/esm/adapters/identity/google.js.map +1 -0
- package/dist/esm/adapters/memory/agentcore.d.ts +19 -0
- package/dist/esm/adapters/memory/agentcore.js +19 -0
- package/dist/esm/adapters/memory/agentcore.js.map +1 -1
- package/dist/esm/adapters/memory/memoryBank.d.ts +390 -0
- package/dist/esm/adapters/memory/memoryBank.js +817 -0
- package/dist/esm/adapters/memory/memoryBank.js.map +1 -0
- package/dist/esm/core/agent/stages/routeTurn.js +13 -1
- package/dist/esm/core/agent/stages/routeTurn.js.map +1 -1
- package/dist/esm/events/payloads.d.ts +23 -0
- package/dist/esm/hosting-providers.d.ts +7 -0
- package/dist/esm/hosting-providers.js +6 -0
- package/dist/esm/hosting-providers.js.map +1 -1
- package/dist/esm/identity.d.ts +1 -0
- package/dist/esm/identity.js +5 -0
- package/dist/esm/identity.js.map +1 -1
- package/dist/esm/lib/injection-engine/buildInjectionEngineSubflow.js +9 -0
- package/dist/esm/lib/injection-engine/buildInjectionEngineSubflow.js.map +1 -1
- package/dist/esm/lib/injection-engine/routingPolicy.d.ts +8 -0
- package/dist/esm/lib/injection-engine/routingPolicy.js.map +1 -1
- package/dist/esm/lib/injection-engine/skillGraph.d.ts +11 -2
- package/dist/esm/lib/injection-engine/skillGraph.js +25 -1
- package/dist/esm/lib/injection-engine/skillGraph.js.map +1 -1
- package/dist/esm/lib/injection-engine/skillIntent.d.ts +13 -5
- package/dist/esm/lib/injection-engine/skillIntent.js +12 -2
- package/dist/esm/lib/injection-engine/skillIntent.js.map +1 -1
- package/dist/esm/lib/injection-engine/skillMatch.d.ts +40 -0
- package/dist/esm/lib/injection-engine/skillMatch.js +60 -0
- package/dist/esm/lib/injection-engine/skillMatch.js.map +1 -1
- package/dist/esm/memory-providers.d.ts +1 -0
- package/dist/esm/memory-providers.js +7 -0
- package/dist/esm/memory-providers.js.map +1 -1
- package/dist/esm/recorders/observability/AgentThinkingTraceRecorder.d.ts +4 -0
- package/dist/esm/recorders/observability/AgentThinkingTraceRecorder.js +4 -1
- package/dist/esm/recorders/observability/AgentThinkingTraceRecorder.js.map +1 -1
- package/dist/esm/recorders/observability/commentary/artifactPhrases.d.ts +50 -0
- package/dist/esm/recorders/observability/commentary/artifactPhrases.js +88 -0
- package/dist/esm/recorders/observability/commentary/artifactPhrases.js.map +1 -0
- package/dist/esm/recorders/observability/commentary/commentaryTemplates.js +233 -9
- package/dist/esm/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
- package/dist/hosting-providers.js +10 -1
- package/dist/hosting-providers.js.map +1 -1
- package/dist/identity.js +8 -1
- package/dist/identity.js.map +1 -1
- package/dist/lib/injection-engine/buildInjectionEngineSubflow.js +9 -0
- package/dist/lib/injection-engine/buildInjectionEngineSubflow.js.map +1 -1
- package/dist/lib/injection-engine/routingPolicy.js.map +1 -1
- package/dist/lib/injection-engine/skillGraph.js +25 -1
- package/dist/lib/injection-engine/skillGraph.js.map +1 -1
- package/dist/lib/injection-engine/skillIntent.js +12 -2
- package/dist/lib/injection-engine/skillIntent.js.map +1 -1
- package/dist/lib/injection-engine/skillMatch.js +61 -1
- package/dist/lib/injection-engine/skillMatch.js.map +1 -1
- package/dist/memory-providers.js +12 -1
- package/dist/memory-providers.js.map +1 -1
- package/dist/recorders/observability/AgentThinkingTraceRecorder.js +4 -1
- package/dist/recorders/observability/AgentThinkingTraceRecorder.js.map +1 -1
- package/dist/recorders/observability/commentary/artifactPhrases.js +94 -0
- package/dist/recorders/observability/commentary/artifactPhrases.js.map +1 -0
- package/dist/recorders/observability/commentary/commentaryTemplates.js +233 -9
- package/dist/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
- package/dist/types/adapters/google/aiPlatform.d.ts +454 -0
- package/dist/types/adapters/google/aiPlatform.d.ts.map +1 -0
- package/dist/types/adapters/hosting/googleAgentEngine.d.ts +157 -0
- package/dist/types/adapters/hosting/googleAgentEngine.d.ts.map +1 -0
- package/dist/types/adapters/identity/google.d.ts +180 -0
- package/dist/types/adapters/identity/google.d.ts.map +1 -0
- package/dist/types/adapters/memory/agentcore.d.ts +19 -0
- package/dist/types/adapters/memory/agentcore.d.ts.map +1 -1
- package/dist/types/adapters/memory/memoryBank.d.ts +391 -0
- package/dist/types/adapters/memory/memoryBank.d.ts.map +1 -0
- package/dist/types/core/agent/stages/routeTurn.d.ts.map +1 -1
- package/dist/types/events/payloads.d.ts +23 -0
- package/dist/types/events/payloads.d.ts.map +1 -1
- package/dist/types/hosting-providers.d.ts +7 -0
- package/dist/types/hosting-providers.d.ts.map +1 -1
- package/dist/types/identity.d.ts +1 -0
- package/dist/types/identity.d.ts.map +1 -1
- package/dist/types/lib/injection-engine/buildInjectionEngineSubflow.d.ts.map +1 -1
- package/dist/types/lib/injection-engine/routingPolicy.d.ts +8 -0
- package/dist/types/lib/injection-engine/routingPolicy.d.ts.map +1 -1
- package/dist/types/lib/injection-engine/skillGraph.d.ts +11 -2
- package/dist/types/lib/injection-engine/skillGraph.d.ts.map +1 -1
- package/dist/types/lib/injection-engine/skillIntent.d.ts +13 -5
- package/dist/types/lib/injection-engine/skillIntent.d.ts.map +1 -1
- package/dist/types/lib/injection-engine/skillMatch.d.ts +40 -0
- package/dist/types/lib/injection-engine/skillMatch.d.ts.map +1 -1
- package/dist/types/memory-providers.d.ts +1 -0
- package/dist/types/memory-providers.d.ts.map +1 -1
- package/dist/types/recorders/observability/AgentThinkingTraceRecorder.d.ts +4 -0
- package/dist/types/recorders/observability/AgentThinkingTraceRecorder.d.ts.map +1 -1
- package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts +51 -0
- package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts.map +1 -0
- package/dist/types/recorders/observability/commentary/commentaryTemplates.d.ts.map +1 -1
- package/package.json +24 -15
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* memoryBankStore — the `MemoryStore` port over Vertex AI **Memory Bank**.
|
|
4
|
+
*
|
|
5
|
+
* import { memoryBankStore } from 'agentfootprint/memory';
|
|
6
|
+
*
|
|
7
|
+
* const store = memoryBankStore({
|
|
8
|
+
* project: 'my-project',
|
|
9
|
+
* location: 'us-central1',
|
|
10
|
+
* reasoningEngine: '1234567890',
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* ── Read this before you write anything into it ─────────────────────────────
|
|
14
|
+
* Memory Bank is a **natural-language** memory service, not a key-value store
|
|
15
|
+
* and not a vector database. A `Memory` is a `fact` string plus an immutable
|
|
16
|
+
* `scope`; retrieval takes a QUESTION IN WORDS, embeds it on Google's side and
|
|
17
|
+
* ranks there. Three consequences, each of which has a silent-failure mode
|
|
18
|
+
* that this adapter turns into something you can see:
|
|
19
|
+
*
|
|
20
|
+
* 1. **It never ranks the vectors you wrote.** `supportsVectorSearch` is
|
|
21
|
+
* `false` and `ranksBy` is `'server-text'`, so the corpus builders
|
|
22
|
+
* (`indexCorpus`, `indexFolder`, `indexDocuments`) refuse this store by
|
|
23
|
+
* name instead of embedding a whole corpus, reporting success, and leaving
|
|
24
|
+
* it unreachable forever. An `embedding` on an entry handed to `put()` is
|
|
25
|
+
* **not stored** — there is nowhere to put it and nothing that would read
|
|
26
|
+
* it — and the two declarations above are how this adapter says so before
|
|
27
|
+
* you spend anything.
|
|
28
|
+
*
|
|
29
|
+
* 2. **The retrieval score is a DISTANCE, and smaller is closer.** The port's
|
|
30
|
+
* `ScoredEntry.score` is a cosine similarity, where HIGHER is closer.
|
|
31
|
+
* Passed through unconverted, `search()` would return the LEAST relevant
|
|
32
|
+
* memories first with a confident-looking number in the right range —
|
|
33
|
+
* which no threshold and no eyeball can separate from a working search.
|
|
34
|
+
* See {@link MemoryBankStore.search} for exactly what this adapter does
|
|
35
|
+
* instead, and why it refuses `minScore` rather than reinterpreting it.
|
|
36
|
+
*
|
|
37
|
+
* 3. **`scope` is an exact match and immutable once written.** A retrieval
|
|
38
|
+
* whose scope is a subset of a memory's scope returns NOTHING — not a
|
|
39
|
+
* superset, not a partial match, nothing. So the scope convention has to
|
|
40
|
+
* be right before the first write, because it cannot be changed after it
|
|
41
|
+
* and a bank written under the wrong one is poisoned permanently. See
|
|
42
|
+
* {@link MemoryBankStoreOptions.scopeFor}.
|
|
43
|
+
*
|
|
44
|
+
* ── How a memory is ADDRESSED, and why it is not just the entry id ──────────
|
|
45
|
+
* A memory's resource name is `<engine>/memories/<resource id>`, and that
|
|
46
|
+
* resource id is composed from **the resolved scope and the entry id
|
|
47
|
+
* together** — never the entry id alone.
|
|
48
|
+
*
|
|
49
|
+
* The reason is that entry ids in this library are deliberately deterministic
|
|
50
|
+
* and identity-free: `msg-<turn>-<index>`, `fact:<key>`, `snap-<turn>`. Two
|
|
51
|
+
* people talking to the same agent produce the SAME entry ids, so an address
|
|
52
|
+
* built from the id alone is one row for both of them — and since a resource
|
|
53
|
+
* name addresses a row directly, the second writer's `fact` lands on the first
|
|
54
|
+
* writer's row while the immutable `scope` stays the first writer's. What comes
|
|
55
|
+
* back is one tenant reading another tenant's private fact, one tenant's own
|
|
56
|
+
* write invisible to them, and `forget()` finding nothing to erase. Every
|
|
57
|
+
* sibling store in this library namespaces by identity (`s3Vectors` keys on
|
|
58
|
+
* `<namespace>#<id>`, `pgVector` on `(namespace, id)`, `InMemoryStore` on a map
|
|
59
|
+
* per namespace); this one does the same thing, keyed on the SCOPE rather than
|
|
60
|
+
* the raw identity so that a widened `scopeFor` widens sharing exactly as much
|
|
61
|
+
* as it widens retrieval, and not one row more.
|
|
62
|
+
*
|
|
63
|
+
* The composed address is a partition, not the boundary itself. The boundary is
|
|
64
|
+
* the stored `scope`, which is re-checked on the way back from every read AND
|
|
65
|
+
* before every overwrite — so even an address collision is refused rather than
|
|
66
|
+
* written through.
|
|
67
|
+
*
|
|
68
|
+
* ── Writes are long-running operations ──────────────────────────────────────
|
|
69
|
+
* `create`, `patch` and `delete` all answer with an Operation rather than the
|
|
70
|
+
* resource — verified against the installed SDK's own return types. Every
|
|
71
|
+
* write here waits for `done` before returning, because a `put` that came back
|
|
72
|
+
* early followed by a `get` is a race whose failure mode is "no data", and
|
|
73
|
+
* nobody can tell that from a memory that was never written.
|
|
74
|
+
*
|
|
75
|
+
* ── What has no primitive here, and is therefore refused ────────────────────
|
|
76
|
+
* `putIfVersion`, `seen`, `recordSignature`, `feedback` and `getFeedback` have
|
|
77
|
+
* no counterpart in this service: a `Memory` carries no etag and there is no
|
|
78
|
+
* dedup or feedback surface. The sibling AgentCore adapter emulates them
|
|
79
|
+
* in-process; this one refuses them by name, and the difference is deliberate.
|
|
80
|
+
* A store you reach for BECAUSE it is shared across a fleet is the worst place
|
|
81
|
+
* for a per-process shadow: `seen()` would answer `false` in the second
|
|
82
|
+
* container for a signature the first one recorded, and an emulated
|
|
83
|
+
* `putIfVersion` across two writers is a lost-update generator that reports
|
|
84
|
+
* `{ applied: true }` to both. A refusal you read once beats a correctness bug
|
|
85
|
+
* you never find.
|
|
86
|
+
*
|
|
87
|
+
* Pattern: Adapter (GoF) — `MemoryStore` onto `reasoningEngines.memories`,
|
|
88
|
+
* through the shared REST client in `adapters/google/aiPlatform.ts`.
|
|
89
|
+
*/
|
|
90
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
91
|
+
exports.scoreFromDistance = exports.memoryBankStore = exports.MemoryBankStore = exports.MAX_PAGE_SIZE = void 0;
|
|
92
|
+
const aiPlatform_js_1 = require("../google/aiPlatform.js");
|
|
93
|
+
const ADAPTER = 'memoryBankStore';
|
|
94
|
+
/**
|
|
95
|
+
* The metadata keys this adapter owns on a `Memory`.
|
|
96
|
+
*
|
|
97
|
+
* Prefixed, because the metadata map belongs to whoever owns the bank and this
|
|
98
|
+
* library is a guest in it: an unprefixed `id` or `version` collides with the
|
|
99
|
+
* next writer, and Memory Bank's metadata filters are exact-key matches, so a
|
|
100
|
+
* collision is not a merge — it is one party's filter quietly matching the
|
|
101
|
+
* other party's rows.
|
|
102
|
+
*/
|
|
103
|
+
const META = {
|
|
104
|
+
id: 'agentfootprint_id',
|
|
105
|
+
version: 'agentfootprint_version',
|
|
106
|
+
createdAt: 'agentfootprint_created_at',
|
|
107
|
+
updatedAt: 'agentfootprint_updated_at',
|
|
108
|
+
tier: 'agentfootprint_tier',
|
|
109
|
+
json: 'agentfootprint_json',
|
|
110
|
+
};
|
|
111
|
+
/** The service's own ceiling on a page or a top-k. Larger values are coerced. */
|
|
112
|
+
exports.MAX_PAGE_SIZE = 100;
|
|
113
|
+
/** What a `list()` page carries when nothing was asked for. */
|
|
114
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
115
|
+
/**
|
|
116
|
+
* A `MemoryStore` over Vertex AI Memory Bank.
|
|
117
|
+
*
|
|
118
|
+
* **Status: contract-shaped and tested; awaiting field use.** Every call is
|
|
119
|
+
* exercised through an injected client and pinned against the really-installed
|
|
120
|
+
* SDK. None of it has yet answered a request from Google in a real project.
|
|
121
|
+
*/
|
|
122
|
+
class MemoryBankStore {
|
|
123
|
+
/**
|
|
124
|
+
* **No.** `search()` exists here, but it is Memory Bank's own retrieval:
|
|
125
|
+
* Google embeds and ranks on its side, over the `fact` strings this store
|
|
126
|
+
* wrote, and never over an `embedding` handed to `put()`. Embeddings are not
|
|
127
|
+
* stored at all.
|
|
128
|
+
*
|
|
129
|
+
* Declared because a method's presence could not say that — and because the
|
|
130
|
+
* sibling column already paid for the lesson once, with a corpus that
|
|
131
|
+
* indexed, billed, reported success, and was unreachable forever.
|
|
132
|
+
*/
|
|
133
|
+
supportsVectorSearch = false;
|
|
134
|
+
/**
|
|
135
|
+
* The query form this store takes: **words, not a vector.** `search()` reads
|
|
136
|
+
* {@link SearchOptions.text} and refuses by name without it. A retriever
|
|
137
|
+
* built over this store therefore needs no `Embedder`, and wiring one would
|
|
138
|
+
* be spend on a vector discarded on arrival.
|
|
139
|
+
*/
|
|
140
|
+
ranksBy = 'server-text';
|
|
141
|
+
memories;
|
|
142
|
+
scope;
|
|
143
|
+
scopeFor;
|
|
144
|
+
operationTimeoutMs;
|
|
145
|
+
pageSize;
|
|
146
|
+
defaultTtl;
|
|
147
|
+
closed = false;
|
|
148
|
+
constructor(options) {
|
|
149
|
+
this.scope = (0, aiPlatform_js_1.resolveEngine)(ADAPTER, options);
|
|
150
|
+
this.memories = (0, aiPlatform_js_1.buildAiPlatformClient)(ADAPTER, options, this.scope).projects.locations.reasoningEngines.memories;
|
|
151
|
+
this.scopeFor = options.scopeFor ?? defaultScopeFor;
|
|
152
|
+
this.operationTimeoutMs = options.operationTimeoutMs ?? aiPlatform_js_1.DEFAULT_OPERATION_TIMEOUT_MS;
|
|
153
|
+
this.pageSize = clampPage(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
|
154
|
+
this.defaultTtl = options.ttl;
|
|
155
|
+
}
|
|
156
|
+
// ── Reads ───────────────────────────────────────────────────────
|
|
157
|
+
/**
|
|
158
|
+
* One memory by id.
|
|
159
|
+
*
|
|
160
|
+
* Two independent things keep this from reading somebody else's memory, and
|
|
161
|
+
* both are deliberate. The **address** carries the scope, so another
|
|
162
|
+
* identity's row for the same entry id is a different resource name that
|
|
163
|
+
* simply is not there. And the **stored scope is re-checked on the way
|
|
164
|
+
* back**, because a resource name addresses a memory directly and a `get`
|
|
165
|
+
* alone would happily read another tenant's row for anyone who could guess a
|
|
166
|
+
* name. A memory whose scope is not this identity's answers `null` — the same
|
|
167
|
+
* `null` a missing one answers, because "exists but not yours" is an oracle
|
|
168
|
+
* for which ids are real.
|
|
169
|
+
*/
|
|
170
|
+
async get(identity, id) {
|
|
171
|
+
this.ensureOpen('get');
|
|
172
|
+
const wanted = this.resolveScope(identity);
|
|
173
|
+
const memory = await this.fetch(this.nameOf(wanted, id));
|
|
174
|
+
if (memory === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
if (!sameScope(memory.scope, wanted))
|
|
177
|
+
return null;
|
|
178
|
+
const entry = toEntry(memory);
|
|
179
|
+
if (entry === null)
|
|
180
|
+
return null;
|
|
181
|
+
if (entry.ttl !== undefined && entry.ttl <= Date.now())
|
|
182
|
+
return null;
|
|
183
|
+
return entry;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A page of this identity's memories, in no particular order.
|
|
187
|
+
*
|
|
188
|
+
* It rides `retrieve` with `simpleRetrievalParams` rather than `memories.list`
|
|
189
|
+
* — deliberately. `list` filters with AIP-160 over the resource's own fields,
|
|
190
|
+
* and whether that filter language can express an exact scope match is not
|
|
191
|
+
* something this adapter is willing to guess at for the call that decides
|
|
192
|
+
* which memories a caller can see. `retrieve` takes the scope as a structured
|
|
193
|
+
* field with semantics the SDK states outright, so the isolation is the
|
|
194
|
+
* service's rather than a filter string's.
|
|
195
|
+
*
|
|
196
|
+
* `tiers` filtering is applied to what comes back, since a tier is this
|
|
197
|
+
* library's own metadata and not something the service ranks on.
|
|
198
|
+
*/
|
|
199
|
+
async list(identity, options = {}) {
|
|
200
|
+
this.ensureOpen('list');
|
|
201
|
+
const scope = this.resolveScope(identity);
|
|
202
|
+
const pageSize = clampPage(options.limit ?? this.pageSize);
|
|
203
|
+
let page;
|
|
204
|
+
try {
|
|
205
|
+
page = (await this.memories.retrieve({
|
|
206
|
+
parent: this.scope.parent,
|
|
207
|
+
requestBody: {
|
|
208
|
+
scope,
|
|
209
|
+
simpleRetrievalParams: {
|
|
210
|
+
pageSize,
|
|
211
|
+
...(options.cursor !== undefined && { pageToken: options.cursor }),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}))?.data;
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
|
|
218
|
+
}
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const entries = [];
|
|
221
|
+
for (const row of page?.retrievedMemories ?? []) {
|
|
222
|
+
const entry = row.memory === undefined ? null : toEntry(row.memory);
|
|
223
|
+
if (entry === null)
|
|
224
|
+
continue;
|
|
225
|
+
if (entry.ttl !== undefined && entry.ttl <= now)
|
|
226
|
+
continue;
|
|
227
|
+
if (options.tiers && (entry.tier === undefined || !options.tiers.includes(entry.tier))) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
entries.push(entry);
|
|
231
|
+
}
|
|
232
|
+
const cursor = page?.nextPageToken;
|
|
233
|
+
return typeof cursor === 'string' && cursor !== '' ? { entries, cursor } : { entries };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Memory Bank's own semantic retrieval — **text in, and the ranking trap
|
|
237
|
+
* handled rather than passed on.**
|
|
238
|
+
*
|
|
239
|
+
* ── It takes WORDS, not the vector ───────────────────────────────────────
|
|
240
|
+
* Google embeds and ranks server-side, so the `query` vector this method is
|
|
241
|
+
* handed cannot be sent anywhere. The query it needs travels in
|
|
242
|
+
* {@link SearchOptions.text}, and omitting it is refused by name — returning
|
|
243
|
+
* `[]` would read as "no matches" when it means "wrong query form".
|
|
244
|
+
*
|
|
245
|
+
* ── The score, and what this adapter refuses to pretend ──────────────────
|
|
246
|
+
* The service reports a **distance**, in its own words "smaller values
|
|
247
|
+
* indicate more similar memories". The port's score is a cosine similarity,
|
|
248
|
+
* where higher is closer. Two things follow, and both are decisions:
|
|
249
|
+
*
|
|
250
|
+
* • The distance is **converted**, never forwarded: `score = 1 / (1 + d)`,
|
|
251
|
+
* which is strictly decreasing in `d`, so **the ordering is right** —
|
|
252
|
+
* the closest memory has the highest score, which is the whole point.
|
|
253
|
+
* The raw distance is carried on `entry.metadata.distance` so nothing is
|
|
254
|
+
* hidden and a caller who knows the metric can do better.
|
|
255
|
+
*
|
|
256
|
+
* • **`minScore` is REFUSED by name**, because that number is calibrated
|
|
257
|
+
* for a cosine similarity and this scale is not one. Silently applying a
|
|
258
|
+
* cosine threshold to a converted distance is precisely the failure this
|
|
259
|
+
* library refuses elsewhere: a number that READS like a similarity, in
|
|
260
|
+
* the right range, that no threshold and no eyeball can separate from a
|
|
261
|
+
* real one. The sibling S3 Vectors adapter refuses a non-cosine index
|
|
262
|
+
* for the same reason; here the metric is Google's and cannot be
|
|
263
|
+
* changed, so the threshold is what goes rather than the store.
|
|
264
|
+
*
|
|
265
|
+
* ── One more thing the service requires ──────────────────────────────────
|
|
266
|
+
* Similarity search only works if the reasoning engine was configured with a
|
|
267
|
+
* similarity-search config. Without it the service refuses the call, and
|
|
268
|
+
* that refusal is passed through with its status — it is a setup fact, not a
|
|
269
|
+
* bug in the query.
|
|
270
|
+
*
|
|
271
|
+
* @throws when `options.text` is absent, or `options.minScore` is present.
|
|
272
|
+
*/
|
|
273
|
+
async search(identity, query, options = {}) {
|
|
274
|
+
this.ensureOpen('search');
|
|
275
|
+
const text = options.text?.trim();
|
|
276
|
+
if (!text) {
|
|
277
|
+
throw new Error(`${ADAPTER}.search() needs the query as TEXT, in \`options.text\`.\n` +
|
|
278
|
+
` Memory Bank embeds and ranks on Google's side, so the ${query.length}-dimension ` +
|
|
279
|
+
`vector this method was handed cannot be sent anywhere — and returning [] would look ` +
|
|
280
|
+
`like "no matches" rather than "wrong query form".\n` +
|
|
281
|
+
` Fix: store.search(identity, vector, { text: theUserQuestion })\n` +
|
|
282
|
+
` Backends that rank locally ignore \`text\`, so passing both is always safe.`);
|
|
283
|
+
}
|
|
284
|
+
if (options.minScore !== undefined) {
|
|
285
|
+
throw new Error(`${ADAPTER}.search() does not accept \`minScore\`.\n` +
|
|
286
|
+
` This service reports a DISTANCE (smaller is closer), not a cosine similarity ` +
|
|
287
|
+
`(higher is closer). This adapter converts it to 1/(1+distance) so the ORDERING is ` +
|
|
288
|
+
`right, but that scale is not a cosine one and your threshold was calibrated for a ` +
|
|
289
|
+
`cosine — applying it here would silently keep or drop the wrong memories, with a ` +
|
|
290
|
+
`number in the right range that nothing can distinguish from a real score.\n` +
|
|
291
|
+
` Fix: drop minScore and use \`k\` to bound the result, or filter yourself on ` +
|
|
292
|
+
`\`entry.metadata.distance\`, which is carried through unmodified.`);
|
|
293
|
+
}
|
|
294
|
+
const scope = this.resolveScope(identity);
|
|
295
|
+
const topK = clampPage(options.k ?? 10);
|
|
296
|
+
let page;
|
|
297
|
+
try {
|
|
298
|
+
page = (await this.memories.retrieve({
|
|
299
|
+
parent: this.scope.parent,
|
|
300
|
+
requestBody: { scope, similaritySearchParams: { searchQuery: text, topK } },
|
|
301
|
+
}))?.data;
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
|
|
305
|
+
}
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
const scored = [];
|
|
308
|
+
for (const row of page?.retrievedMemories ?? []) {
|
|
309
|
+
const entry = row.memory === undefined ? null : toEntry(row.memory, row);
|
|
310
|
+
if (entry === null)
|
|
311
|
+
continue;
|
|
312
|
+
if (entry.ttl !== undefined && entry.ttl <= now)
|
|
313
|
+
continue;
|
|
314
|
+
// A tier is this library's metadata, not something the service ranks on,
|
|
315
|
+
// so a tier filter is applied to what came back rather than ignored.
|
|
316
|
+
if (options.tiers && (entry.tier === undefined || !options.tiers.includes(entry.tier))) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
scored.push({ entry, score: scoreFromDistance(row.distance) });
|
|
320
|
+
}
|
|
321
|
+
// The service returns its own ranking; re-sort anyway so the port's
|
|
322
|
+
// "descending by score" holds even if a future API version reorders.
|
|
323
|
+
scored.sort((a, b) => b.score !== a.score ? b.score - a.score : a.entry.id < b.entry.id ? -1 : 1);
|
|
324
|
+
return scored.slice(0, topK);
|
|
325
|
+
}
|
|
326
|
+
// ── Writes ──────────────────────────────────────────────────────
|
|
327
|
+
/**
|
|
328
|
+
* Write one memory, waiting for the service to say it landed.
|
|
329
|
+
*
|
|
330
|
+
* ── Read-then-write, and what the read is FOR ────────────────────────────
|
|
331
|
+
* This costs one `get` before the write, and that read is not an
|
|
332
|
+
* optimisation — it is the tenant check. A `patch` names a resource
|
|
333
|
+
* directly and the service does not ask whose it is, so a patch sent
|
|
334
|
+
* without looking is a write this adapter cannot promise landed on its own
|
|
335
|
+
* row. The address already carries the scope (see the module header), so
|
|
336
|
+
* the row under this name is ours in every ordinary run; the read is what
|
|
337
|
+
* turns "ordinary" into "checked", and what makes the one case where it is
|
|
338
|
+
* NOT ours a refusal you can read instead of a fact one tenant wrote into
|
|
339
|
+
* another tenant's memory.
|
|
340
|
+
*
|
|
341
|
+
* What the read finds decides the rest: an existing row of ours is patched,
|
|
342
|
+
* an absent one is created, and a row that is somebody else's is refused by
|
|
343
|
+
* name. The `create` race — two writers, neither of whom saw a row — is
|
|
344
|
+
* caught as `ALREADY EXISTS` and folded back into the same checked patch.
|
|
345
|
+
*/
|
|
346
|
+
async put(identity, entry) {
|
|
347
|
+
this.ensureOpen('put');
|
|
348
|
+
if (entry.ttl !== undefined && entry.ttl <= Date.now())
|
|
349
|
+
return;
|
|
350
|
+
const scope = this.resolveScope(identity);
|
|
351
|
+
const body = toMemory(entry, scope, this.defaultTtl);
|
|
352
|
+
const resourceId = resourceIdFor(scope, entry.id);
|
|
353
|
+
const name = `${this.scope.parent}/memories/${resourceId}`;
|
|
354
|
+
if (await this.overwrite(name, scope, entry.id, body))
|
|
355
|
+
return;
|
|
356
|
+
let created;
|
|
357
|
+
try {
|
|
358
|
+
created = await this.memories.create({
|
|
359
|
+
parent: this.scope.parent,
|
|
360
|
+
memoryId: resourceId,
|
|
361
|
+
requestBody: body,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (!(0, aiPlatform_js_1.isAlreadyExists)(err))
|
|
366
|
+
throw this.asFailure(err, 'memories.create');
|
|
367
|
+
// Another writer created it between our read and our create. The row
|
|
368
|
+
// exists now, which is all this wanted — so take the same checked patch
|
|
369
|
+
// path rather than reporting a failure for a race that resolved.
|
|
370
|
+
if (await this.overwrite(name, scope, entry.id, body))
|
|
371
|
+
return;
|
|
372
|
+
// Created by someone and gone again before we could read it. Nothing
|
|
373
|
+
// this adapter can say landed, so it says so.
|
|
374
|
+
throw this.asFailure(err, 'memories.create');
|
|
375
|
+
}
|
|
376
|
+
await this.wait(created?.data, `creating memory '${entry.id}'`);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Sequential, not batched: the service has no batch-write operation, and
|
|
380
|
+
* each write is a long-running operation that has to be waited on
|
|
381
|
+
* individually. An empty batch is a no-op and costs no round trip, which
|
|
382
|
+
* callers rely on.
|
|
383
|
+
*/
|
|
384
|
+
async putMany(identity, entries) {
|
|
385
|
+
this.ensureOpen('putMany');
|
|
386
|
+
for (const entry of entries)
|
|
387
|
+
await this.put(identity, entry);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Remove one memory.
|
|
391
|
+
*
|
|
392
|
+
* Scope-checked first, for the reason {@link get} spells out: a resource
|
|
393
|
+
* name addresses a row directly, and a delete that skipped the check would
|
|
394
|
+
* let anyone who could guess an id remove another tenant's memory. A memory
|
|
395
|
+
* that is not this identity's is left alone and reported as nothing to do —
|
|
396
|
+
* the same answer a missing one gets.
|
|
397
|
+
*/
|
|
398
|
+
async delete(identity, id) {
|
|
399
|
+
this.ensureOpen('delete');
|
|
400
|
+
const scope = this.resolveScope(identity);
|
|
401
|
+
const name = this.nameOf(scope, id);
|
|
402
|
+
const existing = await this.fetch(name);
|
|
403
|
+
if (existing === undefined || !sameScope(existing.scope, scope))
|
|
404
|
+
return;
|
|
405
|
+
let deleted;
|
|
406
|
+
try {
|
|
407
|
+
deleted = await this.memories.delete({ name });
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
if ((0, aiPlatform_js_1.isNotFound)(err))
|
|
411
|
+
return;
|
|
412
|
+
throw this.asFailure(err, 'memories.delete');
|
|
413
|
+
}
|
|
414
|
+
await this.wait(deleted?.data, `deleting memory '${id}'`);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* GDPR — every memory for this identity, gone.
|
|
418
|
+
*
|
|
419
|
+
* Paginated retrieve-then-delete, and **deliberately not `memories.purge`**,
|
|
420
|
+
* for two reasons that are both about not being silently wrong on the one
|
|
421
|
+
* operation where that matters most:
|
|
422
|
+
*
|
|
423
|
+
* 1. `PurgeMemoriesRequest.force` defaults to **false**, which the service
|
|
424
|
+
* documents as "the purge request will be validated but not executed".
|
|
425
|
+
* A forget built on it and written without that flag would report
|
|
426
|
+
* success and delete nothing — a compliance failure that looks exactly
|
|
427
|
+
* like a working erasure.
|
|
428
|
+
* 2. Purge selects rows with an AIP-160 filter STRING, and whether that
|
|
429
|
+
* language can express an exact scope match is not verified. A filter
|
|
430
|
+
* that under-matches leaves data behind; one that over-matches deletes
|
|
431
|
+
* somebody else's. Neither is a guess worth making here.
|
|
432
|
+
*
|
|
433
|
+
* The scoped retrieve has semantics the SDK states outright, so that is what
|
|
434
|
+
* this uses. It costs one delete per memory, which is the right price.
|
|
435
|
+
*/
|
|
436
|
+
async forget(identity) {
|
|
437
|
+
this.ensureOpen('forget');
|
|
438
|
+
const scope = this.resolveScope(identity);
|
|
439
|
+
let cursor;
|
|
440
|
+
do {
|
|
441
|
+
let page;
|
|
442
|
+
try {
|
|
443
|
+
page = (await this.memories.retrieve({
|
|
444
|
+
parent: this.scope.parent,
|
|
445
|
+
requestBody: {
|
|
446
|
+
scope,
|
|
447
|
+
simpleRetrievalParams: {
|
|
448
|
+
pageSize: exports.MAX_PAGE_SIZE,
|
|
449
|
+
...(cursor !== undefined && { pageToken: cursor }),
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
}))?.data;
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'memories.retrieve', err);
|
|
456
|
+
}
|
|
457
|
+
const names = (page?.retrievedMemories ?? [])
|
|
458
|
+
.map((row) => row.memory?.name)
|
|
459
|
+
.filter((name) => typeof name === 'string' && name !== '');
|
|
460
|
+
for (const name of names) {
|
|
461
|
+
let deleted;
|
|
462
|
+
try {
|
|
463
|
+
deleted = await this.memories.delete({ name });
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if ((0, aiPlatform_js_1.isNotFound)(err))
|
|
467
|
+
continue;
|
|
468
|
+
throw this.asFailure(err, 'memories.delete');
|
|
469
|
+
}
|
|
470
|
+
await this.wait(deleted?.data, `deleting memory '${name}'`);
|
|
471
|
+
}
|
|
472
|
+
const next = page?.nextPageToken;
|
|
473
|
+
// A page that deleted everything it listed and hands back the same
|
|
474
|
+
// cursor would loop forever. Erasure walks forward or it stops.
|
|
475
|
+
cursor = typeof next === 'string' && next !== '' && next !== cursor ? next : undefined;
|
|
476
|
+
} while (cursor !== undefined);
|
|
477
|
+
}
|
|
478
|
+
// ── The five with no primitive behind them ──────────────────────
|
|
479
|
+
/**
|
|
480
|
+
* @throws always — this service has no compare-and-set.
|
|
481
|
+
* @see the module header for why this refuses where the sibling adapter
|
|
482
|
+
* emulates.
|
|
483
|
+
*/
|
|
484
|
+
putIfVersion(_identity, entry, expectedVersion) {
|
|
485
|
+
return Promise.reject(unsupported('putIfVersion', `A \`Memory\` carries no etag or generation number, so there is nothing to compare ` +
|
|
486
|
+
`against — the write for '${entry.id}' at version ${expectedVersion} cannot be made ` +
|
|
487
|
+
`conditional.\n` +
|
|
488
|
+
` Emulating it with a read-then-write would report \`{ applied: true }\` to two ` +
|
|
489
|
+
`writers at once, which is a lost update that no log would show. ` +
|
|
490
|
+
`Use put() when you know you are the only writer, or keep memories that need ` +
|
|
491
|
+
`optimistic concurrency in a store that has it (pgVectorStore, RedisStore, ` +
|
|
492
|
+
`sqliteVectorStore).`));
|
|
493
|
+
}
|
|
494
|
+
/** @throws always — this service has no recognition set. */
|
|
495
|
+
seen(_identity, signature) {
|
|
496
|
+
return Promise.reject(unsupported('seen', `There is no dedup primitive here, and answering '${signature}' from a per-process Map ` +
|
|
497
|
+
`would be worse than refusing: this store is one you reach for BECAUSE it is shared ` +
|
|
498
|
+
`across a fleet, and a second container would answer "never seen" for a signature ` +
|
|
499
|
+
`the first one recorded.\n` +
|
|
500
|
+
` Keep the recognition set in a store that is really shared (RedisStore), or ` +
|
|
501
|
+
`dedup on a deterministic entry id and let put() overwrite.`));
|
|
502
|
+
}
|
|
503
|
+
/** @throws always — the write side of a recognition set this service does not have. */
|
|
504
|
+
recordSignature(_identity, signature) {
|
|
505
|
+
return Promise.reject(unsupported('recordSignature', `There is nowhere to record '${signature}'. See seen() — the two are one feature and ` +
|
|
506
|
+
`neither is emulated in-process.`));
|
|
507
|
+
}
|
|
508
|
+
/** @throws always — this service has no feedback primitive. */
|
|
509
|
+
feedback(_identity, id, _usefulness) {
|
|
510
|
+
return Promise.reject(unsupported('feedback', `There is no usefulness signal on a \`Memory\`, so feedback for '${id}' has nowhere ` +
|
|
511
|
+
`durable to go. A per-process tally would be lost on the next deploy and invisible ` +
|
|
512
|
+
`to every other container, which is the shape of a metric that quietly stops ` +
|
|
513
|
+
`meaning anything.`));
|
|
514
|
+
}
|
|
515
|
+
/** @throws always — the read side of feedback this service does not record. */
|
|
516
|
+
getFeedback(_identity, id) {
|
|
517
|
+
return Promise.reject(unsupported('getFeedback', `Nothing records feedback here, so there is none to read for '${id}'. Answering ` +
|
|
518
|
+
`\`null\` would be indistinguishable from "recorded, but neutral", which callers ` +
|
|
519
|
+
`are documented to treat differently.`));
|
|
520
|
+
}
|
|
521
|
+
// ── Lifecycle ───────────────────────────────────────────────────
|
|
522
|
+
/**
|
|
523
|
+
* Stop using this store. Idempotent and final. Nothing is torn down on
|
|
524
|
+
* Google's side — the memories outlive this process, which is the point.
|
|
525
|
+
*/
|
|
526
|
+
close() {
|
|
527
|
+
this.closed = true;
|
|
528
|
+
return Promise.resolve();
|
|
529
|
+
}
|
|
530
|
+
// ── Internals ───────────────────────────────────────────────────
|
|
531
|
+
/** Where this identity's copy of `id` lives. See the module header. */
|
|
532
|
+
nameOf(scope, id) {
|
|
533
|
+
return `${this.scope.parent}/memories/${resourceIdFor(scope, id)}`;
|
|
534
|
+
}
|
|
535
|
+
/** One memory by resource name, or `undefined` when there is none. */
|
|
536
|
+
async fetch(name) {
|
|
537
|
+
try {
|
|
538
|
+
return (await this.memories.get({ name }))?.data;
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
if ((0, aiPlatform_js_1.isNotFound)(err))
|
|
542
|
+
return undefined;
|
|
543
|
+
throw this.asFailure(err, 'memories.get');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Patch the row at `name` if it exists AND is this scope's; answer `false`
|
|
548
|
+
* when there is nothing there to patch.
|
|
549
|
+
*
|
|
550
|
+
* A row that exists under somebody else's scope is REFUSED rather than
|
|
551
|
+
* written. It should be unreachable — the address carries the scope — so
|
|
552
|
+
* reaching it means the fingerprint collided or the bank was written by
|
|
553
|
+
* another tool under a name of ours, and both of those are facts an operator
|
|
554
|
+
* has to be told rather than have resolved in favour of the last writer.
|
|
555
|
+
*/
|
|
556
|
+
async overwrite(name, scope, id, body) {
|
|
557
|
+
const existing = await this.fetch(name);
|
|
558
|
+
if (existing === undefined)
|
|
559
|
+
return false;
|
|
560
|
+
if (!sameScope(existing.scope, scope))
|
|
561
|
+
throw foreignMemory(id, name);
|
|
562
|
+
let patched;
|
|
563
|
+
try {
|
|
564
|
+
patched = await this.memories.patch({
|
|
565
|
+
name,
|
|
566
|
+
// `scope` is immutable and deliberately NOT in the mask: including it
|
|
567
|
+
// would make every update a request to change something the service
|
|
568
|
+
// refuses to change.
|
|
569
|
+
updateMask: 'fact,metadata',
|
|
570
|
+
requestBody: { fact: body.fact, metadata: body.metadata },
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
// Deleted between the read and the patch. Nothing was overwritten, so
|
|
575
|
+
// the caller falls through to a create — the outcome they asked for.
|
|
576
|
+
if ((0, aiPlatform_js_1.isNotFound)(err))
|
|
577
|
+
return false;
|
|
578
|
+
throw this.asFailure(err, 'memories.patch');
|
|
579
|
+
}
|
|
580
|
+
// Awaited OUTSIDE the try: an operation refusal is already sanitized and
|
|
581
|
+
// already says what to do, and re-wrapping would replace a precise
|
|
582
|
+
// diagnosis with a generic one.
|
|
583
|
+
await this.wait(patched?.data, `updating memory '${id}'`);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
ensureOpen(op) {
|
|
587
|
+
if (this.closed)
|
|
588
|
+
throw new Error(`${ADAPTER}.${op}() called after close().`);
|
|
589
|
+
}
|
|
590
|
+
wait(operation, what) {
|
|
591
|
+
return (0, aiPlatform_js_1.awaitOperation)(ADAPTER, this.memories.operations, operation, what, this.operationTimeoutMs);
|
|
592
|
+
}
|
|
593
|
+
/** Keep an already-sanitized refusal; sanitize anything else. */
|
|
594
|
+
asFailure(err, operation) {
|
|
595
|
+
if ((0, aiPlatform_js_1.isSanitizedGoogleError)(err))
|
|
596
|
+
return err;
|
|
597
|
+
return (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, operation, err);
|
|
598
|
+
}
|
|
599
|
+
/** The identity's scope, checked for the two values that would break isolation. */
|
|
600
|
+
resolveScope(identity) {
|
|
601
|
+
const raw = this.scopeFor(identity);
|
|
602
|
+
const entries = Object.entries(raw ?? {}).filter(([key, value]) => typeof value === 'string' && key !== '');
|
|
603
|
+
if (entries.length === 0) {
|
|
604
|
+
throw new TypeError(`${ADAPTER}: 'scopeFor' produced an empty scope for conversation ` +
|
|
605
|
+
`'${identity.conversationId}'.\n` +
|
|
606
|
+
` An empty scope is not "no scoping" — it is a real scope that matches every other ` +
|
|
607
|
+
`empty-scoped memory in the bank, whoever wrote it. Return at least one key.`);
|
|
608
|
+
}
|
|
609
|
+
// The service rejects `*` in a scope value. Replaced rather than passed on,
|
|
610
|
+
// so a tenant named with one is a NAME and not a wildcard.
|
|
611
|
+
return Object.fromEntries(entries.map(([key, value]) => [key, value.replace(/\*/g, '_')]));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
exports.MemoryBankStore = MemoryBankStore;
|
|
615
|
+
/**
|
|
616
|
+
* A `MemoryStore` over Vertex AI Memory Bank.
|
|
617
|
+
*
|
|
618
|
+
* @example Per-person memory that survives a conversation ending
|
|
619
|
+
* const store = memoryBankStore({
|
|
620
|
+
* project: 'my-project',
|
|
621
|
+
* location: 'us-central1',
|
|
622
|
+
* reasoningEngine: '1234567890',
|
|
623
|
+
* scopeFor: (id) => ({ tenant: id.tenant ?? '_', principal: id.principal ?? '_' }),
|
|
624
|
+
* });
|
|
625
|
+
*
|
|
626
|
+
* const hits = await store.search(identity, [], { text: 'what does she prefer?', k: 5 });
|
|
627
|
+
*/
|
|
628
|
+
function memoryBankStore(options) {
|
|
629
|
+
return new MemoryBankStore(options);
|
|
630
|
+
}
|
|
631
|
+
exports.memoryBankStore = memoryBankStore;
|
|
632
|
+
// ─── Mapping ─────────────────────────────────────────────────────────
|
|
633
|
+
/**
|
|
634
|
+
* The resource id one memory is addressed by: **the scope and the entry id,
|
|
635
|
+
* together.**
|
|
636
|
+
*
|
|
637
|
+
* Keyed on the resolved SCOPE rather than on the raw identity, so the two
|
|
638
|
+
* decisions stay one decision: whatever `scopeFor` says two callers share, they
|
|
639
|
+
* share here too, and whatever it keeps apart is kept apart here. A `scopeFor`
|
|
640
|
+
* widened to `{ tenant, principal }` gives one person one row for `fact:tone`
|
|
641
|
+
* across all their conversations — which is the point of widening it — while
|
|
642
|
+
* the default tuple gives them one per conversation.
|
|
643
|
+
*
|
|
644
|
+
* The scope is folded to a fingerprint rather than spelled out: real tenant,
|
|
645
|
+
* principal and conversation ids do not fit in 63 characters together, and the
|
|
646
|
+
* entry id is the half worth being able to read in the console. The `m` prefix
|
|
647
|
+
* is what keeps the composed id starting with a letter, which is what lets an
|
|
648
|
+
* ordinary lowercase entry id survive {@link safeResourceId} unchanged and stay
|
|
649
|
+
* legible.
|
|
650
|
+
*/
|
|
651
|
+
function resourceIdFor(scope, id) {
|
|
652
|
+
return (0, aiPlatform_js_1.safeResourceId)(`m${(0, aiPlatform_js_1.fingerprint)(canonicalScope(scope))}-${id}`);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* A scope as one unambiguous string.
|
|
656
|
+
*
|
|
657
|
+
* Keys sorted, so the order `scopeFor` happened to return them in cannot change
|
|
658
|
+
* an address. Every key and value length-prefixed, so no two different scopes
|
|
659
|
+
* can spell themselves the same way — `{ 'a=1,b': '2' }` and
|
|
660
|
+
* `{ a: '1', b: '2' }` would otherwise be the same string, and two different
|
|
661
|
+
* scopes that share an address are the very bug this composer exists to close.
|
|
662
|
+
*/
|
|
663
|
+
function canonicalScope(scope) {
|
|
664
|
+
return Object.keys(scope)
|
|
665
|
+
.sort()
|
|
666
|
+
.map((key) => `${key.length}:${key}=${scope[key].length}:${scope[key]}`)
|
|
667
|
+
.join(',');
|
|
668
|
+
}
|
|
669
|
+
/** Somebody else's memory is sitting where this identity's would be. */
|
|
670
|
+
function foreignMemory(id, name) {
|
|
671
|
+
const err = new Error(`${ADAPTER}: refusing to overwrite '${id}' — the memory at '${name}' carries a different ` +
|
|
672
|
+
`scope, so it belongs to another identity.\n` +
|
|
673
|
+
` A memory's address is composed from the scope AND the entry id, so this should be ` +
|
|
674
|
+
`unreachable. Reaching it means either the scope fingerprint collided, or something ` +
|
|
675
|
+
`other than this library wrote a memory under a name of ours.\n` +
|
|
676
|
+
` The write is refused rather than applied: the row's scope is immutable, so writing ` +
|
|
677
|
+
`would leave one identity's fact under another identity's scope — readable by them, ` +
|
|
678
|
+
`invisible to you, and missed by forget(). Report the memory id; do not retry.`);
|
|
679
|
+
err.name = 'MemoryScopeConflictError';
|
|
680
|
+
return err;
|
|
681
|
+
}
|
|
682
|
+
/** The default scope: the full identity tuple, matching every other store. */
|
|
683
|
+
function defaultScopeFor(identity) {
|
|
684
|
+
return {
|
|
685
|
+
tenant: identity.tenant || '_',
|
|
686
|
+
principal: identity.principal || '_',
|
|
687
|
+
conversation: identity.conversationId,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Distance → a score whose ORDER is right.
|
|
692
|
+
*
|
|
693
|
+
* `1 / (1 + d)` is strictly decreasing on `d >= 0`, lands in `(0, 1]`, and is
|
|
694
|
+
* exactly 1 at distance 0. It is **not** a cosine similarity and this adapter
|
|
695
|
+
* never says it is — see {@link MemoryBankStore.search} for why `minScore` is
|
|
696
|
+
* refused rather than measured against it.
|
|
697
|
+
*
|
|
698
|
+
* A row with no distance is a simple retrieval, which does no ranking at all;
|
|
699
|
+
* `0` is the honest score for "this was not ranked", and it sorts last.
|
|
700
|
+
*/
|
|
701
|
+
function scoreFromDistance(distance) {
|
|
702
|
+
if (typeof distance !== 'number' || !Number.isFinite(distance))
|
|
703
|
+
return 0;
|
|
704
|
+
return 1 / (1 + Math.max(0, distance));
|
|
705
|
+
}
|
|
706
|
+
exports.scoreFromDistance = scoreFromDistance;
|
|
707
|
+
/** Do two scopes match the way the service matches them — same keys, same values? */
|
|
708
|
+
function sameScope(stored, wanted) {
|
|
709
|
+
if (stored === null || stored === undefined)
|
|
710
|
+
return false;
|
|
711
|
+
const storedKeys = Object.keys(stored);
|
|
712
|
+
const wantedKeys = Object.keys(wanted);
|
|
713
|
+
if (storedKeys.length !== wantedKeys.length)
|
|
714
|
+
return false;
|
|
715
|
+
return wantedKeys.every((key) => stored[key] === wanted[key]);
|
|
716
|
+
}
|
|
717
|
+
/** Clamp a page size or top-k into what the service will actually honour. */
|
|
718
|
+
function clampPage(value) {
|
|
719
|
+
if (!Number.isFinite(value))
|
|
720
|
+
return DEFAULT_PAGE_SIZE;
|
|
721
|
+
return Math.max(1, Math.min(exports.MAX_PAGE_SIZE, Math.floor(value)));
|
|
722
|
+
}
|
|
723
|
+
const str = (value) => ({ stringValue: value });
|
|
724
|
+
const num = (value) => ({ doubleValue: value });
|
|
725
|
+
/**
|
|
726
|
+
* `MemoryEntry` → `Memory`.
|
|
727
|
+
*
|
|
728
|
+
* The value becomes the `fact`. A string goes through as itself — this is a
|
|
729
|
+
* natural-language store and a sentence is what it ranks well. Anything else
|
|
730
|
+
* is JSON, flagged so the read side restores the original type, and worth
|
|
731
|
+
* knowing about: a JSON blob IS what gets embedded and ranked, so semantic
|
|
732
|
+
* retrieval over structured values is close to meaningless. Store a sentence
|
|
733
|
+
* when you want it found.
|
|
734
|
+
*
|
|
735
|
+
* `embedding` is deliberately dropped — see `supportsVectorSearch`.
|
|
736
|
+
*/
|
|
737
|
+
function toMemory(entry, scope, defaultTtl) {
|
|
738
|
+
const isString = typeof entry.value === 'string';
|
|
739
|
+
const metadata = {
|
|
740
|
+
[META.id]: str(entry.id),
|
|
741
|
+
[META.version]: num(entry.version),
|
|
742
|
+
[META.createdAt]: num(entry.createdAt),
|
|
743
|
+
[META.updatedAt]: num(entry.updatedAt),
|
|
744
|
+
[META.json]: { boolValue: !isString },
|
|
745
|
+
...(entry.tier !== undefined && { [META.tier]: str(entry.tier) }),
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
fact: isString ? entry.value : JSON.stringify(entry.value ?? null),
|
|
749
|
+
scope: { ...scope },
|
|
750
|
+
metadata,
|
|
751
|
+
// An entry's own `ttl` is a unix TIMESTAMP; the service takes a DURATION.
|
|
752
|
+
// Converted here rather than at the call site so both spellings of "how
|
|
753
|
+
// long does this live" resolve in one place.
|
|
754
|
+
...(entry.ttl !== undefined
|
|
755
|
+
? { ttl: `${Math.max(1, Math.round((entry.ttl - Date.now()) / 1000))}s` }
|
|
756
|
+
: defaultTtl !== undefined && { ttl: defaultTtl }),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* `Memory` → `MemoryEntry`, or `null` for a row this store did not write.
|
|
761
|
+
*
|
|
762
|
+
* Memory Bank generates memories of its own (that is a headline feature), and
|
|
763
|
+
* those carry a `fact` and no metadata of ours. They are not entries and are
|
|
764
|
+
* skipped rather than dressed up as ones with invented ids and versions — the
|
|
765
|
+
* ids would belong to Google, `store.get()` on them would work by accident,
|
|
766
|
+
* and a caller could not tell which of their "memories" they had ever written.
|
|
767
|
+
*/
|
|
768
|
+
function toEntry(memory, retrieved) {
|
|
769
|
+
const metadata = memory.metadata ?? {};
|
|
770
|
+
const id = metadata[META.id]?.stringValue;
|
|
771
|
+
if (typeof id !== 'string' || id === '')
|
|
772
|
+
return null;
|
|
773
|
+
const fact = typeof memory.fact === 'string' ? memory.fact : '';
|
|
774
|
+
const isJson = metadata[META.json]?.boolValue === true;
|
|
775
|
+
let value = fact;
|
|
776
|
+
if (isJson) {
|
|
777
|
+
try {
|
|
778
|
+
value = JSON.parse(fact);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
// Written as JSON, came back as something else. The bytes are handed
|
|
782
|
+
// through as text rather than dropped: a memory that exists must not
|
|
783
|
+
// read as one that was never written.
|
|
784
|
+
value = fact;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const createdAt = metadata[META.createdAt]?.doubleValue ?? toMillis(memory.createTime);
|
|
788
|
+
const updatedAt = metadata[META.updatedAt]?.doubleValue ?? toMillis(memory.updateTime);
|
|
789
|
+
const tier = metadata[META.tier]?.stringValue;
|
|
790
|
+
const expireTime = toMillis(memory.expireTime);
|
|
791
|
+
return {
|
|
792
|
+
id,
|
|
793
|
+
value: value,
|
|
794
|
+
version: metadata[META.version]?.doubleValue ?? 1,
|
|
795
|
+
createdAt,
|
|
796
|
+
updatedAt,
|
|
797
|
+
lastAccessedAt: Date.now(),
|
|
798
|
+
accessCount: 0,
|
|
799
|
+
...(expireTime > 0 && { ttl: expireTime }),
|
|
800
|
+
...(tier === 'hot' || tier === 'warm' || tier === 'cold' ? { tier } : {}),
|
|
801
|
+
metadata: {
|
|
802
|
+
source: 'vertex-memory-bank',
|
|
803
|
+
...(memory.name !== null && memory.name !== undefined && { resourceName: memory.name }),
|
|
804
|
+
// The raw distance, carried through unmodified — the honest number
|
|
805
|
+
// beside the converted one, for a caller who knows the metric.
|
|
806
|
+
...(typeof retrieved?.distance === 'number' && { distance: retrieved.distance }),
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/** An RFC 3339 timestamp as unix milliseconds, or 0 when it is not one. */
|
|
811
|
+
function toMillis(value) {
|
|
812
|
+
if (typeof value !== 'string')
|
|
813
|
+
return 0;
|
|
814
|
+
const parsed = Date.parse(value);
|
|
815
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
816
|
+
}
|
|
817
|
+
/** One refusal shape for the five operations this service has no primitive for. */
|
|
818
|
+
function unsupported(operation, why) {
|
|
819
|
+
const err = new Error(`${ADAPTER}.${operation}() is not supported by Vertex AI Memory Bank.\n ${why}`);
|
|
820
|
+
err.name = 'MemoryOperationUnsupportedError';
|
|
821
|
+
return err;
|
|
822
|
+
}
|
|
823
|
+
//# sourceMappingURL=memoryBank.js.map
|