@tangleai/context 0.21.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +24 -23
- package/package.json +4 -4
- package/src/archive.d.ts +3 -3
- package/src/archive.js +49 -44
- package/src/environment.d.ts +52 -22
- package/src/environment.js +491 -548
- package/src/evidence.d.ts +5 -10
- package/src/evidence.js +49 -51
- package/src/index.d.ts +10 -9
- package/src/index.js +9 -10
- package/src/ledger.d.ts +123 -83
- package/src/ledger.js +1039 -1185
- package/src/recall.d.ts +44 -23
- package/src/recall.js +48 -68
- package/src/retention.d.ts +7 -7
- package/src/retention.js +88 -71
- package/src/schemas/evidence.d.ts +11 -10
- package/src/schemas/evidence.js +14 -21
- package/src/schemas/ledger.d.ts +737 -430
- package/src/schemas/ledger.js +130 -243
- package/src/schemas/patch.d.ts +122 -108
- package/src/schemas/patch.js +59 -64
- package/src/storage/memory.d.ts +3 -8
- package/src/storage/memory.js +41 -43
- package/src/storage/slot.d.ts +3 -4
- package/src/storage/slot.js +104 -96
- package/src/storage/transaction.d.ts +3 -18
- package/src/storage/transaction.js +22 -34
package/src/environment.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
* @param
|
|
593
|
-
* @param
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
629
|
-
* @returns
|
|
571
|
+
* @param environment - from {@link createEnvironment}
|
|
572
|
+
* @returns tool definitions
|
|
630
573
|
*/
|
|
631
574
|
export function environmentTools(environment) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
710
|
-
additionalProperties: false,
|
|
711
|
-
},
|
|
712
|
-
execute: ({ slot, chars, offset }) => environment.read(slot, { chars, offset }),
|
|
713
|
-
},
|
|
714
|
-
];
|
|
657
|
+
];
|
|
715
658
|
}
|