@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.
@@ -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
+ }