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