@tangleai/agents 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/refine.js ADDED
@@ -0,0 +1,599 @@
1
+ //@ts-check
2
+ /**
3
+ * Refinement: how an agent's durable state improves without anyone
4
+ * rewriting it.
5
+ *
6
+ * After a run, the model is asked what it learned. The answer is not
7
+ * prose and it is not a new system prompt — it is an RFC 6902 JSON Patch
8
+ * over the ledger's supplemental state, generated through
9
+ * `createStructuredOutput` and put through four stages before any of it
10
+ * lands:
11
+ *
12
+ * 1. **shape** — the patch validates against the constrained schema
13
+ * (`schemas/patch.js`): three verbs, a path pattern that matches
14
+ * only the supplemental subtree, a cap on the number of operations.
15
+ * 2. **semantics** — the patch is applied to a COPY of the state
16
+ * through the injected patch engine. An operation that cannot apply
17
+ * is a compile-style error with a pointer into the patch document,
18
+ * which is exactly the error class this package's field notes say
19
+ * small models repair well.
20
+ * 3. **legality** — every record the patched document would store is
21
+ * validated against the LEDGER's own schemas. A memory without
22
+ * evidence dies here, before anything is written, so the model can
23
+ * be told why and try again.
24
+ * 4. **commit** — a snapshot is taken, then the writes go through the
25
+ * ledger's own API. Any rejection rolls the whole thing back: there
26
+ * is no half-applied refinement.
27
+ *
28
+ * Why a patch rather than a rewrite: a rewrite is unreviewable and
29
+ * unbounded, and a model asked to restate its memories will drift them.
30
+ * A patch is small, auditable, and undone by the snapshot from the
31
+ * ledger. Two things follow from that and are worth saying out loud:
32
+ *
33
+ * - **The base system prompt is not a patch target.** Not "should not
34
+ * be" — it is not IN the document a patch is applied to, and no path
35
+ * that could reach it matches the schema's pattern. There is no
36
+ * operation a model can write that edits its own instructions.
37
+ * - **Every stored memory carries evidence**, because the ledger
38
+ * rejects one that does not. That is the mechanism by which
39
+ * "evidence-backed updates" is enforced rather than hoped for.
40
+ *
41
+ * The patch engine is INJECTED (`applyPatch`), like every other heavy
42
+ * thing this package touches: `@jarenjs/json` ships an RFC 6902 engine
43
+ * and this package must not import it. With the seam empty, refinement
44
+ * declines with a stated reason and the rest of the ledger is unaffected
45
+ * — the same degrade-to-nothing posture as the retrieval seam.
46
+ */
47
+
48
+ import { JarenValidator } from '@jarenjs/validate';
49
+
50
+ import { checkOutcome } from '@jarenjs/core/check';
51
+ import { createGuardedRefiner } from '@jarenjs/core/guarded';
52
+ import { createStructuredOutput } from '@tangleai/models/structured';
53
+ import { REFINEMENT_PATCH_SCHEMA, refinementPatchSchema } from '@tangleai/context/schemas/patch';
54
+ import { excerpt, truncate } from '@jarenjs/core/chunk';
55
+
56
+ /**
57
+ * The id stood in while a proposal is validated. A proposal never
58
+ * carries an id — identity is the ledger's to mint, or a model could
59
+ * overwrite a record it never read — so the record validated at stage 3
60
+ * is the record that will be stored with the id filled in at stage 4.
61
+ * The placeholder is the one member that differs, and it is a legal id
62
+ * (non-empty string), so it exercises the same schema the real one will.
63
+ */
64
+ const PENDING_ID = '(minted on commit)';
65
+
66
+ /**
67
+ * The members a proposal may leave out and the ledger fills in. They
68
+ * mirror `addMemory`/`addSkill`, and they are here because stage 3 has
69
+ * to validate the record that WILL be stored rather than the shorter one
70
+ * that was proposed — a memory with no `tags` is storable, and a gate
71
+ * that rejected it would be rejecting the ledger's own default.
72
+ *
73
+ * The two copies cannot drift silently: the ledger validates again on
74
+ * the way in, so a divergence surfaces as a rolled-back commit naming
75
+ * the member, not as a record that should never have been written.
76
+ */
77
+ const RECORD_DEFAULTS = { memory: { tags: [] }, skill: { tools: [] } };
78
+
79
+ /** How much of a run's trajectory travels in the proposal prompt. */
80
+ const TRAJECTORY_CHARS = 4000;
81
+
82
+ /** The per-line cap inside that block. */
83
+ const LINE_CHARS = 200;
84
+
85
+ /** @param {any} value */
86
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
87
+
88
+ /** Structural equality over JSON values, by canonical-enough comparison. */
89
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
90
+
91
+ /**
92
+ * One error record, in the shape the repair prompt carries: a code, a
93
+ * pointer and a reason. `docPath` points into the PATCH document
94
+ * wherever the failure can be attributed to one operation, because
95
+ * "which operation was wrong" is the whole difference between a repair
96
+ * round that converges and one that flails.
97
+ *
98
+ * The codes this module raises, all of them records rather than throws
99
+ * (`errors.js` reserves `AiError` for transport and misuse; a rejected
100
+ * proposal is neither — it is something a model reads and fixes):
101
+ *
102
+ * AI0100 — a supplemental record is not an object
103
+ * AI0101 — a proposal chose its own id
104
+ * AI0102 — the record is not storable (the ledger's own validation
105
+ * keyword travels in place of this wherever it has one)
106
+ * AI0103 — progress proposed with no active goal
107
+ * AI0104 — the goal itself was changed, not just its progress
108
+ * AI0105 — progress is not append-only
109
+ * AI0106 — the patched state is not an object
110
+ *
111
+ * A failure inside the injected patch engine keeps THAT engine's code
112
+ * (`JP0004`, `JP2001`, …) rather than being renumbered here: the pointer
113
+ * and the code are what the model repairs from, and translating them
114
+ * would lose the only precise thing about them.
115
+ * @param {string} code
116
+ * @param {string} message
117
+ * @param {string} docPath
118
+ */
119
+ const problem = (code, message, docPath) => ({ code, docPath, message });
120
+
121
+ /**
122
+ * The coded, pointered form of whatever the injected patch engine threw.
123
+ * `@jarenjs/json` raises `JsonPatchCompileError` / `JsonPatchRuntimeError`
124
+ * with a `code`, a `docPath` into the patch and (at runtime) a
125
+ * `dataPath` into the document; a different engine may raise anything.
126
+ * Both end up as one record the model can act on.
127
+ * @param {any} error
128
+ */
129
+ function patchFailure(error) {
130
+ const code = typeof error?.code === 'string' ? error.code : 'PATCH';
131
+ const docPath = typeof error?.docPath === 'string' ? error.docPath : '';
132
+ const reason = error?.reason ?? error?.message ?? String(error);
133
+ const at = typeof error?.dataPath === 'string' && error.dataPath !== ''
134
+ ? ` (target '${error.dataPath}' in the supplemental state)`
135
+ : '';
136
+ return problem(code, `the patch does not apply: ${reason}${at}`, docPath);
137
+ }
138
+
139
+ /**
140
+ * The run, as the few hundred characters worth putting in front of the
141
+ * model. Tool steps are preferred over wire messages: the steps ARE the
142
+ * evidence a memory would cite, and a transcript's assistant turns are
143
+ * mostly the model reading its own prose back.
144
+ * @param {any} trajectory - a `send` result, its `steps`, or wire messages
145
+ * @param {number} max
146
+ * @returns {string}
147
+ */
148
+ export function describeTrajectory(trajectory, max = TRAJECTORY_CHARS) {
149
+ const steps = Array.isArray(trajectory?.steps) ? trajectory.steps : null;
150
+ const messages = Array.isArray(trajectory)
151
+ ? trajectory
152
+ : (Array.isArray(trajectory?.messages) ? trajectory.messages : null);
153
+
154
+ /** @type {string[]} */
155
+ const lines = [];
156
+ if (steps !== null && steps.length > 0) {
157
+ for (const step of steps) {
158
+ lines.push(`- ${step.name}(${excerpt(step.arguments, 80)}) → `
159
+ + `${excerpt(JSON.stringify(step.result ?? null), LINE_CHARS)}`);
160
+ }
161
+ }
162
+ if (messages !== null) {
163
+ for (const message of messages) {
164
+ if (message?.role === 'system') continue;
165
+ if (steps !== null && steps.length > 0 && message?.role === 'tool') continue;
166
+ const text = excerpt(message?.content, LINE_CHARS);
167
+ if (text !== '') lines.push(`- ${message.role}: ${text}`);
168
+ }
169
+ }
170
+ return truncate(lines.join('\n'), max);
171
+ }
172
+
173
+ /**
174
+ * Create a refiner over a ledger.
175
+ *
176
+ * @param {{ client: any, ledger: any,
177
+ * applyPatch?: ((document: any, patch: any[]) => any) | null,
178
+ * maxOps?: number, maxRepairs?: number, validator?: any,
179
+ * now?: () => string, trajectoryChars?: number,
180
+ * instructions?: string, deduplicate?: 'exact-evidence' }} options
181
+ * - `applyPatch` is the RFC 6902 seam: `(document, patch) => document`,
182
+ * normally `(doc, patch) => applyJSONPatch(doc, patch)` from
183
+ * `@jarenjs/json`. Absent, `refine`/`commit` decline with a stated
184
+ * reason rather than half-working.
185
+ * - `maxOps` caps the operations one refinement may propose (default
186
+ * 6, from the schema).
187
+ * - `maxRepairs` is how many failed rounds go back to the model with
188
+ * the errors before it declines (default 1).
189
+ * - `instructions` replaces the proposal prompt's task description
190
+ * (the state, the rules and the trajectory are always appended).
191
+ * - `now` returns an RFC 3339 timestamp, injected for deterministic
192
+ * tests exactly as the ledger injects its clock.
193
+ * - `deduplicate: 'exact-evidence'` skips new memories with byte-identical
194
+ * text and evidence and the same tags. Existing records are never merged
195
+ * or removed by this option; case, whitespace and independent citations
196
+ * remain distinct. The result reports every skipped proposal in `deduplicated`.
197
+ * @returns {{ state: () => Promise<any>,
198
+ * commit: (patch: any[]) => Promise<any>,
199
+ * refine: (trajectory: any, hooks?: { signal?: AbortSignal }) => Promise<any>,
200
+ * patchSchema: any }}
201
+ */
202
+ export function createRefiner(options) {
203
+ const { client, ledger } = options;
204
+ if (options.deduplicate !== undefined && options.deduplicate !== 'exact-evidence')
205
+ throw new TypeError('deduplicate must be exact-evidence or omitted');
206
+ if (ledger === null || typeof ledger?.snapshot !== 'function') {
207
+ throw new TypeError('createRefiner needs a ledger (createLedger())');
208
+ }
209
+ const applyPatch = typeof options.applyPatch === 'function' ? options.applyPatch : null;
210
+ const now = options.now ?? (() => new Date().toISOString());
211
+ const maxRepairs = options.maxRepairs ?? 1;
212
+ const trajectoryChars = options.trajectoryChars ?? TRAJECTORY_CHARS;
213
+ const patchSchema = options.maxOps === undefined
214
+ ? REFINEMENT_PATCH_SCHEMA
215
+ : refinementPatchSchema({ maxOps: options.maxOps });
216
+ // the shape check, compiled once — the same schema the generator
217
+ // constrains decoding with, so a hand-written patch handed to
218
+ // `commit` is held to exactly what a generated one is held to
219
+ const compiled = new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' })
220
+ .compile(patchSchema);
221
+ const checkShape = (patch) => {
222
+ const full = checkOutcome(compiled(patch));
223
+ return full.valid && options.validator ? checkOutcome(options.validator(patch)) : full;
224
+ };
225
+
226
+ const noEngine = {
227
+ error: 'refinement needs the applyPatch seam — inject '
228
+ + '(doc, patch) => applyJSONPatch(doc, patch) from @jarenjs/json',
229
+ };
230
+ // One refiner serializes state-read, generation and commit together. Hosts
231
+ // sharing a ledger across refiners or other writers still own that coordination.
232
+ let pending = Promise.resolve();
233
+ const serial = (operation) => {
234
+ const result = pending.then(operation);
235
+ pending = result.then(() => undefined, () => undefined);
236
+ return result;
237
+ };
238
+
239
+ /**
240
+ * The supplemental state, as the document a patch is applied to. It is
241
+ * the ledger's own records verbatim — nothing here is a projection
242
+ * that would have to be mapped back — and it contains the goal, the
243
+ * memories and the skills and NOTHING else. The base system prompt is
244
+ * absent by construction, which is the first half of why it cannot be
245
+ * patched (the path pattern is the second).
246
+ */
247
+ async function state() {
248
+ return {
249
+ goal: await ledger.getGoal(),
250
+ memories: await ledger.listMemories(),
251
+ skills: await ledger.listSkills(),
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Stage 3, for one kind: the writes a patched document implies, with
257
+ * every record completed and validated. Identity is the `id`, which a
258
+ * proposal never carries — so an entry without one is a new record and
259
+ * a stored record missing from the next document was removed. A
260
+ * `replace` therefore reads as "remove that one, store this one",
261
+ * which is what revising a memory actually is: a different claim, with
262
+ * different evidence, made at a different time.
263
+ * @param {'memory'|'skill'} kind
264
+ * @param {any[]} previous
265
+ * @param {any[]} next
266
+ * @param {string} field - the document member, for the error pointer
267
+ */
268
+ function planKind(kind, previous, next, field) {
269
+ /** @type {any[]} */
270
+ const errors = [];
271
+ /** @type {any[]} */
272
+ const add = [];
273
+ const known = new Map(previous.map((record) => [record.id, record]));
274
+ const kept = new Set();
275
+ const deduplicated = [];
276
+ // Only records surviving unchanged are witnesses; matching one scheduled
277
+ // for removal would discard both copies of the evidence.
278
+ const witnesses = next.flatMap((record, index) => known.has(record?.id)
279
+ && sameJson(record, known.get(record.id)) ? [{ record, path: `/${field}/${index}` }] : []);
280
+
281
+ next.forEach((entry, index) => {
282
+ const at = `/${field}/${index}`;
283
+ if (!isRecord(entry)) {
284
+ errors.push(problem('AI0100', `a ${kind} must be an object`, at));
285
+ return;
286
+ }
287
+ if (typeof entry.id === 'string' && known.has(entry.id)) {
288
+ kept.add(entry.id);
289
+ // unchanged records are not rewritten: a refinement that touched
290
+ // nothing must leave the timestamps it did not touch alone
291
+ if (sameJson(entry, known.get(entry.id))) return;
292
+ }
293
+ else if (entry.id !== undefined) {
294
+ errors.push(problem('AI0101',
295
+ `a ${kind} may not choose its own id ('${entry.id}') — omit it and the ledger mints one`,
296
+ at));
297
+ return;
298
+ }
299
+ const record = { ...RECORD_DEFAULTS[kind], ...entry, at: entry.at ?? now() };
300
+ const rejected = ledger.validate(kind, { ...record, id: record.id ?? PENDING_ID });
301
+ if (rejected !== null) {
302
+ for (const error of rejected.errors ?? []) {
303
+ errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword,
304
+ `the ${kind} is not storable: ${error.message}`,
305
+ `${at}${error.instancePath ?? ''}`));
306
+ }
307
+ if ((rejected.errors ?? []).length === 0)
308
+ errors.push(problem('AI0102', `the ${kind} is not storable`, at));
309
+ return;
310
+ }
311
+ if (kind === 'memory' && options.deduplicate === 'exact-evidence' && entry.id === undefined
312
+ && Object.keys(entry).every((key) => key === 'text' || key === 'evidence' || key === 'tags')) {
313
+ const match = witnesses.find(({ record: held }) => held.text === record.text && sameJson(held.evidence, record.evidence)
314
+ && sameJson([...held.tags].sort(), [...record.tags].sort()));
315
+ if (match) {
316
+ deduplicated.push({ path: at, retainedPath: match.path,
317
+ ...(match.record.id === undefined ? {} : { retainedId: match.record.id }) });
318
+ return;
319
+ }
320
+ }
321
+ add.push(record);
322
+ witnesses.push({ record, path: at });
323
+ });
324
+
325
+ const remove = previous.filter((record) => !kept.has(record.id)).map((record) => record.id);
326
+ return { add, remove, errors, deduplicated };
327
+ }
328
+
329
+ /**
330
+ * The progress the patched document appends. Append-only is checked
331
+ * rather than assumed: the path pattern cannot express a rewrite, and
332
+ * this is the assertion that says so even if a caller hands `commit` a
333
+ * patch that never met the pattern.
334
+ * @param {any} previous - the stored goal, or null
335
+ * @param {any} next - the patched goal
336
+ */
337
+ function planProgress(previous, next) {
338
+ /** @type {any[]} */
339
+ const errors = [];
340
+ const before = previous?.progress ?? [];
341
+ const after = next?.progress ?? [];
342
+ if (previous === null && (next !== null || after.length > 0)) {
343
+ errors.push(problem('AI0103',
344
+ 'there is no active goal to record progress against — call setGoal first', '/goal'));
345
+ return { append: [], errors };
346
+ }
347
+ if (next !== null && next !== undefined && !sameJson(
348
+ { ...next, progress: before }, { ...previous, progress: before })) {
349
+ errors.push(problem('AI0104',
350
+ 'a refinement may not change the goal itself — only append to its progress', '/goal'));
351
+ return { append: [], errors };
352
+ }
353
+ if (after.length < before.length
354
+ || !sameJson(after.slice(0, before.length), before)) {
355
+ errors.push(problem('AI0105',
356
+ 'progress is append-only — earlier entries may not be edited or removed',
357
+ '/goal/progress'));
358
+ return { append: [], errors };
359
+ }
360
+ const append = after.slice(before.length).map((entry) => (isRecord(entry)
361
+ ? { ...entry, at: entry.at ?? now() }
362
+ : entry));
363
+ if (append.length > 0) {
364
+ // validated as a WHOLE goal, because that is the record the ledger
365
+ // stores: an entry legal on its own but illegal in place would
366
+ // otherwise pass here and be rejected at commit
367
+ const rejected = ledger.validate('goal', { ...previous, progress: [...before, ...append] });
368
+ if (rejected !== null) {
369
+ for (const error of rejected.errors ?? []) {
370
+ errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword,
371
+ `the progress entry is not storable: ${error.message}`,
372
+ `/goal${error.instancePath ?? ''}`));
373
+ }
374
+ }
375
+ }
376
+ return { append, errors };
377
+ }
378
+
379
+ /**
380
+ * Stages 1 to 3 over a copy — everything that can be known without
381
+ * writing anything. This is what the generator's `gate` runs, so a
382
+ * proposal that cannot land comes back to the model with pointers
383
+ * instead of landing half-way and being rolled back.
384
+ * @param {any} document - the state as it stands
385
+ * @param {any[]} patch
386
+ * @returns {{ valid: boolean, errors: any[], plan?: any, next?: any }}
387
+ */
388
+ function dryRun(document, patch) {
389
+ return guarded.prepare(document, patch);
390
+ }
391
+
392
+ const guarded = createGuardedRefiner({
393
+ read: state,
394
+ validateProposal: checkShape,
395
+ apply: (document, patch) => applyPatch(document, patch),
396
+ applyFailure: patchFailure,
397
+ validateCandidate: (next) => isRecord(next)
398
+ ? { valid: true, errors: [] }
399
+ : { valid: false, errors: [problem('AI0106', 'the patched state is not an object', '')] },
400
+ planCommit: (next, document) => {
401
+ const memories = planKind('memory', document.memories, next.memories ?? [], 'memories');
402
+ const skills = planKind('skill', document.skills, next.skills ?? [], 'skills');
403
+ const progress = planProgress(document.goal, next.goal ?? null);
404
+ const errors = [...memories.errors, ...skills.errors, ...progress.errors];
405
+ return { valid: errors.length === 0, errors, plan: { memories, skills, progress } };
406
+ },
407
+ snapshot: () => ledger.snapshot(),
408
+ restore: (token) => ledger.rollback(token),
409
+ commit: async (plan) => {
410
+ const written = { memories: [], skills: [], progress: [] };
411
+ const accept = (outcome, what) => {
412
+ if (outcome?.error !== undefined) throw Object.assign(new Error(`${what}: ${outcome.error}`), { outcome, what });
413
+ };
414
+ for (const record of plan.memories.add) {
415
+ accept(await ledger.addMemory(record), 'a memory could not be stored');
416
+ written.memories.push(record);
417
+ }
418
+ for (const record of plan.skills.add) {
419
+ accept(await ledger.addSkill(record), 'a skill could not be stored');
420
+ written.skills.push(record);
421
+ }
422
+ for (const id of plan.memories.remove) await ledger.deleteMemory(id);
423
+ for (const id of plan.skills.remove) await ledger.deleteSkill(id);
424
+ for (const entry of plan.progress.append) {
425
+ accept(await ledger.recordProgress(entry), 'a progress entry could not be recorded');
426
+ written.progress.push(entry);
427
+ }
428
+ return written;
429
+ },
430
+ });
431
+
432
+ /** Whether a plan would write anything at all. */
433
+ const empty = (plan) => plan.memories.add.length === 0 && plan.memories.remove.length === 0
434
+ && plan.skills.add.length === 0 && plan.skills.remove.length === 0
435
+ && plan.progress.append.length === 0;
436
+
437
+ /**
438
+ * Stage 4: snapshot, then write through the ledger's own API — never
439
+ * into storage, so every record passes the same validation a
440
+ * hand-written one does. A rejection anywhere rolls the whole
441
+ * refinement back, so the state after a failed commit is the state
442
+ * before it, byte for byte.
443
+ * @param {any} document
444
+ * @param {any[]} patch
445
+ */
446
+ async function commitAgainst(document, patch) {
447
+ const dry = dryRun(document, patch);
448
+ if (!dry.valid) {
449
+ return { error: 'the refinement was rejected', errors: dry.errors, patchSchema };
450
+ }
451
+ const plan = dry.plan;
452
+ if (empty(plan)) {
453
+ return { ok: true, patch, snapshot: null, memories: [], skills: [], progress: [],
454
+ ...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {}) };
455
+ }
456
+
457
+ if (ledger.concurrency === 'atomic' && typeof ledger.transaction === 'function') {
458
+ try {
459
+ return await ledger.transaction(document, (scoped) =>
460
+ createRefiner({ ...options, ledger: scoped }).commit(patch));
461
+ }
462
+ catch (error) {
463
+ if (error?.outcome) return { ...error.outcome, snapshot: null };
464
+ return { error: `the refinement was rolled back after a storage failure: ${error instanceof Error ? error.message : String(error)}`,
465
+ snapshot: null, patchSchema };
466
+ }
467
+ }
468
+
469
+ const outcome = await guarded.commitPrepared(document, dry);
470
+ const token = outcome.snapshot;
471
+ if (!outcome.ok) {
472
+ if (outcome.stage === 'snapshot') return { error: 'the refinement could not take a snapshot; nothing was written',
473
+ cause: outcome.cause, errors: outcome.errors, patchSchema };
474
+ if (outcome.restoreError !== undefined) return {
475
+ error: 'the refinement failed and rollback failed; storage requires recovery',
476
+ snapshot: token, patchSchema, cause: outcome.cause, restoreError: outcome.restoreError,
477
+ };
478
+ if (outcome.cause?.outcome) return {
479
+ error: `the refinement was rolled back: ${outcome.cause.what} — ${outcome.cause.outcome.error}`,
480
+ errors: outcome.cause.outcome.errors ?? [], patchSchema, snapshot: token,
481
+ };
482
+ return { error: `the refinement was rolled back after a storage failure: ${outcome.cause instanceof Error ? outcome.cause.message : String(outcome.cause)}`,
483
+ snapshot: token, patchSchema };
484
+ }
485
+ const written = outcome.value;
486
+
487
+ return {
488
+ ok: true,
489
+ patch,
490
+ // the token is returned, not kept: rolling a refinement back is the
491
+ // host's call to make, and a snapshot nobody was told about is a
492
+ // reversal nobody can perform
493
+ snapshot: token,
494
+ removed: { memories: plan.memories.remove, skills: plan.skills.remove },
495
+ ...written,
496
+ ...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {}),
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Apply a patch that already exists — the same four stages a generated
502
+ * one goes through, exposed because a host (or a test) writing the
503
+ * patch itself must not get a weaker gate than a model does.
504
+ * @param {any[]} patch
505
+ */
506
+ async function commit(patch) {
507
+ if (applyPatch === null) return { ...noEngine, patchSchema };
508
+ return serial(async () => commitAgainst(await state(), patch));
509
+ }
510
+
511
+ /**
512
+ * The proposal prompt. The state is shown in full and the rules are
513
+ * stated in the imperative, because the field notes are clear that
514
+ * breadth and posture belong in the prompt while correctness belongs
515
+ * in the gate.
516
+ * @param {any} document
517
+ * @param {any} trajectory
518
+ */
519
+ function prompt(document, trajectory) {
520
+ const task = options.instructions ?? [
521
+ 'You have just finished a run. Propose what should be remembered from it, as an RFC 6902',
522
+ 'JSON Patch over your supplemental state.',
523
+ '',
524
+ 'Rules:',
525
+ '- Every memory and every progress entry MUST carry `evidence`: what in the run below',
526
+ ' establishes it. Quote the tool result or name the fact. Never write a memory you cannot',
527
+ ' cite — an empty patch is a better answer than an invented one.',
528
+ '- Store what will still be true and still be useful next time. Not what happened; what it',
529
+ ' means.',
530
+ '- Do not restate a memory that is already stored, and do not add a near-duplicate of one.',
531
+ '- Append with /memories/-, /skills/- and /goal/progress/-. Replace or remove an existing',
532
+ ' record by its index. Nothing else is addressable.',
533
+ '',
534
+ // Examples help authoring; the schema enforces the path/value relationship.
535
+ 'Each path takes its own shape:',
536
+ ' /memories/- {"text": …, "evidence": …, "tags": [ … ]}',
537
+ ' /skills/- {"name": …, "when": …, "instructions": …, "tools": [ … ]}',
538
+ ' /goal/progress/- {"note": …, "evidence": …} <- note, NOT text',
539
+ '',
540
+ 'Example of a well-formed refinement:',
541
+ '[{"op":"add","path":"/memories/-","value":{"text":"…","evidence":"…","tags":["…"]}},'
542
+ + '{"op":"add","path":"/goal/progress/-","value":{"note":"…","evidence":"…"}}]',
543
+ ].join('\n');
544
+ return [
545
+ task,
546
+ '',
547
+ 'Your supplemental state right now:',
548
+ JSON.stringify(document, null, 1),
549
+ '',
550
+ 'The run:',
551
+ describeTrajectory(trajectory, trajectoryChars),
552
+ ].join('\n');
553
+ }
554
+
555
+ /**
556
+ * Propose and apply a refinement for one run.
557
+ *
558
+ * The gate runs INSIDE generation (as `createStructuredOutput`'s
559
+ * `gate`), so a patch that does not apply or would store an
560
+ * unevidenced memory comes back to the model with its pointers for a
561
+ * bounded repair. What survives generation is committed against the
562
+ * same state the gate ran over.
563
+ * @param {any} trajectory - a `send` result, its `steps`, or messages
564
+ * @param {{ signal?: AbortSignal }} [hooks]
565
+ */
566
+ async function refineAgainst(trajectory, hooks = {}) {
567
+ if (applyPatch === null) return { ...noEngine, patchSchema };
568
+ // read ONCE: the gate has to be synchronous (a compiled check is),
569
+ // and a gate that re-read the ledger between attempts would be
570
+ // judging each attempt against a different state
571
+ const document = await state();
572
+ const generator = createStructuredOutput({
573
+ client,
574
+ schema: patchSchema,
575
+ name: 'refinement',
576
+ // a `remove` carries no `value`, so `value` is optional, and
577
+ // OpenAI's strict mode requires every declared property to be
578
+ // required. The local check is the authority either way.
579
+ strict: false,
580
+ gate: (patch) => dryRun(document, patch),
581
+ maxRepairs,
582
+ });
583
+ const result = await generator.generate([{ role: 'user', content: prompt(document, trajectory) }],
584
+ hooks);
585
+ if (result.errors !== undefined) {
586
+ return {
587
+ error: 'the model did not produce an applicable refinement',
588
+ errors: result.errors,
589
+ attempts: result.attempts,
590
+ raw: result.raw,
591
+ patchSchema,
592
+ };
593
+ }
594
+ return { ...await commitAgainst(document, result.value), attempts: result.attempts };
595
+ }
596
+
597
+ const refine = (trajectory, hooks = {}) => serial(() => refineAgainst(trajectory, hooks));
598
+ return { state, commit, refine, patchSchema };
599
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The program schema.
3
+ *
4
+ * `queryRef` is the `$id` of an injected query grammar
5
+ * (`@jarenjs/json/schemas/jaren-query.schema.json`, or its LLM-profile
6
+ * twin — the twin is the better choice for constrained decoding, which
7
+ * is what it was derived for). Given one, `select` and `reduce` are
8
+ * shape-constrained as well as compile-gated, and the caller must pass
9
+ * the same grammar to `createStructuredOutput` as a `ref` so the
10
+ * validator can resolve it. Given none, `query` accepts any JSON value
11
+ * and the compile gate carries the whole weight.
12
+ *
13
+ * @param {{ queryRef?: string, maxSteps?: number }} [options]
14
+ * @returns {any} a JSON Schema document
15
+ */
16
+ export function programSchema(options?: {
17
+ queryRef?: string;
18
+ maxSteps?: number;
19
+ }): any;
20
+ /**
21
+ * The action language: what a model may say about the environment.
22
+ *
23
+ * The RLM paper's root model writes Python and an interpreter runs it.
24
+ * This package has no interpreter and will never have one — `eval` and
25
+ * `new Function` are forbidden by the house rules and a browser tab is
26
+ * the wrong place for a sandbox — so the model authors a **document**
27
+ * instead, and the document is put through the same two gates every
28
+ * generated Jaren program goes through: a schema that constrains
29
+ * decoding (the shape) and a compiler that runs before anything else
30
+ * does (the semantics).
31
+ *
32
+ * Read the two rules that shaped every line of this file:
33
+ *
34
+ * - **A step names slots; it never carries content** (D2). Every member
35
+ * below is an operation name, a binding name, a slot reference, a
36
+ * bounded instruction or a query document. There is no member a
37
+ * corpus can be poured into, and none can be added later without
38
+ * failing `test/ai/program.test.js` — the schema is walked and every
39
+ * string member must declare a `maxLength`. That is what makes "the
40
+ * program is constant-size whatever the corpus" a property of the
41
+ * grammar rather than a promise about how it will be used.
42
+ * - **`map` is the only step that calls a model.** One construct, one
43
+ * concurrency bound, one place to count spend. A grammar with
44
+ * sub-calls sprinkled through it cannot be bounded, and a runner over
45
+ * such a grammar cannot state what a program will cost before it runs
46
+ * it.
47
+ *
48
+ * Three shape decisions exist for the weak tier specifically (D8), and
49
+ * each one trades expressiveness for a decision the model does not have
50
+ * to make:
51
+ *
52
+ * - **Every step reads `from` and writes `as`.** Not `slot`/`in`/`over`
53
+ * per operation: one input member and one output member across the
54
+ * whole language, so the model picks the *operation* and never the
55
+ * spelling of its argument.
56
+ * - **Bindings are program-local names, not addresses.** The program
57
+ * says `as: "pieces"`; the runner resolves that to whatever the
58
+ * environment's derived addressing produced (HORIZON_06 owns
59
+ * addresses; a model that could write one could name a slot that
60
+ * cannot exist).
61
+ * - **`query` is left open unless a grammar is injected.** The seam is
62
+ * D3: with `@jarenjs/json`'s query grammar passed as a `ref` the
63
+ * shape is constrained too; without it the schema accepts any JSON
64
+ * value here and the compile gate is what refuses a bad one. A schema
65
+ * that hard-`$ref`'d a grammar this package may not import would make
66
+ * the whole language unusable with the seam empty.
67
+ */
68
+ /** Steps one program may have. A plan longer than this is a program
69
+ * that should have been two runs; it is also past the length a small
70
+ * model keeps coherent. */
71
+ export const MAX_STEPS: 12;
72
+ /** The whole document's character cap, enforced by the compiler. This
73
+ * is the constant in "constant-size root request": the program is one
74
+ * more thing the root carries, and it must not grow with the corpus. */
75
+ export const MAX_PROGRAM_CHARS: 4000;
76
+ /** A binding name: short, lowercase, unmistakable in an error message. */
77
+ export const NAME_PATTERN: "^[a-z][a-z0-9_]{0,31}$";
78
+ /** The operations a program may name, in the order a plan uses them. */
79
+ export const PROGRAM_OPS: string[];
80
+ /** The program schema with the query seam empty — what a caller with no
81
+ * grammar injected authors against. */
82
+ export const PROGRAM_SCHEMA: any;