@tangleai/context 0.21.1
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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +488 -0
- package/package.json +98 -0
- package/src/archive.d.ts +47 -0
- package/src/archive.js +53 -0
- package/src/environment.d.ts +66 -0
- package/src/environment.js +715 -0
- package/src/evidence.d.ts +122 -0
- package/src/evidence.js +71 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/ledger.d.ts +354 -0
- package/src/ledger.js +1397 -0
- package/src/recall.d.ts +74 -0
- package/src/recall.js +160 -0
- package/src/retention.d.ts +24 -0
- package/src/retention.js +91 -0
- package/src/schemas/evidence.d.ts +54 -0
- package/src/schemas/evidence.js +32 -0
- package/src/schemas/ledger.d.ts +515 -0
- package/src/schemas/ledger.js +357 -0
- package/src/schemas/patch.d.ts +129 -0
- package/src/schemas/patch.js +135 -0
- package/src/storage/memory.d.ts +86 -0
- package/src/storage/memory.js +118 -0
- package/src/storage/slot.d.ts +30 -0
- package/src/storage/slot.js +107 -0
- package/src/storage/transaction.d.ts +33 -0
- package/src/storage/transaction.js +49 -0
package/src/ledger.js
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The ledger: schema-validated, durable, addressable state that outlives
|
|
4
|
+
* a context window.
|
|
5
|
+
*
|
|
6
|
+
* An agent today is bounded by one transcript — everything it learned is
|
|
7
|
+
* gone when the tab closes, and everything it gathered is gone when the
|
|
8
|
+
* history budget bites. The ledger is the other half: a goal it is
|
|
9
|
+
* working towards, memories it has earned, skills it can reuse, and
|
|
10
|
+
* slots holding content too big to carry. Four kinds, one small
|
|
11
|
+
* interface, over an injected storage adapter.
|
|
12
|
+
*
|
|
13
|
+
* Three decisions shape the whole file:
|
|
14
|
+
*
|
|
15
|
+
* - **Storage is injected, never imported.** The adapter is four async
|
|
16
|
+
* methods (`get`/`set`/`delete`/`keys`); a host backs it with
|
|
17
|
+
* `@jarenjs/db` over OPFS, with `localStorage`, or with nothing. This
|
|
18
|
+
* package keeps exactly two dependencies, so it still loads in a
|
|
19
|
+
* static page — `createLedger()` with no arguments works, in memory.
|
|
20
|
+
* Optional `mutate` atomically transforms a detached record map. Ledger
|
|
21
|
+
* writes stage outside storage and publish with comparison against current
|
|
22
|
+
* state. Four-method adapters retain the explicit single-writer contract.
|
|
23
|
+
* - **Nothing enters without passing its schema**, compiled by
|
|
24
|
+
* `JarenValidator` here at construction. A rejected write answers the
|
|
25
|
+
* same `{ error, errors, inputSchema }` the toolbox answers with (one
|
|
26
|
+
* implementation, in `check.js`) and never throws: a model that wrote
|
|
27
|
+
* a bad memory can read why and fix it.
|
|
28
|
+
* - **Every mutation is reversible.** `snapshot()` before, `rollback()`
|
|
29
|
+
* after — which is what will make model-proposed refinements safe to
|
|
30
|
+
* accept later, because a bad one can be undone without a human
|
|
31
|
+
* reading the diff.
|
|
32
|
+
*
|
|
33
|
+
* And one more, added when recall by meaning arrived:
|
|
34
|
+
*
|
|
35
|
+
* - **Meaning is a seam, not a dependency.** A record may carry an
|
|
36
|
+
* `embedding` with its identity (`embeddedBy: { model, dims }`);
|
|
37
|
+
* `recall({ near })` ranks by cosine similarity through an injected
|
|
38
|
+
* embedder — `createEmbeddingClient(...)`, `createHashEmbedder()`, or
|
|
39
|
+
* any host `{ embed, model, dims }` — and without one it REFUSES,
|
|
40
|
+
* naming the seam, exactly as a `where` predicate refuses without
|
|
41
|
+
* `compileQuery`. It refuses a mixture of identities rather than
|
|
42
|
+
* ranking the matching subset (a silent subset is a silent wrong
|
|
43
|
+
* answer), it reports how many records it skipped for carrying no
|
|
44
|
+
* vector rather than scoring them, and a write never acquires the
|
|
45
|
+
* seam's network dependency unless `embedOnWrite` asks for it;
|
|
46
|
+
* `embedMissing()` is the explicit sweep that closes the gap. Every
|
|
47
|
+
* dot and cosine comes from `@jarenjs/core/vector`; none is computed
|
|
48
|
+
* here. A storage adapter that can rank the records itself may say so
|
|
49
|
+
* with an optional fifth method (`rank`, see
|
|
50
|
+
* {@link createMemoryStorage}'s contract); the ledger then asks it
|
|
51
|
+
* instead of sweeping, holds it to the same identity refusal and skip
|
|
52
|
+
* report, and names which one ran in `via`.
|
|
53
|
+
*
|
|
54
|
+
* Its consumers today: the agent's compaction archive, the environment's
|
|
55
|
+
* slots, refinement's patchable state, and the website assistant's
|
|
56
|
+
* durable memory — all on this one implementation.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
60
|
+
import { excerpt } from '@jarenjs/core/chunk';
|
|
61
|
+
import { isVector, cosineSimilarity } from '@jarenjs/core/vector';
|
|
62
|
+
|
|
63
|
+
import { checkOutcome } from '@jarenjs/core/check';
|
|
64
|
+
import { invalidInput } from '@tangleai/models/check';
|
|
65
|
+
import { validateClaimEvidence } from './evidence.js';
|
|
66
|
+
import { AiError } from '@tangleai/models/errors';
|
|
67
|
+
import { retainArchives } from './archive.js';
|
|
68
|
+
import { jsonBytes, checkpointProgress, validateCheckpoint, goalPrompt } from './retention.js';
|
|
69
|
+
import { atomicTask, isAtomicView } from './storage/transaction.js';
|
|
70
|
+
import { createMemoryStorage } from './storage/memory.js';
|
|
71
|
+
import { LEDGER_SCHEMAS } from './schemas/ledger.js';
|
|
72
|
+
|
|
73
|
+
/** @typedef {import('./schemas/ledger.js').LedgerGoal} LedgerGoal */
|
|
74
|
+
/** @typedef {import('./schemas/ledger.js').LedgerMemory} LedgerMemory */
|
|
75
|
+
/** @typedef {import('./schemas/ledger.js').LedgerSkill} LedgerSkill */
|
|
76
|
+
/** @typedef {import('./schemas/ledger.js').LedgerSlot} LedgerSlot */
|
|
77
|
+
/** @typedef {import('./schemas/ledger.js').LedgerRejection} LedgerRejection */
|
|
78
|
+
/** @typedef {import('./schemas/ledger.js').LedgerEmbeddingPair} LedgerEmbeddingPair */
|
|
79
|
+
/** @typedef {import('./schemas/ledger.js').LedgerEmbeddedBy} LedgerEmbeddedBy */
|
|
80
|
+
/** @typedef {import('@tangleai/models/embed').Embedder} Embedder */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What `recall({ near })` answers: the memories that carry a comparable
|
|
84
|
+
* vector, ranked by cosine similarity (descending; ties by recency, then
|
|
85
|
+
* id), one score per memory in the same order, and the count of records
|
|
86
|
+
* that passed the filter but carry no vector and were therefore skipped
|
|
87
|
+
* — reported, never scored.
|
|
88
|
+
* @typedef {object} LedgerRankedMemories
|
|
89
|
+
* @property {LedgerMemory[]} memories
|
|
90
|
+
* @property {number[]} scores
|
|
91
|
+
* @property {number} skipped
|
|
92
|
+
* @property {'sweep' | 'adapter'} via - which path answered: the ledger's
|
|
93
|
+
* own read-and-rank, or the adapter's `rank` capability
|
|
94
|
+
* @property {LedgerRanking} ranking - candidate selection provenance, not a quality guarantee
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What `recallSkills({ near })` answers — see {@link LedgerRankedMemories}.
|
|
99
|
+
* @typedef {object} LedgerRankedSkills
|
|
100
|
+
* @property {LedgerSkill[]} skills
|
|
101
|
+
* @property {number[]} scores
|
|
102
|
+
* @property {number} skipped
|
|
103
|
+
* @property {'sweep' | 'adapter'} via - see {@link LedgerRankedMemories}
|
|
104
|
+
* @property {LedgerRanking} ranking - see {@link LedgerRankedMemories}
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Optional storage rank metadata. Legacy adapters normalize to exhaustive.
|
|
109
|
+
* Exhaustive means exact candidate selection, not that every record is returned.
|
|
110
|
+
* Candidate count is the number returned before ledger filtering and capping.
|
|
111
|
+
* @typedef {object} LedgerRanking
|
|
112
|
+
* @property {string} algorithm
|
|
113
|
+
* @property {boolean} exhaustive
|
|
114
|
+
* @property {number} candidateCount
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The query both recalls take. `near` is the string to rank by meaning
|
|
119
|
+
* against, and needs the embedder seam; `minScore` filters the ranked
|
|
120
|
+
* result (cosine, in [-1, 1]); `tags` and `where` narrow the candidates
|
|
121
|
+
* first, exactly as they do without `near`.
|
|
122
|
+
* @typedef {object} LedgerQuery
|
|
123
|
+
* @property {string[]} [tags]
|
|
124
|
+
* @property {any} [where]
|
|
125
|
+
* @property {number} [limit]
|
|
126
|
+
* @property {string} [near]
|
|
127
|
+
* @property {number} [minScore]
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** The key space. State and snapshots are separate prefixes on purpose:
|
|
131
|
+
* a rollback wipes state and must not take the other snapshots with it. */
|
|
132
|
+
const STATE = 'ai/state/';
|
|
133
|
+
const SNAP = 'ai/snap/';
|
|
134
|
+
const KEYS = {
|
|
135
|
+
goal: `${STATE}goal/active`,
|
|
136
|
+
goalArchive: `${STATE}goal/archive/`,
|
|
137
|
+
memory: `${STATE}memory/`,
|
|
138
|
+
skill: `${STATE}skill/`,
|
|
139
|
+
slot: `${STATE}slot/`,
|
|
140
|
+
slotContent: `${STATE}slot-content/`,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Slot metadata carries a short excerpt so a root request can list a
|
|
144
|
+
* hundred slots without carrying one slot's content. */
|
|
145
|
+
const SLOT_EXCERPT_CHARS = 120;
|
|
146
|
+
|
|
147
|
+
/** A zero-padded sequence, so `keys()` sorts lexicographically into order. */
|
|
148
|
+
const seq = (n) => String(n).padStart(6, '0');
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The next sequence number under a prefix: one past the highest that
|
|
152
|
+
* still exists, read from the trailing digits of every key. Never the
|
|
153
|
+
* count — a count shrinks when a record is deleted, and a sequence that
|
|
154
|
+
* shrinks hands a new record the address of a live one. A caller-named
|
|
155
|
+
* key that happens to end in digits merely advances the sequence, which
|
|
156
|
+
* costs nothing: uniqueness needs only that no existing key ends in the
|
|
157
|
+
* number minted.
|
|
158
|
+
* @param {string[]} keys - every key under the prefix
|
|
159
|
+
* @returns {number}
|
|
160
|
+
*/
|
|
161
|
+
function nextSequence(keys) {
|
|
162
|
+
let highest = -1;
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const match = /(\d+)$/.exec(key);
|
|
165
|
+
if (match !== null) {
|
|
166
|
+
const n = Number(match[1]);
|
|
167
|
+
if (n > highest) highest = n;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return highest + 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A record with its `undefined` members removed.
|
|
175
|
+
*
|
|
176
|
+
* This is not tidying, it is correctness: a record built as
|
|
177
|
+
* `{ evidence: input?.evidence }` from an input that has no `evidence`
|
|
178
|
+
* carries the KEY with an undefined value, `required` sees the key as
|
|
179
|
+
* present, and the write validates — then `JSON.stringify` drops it on
|
|
180
|
+
* the way to storage and an unevidenced memory is durable. Stripping
|
|
181
|
+
* first makes what is validated exactly what would be stored.
|
|
182
|
+
* @param {Record<string, any>} record
|
|
183
|
+
* @returns {any} the stripped record — `any` so each write site's declared
|
|
184
|
+
* return type (the record typedef) is the statement that binds, not an
|
|
185
|
+
* inference from this generic helper
|
|
186
|
+
*/
|
|
187
|
+
function defined(record) {
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const [key, value] of Object.entries(record)) {
|
|
190
|
+
if (value !== undefined) out[key] = value;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Newest first, ties broken by id so two records written in the same
|
|
197
|
+
* millisecond still come back in a stable order. A retrieval whose order
|
|
198
|
+
* wobbled between calls would make everything downstream of it
|
|
199
|
+
* unreproducible.
|
|
200
|
+
* @param {any[]} records
|
|
201
|
+
*/
|
|
202
|
+
function byRecency(records) {
|
|
203
|
+
return [...records].sort(recencyOrder);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The comparator behind {@link byRecency}: newer first, then id — the
|
|
208
|
+
* same rule ranked recall falls back to between two equal scores.
|
|
209
|
+
* @param {any} a
|
|
210
|
+
* @param {any} b
|
|
211
|
+
*/
|
|
212
|
+
function recencyOrder(a, b) {
|
|
213
|
+
return a.at === b.at
|
|
214
|
+
? String(a.id ?? a.name).localeCompare(String(b.id ?? b.name))
|
|
215
|
+
: String(b.at).localeCompare(String(a.at));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The text a skill is embedded from: its name, when it applies and what
|
|
220
|
+
* to do, one per line — what "near" means for a skill. A memory is
|
|
221
|
+
* embedded from its `text` alone.
|
|
222
|
+
* @param {{ name: string, when: string, instructions: string }} skill
|
|
223
|
+
*/
|
|
224
|
+
const skillText = (skill) => `${skill.name}\n${skill.when}\n${skill.instructions}`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether two vector identities name the same space: the same model at
|
|
228
|
+
* the same width. The rule the ledger applies before any arithmetic
|
|
229
|
+
* happens, exported because it is not the ledger's alone — a storage
|
|
230
|
+
* adapter's optional `rank` selects the records whose `embeddedBy` is
|
|
231
|
+
* the query's, and a host keeping its own vector store beside the
|
|
232
|
+
* ledger has to refuse the same mixtures. A second implementation of a
|
|
233
|
+
* one-line predicate is a second chance to get its edges wrong, and
|
|
234
|
+
* both edges below are ones a re-derivation usually misses.
|
|
235
|
+
*
|
|
236
|
+
* Total, and false rather than a throw for anything malformed, so it
|
|
237
|
+
* can be applied straight to what a store handed back.
|
|
238
|
+
*
|
|
239
|
+
* **Two absent identities are NOT the same.** A record with no identity
|
|
240
|
+
* has no space to share; "unknown" must never rank against "unknown",
|
|
241
|
+
* or an un-embedded record would compare equal to every other one.
|
|
242
|
+
* **A width alone is not an identity either** — two models at 768 dims
|
|
243
|
+
* produce vectors whose cosine is arithmetic without meaning, which is
|
|
244
|
+
* why `dims` matching is necessary and never sufficient.
|
|
245
|
+
*
|
|
246
|
+
* @param {LedgerEmbeddedBy | undefined | null} a
|
|
247
|
+
* @param {LedgerEmbeddedBy | undefined | null} b
|
|
248
|
+
* @returns {boolean} whether both are identities naming one space
|
|
249
|
+
* @example
|
|
250
|
+
* sameIdentity({ model: 'm', dims: 4 }, { model: 'm', dims: 4 }); // true
|
|
251
|
+
* sameIdentity({ model: 'm', dims: 4 }, { model: 'm', dims: 8 }); // false — a re-embed, not a match
|
|
252
|
+
* sameIdentity(undefined, undefined); // false — no space is not a shared space
|
|
253
|
+
*/
|
|
254
|
+
export function sameIdentity(a, b) {
|
|
255
|
+
return a !== undefined && a !== null && b !== undefined && b !== null
|
|
256
|
+
&& a.model === b.model && a.dims === b.dims;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* An identity as a refusal names it — `"text-embedding-3-small (1536
|
|
261
|
+
* dims)"`. Exported for the same reason as {@link sameIdentity}: it is
|
|
262
|
+
* the wording the ledger's mixture refusals use, so a host reporting
|
|
263
|
+
* the same condition reports it in the same words, and it is a stable
|
|
264
|
+
* key for collecting the DISTINCT identities a `rank` adapter owes its
|
|
265
|
+
* caller.
|
|
266
|
+
*
|
|
267
|
+
* @param {LedgerEmbeddedBy} identity
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
export function describeIdentity(identity) {
|
|
271
|
+
return `${identity.model} (${identity.dims} dims)`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The seam refusal, shared by every entry point that needs the embedder. */
|
|
275
|
+
const SEAM_REFUSAL = 'needs the embedder seam — inject createEmbeddingClient(...) or any { embed, model, dims }';
|
|
276
|
+
|
|
277
|
+
/** How many texts one `embedMissing` batch hands the seam. */
|
|
278
|
+
const EMBED_BATCH = 64;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create a ledger.
|
|
282
|
+
*
|
|
283
|
+
* @param {{ storage?: { get: (key: string) => Promise<any>,
|
|
284
|
+
* set: (key: string, value: any) => Promise<void>,
|
|
285
|
+
* delete: (key: string) => Promise<void>,
|
|
286
|
+
* keys: (prefix?: string) => Promise<string[]>,
|
|
287
|
+
* status?: () => any,
|
|
288
|
+
* mutate?: import('./storage/transaction.js').StorageMutation,
|
|
289
|
+
* rank?: (request: { prefix: string, vector: number[], model: string, dims: number,
|
|
290
|
+
* limit?: number, minScore?: number }) => Promise<{ hits: { key: string, score: number }[],
|
|
291
|
+
* skipped: number, identities: LedgerEmbeddedBy[], ranking?: LedgerRanking }> },
|
|
292
|
+
* compileQuery?: (document: any) => (data: any) => any,
|
|
293
|
+
* embedder?: Embedder,
|
|
294
|
+
* embedOnWrite?: boolean,
|
|
295
|
+
* validator?: any,
|
|
296
|
+
* now?: () => string,
|
|
297
|
+
* archiveLimits?: { maxItems?: number, maxBytes?: number },
|
|
298
|
+
* goalLimits?: { maxEntries?: number, maxBytes?: number, maxChars?: number },
|
|
299
|
+
* checkpointReducer?: (goal: any) => any,
|
|
300
|
+
* artifacts?: import('./schemas/evidence.js').ArtifactRecord[] }} [options]
|
|
301
|
+
* - `storage` defaults to an in-memory adapter, so a ledger works with
|
|
302
|
+
* nothing wired. Anything durable is the host's to inject. Its four
|
|
303
|
+
* methods are the contract; an adapter that can rank vectors itself
|
|
304
|
+
* declares an optional fifth (`rank`) and `recall({ near })` asks it
|
|
305
|
+
* instead of reading every record.
|
|
306
|
+
* - `compileQuery` is the retrieval seam — `compileJsonQuery` from
|
|
307
|
+
* `@jarenjs/json/query`, or absent. With it, `recall` filters with a
|
|
308
|
+
* real query document; without it, retrieval degrades to tag match
|
|
309
|
+
* and recency, and a caller-supplied predicate is refused rather
|
|
310
|
+
* than silently ignored.
|
|
311
|
+
* - `embedder` is the meaning seam — `createEmbeddingClient(...)`,
|
|
312
|
+
* `createHashEmbedder()`, or any host `{ embed, model, dims }` whose
|
|
313
|
+
* `embed` returns a Promise and rejects rather than throws. With it,
|
|
314
|
+
* `recall({ near })` ranks and `embedMissing()` sweeps; without it,
|
|
315
|
+
* both refuse naming the seam. Every stored vector is compared
|
|
316
|
+
* against the embedder's `{ model, dims }` before any arithmetic.
|
|
317
|
+
* - `embedOnWrite` (default `false`) embeds a memory or skill that
|
|
318
|
+
* arrives without a vector inside its own write. Off by default on
|
|
319
|
+
* purpose: a write must not silently acquire a network dependency.
|
|
320
|
+
* On, a seam failure stores the record un-embedded and reports it on
|
|
321
|
+
* the returned record (`embedError`) — never a dropped write.
|
|
322
|
+
* - `now` returns an RFC 3339 timestamp (injected for deterministic
|
|
323
|
+
* tests, exactly as the rest of the suite injects its environment).
|
|
324
|
+
*/
|
|
325
|
+
export function createLedger(options = {}) {
|
|
326
|
+
const storage = options.storage ?? createMemoryStorage();
|
|
327
|
+
for (const [limits, names] of [[options.archiveLimits, ['maxItems', 'maxBytes']],
|
|
328
|
+
[options.goalLimits, ['maxEntries', 'maxBytes', 'maxChars']]]) {
|
|
329
|
+
for (const [name, value] of Object.entries(limits ?? {})) {
|
|
330
|
+
if (!names.includes(name)) throw new TypeError(`unknown ledger budget '${name}'`);
|
|
331
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const admittedArtifacts = options.artifacts === undefined ? undefined : JSON.parse(JSON.stringify(options.artifacts));
|
|
335
|
+
if (admittedArtifacts !== undefined && !validateClaimEvidence({ version: 1, artifacts: admittedArtifacts,
|
|
336
|
+
evidence: [], claims: [], visibleEvidence: [] }).valid) throw new TypeError('artifacts must be unique, valid admitted records');
|
|
337
|
+
const compileQuery = typeof options.compileQuery === 'function' ? options.compileQuery : null;
|
|
338
|
+
/** @type {Embedder | null} */
|
|
339
|
+
const embedder = options.embedder ?? null;
|
|
340
|
+
if (embedder !== null) {
|
|
341
|
+
if (typeof embedder !== 'object' || typeof embedder.embed !== 'function')
|
|
342
|
+
throw new AiError('AI0001', 'embedder: expected the seam — { embed(texts) → Promise<Float32Array[]>, model, dims }');
|
|
343
|
+
if (typeof embedder.model !== 'string' || embedder.model === '')
|
|
344
|
+
throw new AiError('AI0001', 'embedder: needs a model name — it is half of every vector\'s identity');
|
|
345
|
+
}
|
|
346
|
+
const embedOnWrite = options.embedOnWrite === true;
|
|
347
|
+
if (embedOnWrite && embedder === null)
|
|
348
|
+
throw new AiError('AI0001', `embedOnWrite ${SEAM_REFUSAL}`);
|
|
349
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
350
|
+
// `unknownFormats: 'ignore'` is the library default; it is passed
|
|
351
|
+
// EXPLICITLY because this project's convention for a schema it ships is
|
|
352
|
+
// the opposite one (`'error'`, so a format nobody registered fails the
|
|
353
|
+
// build instead of checking nothing), and this is the deliberate
|
|
354
|
+
// exception. It has to be: the schemas declare `format: 'date-time'`
|
|
355
|
+
// because that is what the values ARE, but the package may not depend
|
|
356
|
+
// on `@jarenjs/formats` (D3), so no compiler for it can ever be
|
|
357
|
+
// registered here. The enforcement is the `pattern` beside the format,
|
|
358
|
+
// which needs nothing injected; a host passing its own
|
|
359
|
+
// formats-registered `validator` gets the stricter check on top.
|
|
360
|
+
const jaren = options.validator
|
|
361
|
+
?? new JarenValidator({
|
|
362
|
+
skipErrors: false, collectErrors: true, unknownFormats: 'ignore',
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** Every kind's compiled check, built once. */
|
|
366
|
+
const checks = Object.fromEntries(Object.entries(LEDGER_SCHEMAS)
|
|
367
|
+
.map(([kind, schema]) => [kind, jaren.compile(schema)]));
|
|
368
|
+
|
|
369
|
+
/** Compiled retrieval queries, keyed by their document. Recall runs on
|
|
370
|
+
* every turn of a long conversation; compiling the same predicate each
|
|
371
|
+
* time would be the kind of waste that only shows up under load. */
|
|
372
|
+
const queryCache = new Map();
|
|
373
|
+
|
|
374
|
+
/** The tail of the write queue: the promise every mutation waits on. */
|
|
375
|
+
let queue = Promise.resolve();
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Run one mutating operation after every one queued before it has
|
|
379
|
+
* settled. Every write is a read-modify-write — mint an id from what
|
|
380
|
+
* exists, then store; read the goal, then replace it — and two of them
|
|
381
|
+
* interleaved read the same "what exists" and collide. One queue per
|
|
382
|
+
* ledger makes a `Promise.all` of writes behave as the sequence it
|
|
383
|
+
* reads as. A rejected operation does not stall the queue: the next
|
|
384
|
+
* one runs regardless, and only its own caller sees the rejection.
|
|
385
|
+
* @template T
|
|
386
|
+
* @param {(view: any) => Promise<T>} task
|
|
387
|
+
* @param {import('./storage/transaction.js').StorageScope} [scope]
|
|
388
|
+
* @returns {Promise<T>}
|
|
389
|
+
*/
|
|
390
|
+
function enqueue(task, scope = 'ai/') {
|
|
391
|
+
const result = queue.then(() => atomicTask(storage, scope, task));
|
|
392
|
+
queue = result.then(() => undefined, () => undefined);
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Validate a complete record against its kind, or produce the standard
|
|
398
|
+
* rejection. The record is built by the caller (defaults already
|
|
399
|
+
* filled) so that what is validated is exactly what would be stored.
|
|
400
|
+
*
|
|
401
|
+
* Exported as well as used internally, because a caller that wants to
|
|
402
|
+
* know whether a record WOULD be storable before storing it — a
|
|
403
|
+
* model-proposed refinement, which has to reject an unevidenced memory
|
|
404
|
+
* without writing one — must ask the same question the write asks,
|
|
405
|
+
* against the same compiled schema. A second copy of these checks is
|
|
406
|
+
* how "the ledger rejects it" and "the gate rejects it" would come to
|
|
407
|
+
* mean different things.
|
|
408
|
+
* @param {'goal'|'memory'|'skill'|'slot'} kind
|
|
409
|
+
* @param {any} record
|
|
410
|
+
* @returns {null | { error: string, errors: any[], inputSchema: any }}
|
|
411
|
+
* null when the record is storable.
|
|
412
|
+
*/
|
|
413
|
+
function validate(kind, record) {
|
|
414
|
+
const outcome = checkOutcome(checks[kind](record));
|
|
415
|
+
if (!outcome.valid) return invalidInput(kind, outcome, LEDGER_SCHEMAS[kind]);
|
|
416
|
+
if (kind === 'goal') {
|
|
417
|
+
const ids = new Set();
|
|
418
|
+
const errors = [];
|
|
419
|
+
const entries = [...(record.checkpoint?.sources ?? []), ...record.progress];
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
if (entry.id !== undefined && ids.has(entry.id)) errors.push({ keyword: 'uniqueId', instancePath: '/progress', message: 'progress source ids must be unique' });
|
|
422
|
+
if (entry.id !== undefined) ids.add(entry.id);
|
|
423
|
+
}
|
|
424
|
+
for (const source of record.checkpoint?.sources ?? []) {
|
|
425
|
+
if (source.record >= record.checkpoint.records.length)
|
|
426
|
+
errors.push({ keyword: 'reference', instancePath: '/checkpoint/sources', message: 'checkpoint source refers to a missing record' });
|
|
427
|
+
}
|
|
428
|
+
if (errors.length) return invalidInput(kind, { errors }, LEDGER_SCHEMAS[kind]);
|
|
429
|
+
}
|
|
430
|
+
if (kind === 'memory' && typeof record.evidence !== 'string') {
|
|
431
|
+
const references = validateClaimEvidence(record.evidence, { artifacts: admittedArtifacts });
|
|
432
|
+
if (!references.valid) return invalidInput(kind, { errors: references.errors.map((error) => ({
|
|
433
|
+
...error, keyword: error.code ?? error.keyword, instancePath: `/evidence${error.instancePath ?? error.docPath ?? ''}`,
|
|
434
|
+
})) }, LEDGER_SCHEMAS[kind]);
|
|
435
|
+
}
|
|
436
|
+
// the schema has said the pair is present together and shaped; what
|
|
437
|
+
// it cannot say is that the vector IS what its identity declares —
|
|
438
|
+
// `isVector` is the suite's one definition of that
|
|
439
|
+
if (record.embedding !== undefined && !isVector(record.embedding, record.embeddedBy.dims)) {
|
|
440
|
+
return invalidInput(kind, { errors: [{
|
|
441
|
+
instancePath: '/embedding',
|
|
442
|
+
keyword: 'embeddedBy',
|
|
443
|
+
message: `must be exactly ${record.embeddedBy.dims} finite numbers, as embeddedBy.dims declares`,
|
|
444
|
+
}] }, LEDGER_SCHEMAS[kind]);
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Embed texts through the seam, holding a host embedder to the
|
|
451
|
+
* contract the shipped ones keep — one finite vector per input, all of
|
|
452
|
+
* one width — and answering `{ vectors, identity }` or `{ error }`,
|
|
453
|
+
* never a throw. The call sits inside the `try` so that a host `embed`
|
|
454
|
+
* that throws synchronously lands in the same catch as one that
|
|
455
|
+
* rejects. `identity.dims` is the embedder's settled width, or the
|
|
456
|
+
* width this reply settled it at.
|
|
457
|
+
* @param {string[]} texts
|
|
458
|
+
* @returns {Promise<{ vectors: Float32Array[], identity: LedgerEmbeddedBy, error?: undefined }
|
|
459
|
+
* | { error: string, vectors?: undefined, identity?: undefined }>}
|
|
460
|
+
*/
|
|
461
|
+
async function embedThrough(texts) {
|
|
462
|
+
if (embedder === null) return { error: SEAM_REFUSAL };
|
|
463
|
+
try {
|
|
464
|
+
const vectors = await embedder.embed(texts);
|
|
465
|
+
if (!Array.isArray(vectors) || vectors.length !== texts.length) {
|
|
466
|
+
return { error: `the embedder answered ${Array.isArray(vectors) ? vectors.length : 'no'}`
|
|
467
|
+
+ ` vectors for ${texts.length} inputs` };
|
|
468
|
+
}
|
|
469
|
+
const dims = embedder.dims ?? vectors[0].length;
|
|
470
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
471
|
+
if (!isVector(vectors[i], dims))
|
|
472
|
+
return { error: `the embedder answered something other than ${dims} finite numbers for input ${i}` };
|
|
473
|
+
}
|
|
474
|
+
return { vectors, identity: { model: embedder.model, dims } };
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
return { error: /** @type {any} */ (err)?.message ?? String(err) };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The stored form of one seam vector on a record: plain numbers, with
|
|
483
|
+
* the identity beside them.
|
|
484
|
+
* @template {object} T
|
|
485
|
+
* @param {T} record
|
|
486
|
+
* @param {Float32Array} vector
|
|
487
|
+
* @param {LedgerEmbeddedBy} identity
|
|
488
|
+
* @returns {T & { embedding: number[], embeddedBy: LedgerEmbeddedBy }}
|
|
489
|
+
*/
|
|
490
|
+
const withEmbedding = (record, vector, identity) =>
|
|
491
|
+
({ ...record, embedding: Array.from(vector), embeddedBy: { ...identity } });
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The `embedOnWrite` step: a record arriving without a vector is
|
|
495
|
+
* embedded inside its own write. A seam failure is reported on the
|
|
496
|
+
* RESULT, not the record — the record is stored un-embedded and the
|
|
497
|
+
* write succeeds, because one bad network call must not lose a memory;
|
|
498
|
+
* `embedMissing()` closes the gap later.
|
|
499
|
+
* @template {{ embedding?: number[] }} T
|
|
500
|
+
* @param {T} record
|
|
501
|
+
* @param {string} text
|
|
502
|
+
* @returns {Promise<{ record: T, embedError?: string }>}
|
|
503
|
+
*/
|
|
504
|
+
async function embedOnWriteStep(record, text) {
|
|
505
|
+
if (!embedOnWrite || record.embedding !== undefined) return { record };
|
|
506
|
+
const answer = await embedThrough([text]);
|
|
507
|
+
if (answer.error !== undefined)
|
|
508
|
+
return { record, embedError: `the embedder seam failed — ${answer.error}; stored un-embedded` };
|
|
509
|
+
return { record: withEmbedding(record, answer.vectors[0], answer.identity) };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Read every value under a prefix, in key order. */
|
|
513
|
+
async function readAll(prefix, view = storage) {
|
|
514
|
+
const keys = await view.keys(prefix);
|
|
515
|
+
return Promise.all(keys.map((key) => view.get(key)));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* An id for a record the caller did not name. Derived from the
|
|
520
|
+
* timestamp and one past the highest sequence of that kind still
|
|
521
|
+
* stored, so it is unique within a ledger and reproducible under an
|
|
522
|
+
* injected clock. Called only from inside the write queue: the mint
|
|
523
|
+
* and the store that follows it are one step, so no second writer can
|
|
524
|
+
* read the same highest in between.
|
|
525
|
+
*/
|
|
526
|
+
async function mintId(kind, prefix, view) {
|
|
527
|
+
const counter = `ai/counters/${kind}`;
|
|
528
|
+
const n = Math.max(await view.get(counter) ?? 0, nextSequence(await view.keys(prefix)));
|
|
529
|
+
if (!Number.isSafeInteger(n) || n >= Number.MAX_SAFE_INTEGER) throw new RangeError('ledger id sequence exhausted');
|
|
530
|
+
await view.set(counter, n + 1);
|
|
531
|
+
return `${kind}-${now()}-${seq(n)}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
//#region goal
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Set the active objective. Singular by construction: an existing goal
|
|
538
|
+
* is marked `superseded` and moved to the archive rather than
|
|
539
|
+
* overwritten, so the history of what this agent was asked to do
|
|
540
|
+
* survives the change.
|
|
541
|
+
* @param {{ objective: string, createdAt?: string, status?: string,
|
|
542
|
+
* progress?: any[] }} input
|
|
543
|
+
* @returns {Promise<LedgerGoal | LedgerRejection>} the stored goal, or a rejection
|
|
544
|
+
*/
|
|
545
|
+
function setGoal(input) {
|
|
546
|
+
return enqueue(async (storage) => {
|
|
547
|
+
let goal = defined({
|
|
548
|
+
objective: input?.objective,
|
|
549
|
+
createdAt: input?.createdAt ?? now(),
|
|
550
|
+
status: input?.status ?? 'active',
|
|
551
|
+
progress: input?.progress ?? [],
|
|
552
|
+
});
|
|
553
|
+
const rejected = validate('goal', goal);
|
|
554
|
+
if (rejected !== null) return rejected;
|
|
555
|
+
|
|
556
|
+
const bounded = await boundGoal(goal, storage);
|
|
557
|
+
if (bounded.error) return bounded;
|
|
558
|
+
goal = bounded;
|
|
559
|
+
const previous = await storage.get(KEYS.goal);
|
|
560
|
+
if (previous !== undefined) {
|
|
561
|
+
const archived = await storage.keys(KEYS.goalArchive);
|
|
562
|
+
await storage.set(`${KEYS.goalArchive}${seq(nextSequence(archived))}`,
|
|
563
|
+
{ ...previous, status: 'superseded' });
|
|
564
|
+
}
|
|
565
|
+
await storage.set(KEYS.goal, goal);
|
|
566
|
+
return goal;
|
|
567
|
+
}, { prefixes: [KEYS.goalArchive], keys: [KEYS.goal, 'ai/counters/progress'] });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The active objective, or null.
|
|
572
|
+
* @returns {Promise<LedgerGoal | null>}
|
|
573
|
+
*/
|
|
574
|
+
async function getGoal() {
|
|
575
|
+
return (await storage.get(KEYS.goal)) ?? null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Every superseded goal, oldest first.
|
|
580
|
+
* @returns {Promise<LedgerGoal[]>}
|
|
581
|
+
*/
|
|
582
|
+
async function listArchivedGoals() {
|
|
583
|
+
return readAll(KEYS.goalArchive);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Append one evidenced progress entry to the active goal. Storage
|
|
588
|
+
* only: nothing here reads a note, scores it, or decides that a goal
|
|
589
|
+
* is finished — that judgement belongs to whatever drives the agent.
|
|
590
|
+
* @param {{ note: string, evidence: string, at?: string }} entry
|
|
591
|
+
* @returns {Promise<LedgerGoal | LedgerRejection | { error: string }>}
|
|
592
|
+
* the goal with the entry appended; the bare `{ error }` is "no
|
|
593
|
+
* active goal", which is a state problem, not a validation one
|
|
594
|
+
*/
|
|
595
|
+
function recordProgress(entry) {
|
|
596
|
+
return enqueue(async (storage) => {
|
|
597
|
+
const goal = await storage.get(KEYS.goal);
|
|
598
|
+
if (goal === undefined) return { error: 'no active goal — call setGoal first' };
|
|
599
|
+
let next = {
|
|
600
|
+
...goal,
|
|
601
|
+
progress: [...goal.progress, defined({
|
|
602
|
+
at: entry?.at ?? now(),
|
|
603
|
+
note: entry?.note,
|
|
604
|
+
evidence: entry?.evidence,
|
|
605
|
+
})],
|
|
606
|
+
};
|
|
607
|
+
const rejected = validate('goal', next);
|
|
608
|
+
if (rejected !== null) return rejected;
|
|
609
|
+
next = await boundGoal(next, storage);
|
|
610
|
+
if (next.error) return next;
|
|
611
|
+
await storage.set(KEYS.goal, next);
|
|
612
|
+
return next;
|
|
613
|
+
}, { keys: [KEYS.goal, 'ai/counters/progress'] });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Change the active goal's status without superseding it — how a run
|
|
618
|
+
* records that its objective finished or was abandoned.
|
|
619
|
+
* @param {'active'|'done'|'abandoned'|'superseded'} status
|
|
620
|
+
* @returns {Promise<LedgerGoal | LedgerRejection | { error: string }>}
|
|
621
|
+
*/
|
|
622
|
+
function setGoalStatus(status) {
|
|
623
|
+
return enqueue(async (storage) => {
|
|
624
|
+
const goal = await storage.get(KEYS.goal);
|
|
625
|
+
if (goal === undefined) return { error: 'no active goal — call setGoal first' };
|
|
626
|
+
const next = { ...goal, status };
|
|
627
|
+
const rejected = validate('goal', next);
|
|
628
|
+
if (rejected !== null) return rejected;
|
|
629
|
+
await storage.set(KEYS.goal, next);
|
|
630
|
+
return next;
|
|
631
|
+
}, { keys: [KEYS.goal] });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Bound a goal only through validated, lossless coverage, or visibly refuse. */
|
|
635
|
+
async function boundGoal(goal, view) {
|
|
636
|
+
const limits = options.goalLimits;
|
|
637
|
+
if (!limits) return goal;
|
|
638
|
+
if (typeof storage.mutate !== 'function' && !isAtomicView(storage))
|
|
639
|
+
return { error: 'goal budgets require atomic storage', code: 'ATOMIC_REQUIRED' };
|
|
640
|
+
const next = JSON.parse(JSON.stringify(goal));
|
|
641
|
+
let counter = await view.get('ai/counters/progress') ?? 0;
|
|
642
|
+
const ids = new Set([...(next.checkpoint?.sources ?? []), ...next.progress].map((entry) => entry.id));
|
|
643
|
+
for (const entry of next.progress) {
|
|
644
|
+
if (entry.id !== undefined) continue;
|
|
645
|
+
while (ids.has(`progress-${seq(counter)}`)) counter++;
|
|
646
|
+
if (!Number.isSafeInteger(counter) || counter >= Number.MAX_SAFE_INTEGER) throw new RangeError('progress sequence exhausted');
|
|
647
|
+
entry.id = `progress-${seq(counter++)}`;
|
|
648
|
+
ids.add(entry.id);
|
|
649
|
+
}
|
|
650
|
+
const fits = (candidate) => candidate.progress.length <= (limits.maxEntries ?? Infinity)
|
|
651
|
+
&& jsonBytes(candidate) <= (limits.maxBytes ?? Infinity)
|
|
652
|
+
&& goalPrompt(candidate).length <= (limits.maxChars ?? Infinity);
|
|
653
|
+
if (!fits(next)) {
|
|
654
|
+
let checkpoint;
|
|
655
|
+
try { checkpoint = options.checkpointReducer ? options.checkpointReducer(JSON.parse(JSON.stringify(next))) : checkpointProgress(next); }
|
|
656
|
+
catch (error) { return { error: `checkpoint reducer failed: ${error instanceof Error ? error.message : String(error)}`, code: 'GOAL_CHECKPOINT' }; }
|
|
657
|
+
const checked = validateCheckpoint(checkpoint, next);
|
|
658
|
+
if (!checked.valid) return { error: 'invalid goal checkpoint', errors: checked.errors, code: 'GOAL_CHECKPOINT' };
|
|
659
|
+
const compacted = { ...next, progress: [], checkpoint,
|
|
660
|
+
retention: { version: 1, reason: 'goal-budget', retired: next.progress.map((entry) => entry.id) } };
|
|
661
|
+
const rejected = validate('goal', compacted);
|
|
662
|
+
if (rejected) return rejected;
|
|
663
|
+
if (!fits(compacted)) return { error: 'goal budget cannot preserve the objective and evidence; host action required',
|
|
664
|
+
code: 'GOAL_BUDGET', retention: { refused: true, bytes: jsonBytes(compacted), chars: goalPrompt(compacted).length, limits } };
|
|
665
|
+
await view.set('ai/counters/progress', counter);
|
|
666
|
+
return compacted;
|
|
667
|
+
}
|
|
668
|
+
await view.set('ai/counters/progress', counter);
|
|
669
|
+
return next;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Render the complete goal under the host's hard character budget. */
|
|
673
|
+
async function composeGoal() {
|
|
674
|
+
const goal = await getGoal();
|
|
675
|
+
if (!goal || goal.status !== 'active') return { text: '' };
|
|
676
|
+
const rejected = validate('goal', goal);
|
|
677
|
+
if (rejected) return rejected;
|
|
678
|
+
const text = goalPrompt(goal);
|
|
679
|
+
if (text.length > (options.goalLimits?.maxChars ?? Infinity))
|
|
680
|
+
return { error: 'goal prompt exceeds its character budget; host action required', code: 'GOAL_BUDGET' };
|
|
681
|
+
return { text };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
//#endregion
|
|
685
|
+
|
|
686
|
+
//#region memories and skills
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Store a fact worth carrying. `evidence` is required by the schema,
|
|
690
|
+
* and the rejection says so: a memory without it is a guess, and a
|
|
691
|
+
* ledger of guesses is worse than an empty one.
|
|
692
|
+
*
|
|
693
|
+
* The record is EVERY member of the input plus the generated defaults,
|
|
694
|
+
* validated whole — so a member the schema does not know is refused
|
|
695
|
+
* with the same `additionalProperties` answer `validate()` gives,
|
|
696
|
+
* never silently dropped. A write that quietly stored less than it
|
|
697
|
+
* was handed would let "it was accepted" and "it is there" diverge.
|
|
698
|
+
* @param {{ id?: string, text: string, evidence: LedgerMemory['evidence'],
|
|
699
|
+
* tags?: string[], at?: string } & LedgerEmbeddingPair} input
|
|
700
|
+
* `embedding` + `embeddedBy` travel together (plain numbers, never a
|
|
701
|
+
* typed array — the pair type says so, and an orphan does not
|
|
702
|
+
* compile); the vector must be exactly `embeddedBy.dims` finite
|
|
703
|
+
* numbers or the write is rejected
|
|
704
|
+
* @returns {Promise<(LedgerMemory & { embedError?: string }) | LedgerRejection>}
|
|
705
|
+
* the record as stored (defaults filled, undefined members
|
|
706
|
+
* stripped), or the rejection saying why nothing was. `embedError`
|
|
707
|
+
* appears only under `embedOnWrite` when the seam failed: the record
|
|
708
|
+
* is stored WITHOUT a vector and the member is not part of it
|
|
709
|
+
*/
|
|
710
|
+
function addMemory(input) {
|
|
711
|
+
let embedded;
|
|
712
|
+
return enqueue(async (storage) => {
|
|
713
|
+
const memory = defined({
|
|
714
|
+
...input,
|
|
715
|
+
id: input?.id ?? '(pending)',
|
|
716
|
+
tags: input?.tags ?? [],
|
|
717
|
+
at: input?.at ?? now(),
|
|
718
|
+
});
|
|
719
|
+
const rejected = validate('memory', memory);
|
|
720
|
+
if (rejected !== null) return rejected;
|
|
721
|
+
if (input?.id === undefined || input?.id === null) memory.id = await mintId('memory', KEYS.memory, storage);
|
|
722
|
+
embedded ??= await embedOnWriteStep(memory, memory.text);
|
|
723
|
+
const record = { ...embedded.record, id: memory.id, at: memory.at };
|
|
724
|
+
const { embedError } = embedded;
|
|
725
|
+
await storage.set(`${KEYS.memory}${record.id}`, record);
|
|
726
|
+
return embedError === undefined ? record : { ...record, embedError };
|
|
727
|
+
}, { prefixes: [KEYS.memory], keys: ['ai/counters/memory'] });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* One memory by id, or null.
|
|
732
|
+
* @param {string} id
|
|
733
|
+
* @returns {Promise<LedgerMemory | null>}
|
|
734
|
+
*/
|
|
735
|
+
async function getMemory(id) {
|
|
736
|
+
return (await storage.get(`${KEYS.memory}${id}`)) ?? null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Every memory, newest first.
|
|
741
|
+
* @returns {Promise<LedgerMemory[]>}
|
|
742
|
+
*/
|
|
743
|
+
async function listMemories() {
|
|
744
|
+
return byRecency(await readAll(KEYS.memory));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Remove a memory. Answers whether one was there.
|
|
749
|
+
* @param {string} id
|
|
750
|
+
*/
|
|
751
|
+
function deleteMemory(id) {
|
|
752
|
+
return enqueue(async (storage) => {
|
|
753
|
+
const key = `${KEYS.memory}${id}`;
|
|
754
|
+
const existed = (await storage.get(key)) !== undefined;
|
|
755
|
+
await storage.delete(key);
|
|
756
|
+
return existed;
|
|
757
|
+
}, { keys: [`${KEYS.memory}${id}`] });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Store a reusable recipe. Built and validated exactly as a memory is:
|
|
762
|
+
* every input member, defaults filled, the whole record checked.
|
|
763
|
+
* @param {{ id?: string, name: string, when: string,
|
|
764
|
+
* instructions: string, tools?: string[], at?: string, program?: LedgerSkill['program'] }
|
|
765
|
+
* & LedgerEmbeddingPair} input
|
|
766
|
+
* @returns {Promise<(LedgerSkill & { embedError?: string }) | LedgerRejection>}
|
|
767
|
+
* as `addMemory`; under `embedOnWrite` the skill is embedded from its
|
|
768
|
+
* name, when and instructions
|
|
769
|
+
*/
|
|
770
|
+
function addSkill(input) {
|
|
771
|
+
let embedded;
|
|
772
|
+
return enqueue(async (storage) => {
|
|
773
|
+
const skill = defined({
|
|
774
|
+
...input,
|
|
775
|
+
id: input?.id ?? '(pending)',
|
|
776
|
+
tools: input?.tools ?? [],
|
|
777
|
+
at: input?.at ?? now(),
|
|
778
|
+
});
|
|
779
|
+
const rejected = validate('skill', skill);
|
|
780
|
+
if (rejected !== null) return rejected;
|
|
781
|
+
if (input?.id === undefined || input?.id === null) skill.id = await mintId('skill', KEYS.skill, storage);
|
|
782
|
+
embedded ??= await embedOnWriteStep(skill, skillText(skill));
|
|
783
|
+
const record = { ...embedded.record, id: skill.id, at: skill.at };
|
|
784
|
+
const { embedError } = embedded;
|
|
785
|
+
await storage.set(`${KEYS.skill}${record.id}`, record);
|
|
786
|
+
return embedError === undefined ? record : { ...record, embedError };
|
|
787
|
+
}, { prefixes: [KEYS.skill], keys: ['ai/counters/skill'] });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* One skill by id, or null.
|
|
792
|
+
* @param {string} id
|
|
793
|
+
* @returns {Promise<LedgerSkill | null>}
|
|
794
|
+
*/
|
|
795
|
+
async function getSkill(id) {
|
|
796
|
+
return (await storage.get(`${KEYS.skill}${id}`)) ?? null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Every skill, newest first.
|
|
801
|
+
* @returns {Promise<LedgerSkill[]>}
|
|
802
|
+
*/
|
|
803
|
+
async function listSkills() {
|
|
804
|
+
return byRecency(await readAll(KEYS.skill));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Remove a skill. Answers whether one was there.
|
|
809
|
+
* @param {string} id
|
|
810
|
+
*/
|
|
811
|
+
function deleteSkill(id) {
|
|
812
|
+
return enqueue(async (storage) => {
|
|
813
|
+
const key = `${KEYS.skill}${id}`;
|
|
814
|
+
const existed = (await storage.get(key)) !== undefined;
|
|
815
|
+
await storage.delete(key);
|
|
816
|
+
return existed;
|
|
817
|
+
}, { keys: [`${KEYS.skill}${id}`] });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
//#endregion
|
|
821
|
+
|
|
822
|
+
//#region retrieval
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The tag predicate as a query document. Jaren's comparison operators
|
|
826
|
+
* are existentially lifted over sequences, so `$eq` between the record's
|
|
827
|
+
* tag sequence and the requested one is exactly "the two overlap" —
|
|
828
|
+
* one operator, no loop, and the same meaning as the fallback's
|
|
829
|
+
* `some`/`includes`.
|
|
830
|
+
* @param {string[]} tags
|
|
831
|
+
*/
|
|
832
|
+
const tagPredicate = (tags) => ({ $eq: ['$it.tags[*]', { $seq: tags }] });
|
|
833
|
+
|
|
834
|
+
/** Compile once per distinct query document. */
|
|
835
|
+
function compiled(document) {
|
|
836
|
+
const key = JSON.stringify(document);
|
|
837
|
+
let run = queryCache.get(key);
|
|
838
|
+
if (run === undefined) {
|
|
839
|
+
run = compileQuery(document);
|
|
840
|
+
queryCache.set(key, run);
|
|
841
|
+
}
|
|
842
|
+
return run;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Filter a record set. With the query seam wired this is a real jaren
|
|
847
|
+
* query — one document, which a host that also wired `@jarenjs/db`
|
|
848
|
+
* could push down to SQL unchanged. With the seam empty it is tag
|
|
849
|
+
* match, and a caller predicate is REFUSED rather than ignored:
|
|
850
|
+
* silently dropping a filter would answer the wrong question with a
|
|
851
|
+
* straight face.
|
|
852
|
+
*/
|
|
853
|
+
function filter(records, tags, where) {
|
|
854
|
+
const parts = [];
|
|
855
|
+
if (tags !== undefined && tags.length > 0) parts.push(tagPredicate(tags));
|
|
856
|
+
if (where !== undefined && where !== null) parts.push(where);
|
|
857
|
+
|
|
858
|
+
if (compileQuery === null) {
|
|
859
|
+
if (where !== undefined && where !== null) {
|
|
860
|
+
return { error: 'recall: a `where` predicate needs the compileQuery seam — '
|
|
861
|
+
+ 'inject compileJsonQuery from @jarenjs/json/query, or filter by tags only' };
|
|
862
|
+
}
|
|
863
|
+
if (parts.length === 0) return { value: records };
|
|
864
|
+
return { value: records.filter((record) =>
|
|
865
|
+
(record.tags ?? []).some((tag) => tags.includes(tag))) };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (parts.length === 0) return { value: records };
|
|
869
|
+
const predicate = parts.length === 1 ? parts[0] : { $and: parts };
|
|
870
|
+
try {
|
|
871
|
+
const run = compiled([{ $for: { it: '$[*]' }, $where: predicate, $return: '$it' }]);
|
|
872
|
+
return { value: run(records) };
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
// a caller's predicate that does not compile is content, not a
|
|
876
|
+
// crash — same posture as a tool call the model got wrong
|
|
877
|
+
return { error: `recall: ${/** @type {any} */ (err)?.reason ?? /** @type {any} */ (err)?.message ?? err}` };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Retrieve by relevance: tag match, then recency, plus any predicate
|
|
883
|
+
* the seam can evaluate.
|
|
884
|
+
* @param {any[]} records
|
|
885
|
+
* @param {LedgerQuery} query
|
|
886
|
+
*/
|
|
887
|
+
function retrieve(records, query = {}) {
|
|
888
|
+
const filtered = filter(records, query.tags, query.where);
|
|
889
|
+
if (filtered.error !== undefined) return filtered;
|
|
890
|
+
const ordered = byRecency(filtered.value);
|
|
891
|
+
return typeof query.limit === 'number' ? ordered.slice(0, query.limit) : ordered;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The mixture refusal, in one place, so the sweep and an adapter that
|
|
896
|
+
* ranks for us word it identically and in the same order.
|
|
897
|
+
* @param {string} name
|
|
898
|
+
* @param {Map<string, LedgerEmbeddedBy>} found - every identity seen, in the order seen
|
|
899
|
+
* @param {LedgerEmbeddedBy} identity - the query embedder's
|
|
900
|
+
* @returns {{ error: string } | null}
|
|
901
|
+
*/
|
|
902
|
+
function refuseMixture(name, found, identity) {
|
|
903
|
+
if ([...found.values()].every((stored) => sameIdentity(stored, identity))) return null;
|
|
904
|
+
return { error: `${name}: near cannot rank across embedders — the ledger holds vectors from`
|
|
905
|
+
+ ` ${[...found.keys()].join(', ')} and the query embedder is ${describeIdentity(identity)};`
|
|
906
|
+
+ ' rank through the embedder that wrote them, or re-embed every record with one embedder' };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* The last two steps both ranked paths share: the kernels score every
|
|
911
|
+
* record the path selected, `minScore` filters and `limit` caps.
|
|
912
|
+
*
|
|
913
|
+
* The score is always `cosineSimilarity` over the record as stored —
|
|
914
|
+
* never a number an adapter handed back — so the two paths cannot
|
|
915
|
+
* report different similarities for the same record. Equal scores
|
|
916
|
+
* fall back to recency, then id.
|
|
917
|
+
* @param {any[]} records
|
|
918
|
+
* @param {any} probe
|
|
919
|
+
* @param {LedgerQuery} query
|
|
920
|
+
* @param {'memories' | 'skills'} member
|
|
921
|
+
* @param {number} skipped
|
|
922
|
+
* @param {'sweep' | 'adapter'} via
|
|
923
|
+
* @param {LedgerRanking} [ranking]
|
|
924
|
+
*/
|
|
925
|
+
function rankedResult(records, probe, query, member, skipped, via,
|
|
926
|
+
ranking = { algorithm: 'exact-cosine', exhaustive: true, candidateCount: records.length }) {
|
|
927
|
+
const ranked = records
|
|
928
|
+
.map((record) => ({ record, score: cosineSimilarity(probe, record.embedding) }))
|
|
929
|
+
.sort((a, b) => (b.score - a.score) || recencyOrder(a.record, b.record));
|
|
930
|
+
const kept = typeof query.minScore === 'number'
|
|
931
|
+
? ranked.filter((entry) => entry.score >= /** @type {number} */ (query.minScore))
|
|
932
|
+
: ranked;
|
|
933
|
+
const capped = typeof query.limit === 'number' ? kept.slice(0, query.limit) : kept;
|
|
934
|
+
return {
|
|
935
|
+
[member]: capped.map((entry) => entry.record),
|
|
936
|
+
scores: capped.map((entry) => entry.score),
|
|
937
|
+
skipped,
|
|
938
|
+
via,
|
|
939
|
+
ranking,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Retrieve by meaning: the records that pass the tag/where filter AND
|
|
945
|
+
* carry a vector, ranked by cosine similarity to the embedded query.
|
|
946
|
+
*
|
|
947
|
+
* The refusals come before the arithmetic, in this order: `near` must
|
|
948
|
+
* be a non-empty string; the seam must be wired (the `compileQuery`
|
|
949
|
+
* precedent — an absent capability refuses, it never degrades); the
|
|
950
|
+
* filter must compile; the seam must answer; and every candidate's
|
|
951
|
+
* identity must equal the query embedder's. A mixture — two models in
|
|
952
|
+
* the ledger, or a ledger embedded by one model and queried through
|
|
953
|
+
* another — is refused naming every identity found, because ranking
|
|
954
|
+
* the matching subset would be a silent wrong answer. Records without
|
|
955
|
+
* a vector are not scored (a fabricated score poisons a ranking) and
|
|
956
|
+
* not hidden (a silent drop poisons trust): they are counted in
|
|
957
|
+
* `skipped`.
|
|
958
|
+
*
|
|
959
|
+
* TWO paths reach that answer and both report which one ran. By
|
|
960
|
+
* default the ledger reads every record under the prefix and ranks
|
|
961
|
+
* them here. An adapter that declares `rank` is asked instead — it
|
|
962
|
+
* knows how to narrow to the k best without handing over the whole
|
|
963
|
+
* ledger — and is held to exactly the same contract: it reports the
|
|
964
|
+
* distinct identities it holds so the mixture refusal is the ledger's,
|
|
965
|
+
* it reports what it skipped, and the kernels re-score what it
|
|
966
|
+
* returns. An adapter whose own ranking is approximate makes recall
|
|
967
|
+
* approximate: the ledger can re-score what it is handed, never
|
|
968
|
+
* recover a record the adapter did not return.
|
|
969
|
+
*
|
|
970
|
+
* `tags` and `where` narrow candidates the adapter knows nothing
|
|
971
|
+
* about, so a query carrying either takes the sweep.
|
|
972
|
+
*
|
|
973
|
+
* Ranking is `cosineSimilarity` from the kernels, descending; equal
|
|
974
|
+
* scores fall back to recency, then id, so the order is deterministic.
|
|
975
|
+
* `minScore` filters the ranked list; `limit` caps what survives.
|
|
976
|
+
* @param {LedgerQuery} query
|
|
977
|
+
* @param {'recall' | 'recallSkills'} name - the entry point, for the refusal text
|
|
978
|
+
* @param {'memories' | 'skills'} member - the result member the ranked records go under
|
|
979
|
+
* @param {string} prefix - the key space the records live under
|
|
980
|
+
* @returns {Promise<{ error: string }
|
|
981
|
+
* | { [member: string]: any, scores: number[], skipped: number, via: 'sweep' | 'adapter' }>}
|
|
982
|
+
*/
|
|
983
|
+
async function rankNear(query, name, member, prefix) {
|
|
984
|
+
const { near } = query;
|
|
985
|
+
if (typeof near !== 'string' || near === '')
|
|
986
|
+
return { error: `${name}: near must be a non-empty string — the text to rank by meaning against` };
|
|
987
|
+
if (embedder === null) return { error: `${name}: near ${SEAM_REFUSAL}` };
|
|
988
|
+
|
|
989
|
+
const narrowed = query.tags !== undefined || query.where !== undefined;
|
|
990
|
+
const delegated = typeof storage.rank === 'function' && !narrowed;
|
|
991
|
+
// the sweep reads first so that a `where` without the compileQuery
|
|
992
|
+
// seam still refuses before the embedder is called; the adapter path
|
|
993
|
+
// has no filter to compile
|
|
994
|
+
const filtered = delegated ? null : filter(await readAll(prefix), query.tags, query.where);
|
|
995
|
+
if (filtered !== null && filtered.error !== undefined) return filtered;
|
|
996
|
+
|
|
997
|
+
const answer = await embedThrough([near]);
|
|
998
|
+
if (answer.error !== undefined) return { error: `${name}: the embedder seam failed — ${answer.error}` };
|
|
999
|
+
const probe = answer.vectors[0];
|
|
1000
|
+
const identity = answer.identity;
|
|
1001
|
+
|
|
1002
|
+
if (delegated) {
|
|
1003
|
+
const hits = await /** @type {any} */ (storage).rank({
|
|
1004
|
+
prefix, vector: Array.from(probe), model: identity.model, dims: identity.dims,
|
|
1005
|
+
limit: query.limit, minScore: query.minScore,
|
|
1006
|
+
});
|
|
1007
|
+
if (hits === null || typeof hits !== 'object' || !Array.isArray(hits.hits)
|
|
1008
|
+
|| !Array.isArray(hits.identities) || !Number.isSafeInteger(hits.skipped) || hits.skipped < 0) {
|
|
1009
|
+
return { error: `${name}: the storage adapter's rank answered something other than`
|
|
1010
|
+
+ ' { hits, skipped, identities } — a capability that cannot be trusted to report what it'
|
|
1011
|
+
+ ' skipped is worse than one that is absent' };
|
|
1012
|
+
}
|
|
1013
|
+
const ranking = hits.ranking === undefined ? { algorithm: 'legacy-exact', exhaustive: true, candidateCount: hits.hits.length } : hits.ranking;
|
|
1014
|
+
if (ranking === null || typeof ranking !== 'object' || typeof ranking.algorithm !== 'string' || ranking.algorithm.trim() === ''
|
|
1015
|
+
|| typeof ranking.exhaustive !== 'boolean' || ranking.candidateCount !== hits.hits.length)
|
|
1016
|
+
return { error: `${name}: invalid storage rank metadata` };
|
|
1017
|
+
const keys = new Set();
|
|
1018
|
+
for (const hit of hits.hits) {
|
|
1019
|
+
if (typeof hit?.key !== 'string' || !hit.key.startsWith(prefix) || hit.key.length === prefix.length
|
|
1020
|
+
|| keys.has(hit.key) || typeof hit.score !== 'number' || !Number.isFinite(hit.score))
|
|
1021
|
+
return { error: `${name}: invalid or duplicate storage rank candidate` };
|
|
1022
|
+
keys.add(hit.key);
|
|
1023
|
+
}
|
|
1024
|
+
/** @type {Map<string, LedgerEmbeddedBy>} */
|
|
1025
|
+
const found = new Map();
|
|
1026
|
+
for (const stored of hits.identities) {
|
|
1027
|
+
if (typeof stored?.model !== 'string' || stored.model === '' || !Number.isSafeInteger(stored.dims) || stored.dims < 1)
|
|
1028
|
+
return { error: `${name}: invalid storage rank identity` };
|
|
1029
|
+
found.set(describeIdentity(stored), stored);
|
|
1030
|
+
}
|
|
1031
|
+
const records = await Promise.all(hits.hits.map((/** @type {any} */ hit) => storage.get(hit.key)));
|
|
1032
|
+
for (let i = 0; i < records.length; i++) {
|
|
1033
|
+
const record = records[i];
|
|
1034
|
+
if (record == null) continue; // Deleted between selection and read.
|
|
1035
|
+
if (`${prefix}${record.id}` !== hits.hits[i].key || !sameIdentity(record.embeddedBy, identity))
|
|
1036
|
+
return { error: `${name}: storage rank candidate identity mismatch` };
|
|
1037
|
+
if (!isVector(record.embedding, identity.dims))
|
|
1038
|
+
return { error: `${name}: invalid storage rank candidate vector` };
|
|
1039
|
+
found.set(describeIdentity(record.embeddedBy), record.embeddedBy);
|
|
1040
|
+
}
|
|
1041
|
+
const refused = refuseMixture(name, found, identity);
|
|
1042
|
+
if (refused !== null) return refused;
|
|
1043
|
+
// a key the adapter ranked and a read that no longer finds it is a
|
|
1044
|
+
// record deleted in between, not a record without a vector
|
|
1045
|
+
return rankedResult(records.filter((record) => record?.embedding !== undefined),
|
|
1046
|
+
probe, query, member, hits.skipped, 'adapter',
|
|
1047
|
+
{ algorithm: ranking.algorithm, exhaustive: ranking.exhaustive, candidateCount: ranking.candidateCount });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// the identity check, before any math: every candidate's identity
|
|
1051
|
+
// must be the query's, or nothing is ranked
|
|
1052
|
+
/** @type {any[]} */
|
|
1053
|
+
const candidates = [];
|
|
1054
|
+
/** @type {Map<string, LedgerEmbeddedBy>} */
|
|
1055
|
+
const found = new Map();
|
|
1056
|
+
let skipped = 0;
|
|
1057
|
+
for (const record of /** @type {any} */ (filtered).value) {
|
|
1058
|
+
if (record.embedding === undefined) {
|
|
1059
|
+
skipped += 1;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
found.set(describeIdentity(record.embeddedBy), record.embeddedBy);
|
|
1063
|
+
candidates.push(record);
|
|
1064
|
+
}
|
|
1065
|
+
const refused = refuseMixture(name, found, identity);
|
|
1066
|
+
if (refused !== null) return refused;
|
|
1067
|
+
return rankedResult(candidates, probe, query, member, skipped, 'sweep');
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Memories relevant to a query. Without `near`: tag match, then
|
|
1072
|
+
* recency — an array, newest first. With `near`: ranked by meaning
|
|
1073
|
+
* through the embedder seam — `{ memories, scores, skipped, via }` — or
|
|
1074
|
+
* the refusal that says why not.
|
|
1075
|
+
* @param {LedgerQuery} [query]
|
|
1076
|
+
* @returns {Promise<LedgerMemory[] | LedgerRankedMemories | { error: string }>}
|
|
1077
|
+
* the `{ error }` is a query problem — no seam for a `where` or a
|
|
1078
|
+
* `near`, a predicate that does not compile, a seam that failed, or
|
|
1079
|
+
* a mixture of vector identities — content, not a crash
|
|
1080
|
+
*/
|
|
1081
|
+
async function recall(query = {}) {
|
|
1082
|
+
return query.near === undefined
|
|
1083
|
+
? retrieve(await readAll(KEYS.memory), query)
|
|
1084
|
+
: /** @type {any} */ (rankNear(query, 'recall', 'memories', KEYS.memory));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Skills relevant to a query — what a host composes into a system
|
|
1089
|
+
* prompt. The same two paths as `recall`; a skill's meaning is its
|
|
1090
|
+
* name, when and instructions together.
|
|
1091
|
+
* @param {LedgerQuery} [query]
|
|
1092
|
+
* @returns {Promise<LedgerSkill[] | LedgerRankedSkills | { error: string }>}
|
|
1093
|
+
*/
|
|
1094
|
+
async function recallSkills(query = {}) {
|
|
1095
|
+
return query.near === undefined
|
|
1096
|
+
? retrieve(await readAll(KEYS.skill), query)
|
|
1097
|
+
: /** @type {any} */ (rankNear(query, 'recallSkills', 'skills', KEYS.skill));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The explicit sweep: embed every memory and skill that carries no
|
|
1102
|
+
* vector, through the seam, in batches, inside the write chain — so no
|
|
1103
|
+
* other write moves a record under it. Memories first, then skills,
|
|
1104
|
+
* each in key order; `limit` caps how many records this run embeds,
|
|
1105
|
+
* `batch` how many texts one seam call carries. Positive fractional batch sizes
|
|
1106
|
+
* are floored to an integer of at least one.
|
|
1107
|
+
*
|
|
1108
|
+
* Honest about what it did not do. A second run over a swept ledger
|
|
1109
|
+
* embeds zero and makes no seam call (the two-run check). A failing
|
|
1110
|
+
* batch ends the run: the records embedded before it are written, the
|
|
1111
|
+
* failed batch and everything after it stay un-embedded and are
|
|
1112
|
+
* counted in `remaining`, and the error is surfaced once — never a
|
|
1113
|
+
* throw that loses the batch, and never a per-record retry against a
|
|
1114
|
+
* provider that just refused (the seam's own retry policy has already
|
|
1115
|
+
* run). A ledger that already holds vectors under another identity is
|
|
1116
|
+
* refused up front rather than turned into a mixture `recall({ near })`
|
|
1117
|
+
* would then refuse.
|
|
1118
|
+
* @param {{ limit?: number, batch?: number }} [options]
|
|
1119
|
+
* @returns {Promise<{ embedded: number, remaining: number, error?: string }>}
|
|
1120
|
+
*/
|
|
1121
|
+
function embedMissing(options = {}) {
|
|
1122
|
+
const batches = new Map();
|
|
1123
|
+
return enqueue(async (storage) => {
|
|
1124
|
+
const batch = typeof options.batch === 'number' && options.batch > 0 ? Math.max(1, Math.floor(options.batch)) : EMBED_BATCH;
|
|
1125
|
+
/** @type {Array<{ key: string, record: any, text: string }>} */
|
|
1126
|
+
const pending = [];
|
|
1127
|
+
/** @type {Map<string, LedgerEmbeddedBy>} */
|
|
1128
|
+
const held = new Map();
|
|
1129
|
+
for (const memory of await readAll(KEYS.memory, storage)) {
|
|
1130
|
+
if (memory.embedding === undefined) pending.push({ key: `${KEYS.memory}${memory.id}`, record: memory, text: memory.text });
|
|
1131
|
+
else held.set(describeIdentity(memory.embeddedBy), memory.embeddedBy);
|
|
1132
|
+
}
|
|
1133
|
+
for (const skill of await readAll(KEYS.skill, storage)) {
|
|
1134
|
+
if (skill.embedding === undefined) pending.push({ key: `${KEYS.skill}${skill.id}`, record: skill, text: skillText(skill) });
|
|
1135
|
+
else held.set(describeIdentity(skill.embeddedBy), skill.embeddedBy);
|
|
1136
|
+
}
|
|
1137
|
+
const total = pending.length;
|
|
1138
|
+
if (embedder === null) return { embedded: 0, remaining: total, error: `embedMissing: ${SEAM_REFUSAL}` };
|
|
1139
|
+
|
|
1140
|
+
/** The mixture refusal, once the embedder's width is known. */
|
|
1141
|
+
const mixture = (/** @type {LedgerEmbeddedBy} */ identity) => {
|
|
1142
|
+
const foreign = [...held.keys()].filter((key) => !sameIdentity(held.get(key), identity));
|
|
1143
|
+
return foreign.length === 0 ? null
|
|
1144
|
+
: { embedded: 0, remaining: total, error: 'embedMissing: would mix vector identities — the ledger'
|
|
1145
|
+
+ ` already holds vectors from ${foreign.join(', ')} and the embedder is ${describeIdentity(identity)};`
|
|
1146
|
+
+ ' sweep with the embedder that wrote them, or re-add those records without a vector first' };
|
|
1147
|
+
};
|
|
1148
|
+
if (embedder.dims !== undefined) {
|
|
1149
|
+
const refused = mixture({ model: embedder.model, dims: embedder.dims });
|
|
1150
|
+
if (refused !== null) return refused;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const todo = typeof options.limit === 'number' ? pending.slice(0, Math.max(0, Math.floor(options.limit))) : pending;
|
|
1154
|
+
let embedded = 0;
|
|
1155
|
+
for (let i = 0; i < todo.length; i += batch) {
|
|
1156
|
+
const slice = todo.slice(i, i + batch);
|
|
1157
|
+
const texts = slice.map((entry) => entry.text), key = JSON.stringify(texts);
|
|
1158
|
+
if (!batches.has(key)) batches.set(key, await embedThrough(texts));
|
|
1159
|
+
const answer = batches.get(key);
|
|
1160
|
+
if (answer.error !== undefined)
|
|
1161
|
+
return { embedded, remaining: total - embedded, error: `embedMissing: the embedder seam failed — ${answer.error}` };
|
|
1162
|
+
if (i === 0) {
|
|
1163
|
+
// a wire client settles its width on its first reply; the
|
|
1164
|
+
// check that could not run up front runs here, before a write
|
|
1165
|
+
const refused = mixture(answer.identity);
|
|
1166
|
+
if (refused !== null) return refused;
|
|
1167
|
+
}
|
|
1168
|
+
for (let j = 0; j < slice.length; j++) {
|
|
1169
|
+
await storage.set(slice[j].key, withEmbedding(slice[j].record, answer.vectors[j], answer.identity));
|
|
1170
|
+
embedded += 1;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return { embedded, remaining: total - embedded };
|
|
1174
|
+
}, { prefixes: [KEYS.memory, KEYS.skill] });
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
//#endregion
|
|
1178
|
+
|
|
1179
|
+
//#region slots
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Write an addressable blob. The metadata and the content go to
|
|
1183
|
+
* separate keys, and only the metadata is ever handed around: that
|
|
1184
|
+
* separation is the point of a slot, and it is what will let a request
|
|
1185
|
+
* name a hundred of them without carrying any of their content.
|
|
1186
|
+
* @param {string} name
|
|
1187
|
+
* @param {string} content
|
|
1188
|
+
* @param {{ kind?: string, at?: string, count?: number, pinned?: boolean }} [meta]
|
|
1189
|
+
* `count` is what the content HOLDS (lines, records, pieces) where
|
|
1190
|
+
* the writer knows it; absent where it does not, rather than guessed.
|
|
1191
|
+
* @returns {Promise<LedgerSlot | LedgerRejection>}
|
|
1192
|
+
*/
|
|
1193
|
+
function putSlot(name, content, meta = {}) {
|
|
1194
|
+
return enqueue(async (storage) => {
|
|
1195
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content ?? null);
|
|
1196
|
+
const slot = defined({
|
|
1197
|
+
name,
|
|
1198
|
+
kind: meta.kind ?? 'text',
|
|
1199
|
+
size: text.length,
|
|
1200
|
+
excerpt: excerpt(text, SLOT_EXCERPT_CHARS),
|
|
1201
|
+
at: meta.at ?? now(),
|
|
1202
|
+
count: meta.count,
|
|
1203
|
+
pinned: meta.pinned,
|
|
1204
|
+
});
|
|
1205
|
+
const rejected = validate('slot', slot);
|
|
1206
|
+
if (rejected !== null) return rejected;
|
|
1207
|
+
if (options.archiveLimits && (slot.kind === 'agent-round' || slot.kind === 'agent-round-index')) {
|
|
1208
|
+
const result = await storeArchive(storage, [{ slot, text }]);
|
|
1209
|
+
return result.error ? result : slot;
|
|
1210
|
+
}
|
|
1211
|
+
await storage.set(`${KEYS.slotContent}${name}`, text);
|
|
1212
|
+
await storage.set(`${KEYS.slot}${name}`, slot);
|
|
1213
|
+
return slot;
|
|
1214
|
+
}, options.archiveLimits && (meta.kind === 'agent-round' || meta.kind === 'agent-round-index') ? 'ai/state/' : { keys: [`${KEYS.slot}${name}`, `${KEYS.slotContent}${name}`, `ai/state/evicted/${name}`] });
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* A slot's metadata — never its content.
|
|
1219
|
+
* @param {string} name
|
|
1220
|
+
* @returns {Promise<LedgerSlot | null>}
|
|
1221
|
+
*/
|
|
1222
|
+
async function getSlot(name) {
|
|
1223
|
+
return (await storage.get(`${KEYS.slot}${name}`)) ?? null;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* A slot's content, or undefined. The one call that returns the bytes.
|
|
1228
|
+
* @param {string} name
|
|
1229
|
+
* @returns {Promise<string | undefined | { status: 'evicted', name: string, bytes: number, reason: string }>}
|
|
1230
|
+
*/
|
|
1231
|
+
async function readSlot(name) {
|
|
1232
|
+
return (await storage.get(`${KEYS.slotContent}${name}`))
|
|
1233
|
+
?? (await storage.get(`ai/state/evicted/${name}`));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Every slot's metadata, newest first.
|
|
1238
|
+
* @returns {Promise<LedgerSlot[]>}
|
|
1239
|
+
*/
|
|
1240
|
+
async function listSlots() {
|
|
1241
|
+
return byRecency(await readAll(KEYS.slot));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Remove a slot and its content. Answers whether one was there.
|
|
1246
|
+
* @param {string} name
|
|
1247
|
+
*/
|
|
1248
|
+
function deleteSlot(name) {
|
|
1249
|
+
return enqueue(async (storage) => {
|
|
1250
|
+
const key = `${KEYS.slot}${name}`;
|
|
1251
|
+
const existed = (await storage.get(key)) !== undefined;
|
|
1252
|
+
await storage.delete(key);
|
|
1253
|
+
await storage.delete(`${KEYS.slotContent}${name}`);
|
|
1254
|
+
await storage.delete(`ai/state/evicted/${name}`);
|
|
1255
|
+
return existed;
|
|
1256
|
+
}, { keys: [`${KEYS.slot}${name}`, `${KEYS.slotContent}${name}`, `ai/state/evicted/${name}`] });
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Apply a complete archive plan only after retention accepts its exact bytes. */
|
|
1260
|
+
async function storeArchive(view, entries, protection = {}) {
|
|
1261
|
+
if (options.archiveLimits && typeof storage.mutate !== 'function' && !isAtomicView(storage))
|
|
1262
|
+
return { error: 'archive budgets require atomic storage', code: 'ATOMIC_REQUIRED' };
|
|
1263
|
+
const keys = await view.keys('ai/state/');
|
|
1264
|
+
const current = Object.fromEntries(await Promise.all(keys.map(async (key) => [key, await view.get(key)])));
|
|
1265
|
+
const planned = retainArchives(current, entries, options.archiveLimits ?? {}, protection);
|
|
1266
|
+
if (planned.error) return planned;
|
|
1267
|
+
for (const key of Object.keys(current)) if (!Object.hasOwn(planned.next, key)) await view.delete(key);
|
|
1268
|
+
for (const [key, value] of Object.entries(planned.next))
|
|
1269
|
+
if (JSON.stringify(value) !== JSON.stringify(current[key])) await view.set(key, value);
|
|
1270
|
+
return { ok: true, retention: planned.report, footprint: planned.footprint };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Atomically store rounds and their index before removing any transcript.
|
|
1274
|
+
* `protection.immutable` refuses address collisions, including within one batch;
|
|
1275
|
+
* ordinary host-named archives retain their replacement behavior. */
|
|
1276
|
+
function putArchive(entries, protection = {}) {
|
|
1277
|
+
return enqueue(async (view) => {
|
|
1278
|
+
const records = [];
|
|
1279
|
+
for (const entry of entries) {
|
|
1280
|
+
if ((entry.kind !== 'agent-round' && entry.kind !== 'agent-round-index') || typeof entry.text !== 'string')
|
|
1281
|
+
return { error: 'archive entries require round/index kind and string content', code: 'ARCHIVE_INPUT' };
|
|
1282
|
+
const held = await view.get(`${KEYS.slot}${entry.name}`);
|
|
1283
|
+
const unchanged = held?.kind === entry.kind && await view.get(`${KEYS.slotContent}${entry.name}`) === entry.text;
|
|
1284
|
+
records.push({ text: entry.text, slot: unchanged ? held : {
|
|
1285
|
+
name: entry.name, kind: entry.kind, size: entry.text.length,
|
|
1286
|
+
excerpt: excerpt(entry.text, SLOT_EXCERPT_CHARS), at: now(),
|
|
1287
|
+
...(held?.pinned === undefined ? {} : { pinned: held.pinned }),
|
|
1288
|
+
} });
|
|
1289
|
+
}
|
|
1290
|
+
for (const entry of records) {
|
|
1291
|
+
const rejected = validate('slot', entry.slot);
|
|
1292
|
+
if (rejected) return rejected;
|
|
1293
|
+
}
|
|
1294
|
+
return storeArchive(view, records, protection);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Read the last durable archive decision and current host capability. */
|
|
1299
|
+
async function retentionReport() {
|
|
1300
|
+
return (await storage.get('ai/state/retention/archive')) ?? null;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Intentionally remove all conversation archives, tombstones and their report. */
|
|
1304
|
+
function clearArchives(prefix = '') {
|
|
1305
|
+
return enqueue(async (view) => {
|
|
1306
|
+
for (const slot of await readAll(KEYS.slot, view)) {
|
|
1307
|
+
if (!slot.name.startsWith(prefix) || (slot.kind !== 'agent-round' && slot.kind !== 'agent-round-index')) continue;
|
|
1308
|
+
await view.delete(`${KEYS.slot}${slot.name}`);
|
|
1309
|
+
await view.delete(`${KEYS.slotContent}${slot.name}`);
|
|
1310
|
+
}
|
|
1311
|
+
for (const key of await view.keys(`ai/state/evicted/${prefix}`)) await view.delete(key);
|
|
1312
|
+
const report = await view.get('ai/state/retention/archive');
|
|
1313
|
+
if (prefix === '') await view.delete('ai/state/retention/archive');
|
|
1314
|
+
else if (report) await view.set('ai/state/retention/archive', { ...report,
|
|
1315
|
+
evicted: report.evicted.filter((entry) => !entry.name.startsWith(prefix)),
|
|
1316
|
+
written: report.written.filter((name) => !name.startsWith(prefix)) });
|
|
1317
|
+
return true;
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
//#endregion
|
|
1322
|
+
|
|
1323
|
+
//#region snapshots
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Record the whole state under a token. Cheap — a ledger is small JSON
|
|
1327
|
+
* — and it is what makes an automatic write safe to accept: a bad one
|
|
1328
|
+
* is undone by its token, with no human reading a diff. The token
|
|
1329
|
+
* continues the sequence already in storage, exactly as an id does: a
|
|
1330
|
+
* second ledger over the same adapter — the next process over a
|
|
1331
|
+
* durable one — mints the next token, never the first one's again.
|
|
1332
|
+
* @returns {Promise<string>} an opaque token for `rollback`
|
|
1333
|
+
*/
|
|
1334
|
+
function snapshot() {
|
|
1335
|
+
return enqueue(async (storage) => {
|
|
1336
|
+
const keys = await storage.keys(STATE);
|
|
1337
|
+
const entries = await Promise.all(keys.map(async (key) => [key, await storage.get(key)]));
|
|
1338
|
+
const token = `snap-${seq(nextSequence(await storage.keys(SNAP)))}`;
|
|
1339
|
+
await storage.set(`${SNAP}${token}`, entries);
|
|
1340
|
+
return token;
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Restore the state a token recorded. Every current state key is
|
|
1346
|
+
* removed first, so a rollback is a restore and not a merge — a record
|
|
1347
|
+
* created after the snapshot is gone afterwards, which is the only
|
|
1348
|
+
* reading of "rollback" that can be relied on.
|
|
1349
|
+
* @param {string} token
|
|
1350
|
+
* @returns {Promise<true | { error: string }>}
|
|
1351
|
+
*/
|
|
1352
|
+
function rollback(token) {
|
|
1353
|
+
return enqueue(async (storage) => {
|
|
1354
|
+
const entries = await storage.get(`${SNAP}${token}`);
|
|
1355
|
+
if (entries === undefined) return { error: `unknown snapshot '${token}'` };
|
|
1356
|
+
for (const key of await storage.keys(STATE)) await storage.delete(key);
|
|
1357
|
+
for (const [key, value] of entries) await storage.set(key, value);
|
|
1358
|
+
return true;
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
//#endregion
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Commit guarded supplemental work against the exact document it read.
|
|
1366
|
+
* Atomic adapters publish the snapshot and all writes together. A stale
|
|
1367
|
+
* proposal is refused, never rebased onto different record indices.
|
|
1368
|
+
* @param {any} expected
|
|
1369
|
+
* @param {(ledger: any) => Promise<any>} work
|
|
1370
|
+
*/
|
|
1371
|
+
function transaction(expected, work) {
|
|
1372
|
+
return enqueue(async (view) => {
|
|
1373
|
+
const scoped = createLedger({ ...options, artifacts: admittedArtifacts, storage: view });
|
|
1374
|
+
const current = { goal: await scoped.getGoal(), memories: await scoped.listMemories(),
|
|
1375
|
+
skills: await scoped.listSkills() };
|
|
1376
|
+
if (JSON.stringify(current) !== JSON.stringify(expected))
|
|
1377
|
+
return { error: 'the refinement was rejected: supplemental state changed; regenerate the proposal',
|
|
1378
|
+
errors: [{ code: 'LEDGER_CONFLICT', docPath: '', message: 'supplemental state changed' }] };
|
|
1379
|
+
const outcome = await work(scoped);
|
|
1380
|
+
if (outcome?.error) throw Object.assign(new Error(outcome.error), { outcome });
|
|
1381
|
+
return outcome;
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
return {
|
|
1386
|
+
storageStatus: () => typeof storage.status === 'function' ? storage.status()
|
|
1387
|
+
: { concurrency: typeof storage.mutate === 'function' ? 'atomic' : 'single-writer', durability: 'host-defined', error: null },
|
|
1388
|
+
concurrency: typeof storage.mutate === 'function' ? 'atomic' : 'single-writer',
|
|
1389
|
+
validate,
|
|
1390
|
+
setGoal, getGoal, listArchivedGoals, recordProgress, setGoalStatus, composeGoal,
|
|
1391
|
+
addMemory, getMemory, listMemories, deleteMemory,
|
|
1392
|
+
addSkill, getSkill, listSkills, deleteSkill,
|
|
1393
|
+
recall, recallSkills, embedMissing,
|
|
1394
|
+
putSlot, getSlot, readSlot, listSlots, deleteSlot, putArchive, clearArchives, retentionReport,
|
|
1395
|
+
snapshot, rollback, transaction,
|
|
1396
|
+
};
|
|
1397
|
+
}
|