@jxsuite/ai 0.39.5 → 0.41.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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * The tree tools as producers (specs/ai.md §3.9): each one reads a document and answers the doc ops
3
+ * its edit is, or the sentence refusing it, and writes nothing. A host applies the ops in its own
4
+ * transaction, so the same tool edits a Studio tab, a file on disk or a room's document.
5
+ *
6
+ * Descriptions and parameter schemas are the ones the model has always been sent, byte for byte;
7
+ * the ops are the ones Studio's mutators recorded for the same calls, op for op.
8
+ *
9
+ * @module @jxsuite/ai/jx-tools
10
+ * @license MIT
11
+ */
12
+
13
+ import {
14
+ cloneValue,
15
+ coerceIndex,
16
+ getNodeAtPath,
17
+ insertionSlot,
18
+ isAncestor,
19
+ isSpliceablePath,
20
+ planMove,
21
+ } from "@jxsuite/schema/doc-ops";
22
+ import type { JxDocOp } from "@jxsuite/schema/doc-ops";
23
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
24
+ import type { JxDocumentValue, JxMutableNode, JxPath, JxStyle } from "@jxsuite/schema/types";
25
+ import type { JsonValue } from "../tools.ts";
26
+ import type { DocEdit, DocTool } from "./types.ts";
27
+
28
+ export const PATH_DESCRIPTION =
29
+ "Path to a node in the document, as a JSON array of keys/indices from the root " +
30
+ '(e.g. ["children", 0, "children", 1]). Use read_document to discover valid paths.';
31
+
32
+ /**
33
+ * A path as the model wrote it, with each index after a `"children"` segment that was written as a
34
+ * canonical numeric string (`"0"`, `"12"`) read as its number. The tool schema admits strings in a
35
+ * path because keys are strings, and the registry's validator admits a numeric string where an
36
+ * integer is declared; either way `["children", "1"]` resolves (an array answers `"1"` as it does
37
+ * `1`) and then fails every splice test downstream, which is how an edit succeeded without effect.
38
+ *
39
+ * @param {JxPath} path
40
+ * @returns {JxPath}
41
+ */
42
+ export function coercePath(path: JxPath): JxPath {
43
+ return path.map((segment, at) => {
44
+ const index = at > 0 && path[at - 1] === "children" ? coerceIndex(segment) : null;
45
+ return index ?? segment;
46
+ });
47
+ }
48
+
49
+ /** The node at `path`, or undefined where the path runs off the tree. */
50
+ export function nodeAt(doc: Readonly<JxMutableNode>, path: JxPath): JxMutableNode | undefined {
51
+ return getNodeAtPath(doc, path);
52
+ }
53
+
54
+ function refuse(error: string): DocEdit {
55
+ return { error, ok: false };
56
+ }
57
+
58
+ /** Refusal for an index that is not a non-negative integer, naming the argument and its value. */
59
+ function indexError(name: string, value: JsonValue | undefined): DocEdit {
60
+ return refuse(
61
+ `${name} must be a non-negative integer, such as 0 or 2 (got ${JSON.stringify(value)}).`,
62
+ );
63
+ }
64
+
65
+ /** What {@link elementAt} answers: the element, or the refusal to hand back. */
66
+ type ElementLookup = { node: JxMutableNode; refusal?: never } | { refusal: DocEdit };
67
+
68
+ /**
69
+ * The element `path` names, or the edit refusing a write there. A key set on a text child or a
70
+ * children array lands where nothing renders it, so the write would report success and change
71
+ * nothing; a primitive value cannot hold keys at all.
72
+ *
73
+ * @param {Readonly<JxMutableNode>} doc
74
+ * @param {JxPath} path
75
+ * @param {string} name - The argument the path came from, as the model knows it
76
+ * @returns {ElementLookup}
77
+ */
78
+ function elementAt(doc: Readonly<JxMutableNode>, path: JxPath, name = "path"): ElementLookup {
79
+ const node = nodeAt(doc, path);
80
+ const where = `${name} ${JSON.stringify(path)}`;
81
+ if (node === undefined) {
82
+ return { refusal: refuse(`No node exists at ${where}.`) };
83
+ }
84
+ if (Array.isArray(node)) {
85
+ return {
86
+ refusal: refuse(
87
+ `${where} points at a children array, not a node. Drop the trailing "children" segment.`,
88
+ ),
89
+ };
90
+ }
91
+ if (!isNonNullObject(node)) {
92
+ const what = isString(node) ? "a text child" : "a value";
93
+ return {
94
+ refusal: refuse(`${where} points at ${what}, not a node. Address the element that holds it.`),
95
+ };
96
+ }
97
+ return { node };
98
+ }
99
+
100
+ /**
101
+ * A `set-key` op as Studio's mutators record one: `value` absent or `undefined` deletes the key,
102
+ * and any other value is a clone, so the op never aliases the document or the arguments.
103
+ */
104
+ function setKey(path: JxPath, key: string, value: JxDocumentValue | undefined): JxDocOp {
105
+ return { key, op: "set-key", path, value: cloneValue(value) };
106
+ }
107
+
108
+ /**
109
+ * The value a property write stores: `null`, `""` and an absent value delete the key, as every
110
+ * Inspector field always has.
111
+ */
112
+ function storedValue(value: JsonValue | undefined): JsonValue | undefined {
113
+ return value === null || value === "" ? undefined : value;
114
+ }
115
+
116
+ /** The document's state entries, or none. */
117
+ function stateOf(doc: Readonly<JxMutableNode>): Record<string, JsonValue> | undefined {
118
+ /* SAFETY: `state` is the document's map of entry names to their declarations, every one of them
119
+ JSON; the type names it by its declaration shapes, which are JSON too. */
120
+ return doc.state as Record<string, JsonValue> | undefined;
121
+ }
122
+
123
+ const setProperty: DocTool = {
124
+ description:
125
+ "Set or remove a property on a node (e.g. tagName, textContent, className, style, " +
126
+ "attributes, $props). Pass value: null to remove the property.",
127
+ edit(doc, args) {
128
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed: `path` is a present
129
+ array and `key` a present string. `value` declares no type, so it is any JSON value or
130
+ absent. A `path` whose items do not resolve is caught by `elementAt` just below. */
131
+ const { key, path: written } = args as { key: string; path: JxPath };
132
+ const path = coercePath(written);
133
+ const target = elementAt(doc, path);
134
+ if (target.refusal) {
135
+ return target.refusal;
136
+ }
137
+ return {
138
+ ok: true,
139
+ ops: [setKey(path, key, storedValue(args.value))],
140
+ // Replacing the children replaces every node under them, so nothing selected there survives.
141
+ selection:
142
+ key === "children" ? { kind: "prune", under: [[...path, "children"]] } : { kind: "keep" },
143
+ summary: `Set "${key}" at ${JSON.stringify(path)}.`,
144
+ };
145
+ },
146
+ name: "set_property",
147
+ title: "Set a property",
148
+ parameters: {
149
+ type: "object",
150
+ properties: {
151
+ path: {
152
+ type: "array",
153
+ description: PATH_DESCRIPTION,
154
+ items: { type: ["string", "number"] },
155
+ },
156
+ key: {
157
+ type: "string",
158
+ description: 'The property name to set, e.g. "textContent" or "className".',
159
+ },
160
+ value: {
161
+ description: "The new value, or null to remove the property.",
162
+ },
163
+ },
164
+ /*
165
+ * "value" omitted from required: passing null / omitting it means "remove the property",
166
+ * but the registry rejects null on required args (tools.js:181). See `ai.md` §3.6.
167
+ */
168
+ required: ["path", "key"],
169
+ },
170
+ };
171
+
172
+ const addChild: DocTool = {
173
+ description:
174
+ "Insert a new child node into the children array of the node at parentPath, at the " +
175
+ "given index (0 = first child). The node definition follows the Jx document schema " +
176
+ '(e.g. { "tagName": "p", "textContent": "Hello" }).',
177
+ edit(doc, args) {
178
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed: `parentPath` is a
179
+ present array and `node` a present object. `node` is a model-written tree whose shape the
180
+ schema check after the write judges, and a `parentPath` that does not resolve is caught just
181
+ below. `index` is read untyped: the validator admits a numeric string for an integer, so
182
+ `coerceIndex` decides what it names. */
183
+ const { node: childNode, parentPath: writtenParent } = args as {
184
+ node: JxMutableNode;
185
+ parentPath: JxPath;
186
+ };
187
+ const parentPath = coercePath(writtenParent);
188
+ const index = coerceIndex(args.index);
189
+ if (index === null || index < 0) {
190
+ return indexError("index", args.index);
191
+ }
192
+ const parent = nodeAt(doc, parentPath);
193
+ if (parent === undefined) {
194
+ return refuse(`No node exists at path ${JSON.stringify(parentPath)}.`);
195
+ }
196
+ /* The common failure is a trailing "children" segment (e.g. ["children",0,"children"]):
197
+ add_child appends "children" and the index itself, so an array-valued parentPath would store
198
+ the node where nothing renders it. Refused with a precise message so the loop self-corrects. */
199
+ if (Array.isArray(parent)) {
200
+ return refuse(
201
+ `parentPath ${JSON.stringify(parentPath)} points at a children array, not a node. ` +
202
+ `Drop the trailing "children" segment — add_child appends "children" and the index ` +
203
+ `automatically. For example, to insert into the node at ["children",0,"children",1], ` +
204
+ `pass parentPath: ["children",0,"children",1].`,
205
+ );
206
+ }
207
+ const target = elementAt(doc, parentPath, "parentPath");
208
+ if (target.refusal) {
209
+ return target.refusal;
210
+ }
211
+ if (parent.children !== undefined && !Array.isArray(parent.children)) {
212
+ return refuse("Cannot insert into mapped-array children; edit the map template instead.");
213
+ }
214
+ // Past the end appends, and both the op and the summary name the slot the node really lands in.
215
+ const slot = insertionSlot(index, parent.children?.length ?? 0);
216
+ return {
217
+ ok: true,
218
+ ops: [{ index: slot, node: cloneValue(childNode), op: "insert-child", parentPath }],
219
+ selection: { kind: "keep" },
220
+ summary: `Inserted node at ${JSON.stringify([...parentPath, "children", slot])}.`,
221
+ };
222
+ },
223
+ name: "add_child",
224
+ title: "Add a child",
225
+ parameters: {
226
+ type: "object",
227
+ properties: {
228
+ parentPath: {
229
+ type: "array",
230
+ description: `${PATH_DESCRIPTION} Must point at a node, not a children array.`,
231
+ items: { type: ["string", "number"] },
232
+ },
233
+ index: { type: "integer", description: "Position in the children array to insert at." },
234
+ node: {
235
+ type: "object",
236
+ description: 'The Jx node definition to insert, e.g. { "tagName": "div", "children": [] }.',
237
+ },
238
+ },
239
+ required: ["parentPath", "index", "node"],
240
+ },
241
+ };
242
+
243
+ const setStyle: DocTool = {
244
+ description:
245
+ "Set or remove a CSS style property on a node. Style property names use camelCase " +
246
+ '(e.g. "backgroundColor", "fontSize", "borderRadius"). Values are always strings ' +
247
+ '(e.g. "10px", "center", "var(--color-accent)"). Pass value: null to remove.',
248
+ edit(doc, args) {
249
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed: `path` is a present
250
+ array and `property` a present string; a `path` that does not resolve is caught just below.
251
+ `value` is read untyped and stringified. */
252
+ const { path: written, property } = args as { path: JxPath; property: string };
253
+ const { value } = args;
254
+ const path = coercePath(written);
255
+ const target = elementAt(doc, path);
256
+ if (target.refusal) {
257
+ return target.refusal;
258
+ }
259
+ /* A nested key is a whole rule (`@media …`, `:hover`, `&.active`, `[open]`) whose value is an
260
+ object; written here it would be stringified to "[object Object]". */
261
+ if (/^[@:&[]/.test(property)) {
262
+ return refuse(
263
+ `"${property}" is a nested style rule (a media query, pseudo-class or selector), ` +
264
+ 'which set_style cannot write. Use set_property with key "style" and the whole ' +
265
+ "style object instead.",
266
+ );
267
+ }
268
+ const style: JxStyle = { ...target.node.style };
269
+ const val = value == null ? "" : String(value);
270
+ if (val === "") {
271
+ delete style[property];
272
+ } else {
273
+ style[property] = val;
274
+ }
275
+ return {
276
+ ok: true,
277
+ // The last property removed takes the empty `style` object with it.
278
+ ops: [setKey(path, "style", Object.keys(style).length > 0 ? style : undefined)],
279
+ selection: { kind: "keep" },
280
+ summary: `Set style "${property}" at ${JSON.stringify(path)}.`,
281
+ };
282
+ },
283
+ name: "set_style",
284
+ title: "Set a style",
285
+ parameters: {
286
+ type: "object",
287
+ properties: {
288
+ path: {
289
+ type: "array",
290
+ description: PATH_DESCRIPTION,
291
+ items: { type: ["string", "number"] },
292
+ },
293
+ property: {
294
+ type: "string",
295
+ description: 'CSS property name in camelCase, e.g. "backgroundColor".',
296
+ },
297
+ value: {
298
+ description:
299
+ 'CSS value as a string (e.g. "10px", "var(--color-accent)"), or null to remove.',
300
+ },
301
+ },
302
+ /*
303
+ * "value" omitted from required so null / omitted = remove (registry rejects null on
304
+ * required args, tools.js:181). See `ai.md` §3.6.
305
+ */
306
+ required: ["path", "property"],
307
+ },
308
+ };
309
+
310
+ const setText: DocTool = {
311
+ description:
312
+ "Set the textContent of a node. Convenience alias for set_property with key: 'textContent'.",
313
+ edit(doc, args) {
314
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed: `path` is a present
315
+ array and `value` a present string; a `path` that does not resolve is caught just below. */
316
+ const { path: written, value } = args as { path: JxPath; value: string };
317
+ const path = coercePath(written);
318
+ const target = elementAt(doc, path);
319
+ if (target.refusal) {
320
+ return target.refusal;
321
+ }
322
+ return {
323
+ ok: true,
324
+ // Two `set-key` ops, as the Inspector writes text: the text is the node's one child.
325
+ ops: [setKey(path, "textContent", undefined), setKey(path, "children", [value])],
326
+ selection: { kind: "prune", under: [[...path, "children"]] },
327
+ summary: `Set text at ${JSON.stringify(path)}.`,
328
+ };
329
+ },
330
+ name: "set_text",
331
+ title: "Set text",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {
335
+ path: {
336
+ type: "array",
337
+ description: PATH_DESCRIPTION,
338
+ items: { type: ["string", "number"] },
339
+ },
340
+ value: { type: "string", description: "Text content." },
341
+ },
342
+ required: ["path", "value"],
343
+ },
344
+ };
345
+
346
+ const addState: DocTool = {
347
+ description:
348
+ "Add a new reactive state variable under the document's `state` object. The value can be a scalar " +
349
+ '(e.g. 0, ""), a typed object ({ "type": "string", "default": "" }), a computed ' +
350
+ 'string ("${state.other}"), a function ({ "$prototype": "Function", "body": "..." }), ' +
351
+ 'or a data source ({ "$prototype": "Data", "$src": "./data.json" }).',
352
+ edit(doc, args) {
353
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed, so `key` is a
354
+ present string. */
355
+ const { key } = args as { key: string };
356
+ const state = stateOf(doc);
357
+ if (state && state[key] !== undefined) {
358
+ return refuse(
359
+ `State key "${key}" already exists. Use update_state to change it, or remove it first.`,
360
+ );
361
+ }
362
+ return {
363
+ ok: true,
364
+ /* The whole `state` object, not the entry: an empty-string default (`"title": ""`) is a value
365
+ here, where a property write would delete it. */
366
+ ops: [setKey([], "state", { ...state, [key]: args.value ?? null })],
367
+ selection: { kind: "keep" },
368
+ summary: `Added state "${key}".`,
369
+ };
370
+ },
371
+ name: "add_state",
372
+ title: "Add state",
373
+ parameters: {
374
+ type: "object",
375
+ properties: {
376
+ key: { type: "string", description: 'State variable name, e.g. "count", "isOpen".' },
377
+ value: {
378
+ description:
379
+ "Initial value or state shape object (scalar, typed, computed, function, or data source).",
380
+ },
381
+ },
382
+ required: ["key", "value"],
383
+ },
384
+ };
385
+
386
+ const updateState: DocTool = {
387
+ description:
388
+ "Update or remove an existing state variable at the document root. Pass value: null to remove.",
389
+ edit(doc, args) {
390
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed, so `key` is a
391
+ present string. */
392
+ const { key } = args as { key: string };
393
+ const { value } = args;
394
+ const state = stateOf(doc);
395
+ if (!state || state[key] === undefined) {
396
+ return refuse(
397
+ `State key "${key}" does not exist. Use add_state to create it, or check the name. Current state keys: ${Object.keys(state || {}).join(", ") || "(none)"}`,
398
+ );
399
+ }
400
+ const next = { ...state };
401
+ if (value == null) {
402
+ delete next[key];
403
+ } else {
404
+ next[key] = value;
405
+ }
406
+ return {
407
+ ok: true,
408
+ // Removing the last entry drops the empty `state` object, as the Inspector's own removal does.
409
+ ops: [setKey([], "state", Object.keys(next).length > 0 ? next : undefined)],
410
+ selection: { kind: "keep" },
411
+ summary: value == null ? `Removed state "${key}".` : `Updated state "${key}".`,
412
+ };
413
+ },
414
+ name: "update_state",
415
+ title: "Update state",
416
+ parameters: {
417
+ type: "object",
418
+ properties: {
419
+ key: { type: "string", description: 'State variable name to update, e.g. "count".' },
420
+ value: {
421
+ description: "New value (same shapes as add_state), or null to remove the state variable.",
422
+ },
423
+ },
424
+ /*
425
+ * "value" omitted from required so null = remove (registry rejects null on required
426
+ * args, tools.js:181). See `ai.md` §3.6.
427
+ */
428
+ required: ["key"],
429
+ },
430
+ };
431
+
432
+ const moveNode: DocTool = {
433
+ description:
434
+ "Move a node from one location to another in the document tree. The node is removed " +
435
+ "from fromPath and inserted into toParentPath at toIndex.",
436
+ edit(doc, args) {
437
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed: both paths are
438
+ present arrays, and paths that do not resolve are caught just below. `toIndex` is read
439
+ untyped: the validator admits a numeric string for an integer, so `coerceIndex` decides what
440
+ it names. */
441
+ const written = args as { fromPath: JxPath; toParentPath: JxPath };
442
+ const fromPath = coercePath(written.fromPath);
443
+ const toParentPath = coercePath(written.toParentPath);
444
+ const toIndex = coerceIndex(args.toIndex);
445
+ if (toIndex === null || toIndex < 0) {
446
+ return indexError("toIndex", args.toIndex);
447
+ }
448
+ if (fromPath.length < 2) {
449
+ return refuse("Cannot move the document root.");
450
+ }
451
+ if (nodeAt(doc, fromPath) === undefined) {
452
+ return refuse(`No node exists at fromPath ${JSON.stringify(fromPath)}.`);
453
+ }
454
+ if (!isSpliceablePath(fromPath)) {
455
+ return refuse(
456
+ `fromPath ${JSON.stringify(fromPath)} is not a child in a children array, so it ` +
457
+ "cannot be moved. A $switch case or a repeater's template is changed with " +
458
+ "set_property on the node that holds it.",
459
+ );
460
+ }
461
+ const target = elementAt(doc, toParentPath, "toParentPath");
462
+ if (target.refusal) {
463
+ return target.refusal;
464
+ }
465
+ if (isAncestor(fromPath, toParentPath)) {
466
+ return refuse(
467
+ `Cannot move a node into itself or its own descendants: toParentPath ` +
468
+ `${JSON.stringify(toParentPath)} is inside fromPath ${JSON.stringify(fromPath)}.`,
469
+ );
470
+ }
471
+ /* SAFETY: `fromPath` resolves and is a splice coordinate, so its parent is a node with a static
472
+ children array holding it, and `toParentPath` resolves to a node: every case in which
473
+ `planMove` answers null was refused above. */
474
+ const { forward, inverse } = planMove(doc, fromPath, toParentPath, toIndex)!;
475
+ // Where the node is after the move, which the summary must name for the next call to use.
476
+ const landed = [...inverse.fromParentPath, "children", inverse.fromIndex];
477
+ return {
478
+ ok: true,
479
+ ops: [forward],
480
+ selection: { from: fromPath, kind: "follow", to: landed },
481
+ summary: `Moved node from ${JSON.stringify(fromPath)} to ${JSON.stringify(landed)}.`,
482
+ };
483
+ },
484
+ name: "move_node",
485
+ title: "Move a node",
486
+ parameters: {
487
+ type: "object",
488
+ properties: {
489
+ fromPath: {
490
+ type: "array",
491
+ description: `${PATH_DESCRIPTION} The node to move.`,
492
+ items: { type: ["string", "number"] },
493
+ },
494
+ toParentPath: {
495
+ type: "array",
496
+ description: `${PATH_DESCRIPTION} The target parent (a node, not a children array).`,
497
+ items: { type: ["string", "number"] },
498
+ },
499
+ toIndex: {
500
+ type: "integer",
501
+ description: "Insertion index in the target parent's children array.",
502
+ },
503
+ },
504
+ required: ["fromPath", "toParentPath", "toIndex"],
505
+ },
506
+ };
507
+
508
+ /** The tree tools, in the order the model has always been offered them. */
509
+ export const DOC_TOOLS: readonly DocTool[] = [
510
+ setProperty,
511
+ addChild,
512
+ setStyle,
513
+ setText,
514
+ addState,
515
+ updateState,
516
+ moveNode,
517
+ ];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The tree tools as registry entries over a {@link DocumentHost} (specs/ai.md §3.9).
3
+ *
4
+ * @module @jxsuite/ai/jx-tools
5
+ * @license MIT
6
+ */
7
+
8
+ import { createToolDefinition, toolError } from "../tools.ts";
9
+ import type { ToolDefinition } from "../tools.ts";
10
+ import { coercePath, DOC_TOOLS, nodeAt, PATH_DESCRIPTION } from "./doc-tools.ts";
11
+ import type { DocEdit, DocTool, DocumentHost, WriteReporter } from "./types.ts";
12
+ import { JX_TOOL_FACTS } from "./facts.ts";
13
+ import { isString } from "@jxsuite/schema/guards";
14
+ import type { JxPath } from "@jxsuite/schema/types";
15
+
16
+ /** What the model reads when the document refuses structural edits for now. */
17
+ export const FROZEN_SENTENCE =
18
+ "The document is frozen: a collaborator is editing its source, so structural edits are " +
19
+ "paused. Nothing was changed. Try again once source editing ends.";
20
+
21
+ /** `read_document`: the document, or the subtree at a path. */
22
+ function readDocument(host: DocumentHost): ToolDefinition {
23
+ return createToolDefinition({
24
+ name: "read_document",
25
+ title: "Read the document",
26
+ annotations: JX_TOOL_FACTS.read_document.annotations,
27
+ description:
28
+ "Read the current Jx document, or the subtree at a given path. Use this to discover " +
29
+ "node paths before calling set_property, add_child, or delete_node.",
30
+ parameters: {
31
+ type: "object",
32
+ properties: {
33
+ path: {
34
+ type: "array",
35
+ description: `${PATH_DESCRIPTION} Omit to read the whole document.`,
36
+ items: { type: ["string", "number"] },
37
+ },
38
+ },
39
+ required: [],
40
+ },
41
+ execute(args, ctx) {
42
+ const ref = host.resolve(ctx);
43
+ if (isString(ref)) {
44
+ return toolError(ref);
45
+ }
46
+ /* SAFETY: a strict tool runs only after the registry's `validate()` passed, which refuses a
47
+ `path` that is present but not an array. Its items are not checked; a key that does not
48
+ resolve makes `nodeAt` answer undefined, reported just below. */
49
+ const { path: written } = args as { path?: JxPath };
50
+ const path = written && coercePath(written);
51
+ const doc = host.snapshot(ref);
52
+ const node = path && path.length > 0 ? nodeAt(doc, path) : doc;
53
+ if (node === undefined) {
54
+ return toolError(`No node exists at path ${JSON.stringify(path)}.`);
55
+ }
56
+ return { success: true, data: node };
57
+ },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * One tree tool as a registry entry. A refused call costs no validation: the producer runs once
63
+ * against the document as it stands, and only an edit it accepts is bracketed by the reporter and
64
+ * run again inside the host's transaction, where the document cannot move under it.
65
+ */
66
+ function treeTool<Before>(
67
+ host: DocumentHost,
68
+ reporter: WriteReporter<Before>,
69
+ tool: DocTool,
70
+ ): ToolDefinition {
71
+ return createToolDefinition({
72
+ name: tool.name,
73
+ title: tool.title,
74
+ description: tool.description,
75
+ parameters: tool.parameters,
76
+ annotations: JX_TOOL_FACTS[tool.name].annotations,
77
+ async execute(args, ctx) {
78
+ const ref = host.resolve(ctx);
79
+ if (isString(ref)) {
80
+ return toolError(ref);
81
+ }
82
+ const planned = tool.edit(host.snapshot(ref), args);
83
+ if (!planned.ok) {
84
+ return toolError(planned.error);
85
+ }
86
+ const before = await reporter.before(ref);
87
+ // The summary of the edit that ran, which names where it landed in the document it ran on.
88
+ let { summary } = planned;
89
+ const receipt = await host.edit(
90
+ ref,
91
+ (doc): DocEdit => {
92
+ const edit = tool.edit(doc, args);
93
+ if (edit.ok) {
94
+ ({ summary } = edit);
95
+ }
96
+ return edit;
97
+ },
98
+ { actor: ctx.actor, callId: ctx.callId, tool: tool.name },
99
+ );
100
+ if (!receipt.applied) {
101
+ return toolError(receipt.reason === "frozen" ? FROZEN_SENTENCE : receipt.message);
102
+ }
103
+ /* One ledger entry per write, so "Changed N files" counts documents rather than sentences
104
+ (specs/ai.md §3.2). `disk` is whether undo can reach it, which is the host's to say. */
105
+ ctx.ledger.record({
106
+ disk: host.undoable === "none",
107
+ ok: true,
108
+ path: ref.path ?? "(untitled)",
109
+ tool: summary,
110
+ });
111
+ return reporter.after(ref, before, summary);
112
+ },
113
+ });
114
+ }
115
+
116
+ /**
117
+ * `read_document` and the seven tree tools, in the order the model has always been offered them,
118
+ * over `host`. `reporter` brackets every write that the producer accepts.
119
+ *
120
+ * @param {DocumentHost} host
121
+ * @param {{ reporter: WriteReporter<Before> }} options
122
+ * @returns {ToolDefinition[]}
123
+ */
124
+ export function docToolEntries<Before>(
125
+ host: DocumentHost,
126
+ { reporter }: { readonly reporter: WriteReporter<Before> },
127
+ ): ToolDefinition[] {
128
+ return [readDocument(host), ...DOC_TOOLS.map((tool) => treeTool(host, reporter, tool))];
129
+ }