@tangleai/agents 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/src/refine.js CHANGED
@@ -1,4 +1,3 @@
1
- //@ts-check
2
1
  /**
3
2
  * Refinement: how an agent's durable state improves without anyone
4
3
  * rewriting it.
@@ -10,7 +9,7 @@
10
9
  * lands:
11
10
  *
12
11
  * 1. **shape** — the patch validates against the constrained schema
13
- * (`schemas/patch.js`): three verbs, a path pattern that matches
12
+ * (`schemas/patch.ts`): three verbs, a path pattern that matches
14
13
  * only the supplemental subtree, a cap on the number of operations.
15
14
  * 2. **semantics** — the patch is applied to a COPY of the state
16
15
  * through the injected patch engine. An operation that cannot apply
@@ -44,15 +43,12 @@
44
43
  * declines with a stated reason and the rest of the ledger is unaffected
45
44
  * — the same degrade-to-nothing posture as the retrieval seam.
46
45
  */
47
-
48
46
  import { JarenValidator } from '@jarenjs/validate';
49
-
50
47
  import { checkOutcome } from '@jarenjs/core/check';
51
48
  import { createGuardedRefiner } from '@jarenjs/core/guarded';
52
49
  import { createStructuredOutput } from '@tangleai/models/structured';
53
50
  import { REFINEMENT_PATCH_SCHEMA, refinementPatchSchema } from '@tangleai/context/schemas/patch';
54
51
  import { excerpt, truncate } from '@jarenjs/core/chunk';
55
-
56
52
  /**
57
53
  * The id stood in while a proposal is validated. A proposal never
58
54
  * carries an id — identity is the ledger's to mint, or a model could
@@ -62,7 +58,6 @@ import { excerpt, truncate } from '@jarenjs/core/chunk';
62
58
  * (non-empty string), so it exercises the same schema the real one will.
63
59
  */
64
60
  const PENDING_ID = '(minted on commit)';
65
-
66
61
  /**
67
62
  * The members a proposal may leave out and the ledger fills in. They
68
63
  * mirror `addMemory`/`addSkill`, and they are here because stage 3 has
@@ -75,19 +70,14 @@ const PENDING_ID = '(minted on commit)';
75
70
  * the member, not as a record that should never have been written.
76
71
  */
77
72
  const RECORD_DEFAULTS = { memory: { tags: [] }, skill: { tools: [] } };
78
-
79
73
  /** How much of a run's trajectory travels in the proposal prompt. */
80
74
  const TRAJECTORY_CHARS = 4000;
81
-
82
75
  /** The per-line cap inside that block. */
83
76
  const LINE_CHARS = 200;
84
-
85
- /** @param {any} value */
77
+ /** @param value */
86
78
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
87
-
88
79
  /** Structural equality over JSON values, by canonical-enough comparison. */
89
80
  const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
90
-
91
81
  /**
92
82
  * One error record, in the shape the repair prompt carries: a code, a
93
83
  * pointer and a reason. `docPath` points into the PATCH document
@@ -96,7 +86,7 @@ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
96
86
  * round that converges and one that flails.
97
87
  *
98
88
  * The codes this module raises, all of them records rather than throws
99
- * (`errors.js` reserves `AiError` for transport and misuse; a rejected
89
+ * (`errors.ts` reserves `AiError` for transport and misuse; a rejected
100
90
  * proposal is neither — it is something a model reads and fixes):
101
91
  *
102
92
  * AI0100 — a supplemental record is not an object
@@ -112,72 +102,59 @@ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
112
102
  * (`JP0004`, `JP2001`, …) rather than being renumbered here: the pointer
113
103
  * and the code are what the model repairs from, and translating them
114
104
  * would lose the only precise thing about them.
115
- * @param {string} code
116
- * @param {string} message
117
- * @param {string} docPath
118
105
  */
119
106
  const problem = (code, message, docPath) => ({ code, docPath, message });
120
-
121
107
  /**
122
108
  * The coded, pointered form of whatever the injected patch engine threw.
123
109
  * `@jarenjs/json` raises `JsonPatchCompileError` / `JsonPatchRuntimeError`
124
110
  * with a `code`, a `docPath` into the patch and (at runtime) a
125
111
  * `dataPath` into the document; a different engine may raise anything.
126
112
  * Both end up as one record the model can act on.
127
- * @param {any} error
128
113
  */
129
114
  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);
115
+ const code = typeof error?.code === 'string' ? error.code : 'PATCH';
116
+ const docPath = typeof error?.docPath === 'string' ? error.docPath : '';
117
+ const reason = error?.reason ?? error?.message ?? String(error);
118
+ const at = typeof error?.dataPath === 'string' && error.dataPath !== ''
119
+ ? ` (target '${error.dataPath}' in the supplemental state)`
120
+ : '';
121
+ return problem(code, `the patch does not apply: ${reason}${at}`, docPath);
137
122
  }
138
-
139
123
  /**
140
124
  * The run, as the few hundred characters worth putting in front of the
141
125
  * model. Tool steps are preferred over wire messages: the steps ARE the
142
126
  * evidence a memory would cite, and a transcript's assistant turns are
143
127
  * 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}
128
+ * @param trajectory - a `send` result, its `steps`, or wire messages
147
129
  */
148
130
  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)}`);
131
+ const steps = Array.isArray(trajectory?.steps) ? trajectory.steps : null;
132
+ const messages = Array.isArray(trajectory)
133
+ ? trajectory
134
+ : (Array.isArray(trajectory?.messages) ? trajectory.messages : null);
135
+ const lines = [];
136
+ if (steps !== null && steps.length > 0) {
137
+ for (const step of steps) {
138
+ lines.push(`- ${step.name}(${excerpt(step.arguments, 80)}) → `
139
+ + `${excerpt(JSON.stringify(step.result ?? null), LINE_CHARS)}`);
140
+ }
160
141
  }
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}`);
142
+ if (messages !== null) {
143
+ for (const message of messages) {
144
+ if (message?.role === 'system')
145
+ continue;
146
+ if (steps !== null && steps.length > 0 && message?.role === 'tool')
147
+ continue;
148
+ const text = excerpt(message?.content, LINE_CHARS);
149
+ if (text !== '')
150
+ lines.push(`- ${message.role}: ${text}`);
151
+ }
168
152
  }
169
- }
170
- return truncate(lines.join('\n'), max);
153
+ return truncate(lines.join('\n'), max);
171
154
  }
172
-
173
155
  /**
174
156
  * Create a refiner over a ledger.
175
157
  *
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
158
  * - `applyPatch` is the RFC 6902 seam: `(document, patch) => document`,
182
159
  * normally `(doc, patch) => applyJSONPatch(doc, patch)` from
183
160
  * `@jarenjs/json`. Absent, `refine`/`commit` decline with a stated
@@ -194,406 +171,380 @@ export function describeTrajectory(trajectory, max = TRAJECTORY_CHARS) {
194
171
  * text and evidence and the same tags. Existing records are never merged
195
172
  * or removed by this option; case, whitespace and independent citations
196
173
  * 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
174
  */
202
175
  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 };
176
+ const { client, ledger } = options;
177
+ if (options.deduplicate !== undefined && options.deduplicate !== 'exact-evidence')
178
+ throw new TypeError('deduplicate must be exact-evidence or omitted');
179
+ if (ledger === null || typeof ledger?.snapshot !== 'function') {
180
+ throw new TypeError('createRefiner needs a ledger (createLedger())');
346
181
  }
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 };
182
+ const applyPatch = typeof options.applyPatch === 'function' ? options.applyPatch : null;
183
+ const now = options.now ?? (() => new Date().toISOString());
184
+ const maxRepairs = options.maxRepairs ?? 1;
185
+ const trajectoryChars = options.trajectoryChars ?? TRAJECTORY_CHARS;
186
+ const patchSchema = options.maxOps === undefined
187
+ ? REFINEMENT_PATCH_SCHEMA
188
+ : refinementPatchSchema({ maxOps: options.maxOps });
189
+ // the shape check, compiled once — the same schema the generator
190
+ // constrains decoding with, so a hand-written patch handed to
191
+ // `commit` is held to exactly what a generated one is held to
192
+ const compiled = new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' })
193
+ .compile(patchSchema);
194
+ const checkShape = (patch) => {
195
+ const full = checkOutcome(compiled(patch));
196
+ return full.valid && options.validator ? checkOutcome(options.validator(patch)) : full;
197
+ };
198
+ const noEngine = {
199
+ error: 'refinement needs the applyPatch seam — inject '
200
+ + '(doc, patch) => applyJSONPatch(doc, patch) from @jarenjs/json',
201
+ };
202
+ // One refiner serializes state-read, generation and commit together. Hosts
203
+ // sharing a ledger across refiners or other writers still own that coordination.
204
+ let pending = Promise.resolve();
205
+ const serial = (operation) => {
206
+ const result = pending.then(operation);
207
+ pending = result.then(() => undefined, () => undefined);
208
+ return result;
209
+ };
210
+ /**
211
+ * The supplemental state, as the document a patch is applied to. It is
212
+ * the ledger's own records verbatim — nothing here is a projection
213
+ * that would have to be mapped back — and it contains the goal, the
214
+ * memories and the skills and NOTHING else. The base system prompt is
215
+ * absent by construction, which is the first half of why it cannot be
216
+ * patched (the path pattern is the second).
217
+ */
218
+ async function state() {
219
+ return {
220
+ goal: await ledger.getGoal(),
221
+ memories: await ledger.listMemories(),
222
+ skills: await ledger.listSkills(),
223
+ };
352
224
  }
353
- if (after.length < before.length
354
- || !sameJson(after.slice(0, before.length), before)) {
355
- errors.push(problem('AI0105',
356
- 'progress is append-onlyearlier entries may not be edited or removed',
357
- '/goal/progress'));
358
- return { append: [], errors };
225
+ /**
226
+ * Stage 3, for one kind: the writes a patched document implies, with
227
+ * every record completed and validated. Identity is the `id`, which a
228
+ * proposal never carries so an entry without one is a new record and
229
+ * a stored record missing from the next document was removed. A
230
+ * `replace` therefore reads as "remove that one, store this one",
231
+ * which is what revising a memory actually is: a different claim, with
232
+ * different evidence, made at a different time.
233
+ * @param field - the document member, for the error pointer
234
+ */
235
+ function planKind(kind, previous, next, field) {
236
+ const errors = [];
237
+ const add = [];
238
+ const known = new Map(previous.map((record) => [record.id, record]));
239
+ const kept = new Set();
240
+ const deduplicated = [];
241
+ // Only records surviving unchanged are witnesses; matching one scheduled
242
+ // for removal would discard both copies of the evidence.
243
+ const witnesses = next.flatMap((record, index) => known.has(record?.id)
244
+ && sameJson(record, known.get(record.id)) ? [{ record, path: `/${field}/${index}` }] : []);
245
+ next.forEach((entry, index) => {
246
+ const at = `/${field}/${index}`;
247
+ if (!isRecord(entry)) {
248
+ errors.push(problem('AI0100', `a ${kind} must be an object`, at));
249
+ return;
250
+ }
251
+ if (typeof entry.id === 'string' && known.has(entry.id)) {
252
+ kept.add(entry.id);
253
+ // unchanged records are not rewritten: a refinement that touched
254
+ // nothing must leave the timestamps it did not touch alone
255
+ if (sameJson(entry, known.get(entry.id)))
256
+ return;
257
+ }
258
+ else if (entry.id !== undefined) {
259
+ errors.push(problem('AI0101', `a ${kind} may not choose its own id ('${entry.id}') — omit it and the ledger mints one`, at));
260
+ return;
261
+ }
262
+ const record = { ...RECORD_DEFAULTS[kind], ...entry, at: entry.at ?? now() };
263
+ const rejected = ledger.validate(kind, { ...record, id: record.id ?? PENDING_ID });
264
+ if (rejected !== null) {
265
+ for (const error of rejected.errors ?? []) {
266
+ errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword, `the ${kind} is not storable: ${error.message}`, `${at}${error.instancePath ?? ''}`));
267
+ }
268
+ if ((rejected.errors ?? []).length === 0)
269
+ errors.push(problem('AI0102', `the ${kind} is not storable`, at));
270
+ return;
271
+ }
272
+ if (kind === 'memory' && options.deduplicate === 'exact-evidence' && entry.id === undefined
273
+ && Object.keys(entry).every((key) => key === 'text' || key === 'evidence' || key === 'tags')) {
274
+ const match = witnesses.find(({ record: held }) => held.text === record.text && sameJson(held.evidence, record.evidence)
275
+ && sameJson([...held.tags].sort(), [...record.tags].sort()));
276
+ if (match) {
277
+ deduplicated.push({
278
+ path: at, retainedPath: match.path,
279
+ ...(match.record.id === undefined ? {} : { retainedId: match.record.id })
280
+ });
281
+ return;
282
+ }
283
+ }
284
+ add.push(record);
285
+ witnesses.push({ record, path: at });
286
+ });
287
+ const remove = previous.filter((record) => !kept.has(record.id)).map((record) => record.id);
288
+ return { add, remove, errors, deduplicated };
359
289
  }
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 ?? ''}`));
290
+ /**
291
+ * The progress the patched document appends. Append-only is checked
292
+ * rather than assumed: the path pattern cannot express a rewrite, and
293
+ * this is the assertion that says so even if a caller hands `commit` a
294
+ * patch that never met the pattern.
295
+ * @param previous - the stored goal, or null
296
+ * @param next - the patched goal
297
+ */
298
+ function planProgress(previous, next) {
299
+ const errors = [];
300
+ const before = previous?.progress ?? [];
301
+ const after = next?.progress ?? [];
302
+ if (previous === null && (next !== null || after.length > 0)) {
303
+ errors.push(problem('AI0103', 'there is no active goal to record progress against — call setGoal first', '/goal'));
304
+ return { append: [], errors };
305
+ }
306
+ if (next !== null && next !== undefined && !sameJson({ ...next, progress: before }, { ...previous, progress: before })) {
307
+ errors.push(problem('AI0104', 'a refinement may not change the goal itself — only append to its progress', '/goal'));
308
+ return { append: [], errors };
373
309
  }
374
- }
310
+ if (after.length < before.length
311
+ || !sameJson(after.slice(0, before.length), before)) {
312
+ errors.push(problem('AI0105', 'progress is append-only — earlier entries may not be edited or removed', '/goal/progress'));
313
+ return { append: [], errors };
314
+ }
315
+ const append = after.slice(before.length).map((entry) => (isRecord(entry)
316
+ ? { ...entry, at: entry.at ?? now() }
317
+ : entry));
318
+ if (append.length > 0) {
319
+ // validated as a WHOLE goal, because that is the record the ledger
320
+ // stores: an entry legal on its own but illegal in place would
321
+ // otherwise pass here and be rejected at commit
322
+ const rejected = ledger.validate('goal', { ...previous, progress: [...before, ...append] });
323
+ if (rejected !== null) {
324
+ for (const error of rejected.errors ?? []) {
325
+ errors.push(problem(error.keyword === '' ? 'AI0102' : error.keyword, `the progress entry is not storable: ${error.message}`, `/goal${error.instancePath ?? ''}`));
326
+ }
327
+ }
328
+ }
329
+ return { append, errors };
375
330
  }
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 };
331
+ /**
332
+ * Stages 1 to 3 over a copy — everything that can be known without
333
+ * writing anything. This is what the generator's `gate` runs, so a
334
+ * proposal that cannot land comes back to the model with pointers
335
+ * instead of landing half-way and being rolled back.
336
+ * @param document - the state as it stands
337
+ */
338
+ function dryRun(document, patch) {
339
+ return guarded.prepare(document, patch);
450
340
  }
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 } : {}) };
341
+ const guarded = createGuardedRefiner({
342
+ read: state,
343
+ validateProposal: checkShape,
344
+ apply: (document, patch) => applyPatch(document, patch),
345
+ applyFailure: patchFailure,
346
+ validateCandidate: (next) => isRecord(next)
347
+ ? { valid: true, errors: [] }
348
+ : { valid: false, errors: [problem('AI0106', 'the patched state is not an object', '')] },
349
+ planCommit: (next, document) => {
350
+ const memories = planKind('memory', document.memories, next.memories ?? [], 'memories');
351
+ const skills = planKind('skill', document.skills, next.skills ?? [], 'skills');
352
+ const progress = planProgress(document.goal, next.goal ?? null);
353
+ const errors = [...memories.errors, ...skills.errors, ...progress.errors];
354
+ return { valid: errors.length === 0, errors, plan: { memories, skills, progress } };
355
+ },
356
+ snapshot: () => ledger.snapshot(),
357
+ restore: (token) => ledger.rollback(token),
358
+ commit: async (plan) => {
359
+ const written = { memories: [], skills: [], progress: [] };
360
+ const accept = (outcome, what) => {
361
+ if (outcome?.error !== undefined)
362
+ throw Object.assign(new Error(`${what}: ${outcome.error}`), { outcome, what });
363
+ };
364
+ for (const record of plan.memories.add) {
365
+ accept(await ledger.addMemory(record), 'a memory could not be stored');
366
+ written.memories.push(record);
367
+ }
368
+ for (const record of plan.skills.add) {
369
+ accept(await ledger.addSkill(record), 'a skill could not be stored');
370
+ written.skills.push(record);
371
+ }
372
+ for (const id of plan.memories.remove)
373
+ await ledger.deleteMemory(id);
374
+ for (const id of plan.skills.remove)
375
+ await ledger.deleteSkill(id);
376
+ for (const entry of plan.progress.append) {
377
+ accept(await ledger.recordProgress(entry), 'a progress entry could not be recorded');
378
+ written.progress.push(entry);
379
+ }
380
+ return written;
381
+ },
382
+ });
383
+ /** Whether a plan would write anything at all. */
384
+ const empty = (plan) => plan.memories.add.length === 0 && plan.memories.remove.length === 0
385
+ && plan.skills.add.length === 0 && plan.skills.remove.length === 0
386
+ && plan.progress.append.length === 0;
387
+ /**
388
+ * Stage 4: snapshot, then write through the ledger's own API — never
389
+ * into storage, so every record passes the same validation a
390
+ * hand-written one does. A rejection anywhere rolls the whole
391
+ * refinement back, so the state after a failed commit is the state
392
+ * before it, byte for byte.
393
+ */
394
+ async function commitAgainst(document, patch) {
395
+ const dry = dryRun(document, patch);
396
+ if (!dry.valid) {
397
+ return { error: 'the refinement was rejected', errors: dry.errors, patchSchema };
398
+ }
399
+ const plan = dry.plan;
400
+ if (empty(plan)) {
401
+ return {
402
+ ok: true, patch, snapshot: null, memories: [], skills: [], progress: [],
403
+ ...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {})
404
+ };
405
+ }
406
+ if (ledger.concurrency === 'atomic' && typeof ledger.transaction === 'function') {
407
+ try {
408
+ return await ledger.transaction(document, (scoped) => createRefiner({ ...options, ledger: scoped }).commit(patch));
409
+ }
410
+ catch (error) {
411
+ if (error?.outcome)
412
+ return { ...error.outcome, snapshot: null };
413
+ return {
414
+ error: `the refinement was rolled back after a storage failure: ${error instanceof Error ? error.message : String(error)}`,
415
+ snapshot: null, patchSchema
416
+ };
417
+ }
418
+ }
419
+ const outcome = await guarded.commitPrepared(document, dry);
420
+ const token = outcome.snapshot;
421
+ if (!outcome.ok) {
422
+ if (outcome.stage === 'snapshot')
423
+ return {
424
+ error: 'the refinement could not take a snapshot; nothing was written',
425
+ cause: outcome.cause, errors: outcome.errors, patchSchema
426
+ };
427
+ if ('restoreError' in outcome && outcome.restoreError !== undefined)
428
+ return {
429
+ error: 'the refinement failed and rollback failed; storage requires recovery',
430
+ snapshot: token, patchSchema, cause: outcome.cause, restoreError: outcome.restoreError,
431
+ };
432
+ const cause = outcome.cause;
433
+ if (cause?.outcome)
434
+ return {
435
+ error: `the refinement was rolled back: ${cause.what} — ${cause.outcome.error}`,
436
+ errors: cause.outcome.errors ?? [], patchSchema, snapshot: token,
437
+ };
438
+ return {
439
+ error: `the refinement was rolled back after a storage failure: ${outcome.cause instanceof Error ? outcome.cause.message : String(outcome.cause)}`,
440
+ snapshot: token, patchSchema
441
+ };
442
+ }
443
+ const written = outcome.value;
444
+ return {
445
+ ok: true,
446
+ patch,
447
+ // the token is returned, not kept: rolling a refinement back is the
448
+ // host's call to make, and a snapshot nobody was told about is a
449
+ // reversal nobody can perform
450
+ snapshot: token,
451
+ removed: { memories: plan.memories.remove, skills: plan.skills.remove },
452
+ ...written,
453
+ ...(options.deduplicate ? { deduplicated: plan.memories.deduplicated } : {}),
454
+ };
455
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
- }
456
+ /**
457
+ * Apply a patch that already exists the same four stages a generated
458
+ * one goes through, exposed because a host (or a test) writing the
459
+ * patch itself must not get a weaker gate than a model does.
460
+ */
461
+ async function commit(patch) {
462
+ if (applyPatch === null)
463
+ return { ...noEngine, patchSchema };
464
+ return serial(async () => commitAgainst(await state(), patch));
467
465
  }
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 };
466
+ /**
467
+ * The proposal prompt. The state is shown in full and the rules are
468
+ * stated in the imperative, because the field notes are clear that
469
+ * breadth and posture belong in the prompt while correctness belongs
470
+ * in the gate.
471
+ */
472
+ function prompt(document, trajectory) {
473
+ const task = options.instructions ?? [
474
+ 'You have just finished a run. Propose what should be remembered from it, as an RFC 6902',
475
+ 'JSON Patch over your supplemental state.',
476
+ '',
477
+ 'Rules:',
478
+ '- Every memory and every progress entry MUST carry `evidence`: what in the run below',
479
+ ' establishes it. Quote the tool result or name the fact. Never write a memory you cannot',
480
+ ' cite an empty patch is a better answer than an invented one.',
481
+ '- Store what will still be true and still be useful next time. Not what happened; what it',
482
+ ' means.',
483
+ '- Do not restate a memory that is already stored, and do not add a near-duplicate of one.',
484
+ '- Append with /memories/-, /skills/- and /goal/progress/-. Replace or remove an existing',
485
+ ' record by its index. Nothing else is addressable.',
486
+ '',
487
+ // Examples help authoring; the schema enforces the path/value relationship.
488
+ 'Each path takes its own shape:',
489
+ ' /memories/- {"text": …, "evidence": …, "tags": [ … ]}',
490
+ ' /skills/- {"name": …, "when": …, "instructions": …, "tools": [ … ]}',
491
+ ' /goal/progress/- {"note": …, "evidence": …} <- note, NOT text',
492
+ '',
493
+ 'Example of a well-formed refinement:',
494
+ '[{"op":"add","path":"/memories/-","value":{"text":"…","evidence":"…","tags":["…"]}},'
495
+ + '{"op":"add","path":"/goal/progress/-","value":{"note":"…","evidence":"…"}}]',
496
+ ].join('\n');
497
+ return [
498
+ task,
499
+ '',
500
+ 'Your supplemental state right now:',
501
+ JSON.stringify(document, null, 1),
502
+ '',
503
+ 'The run:',
504
+ describeTrajectory(trajectory, trajectoryChars),
505
+ ].join('\n');
484
506
  }
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
- };
507
+ /**
508
+ * Propose and apply a refinement for one run.
509
+ *
510
+ * The gate runs INSIDE generation (as `createStructuredOutput`'s
511
+ * `gate`), so a patch that does not apply or would store an
512
+ * unevidenced memory comes back to the model with its pointers for a
513
+ * bounded repair. What survives generation is committed against the
514
+ * same state the gate ran over.
515
+ * @param trajectory - a `send` result, its `steps`, or messages
516
+ * @param [hooks]
517
+ */
518
+ async function refineAgainst(trajectory, hooks = {}) {
519
+ if (applyPatch === null)
520
+ return { ...noEngine, patchSchema };
521
+ // read ONCE: the gate has to be synchronous (a compiled check is),
522
+ // and a gate that re-read the ledger between attempts would be
523
+ // judging each attempt against a different state
524
+ const document = await state();
525
+ const generator = createStructuredOutput({
526
+ client,
527
+ schema: patchSchema,
528
+ name: 'refinement',
529
+ // a `remove` carries no `value`, so `value` is optional, and
530
+ // OpenAI's strict mode requires every declared property to be
531
+ // required. The local check is the authority either way.
532
+ strict: false,
533
+ gate: (patch) => dryRun(document, patch),
534
+ maxRepairs,
535
+ });
536
+ const result = await generator.generate([{ role: 'user', content: prompt(document, trajectory) }], hooks);
537
+ if (result.errors !== undefined) {
538
+ return {
539
+ error: 'the model did not produce an applicable refinement',
540
+ errors: result.errors,
541
+ attempts: result.attempts,
542
+ raw: result.raw,
543
+ patchSchema,
544
+ };
545
+ }
546
+ return { ...await commitAgainst(document, result.value), attempts: result.attempts };
593
547
  }
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 };
548
+ const refine = (trajectory, hooks = {}) => serial(() => refineAgainst(trajectory, hooks));
549
+ return { state, commit, refine, patchSchema };
599
550
  }