@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
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The environment: a corpus the agent works ON rather than reads.
|
|
4
|
+
*
|
|
5
|
+
* Everything before this file tried to make a long context fit. The
|
|
6
|
+
* ledger stopped compaction destroying what it cut, and that moved the
|
|
7
|
+
* needle question from 2.5% to fully recoverable — but a question over
|
|
8
|
+
* *everything at once* stayed unanswerable, because the answer was never
|
|
9
|
+
* about how well the transcript was summarised. It was about the corpus
|
|
10
|
+
* being in the transcript at all.
|
|
11
|
+
*
|
|
12
|
+
* So it is not. Content lives in named slots; the root model sees a
|
|
13
|
+
* **digest** — name, kind, size, count, one line of excerpt — and works
|
|
14
|
+
* by naming slots in operations. Five of them, and they are the ones the
|
|
15
|
+
* RLM paper reports its models discovering for themselves:
|
|
16
|
+
*
|
|
17
|
+
* peek metadata plus a head excerpt — the default view of anything
|
|
18
|
+
* chunk split a slot into addressable pieces, deterministically
|
|
19
|
+
* grep scan for a pattern, answer with ADDRESSES and match lines
|
|
20
|
+
* select run a query over a structured slot, store the result
|
|
21
|
+
* stat counts, sizes, shape — answers that need no model at all
|
|
22
|
+
*
|
|
23
|
+
* Three rules hold without exception, and the tests assert each one:
|
|
24
|
+
*
|
|
25
|
+
* - **No operation returns bulk content** (D2). Every result is capped
|
|
26
|
+
* by construction — excerpts, match counts, listed slots — so a
|
|
27
|
+
* result is the same size whether the slot holds 10 kB or 10 MB. The
|
|
28
|
+
* one call that returns content is `read`, which requires an explicit
|
|
29
|
+
* character budget, and it exists for a sub-call payload or a user
|
|
30
|
+
* asking, not for the root's convenience.
|
|
31
|
+
* - **The root view is constant-size.** The digest lists at most
|
|
32
|
+
* `digestSlots` slots and says how many it did not list. That cap is
|
|
33
|
+
* what makes the claim true rather than clever: a corpus three orders
|
|
34
|
+
* of magnitude larger produces the same-size root request, and the
|
|
35
|
+
* model narrows with `grep` and `stat` instead of with a longer list.
|
|
36
|
+
* - **Addressing is derived, never stored.** A chunk's name is a pure
|
|
37
|
+
* function of its parent, the strategy and its index, so chunking the
|
|
38
|
+
* same slot twice writes the same slots instead of a second copy.
|
|
39
|
+
*
|
|
40
|
+
* Storage and the query compiler are injected (D3) — the same seams the
|
|
41
|
+
* ledger takes, because this IS the ledger's slot kind with operations
|
|
42
|
+
* over it rather than a second store.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { chunkText, excerpt, truncate } from '@jarenjs/core/chunk';
|
|
46
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
47
|
+
|
|
48
|
+
import { createLedger } from './ledger.js';
|
|
49
|
+
|
|
50
|
+
/** How much of a slot one excerpt shows. */
|
|
51
|
+
const EXCERPT_CHARS = 160;
|
|
52
|
+
|
|
53
|
+
/** Slots one digest lists before it starts counting instead. */
|
|
54
|
+
const DIGEST_SLOTS = 12;
|
|
55
|
+
|
|
56
|
+
/** Matches one `grep` reports before it starts counting instead. */
|
|
57
|
+
const MATCH_LIMIT = 20;
|
|
58
|
+
|
|
59
|
+
/** Characters of context one match line carries. */
|
|
60
|
+
const MATCH_CHARS = 120;
|
|
61
|
+
|
|
62
|
+
/** Chunk metadata entries one `chunk` result lists. */
|
|
63
|
+
const CHUNK_PREVIEW = 8;
|
|
64
|
+
|
|
65
|
+
/** The default piece size, in characters. */
|
|
66
|
+
const CHUNK_SIZE = 4000;
|
|
67
|
+
|
|
68
|
+
/** Validate a finite host budget before it can become a slice endpoint. */
|
|
69
|
+
function budgetOption(value, fallback, name, minimum = 0) {
|
|
70
|
+
const result = value ?? fallback;
|
|
71
|
+
if (!Number.isSafeInteger(result) || result < minimum)
|
|
72
|
+
throw new TypeError(`${name} must be a ${minimum ? 'positive' : 'non-negative'} safe integer`);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The kind a chunk slot is written under, so a digest can group them. */
|
|
77
|
+
export const CHUNK_KIND = 'chunk';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The address of one chunk: parent, strategy, size, index. Derived, so
|
|
81
|
+
* the same split always names the same slots — that is what makes
|
|
82
|
+
* re-chunking idempotent rather than duplicating, and it is why nothing
|
|
83
|
+
* here keeps a mapping from a parent to its pieces.
|
|
84
|
+
* @param {string} parent
|
|
85
|
+
* @param {string} strategy
|
|
86
|
+
* @param {number} size
|
|
87
|
+
* @param {number} index
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function chunkSlotName(parent, strategy, size, index) {
|
|
91
|
+
return `${parent}#${strategy}:${size}/${index}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The prefix every chunk of one split shares — a family, addressable as one. */
|
|
95
|
+
export function chunkFamily(parent, strategy, size) {
|
|
96
|
+
return `${parent}#${strategy}:${size}/`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The same slot store, confined to a prefix.
|
|
101
|
+
*
|
|
102
|
+
* **The scope is invisible from inside.** Names go in prefixed and come
|
|
103
|
+
* out stripped, so a child sees a clean namespace — its corpus is
|
|
104
|
+
* `corpus`, not `child/1/0/corpus` — and authors exactly the program it
|
|
105
|
+
* would author at the root. That is not a convenience: a child that had
|
|
106
|
+
* to know its own address would need it in the prompt, and a model that
|
|
107
|
+
* knows its address is a model that can type a sibling's.
|
|
108
|
+
*
|
|
109
|
+
* Isolation follows from the same rule. A child naming
|
|
110
|
+
* `child/1/1/mine` gets `child/1/0/child/1/1/mine`, which does not
|
|
111
|
+
* exist — the sibling is unreachable rather than merely discouraged, and
|
|
112
|
+
* no check has to remember to run.
|
|
113
|
+
*
|
|
114
|
+
* Everything that is not slot addressing (goals, memories, snapshots)
|
|
115
|
+
* passes through untouched: the ledger is shared on purpose, because a
|
|
116
|
+
* tree that could not record what it learned would defeat Phase A.
|
|
117
|
+
* @param {any} ledger
|
|
118
|
+
* @param {string} scope
|
|
119
|
+
*/
|
|
120
|
+
function scopedLedger(ledger, scope) {
|
|
121
|
+
/** A name on the way IN: always beneath the scope. */
|
|
122
|
+
const within = (name) => scope + String(name ?? '');
|
|
123
|
+
|
|
124
|
+
/** A slot on the way OUT: named as the scope's occupant sees it. */
|
|
125
|
+
const relative = (slot) => (slot !== null && typeof slot === 'object'
|
|
126
|
+
&& typeof slot.name === 'string' && slot.name.startsWith(scope)
|
|
127
|
+
? { ...slot, name: slot.name.slice(scope.length) }
|
|
128
|
+
: slot);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...ledger,
|
|
132
|
+
scope,
|
|
133
|
+
putSlot: async (name, content, meta) =>
|
|
134
|
+
relative(await ledger.putSlot(within(name), content, meta)),
|
|
135
|
+
getSlot: async (name) => relative(await ledger.getSlot(within(name))),
|
|
136
|
+
readSlot: async (name) => relative(await ledger.readSlot(within(name))),
|
|
137
|
+
deleteSlot: (name) => ledger.deleteSlot(within(name)),
|
|
138
|
+
putArchive: typeof ledger.putArchive !== 'function' ? undefined : (entries, protection = {}) =>
|
|
139
|
+
ledger.putArchive(entries.map((entry) => ({ ...entry, name: within(entry.name) })),
|
|
140
|
+
{ ...protection, protectedNames: (protection.protectedNames ?? []).map(within) }),
|
|
141
|
+
clearArchives: () => ledger.clearArchives(scope),
|
|
142
|
+
retentionReport: async () => {
|
|
143
|
+
const report = await ledger.retentionReport?.();
|
|
144
|
+
return !report ? null : { ...report,
|
|
145
|
+
evicted: report.evicted.filter((entry) => entry.name.startsWith(scope)).map(relative),
|
|
146
|
+
written: report.written.filter((name) => name.startsWith(scope)).map((name) => name.slice(scope.length)) };
|
|
147
|
+
},
|
|
148
|
+
listSlots: async () => (await ledger.listSlots())
|
|
149
|
+
.filter((slot) => slot.name.startsWith(scope))
|
|
150
|
+
.map(relative),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A regular expression from a model-supplied pattern, or null.
|
|
156
|
+
*
|
|
157
|
+
* The pattern is a string and the flags are constrained: a model that
|
|
158
|
+
* asked for `g` would change `exec`'s statefulness under us, and one
|
|
159
|
+
* that asked for something unknown would throw. Invalid patterns answer
|
|
160
|
+
* `{ error }` like every other content-level problem in this package —
|
|
161
|
+
* a bad regex is something to correct, not a crash.
|
|
162
|
+
* @param {string} pattern
|
|
163
|
+
* @param {string} flags
|
|
164
|
+
*/
|
|
165
|
+
function compilePattern(pattern, flags) {
|
|
166
|
+
try {
|
|
167
|
+
return { value: new RegExp(pattern, flags.replace(/[^im]/g, '')) };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return { error: `not a usable pattern: ${/** @type {Error} */ (err).message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create an environment over a slot store.
|
|
176
|
+
*
|
|
177
|
+
* @param {{ ledger?: any,
|
|
178
|
+
* storage?: { get: (key: string) => Promise<any>,
|
|
179
|
+
* set: (key: string, value: any) => Promise<void>,
|
|
180
|
+
* delete: (key: string) => Promise<void>,
|
|
181
|
+
* keys: (prefix?: string) => Promise<string[]> },
|
|
182
|
+
* compileQuery?: (document: any) => (data: any) => any,
|
|
183
|
+
* excerptChars?: number, digestSlots?: number, matchLimit?: number,
|
|
184
|
+
* chunkSize?: number, now?: () => string }} [options]
|
|
185
|
+
* - `ledger` shares an existing ledger — the normal case, because the
|
|
186
|
+
* agent's archived rounds and the corpus then live in one store and
|
|
187
|
+
* one `recall` reaches both. Given `storage` instead, a ledger is
|
|
188
|
+
* built over it; given neither, everything runs in memory.
|
|
189
|
+
* - `compileQuery` is the `select` seam (`compileJsonQuery` from
|
|
190
|
+
* `@jarenjs/json/query`). Absent, `select` declines with a stated
|
|
191
|
+
* reason and every other operation is unaffected.
|
|
192
|
+
* @returns {any}
|
|
193
|
+
*/
|
|
194
|
+
export function createEnvironment(options = {}) {
|
|
195
|
+
const base = options.ledger ?? createLedger({
|
|
196
|
+
storage: options.storage, now: options.now,
|
|
197
|
+
});
|
|
198
|
+
// a scoped environment is the SAME store seen through a prefix, not a
|
|
199
|
+
// second store: a child of a recursive run must not be able to read or
|
|
200
|
+
// overwrite a sibling's slots, and confining it here means every
|
|
201
|
+
// operation inherits the confinement rather than each one remembering
|
|
202
|
+
const ledger = typeof options.scope === 'string' && options.scope !== ''
|
|
203
|
+
? scopedLedger(base, options.scope)
|
|
204
|
+
: base;
|
|
205
|
+
const compileQuery = typeof options.compileQuery === 'function' ? options.compileQuery : null;
|
|
206
|
+
const excerptChars = budgetOption(options.excerptChars, EXCERPT_CHARS, 'excerptChars');
|
|
207
|
+
const digestSlots = budgetOption(options.digestSlots, DIGEST_SLOTS, 'digestSlots');
|
|
208
|
+
const matchLimit = budgetOption(options.matchLimit, MATCH_LIMIT, 'matchLimit');
|
|
209
|
+
const defaultChunkSize = budgetOption(options.chunkSize, CHUNK_SIZE, 'chunkSize', 1);
|
|
210
|
+
|
|
211
|
+
/** Compiled selections, keyed by their document — `select` on a
|
|
212
|
+
* hundred chunks compiles one query, not a hundred. */
|
|
213
|
+
const queries = new Map();
|
|
214
|
+
|
|
215
|
+
/** The metadata shape everything here answers with. Never content. */
|
|
216
|
+
const view = (slot) => (slot === null || slot === undefined ? null : {
|
|
217
|
+
name: slot.name,
|
|
218
|
+
kind: slot.kind,
|
|
219
|
+
size: slot.size,
|
|
220
|
+
...(slot.count === undefined ? {} : { count: slot.count }),
|
|
221
|
+
excerpt: excerpt(slot.excerpt, excerptChars),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/** Every slot under a prefix, newest first (metadata only). */
|
|
225
|
+
async function slotsUnder(prefix) {
|
|
226
|
+
const all = await ledger.listSlots();
|
|
227
|
+
return prefix === '' || prefix === undefined
|
|
228
|
+
? all
|
|
229
|
+
: all.filter((slot) => slot.name === prefix || slot.name.startsWith(prefix));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Put content in the environment. The one entry point that takes bulk
|
|
234
|
+
* content, and it takes it from the HOST — a corpus arrives from a
|
|
235
|
+
* file, a fetch or a paste, never from a model.
|
|
236
|
+
* @param {string} name
|
|
237
|
+
* @param {string} content
|
|
238
|
+
* @param {{ kind?: string, count?: number }} [meta]
|
|
239
|
+
*/
|
|
240
|
+
async function put(name, content, meta = {}) {
|
|
241
|
+
const stored = await ledger.putSlot(name, content, {
|
|
242
|
+
kind: meta.kind ?? 'text',
|
|
243
|
+
count: meta.count,
|
|
244
|
+
});
|
|
245
|
+
return stored?.error === undefined ? view(stored) : stored;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Write a corpus that arrives in pieces, one piece at a time.
|
|
250
|
+
*
|
|
251
|
+
* This is the path that keeps a 10 MB corpus out of memory: each piece
|
|
252
|
+
* is written and dropped, and nothing here ever holds the whole. It is
|
|
253
|
+
* also how a caller who already has natural units (files, records,
|
|
254
|
+
* pages) keeps them as the chunk boundaries instead of re-cutting
|
|
255
|
+
* them.
|
|
256
|
+
* @param {string} name - the family name; pieces are named under it
|
|
257
|
+
* @param {AsyncIterable<string> | Iterable<string>} pieces
|
|
258
|
+
* @param {{ kind?: string, strategy?: string, size?: number }} [meta]
|
|
259
|
+
*/
|
|
260
|
+
async function ingest(name, pieces, meta = {}) {
|
|
261
|
+
const strategy = meta.strategy ?? 'given';
|
|
262
|
+
const size = meta.size ?? 0;
|
|
263
|
+
let index = 0;
|
|
264
|
+
let bytes = 0;
|
|
265
|
+
for await (const piece of pieces) {
|
|
266
|
+
const text = String(piece);
|
|
267
|
+
const written = await ledger.putSlot(chunkSlotName(name, strategy, size, index),
|
|
268
|
+
text, { kind: meta.kind ?? CHUNK_KIND });
|
|
269
|
+
if (written?.error !== undefined) return written;
|
|
270
|
+
bytes += text.length;
|
|
271
|
+
index += 1;
|
|
272
|
+
}
|
|
273
|
+
await removeStalePieces(chunkFamily(name, strategy, size), index);
|
|
274
|
+
return { name, family: chunkFamily(name, strategy, size), count: index, size: bytes };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A replacement owns indexed family members, never a host's named siblings. */
|
|
278
|
+
async function removeStalePieces(family, count) {
|
|
279
|
+
for (const old of await ledger.listSlots()) {
|
|
280
|
+
if (old.name.startsWith(family) && /^\d+$/.test(old.name.slice(family.length))
|
|
281
|
+
&& Number(old.name.slice(family.length)) >= count) await ledger.deleteSlot(old.name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Metadata plus a head excerpt — the root's default view of anything.
|
|
287
|
+
* @param {string} name
|
|
288
|
+
* @param {{ chars?: number }} [options_]
|
|
289
|
+
*/
|
|
290
|
+
async function peek(name, options_ = {}) {
|
|
291
|
+
const chars = Math.min(budgetOption(options_.chars, excerptChars, 'chars'), excerptChars * 4);
|
|
292
|
+
const slot = await ledger.getSlot(name);
|
|
293
|
+
if (slot === null) return unknown(name);
|
|
294
|
+
const content = await ledger.readSlot(name);
|
|
295
|
+
return {
|
|
296
|
+
...view(slot),
|
|
297
|
+
// a HEAD excerpt, not the stored one-line summary: `peek` is the
|
|
298
|
+
// call a reader makes to decide whether this is the right slot,
|
|
299
|
+
// and the first lines are what answers that
|
|
300
|
+
head: truncate(String(content ?? '').slice(0, chars), chars, '…'),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Split a slot into addressable pieces. Deterministic in its
|
|
306
|
+
* addressing and idempotent in its storage: the same slot split the
|
|
307
|
+
* same way names the same pieces and overwrites them with identical
|
|
308
|
+
* bytes.
|
|
309
|
+
* @param {string} name
|
|
310
|
+
* @param {{ strategy?: 'size'|'line'|'separator', size?: number,
|
|
311
|
+
* overlap?: number, separator?: string, preview?: number }} [options_]
|
|
312
|
+
*/
|
|
313
|
+
async function chunk(name, options_ = {}) {
|
|
314
|
+
const preview = Math.min(budgetOption(options_.preview, CHUNK_PREVIEW, 'preview'), CHUNK_PREVIEW);
|
|
315
|
+
const slot = await ledger.getSlot(name);
|
|
316
|
+
if (slot === null) return unknown(name);
|
|
317
|
+
const content = await ledger.readSlot(name);
|
|
318
|
+
const strategy = options_.strategy ?? 'size';
|
|
319
|
+
const size = budgetOption(options_.size, defaultChunkSize, 'size', 1);
|
|
320
|
+
const pieces = chunkText(String(content ?? ''), { ...options_, strategy, size });
|
|
321
|
+
|
|
322
|
+
for (const piece of pieces) {
|
|
323
|
+
const written = await ledger.putSlot(chunkSlotName(name, strategy, size, piece.index),
|
|
324
|
+
piece.text, { kind: CHUNK_KIND });
|
|
325
|
+
if (written?.error !== undefined) return written;
|
|
326
|
+
}
|
|
327
|
+
// A shorter replacement must not leave old pieces reachable by grep/map.
|
|
328
|
+
await removeStalePieces(chunkFamily(name, strategy, size), pieces.length);
|
|
329
|
+
return {
|
|
330
|
+
source: name,
|
|
331
|
+
strategy,
|
|
332
|
+
size,
|
|
333
|
+
count: pieces.length,
|
|
334
|
+
family: chunkFamily(name, strategy, size),
|
|
335
|
+
// capped like everything else: a 10 MB corpus splits into hundreds
|
|
336
|
+
// of pieces and listing them all would put the corpus back in the
|
|
337
|
+
// request in another shape
|
|
338
|
+
chunks: pieces.slice(0, preview).map((piece) => ({
|
|
339
|
+
name: chunkSlotName(name, strategy, size, piece.index),
|
|
340
|
+
size: piece.text.length,
|
|
341
|
+
excerpt: excerpt(piece.text, excerptChars),
|
|
342
|
+
})),
|
|
343
|
+
omitted: Math.max(0, pieces.length - preview),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Scan for a pattern and answer with addresses.
|
|
349
|
+
*
|
|
350
|
+
* This is how the root narrows without reading: it learns WHICH slot
|
|
351
|
+
* holds what it is looking for and one line of context per hit, and
|
|
352
|
+
* then decides whether to spend a `read` on it. Slots are scanned one
|
|
353
|
+
* at a time and released, so grepping a corpus never holds more than
|
|
354
|
+
* one piece of it.
|
|
355
|
+
* @param {string} pattern - a regular expression source
|
|
356
|
+
* @param {{ in?: string, limit?: number, chars?: number, flags?: string }} [options_]
|
|
357
|
+
*/
|
|
358
|
+
async function grep(pattern, options_ = {}) {
|
|
359
|
+
const compiled = compilePattern(String(pattern ?? ''), options_.flags ?? 'i');
|
|
360
|
+
if (compiled.error !== undefined) return { error: compiled.error, pattern };
|
|
361
|
+
const regex = compiled.value;
|
|
362
|
+
const scope = options_.in ?? '';
|
|
363
|
+
const limit = Math.min(budgetOption(options_.limit, matchLimit, 'limit'), matchLimit);
|
|
364
|
+
const chars = Math.min(budgetOption(options_.chars, MATCH_CHARS, 'chars'), MATCH_CHARS);
|
|
365
|
+
|
|
366
|
+
const slots = await slotsUnder(scope);
|
|
367
|
+
/** @type {any[]} */
|
|
368
|
+
const matches = [];
|
|
369
|
+
let total = 0;
|
|
370
|
+
let scanned = 0;
|
|
371
|
+
for (const slot of [...slots].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
372
|
+
const content = String(await ledger.readSlot(slot.name) ?? '');
|
|
373
|
+
scanned += 1;
|
|
374
|
+
// walked with offsets rather than `split`, because an address that
|
|
375
|
+
// says WHERE is worth more than one that says which: a match on a
|
|
376
|
+
// 500-character tool result gives a window around the hit and the
|
|
377
|
+
// offset to read the rest from, so narrowing costs one read of a
|
|
378
|
+
// few hundred characters instead of a read of the whole slot
|
|
379
|
+
let at = 0;
|
|
380
|
+
while (at <= content.length) {
|
|
381
|
+
const nextBreak = content.indexOf('\n', at);
|
|
382
|
+
const end = nextBreak === -1 ? content.length : nextBreak;
|
|
383
|
+
const line = content.slice(at, end);
|
|
384
|
+
const hit = regex.exec(line);
|
|
385
|
+
if (hit !== null) {
|
|
386
|
+
total += 1;
|
|
387
|
+
if (matches.length < limit) {
|
|
388
|
+
matches.push({
|
|
389
|
+
slot: slot.name,
|
|
390
|
+
offset: at,
|
|
391
|
+
line: around(line, hit.index, hit[0].length, chars),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (nextBreak === -1) break;
|
|
396
|
+
at = nextBreak + 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
pattern,
|
|
401
|
+
in: scope === '' ? '(everything)' : scope,
|
|
402
|
+
scanned,
|
|
403
|
+
total,
|
|
404
|
+
matches,
|
|
405
|
+
omitted: Math.max(0, total - matches.length),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Run a query document over a structured slot and store the result as
|
|
411
|
+
* a new slot. The seam is `@jarenjs/json`'s query compiler; with it
|
|
412
|
+
* empty this declines with a stated reason rather than pretending —
|
|
413
|
+
* the same posture the ledger's retrieval takes, for the same reason.
|
|
414
|
+
* @param {string} name
|
|
415
|
+
* @param {any} query - a jaren-query document
|
|
416
|
+
* @param {{ as?: string }} [options_]
|
|
417
|
+
*/
|
|
418
|
+
async function select(name, query, options_ = {}) {
|
|
419
|
+
if (compileQuery === null) {
|
|
420
|
+
return { error: 'select needs the compileQuery seam — inject compileJsonQuery from '
|
|
421
|
+
+ '@jarenjs/json/query, or narrow with grep and read a chunk instead' };
|
|
422
|
+
}
|
|
423
|
+
const slot = await ledger.getSlot(name);
|
|
424
|
+
if (slot === null) return unknown(name);
|
|
425
|
+
const raw = await ledger.readSlot(name);
|
|
426
|
+
/** @type {any} */
|
|
427
|
+
let data;
|
|
428
|
+
try {
|
|
429
|
+
data = JSON.parse(String(raw ?? 'null'));
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
return { error: `slot '${name}' is not JSON: ${/** @type {Error} */ (err).message}` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const key = JSON.stringify(query);
|
|
436
|
+
let compiled = queries.get(key);
|
|
437
|
+
if (compiled === undefined) {
|
|
438
|
+
try {
|
|
439
|
+
compiled = { run: compileQuery(query), index: queries.size };
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
const e = /** @type {any} */ (err);
|
|
443
|
+
return { error: `the query does not compile: ${e?.reason ?? e?.message ?? err}`,
|
|
444
|
+
code: e?.code, docPath: e?.docPath };
|
|
445
|
+
}
|
|
446
|
+
queries.set(key, compiled);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @type {any} */
|
|
450
|
+
let result;
|
|
451
|
+
try {
|
|
452
|
+
result = compiled.run(data);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
const e = /** @type {any} */ (err);
|
|
456
|
+
return { error: `the query failed on '${name}': ${e?.reason ?? e?.message ?? err}` };
|
|
457
|
+
}
|
|
458
|
+
const text = JSON.stringify(result ?? null);
|
|
459
|
+
const target = options_.as ?? `${name}#select/${compiled.index}`;
|
|
460
|
+
const written = await ledger.putSlot(target, text, {
|
|
461
|
+
kind: 'selection',
|
|
462
|
+
count: Array.isArray(result) ? result.length : undefined,
|
|
463
|
+
});
|
|
464
|
+
return written?.error === undefined ? view(written) : written;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Counts, sizes and shape — the answers that need no model.
|
|
469
|
+
*
|
|
470
|
+
* Given one slot's name it reports that slot; given a prefix it
|
|
471
|
+
* aggregates the family, which is how "how big is this corpus, in how
|
|
472
|
+
* many pieces" is answered without touching content.
|
|
473
|
+
* @param {string} [nameOrPrefix]
|
|
474
|
+
*/
|
|
475
|
+
async function stat(nameOrPrefix = '') {
|
|
476
|
+
const one = nameOrPrefix === '' ? null : await ledger.getSlot(nameOrPrefix);
|
|
477
|
+
if (one !== null) {
|
|
478
|
+
const content = String(await ledger.readSlot(nameOrPrefix) ?? '');
|
|
479
|
+
return {
|
|
480
|
+
...view(one),
|
|
481
|
+
lines: content === '' ? 0 : content.split('\n').length,
|
|
482
|
+
json: looksJson(content),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const slots = await slotsUnder(nameOrPrefix);
|
|
486
|
+
if (slots.length === 0) return unknown(nameOrPrefix);
|
|
487
|
+
/** @type {Record<string, number>} */
|
|
488
|
+
const kinds = {};
|
|
489
|
+
let size = 0;
|
|
490
|
+
let largest = slots[0];
|
|
491
|
+
for (const slot of slots) {
|
|
492
|
+
setObjectMember(kinds, slot.kind, (Object.hasOwn(kinds, slot.kind) ? kinds[slot.kind] : 0) + 1);
|
|
493
|
+
size += slot.size;
|
|
494
|
+
if (slot.size > largest.size) largest = slot;
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
prefix: nameOrPrefix === '' ? '(everything)' : nameOrPrefix,
|
|
498
|
+
slots: slots.length,
|
|
499
|
+
size,
|
|
500
|
+
kinds,
|
|
501
|
+
largest: { name: largest.name, size: largest.size },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The root's whole view of the environment: capped, and honest about
|
|
507
|
+
* the cap.
|
|
508
|
+
*
|
|
509
|
+
* This is the object a request carries, and its size is bounded by
|
|
510
|
+
* construction — `digestSlots` entries, each with a capped excerpt.
|
|
511
|
+
* `omitted` is not a nicety: a digest that quietly listed the first
|
|
512
|
+
* twelve of four hundred slots would let a model conclude the corpus
|
|
513
|
+
* is twelve slots long, which is a worse failure than a truncated list.
|
|
514
|
+
* @param {{ limit?: number, prefix?: string }} [options_]
|
|
515
|
+
*/
|
|
516
|
+
async function digest(options_ = {}) {
|
|
517
|
+
const slots = await slotsUnder(options_.prefix ?? '');
|
|
518
|
+
const limit = Math.min(budgetOption(options_.limit, digestSlots, 'limit'), digestSlots);
|
|
519
|
+
const listed = slots.slice(0, limit);
|
|
520
|
+
let size = 0;
|
|
521
|
+
for (const slot of slots) size += slot.size;
|
|
522
|
+
return {
|
|
523
|
+
slots: listed.map(view),
|
|
524
|
+
listed: listed.length,
|
|
525
|
+
total: slots.length,
|
|
526
|
+
omitted: Math.max(0, slots.length - listed.length),
|
|
527
|
+
size,
|
|
528
|
+
hint: 'Nothing here carries content. Name a slot in peek/stat/grep/select/chunk,'
|
|
529
|
+
+ ' or read(name, { chars }) when you need the text itself.',
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The one call that returns content, and it makes the caller say how
|
|
535
|
+
* much. Kept deliberately awkward: everything else in this module
|
|
536
|
+
* exists so that a root turn does not need this, and a design where
|
|
537
|
+
* reading is as easy as peeking is a design that ends up back in the
|
|
538
|
+
* transcript.
|
|
539
|
+
* @param {string} name
|
|
540
|
+
* @param {{ chars: number, offset?: number }} options_
|
|
541
|
+
*/
|
|
542
|
+
async function read(name, options_) {
|
|
543
|
+
const chars = Math.floor(options_?.chars ?? 0);
|
|
544
|
+
if (!Number.isSafeInteger(chars) || !(chars > 0)) {
|
|
545
|
+
return { error: 'read needs an explicit character budget: read(name, { chars })' };
|
|
546
|
+
}
|
|
547
|
+
const slot = await ledger.getSlot(name);
|
|
548
|
+
if (slot === null) return unknown(name);
|
|
549
|
+
const offset = Math.max(0, Math.floor(options_.offset ?? 0));
|
|
550
|
+
const content = String(await ledger.readSlot(name) ?? '');
|
|
551
|
+
const text = content.slice(offset, offset + chars);
|
|
552
|
+
return {
|
|
553
|
+
name,
|
|
554
|
+
offset,
|
|
555
|
+
size: slot.size,
|
|
556
|
+
returned: text.length,
|
|
557
|
+
more: offset + text.length < content.length,
|
|
558
|
+
text,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Remove a slot, or a whole chunk family. Answers how many went. */
|
|
563
|
+
async function forget(prefix) {
|
|
564
|
+
const slots = await slotsUnder(prefix);
|
|
565
|
+
for (const slot of slots) await ledger.deleteSlot(slot.name);
|
|
566
|
+
return { prefix, removed: slots.length };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const unknown = async (name) => {
|
|
570
|
+
const content = await ledger.readSlot(name);
|
|
571
|
+
return content?.status === 'evicted'
|
|
572
|
+
? { error: `slot '${name}' was evicted`, ...content }
|
|
573
|
+
: { error: `no slot '${name}'`, hint: 'call digest() for what the environment holds' };
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
ledger, put, ingest, peek, chunk, grep, select, stat, digest, read, forget,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A window of a line CENTRED ON THE MATCH, with a marker on whichever
|
|
583
|
+
* side was cut.
|
|
584
|
+
*
|
|
585
|
+
* The obvious implementation shows the head of the matching line, and it
|
|
586
|
+
* is wrong in exactly the case that matters: a tool result is one long
|
|
587
|
+
* line and the fact worth finding is usually not in its first hundred
|
|
588
|
+
* characters. A grep that reported "this line matched" while showing
|
|
589
|
+
* none of the match is a grep that makes a model call `read` on every
|
|
590
|
+
* hit — which is the transcript coming back in another shape.
|
|
591
|
+
* @param {string} line
|
|
592
|
+
* @param {number} at - the match's offset in the line
|
|
593
|
+
* @param {number} length - the match's own length
|
|
594
|
+
* @param {number} chars - the window
|
|
595
|
+
*/
|
|
596
|
+
function around(line, at, length, chars) {
|
|
597
|
+
if (line.length <= chars) return excerpt(line, chars);
|
|
598
|
+
// a third of the window ahead of the match, so the match and what
|
|
599
|
+
// follows it — usually the value — both survive
|
|
600
|
+
const start = Math.max(0, Math.min(at - Math.floor(chars / 3), line.length - chars));
|
|
601
|
+
const window = line.slice(start, start + chars);
|
|
602
|
+
const head = start > 0 ? '…' : '';
|
|
603
|
+
const tail = start + chars < line.length ? '…' : '';
|
|
604
|
+
// the match itself must be inside the window even when it is longer
|
|
605
|
+
// than the window: then the window starts at the match
|
|
606
|
+
return length > chars
|
|
607
|
+
? `${head}${excerpt(line.slice(at, at + chars), chars)}…`
|
|
608
|
+
: `${head}${window.replace(/\s+/g, ' ').trim()}${tail}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Whether a text is plausibly a JSON document — cheap, first character. */
|
|
612
|
+
function looksJson(text) {
|
|
613
|
+
const head = text.trimStart()[0];
|
|
614
|
+
return head === '{' || head === '[';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The environment as a toolbox definition list: the five operations plus
|
|
619
|
+
* `read`, ready for `createToolbox().add(...)`.
|
|
620
|
+
*
|
|
621
|
+
* They are defined here rather than in the agent for the reason `recall`
|
|
622
|
+
* is: a tool a model calls and an operation a harness calls have to be
|
|
623
|
+
* the same thing, or the tested path and the shipped path drift.
|
|
624
|
+
*
|
|
625
|
+
* Every schema is deliberately small — one required string, optional
|
|
626
|
+
* numbers — because the tier this package targets gets a tool call right
|
|
627
|
+
* in proportion to how few decisions it has to make.
|
|
628
|
+
* @param {any} environment - from {@link createEnvironment}
|
|
629
|
+
* @returns {any[]} tool definitions
|
|
630
|
+
*/
|
|
631
|
+
export function environmentTools(environment) {
|
|
632
|
+
const slotArg = { type: 'string', minLength: 1, description: 'A slot name from digest or grep.' };
|
|
633
|
+
return [
|
|
634
|
+
{
|
|
635
|
+
name: 'env_digest',
|
|
636
|
+
description: 'List what the environment holds: name, kind, size and one line of each slot.'
|
|
637
|
+
+ ' Carries no content. Start here.',
|
|
638
|
+
inputSchema: {
|
|
639
|
+
type: 'object',
|
|
640
|
+
properties: { prefix: { type: 'string', description: 'Only slots whose name starts with this.' } },
|
|
641
|
+
additionalProperties: false,
|
|
642
|
+
},
|
|
643
|
+
execute: ({ prefix }) => environment.digest({ prefix }),
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
name: 'env_peek',
|
|
647
|
+
description: 'One slot: its metadata and the first characters of it.',
|
|
648
|
+
inputSchema: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
properties: { slot: slotArg, chars: { type: 'integer', minimum: 1 } },
|
|
651
|
+
required: ['slot'],
|
|
652
|
+
additionalProperties: false,
|
|
653
|
+
},
|
|
654
|
+
execute: ({ slot, chars }) => environment.peek(slot, { chars }),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: 'env_grep',
|
|
658
|
+
description: 'Search the environment for a regular expression. Answers with the ADDRESSES'
|
|
659
|
+
+ ' that matched, a window around each hit and the offset it is at — never the whole'
|
|
660
|
+
+ ' slot. Pass a match\'s slot and offset to env_read to see the rest of it.',
|
|
661
|
+
inputSchema: {
|
|
662
|
+
type: 'object',
|
|
663
|
+
properties: {
|
|
664
|
+
pattern: { type: 'string', minLength: 1 },
|
|
665
|
+
in: { type: 'string', description: 'Restrict to slots whose name starts with this.' },
|
|
666
|
+
limit: { type: 'integer', minimum: 1 },
|
|
667
|
+
},
|
|
668
|
+
required: ['pattern'],
|
|
669
|
+
additionalProperties: false,
|
|
670
|
+
},
|
|
671
|
+
execute: ({ pattern, in: scope, limit }) => environment.grep(pattern, { in: scope, limit }),
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
name: 'env_chunk',
|
|
675
|
+
description: 'Split a slot into addressable pieces so they can be worked on one at a time.',
|
|
676
|
+
inputSchema: {
|
|
677
|
+
type: 'object',
|
|
678
|
+
properties: {
|
|
679
|
+
slot: slotArg,
|
|
680
|
+
strategy: { enum: ['size', 'line', 'separator'] },
|
|
681
|
+
size: { type: 'integer', minimum: 1 },
|
|
682
|
+
},
|
|
683
|
+
required: ['slot'],
|
|
684
|
+
additionalProperties: false,
|
|
685
|
+
},
|
|
686
|
+
execute: ({ slot, strategy, size }) => environment.chunk(slot, { strategy, size }),
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: 'env_stat',
|
|
690
|
+
description: 'Counts and sizes for one slot or a whole family of them. Needs no reading.',
|
|
691
|
+
inputSchema: {
|
|
692
|
+
type: 'object',
|
|
693
|
+
properties: { slot: { type: 'string' } },
|
|
694
|
+
additionalProperties: false,
|
|
695
|
+
},
|
|
696
|
+
execute: ({ slot }) => environment.stat(slot ?? ''),
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: 'env_read',
|
|
700
|
+
description: 'Return the text of a slot. Say how many characters you need; narrow with'
|
|
701
|
+
+ ' grep first when the slot is large.',
|
|
702
|
+
inputSchema: {
|
|
703
|
+
type: 'object',
|
|
704
|
+
properties: {
|
|
705
|
+
slot: slotArg,
|
|
706
|
+
chars: { type: 'integer', minimum: 1, maximum: 8000 },
|
|
707
|
+
offset: { type: 'integer', minimum: 0 },
|
|
708
|
+
},
|
|
709
|
+
required: ['slot', 'chars'],
|
|
710
|
+
additionalProperties: false,
|
|
711
|
+
},
|
|
712
|
+
execute: ({ slot, chars, offset }) => environment.read(slot, { chars, offset }),
|
|
713
|
+
},
|
|
714
|
+
];
|
|
715
|
+
}
|