@zvndev/darkmown 2.4.1-next.7.ge25dbba → 2.5.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,274 @@
1
+ // ---------------------------------------------------------------------------
2
+ // TOOL: apply(files, entry, edits)
3
+ //
4
+ // Applies targeted edits to one file and hands back the new content. It does not
5
+ // write anything and does not compile: the caller applies, then validates, so a
6
+ // rejected edit costs nothing.
7
+ //
8
+ // WHY THERE ARE THREE WAYS TO SAY WHERE
9
+ //
10
+ // The obvious design is anchored edits only, ruling out line numbers because
11
+ // "small models cannot count lines and a stale number silently corrupts a
12
+ // file". The first half of that is the wrong worry and the second half is real
13
+ // but fixable.
14
+ //
15
+ // Measured across file sizes, quoting a line back verbatim gets HARDER as a
16
+ // file grows, and the anchor is where anchored editing dies: it accounts for
17
+ // 42% of failed tasks at 16 lines and 67% at 244. The model is not failing to
18
+ // count. It is failing to recall a string it read once.
19
+ //
20
+ // So this tool accepts three ways to say where, and the model is expected to use
21
+ // whichever the previous tool call already handed it:
22
+ //
23
+ // { anchor: "…" } verbatim source text, must appear exactly once
24
+ // { line: 7 } a line number, as printed by `outline`
25
+ // { symbol: "cart" } a declaration, as named by `outline` / `refs`
26
+ //
27
+ // The objection to line numbers was staleness, and staleness is what the design
28
+ // prevents rather than what it ignores: every target is resolved against the
29
+ // snapshot passed to THIS call, all edits in a call are applied against that one
30
+ // snapshot in descending line order, and a `line` target must still contain what
31
+ // the caller expected if it says so. The model is never counting; it is quoting
32
+ // a number a tool just printed.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import { symbolsOf } from "./outline.js";
36
+ import { shortPath } from "./validate.js";
37
+
38
+ /** The operations a caller may ask for. Closed, and validated per edit. */
39
+ export const OPS = new Set(["replace", "insert_after", "insert_before", "delete"]);
40
+
41
+ /**
42
+ * One edit, as a caller (or a model) writes it.
43
+ *
44
+ * Every field is optional because a malformed edit is REPORTED, not thrown: the
45
+ * whole point of this surface is that a model gets a sentence back telling it
46
+ * what to fix. Typing them as required would describe a contract this module
47
+ * deliberately does not enforce at the boundary.
48
+ *
49
+ * @typedef {object} Edit
50
+ * @property {string} [op] One of {@link OPS}.
51
+ * @property {number} [line] 1-based line, as printed by `outline`.
52
+ * @property {string} [symbol] A symbol target, e.g. `state:cart`.
53
+ * @property {string} [anchor] Exact source text to find.
54
+ * @property {string} [text] Replacement or inserted text.
55
+ * @property {string} [expect] What the caller believed is at `line`, for staleness.
56
+ */
57
+
58
+ /** Symbol kinds that DEFINE the name they carry, as opposed to referencing it. */
59
+ const DECLARING_KINDS = new Set([
60
+ "state",
61
+ "store",
62
+ "computed",
63
+ "theme",
64
+ "fetch",
65
+ "form",
66
+ "field",
67
+ "loop"
68
+ ]);
69
+
70
+ /**
71
+ * Resolve one edit's target to a 1-based inclusive line range in `source`.
72
+ *
73
+ * Every rejection names what was wrong AND what would have worked, because a
74
+ * rejection a model cannot act on is just a failed attempt.
75
+ *
76
+ * @param {string} source
77
+ * @param {Edit} edit
78
+ * @param {import("../compiler/context.js").Symbol[]} symbols
79
+ * @returns {{ok: true, from: number, to: number} | {ok: false, error: string}}
80
+ */
81
+ export function resolveTarget(source, edit, symbols) {
82
+ const lines = source.split("\n");
83
+
84
+ if (typeof edit.line === "number") {
85
+ if (!Number.isInteger(edit.line) || edit.line < 1 || edit.line > lines.length) {
86
+ return {
87
+ ok: false,
88
+ error: `line ${edit.line} is outside the file, which has ${lines.length} lines. To add something at the end, use {"op": "insert_after", "line": ${lines.length}}.`
89
+ };
90
+ }
91
+ // Optional belt and braces: if the caller says what it expected to find, a
92
+ // snapshot that moved under it is caught here instead of corrupting the file.
93
+ if (typeof edit.expect === "string" && !lines[edit.line - 1].includes(edit.expect)) {
94
+ return {
95
+ ok: false,
96
+ error: `line ${edit.line} is "${lines[edit.line - 1].trim()}", not the expected "${edit.expect}". Run outline again; the file has changed.`
97
+ };
98
+ }
99
+ return { ok: true, from: edit.line, to: edit.line };
100
+ }
101
+
102
+ if (typeof edit.symbol === "string") {
103
+ // `state:cart` disambiguates when a name is both declared and looped.
104
+ const [maybeKind, maybeName] = edit.symbol.includes(":")
105
+ ? edit.symbol.split(":")
106
+ : [null, edit.symbol];
107
+ // Unqualified means "where this is DECLARED". A read or a conditional that
108
+ // mentions the name is a reference, not a definition, and is targeted by the
109
+ // line `refs` prints for it. Without this, the most natural target form is
110
+ // ambiguous on every page that both declares and uses a symbol, which is
111
+ // every page worth editing.
112
+ const candidates = symbols.filter(
113
+ (s) => s.line != null && s.name === maybeName && (!maybeKind || s.kind === maybeKind)
114
+ );
115
+ const hits = maybeKind ? candidates : candidates.filter((s) => DECLARING_KINDS.has(s.kind));
116
+ if (hits.length === 0) {
117
+ const known = [
118
+ ...new Set(symbols.filter((s) => s.line != null).map((s) => `${s.kind}:${s.name}`))
119
+ ].sort();
120
+ return {
121
+ ok: false,
122
+ error: `no symbol "${edit.symbol}". This file declares: ${known.join(", ") || "nothing"}`
123
+ };
124
+ }
125
+ if (hits.length > 1) {
126
+ return {
127
+ ok: false,
128
+ error: `"${edit.symbol}" is ambiguous: ${hits.map((h) => `${h.kind}:${h.name} at line ${h.line}`).join(", ")}. Qualify it as kind:name, or use a line.`
129
+ };
130
+ }
131
+ // A block directive spans its whole construct. Replacing a `@loop` means
132
+ // replacing the loop, not decapitating it and stranding the `@endloop`,
133
+ // which is the most common way a symbol-targeted edit breaks a file.
134
+ //
135
+ // `line` is nullable on a Symbol — a prose read the compiler could not
136
+ // locate carries none — but `candidates` above already excluded every one
137
+ // of those, so a hit always has a line. The cast records that guarantee
138
+ // instead of re-checking it: the check that used to sit here could not be
139
+ // reached, and unreachable error paths are worse than none, because they
140
+ // read as handled cases and are never exercised.
141
+ const at = /** @type {number} */ (hits[0].line);
142
+ return { ok: true, from: at, to: hits[0].endLine ?? at };
143
+ }
144
+
145
+ if (typeof edit.anchor === "string") {
146
+ const anchor = edit.anchor.replace(/\n+$/, "");
147
+ if (!anchor.trim()) return { ok: false, error: "an empty anchor matches nothing" };
148
+ const anchorLines = anchor.split("\n");
149
+ /** @type {number[]} */
150
+ const starts = [];
151
+ for (let i = 0; i + anchorLines.length <= lines.length; i++) {
152
+ if (anchorLines.every((a, k) => lines[i + k] === a)) starts.push(i + 1);
153
+ }
154
+ if (starts.length === 0) {
155
+ return {
156
+ ok: false,
157
+ error: `that anchor is not in the file, character for character. Use outline to get a line number instead of quoting.`
158
+ };
159
+ }
160
+ if (starts.length > 1) {
161
+ return {
162
+ ok: false,
163
+ error: `that anchor appears ${starts.length} times (lines ${starts.join(", ")}). Add a neighbouring line to make it unique, or use a line number.`
164
+ };
165
+ }
166
+ return { ok: true, from: starts[0], to: starts[0] + anchorLines.length - 1 };
167
+ }
168
+
169
+ return {
170
+ ok: false,
171
+ error: `this edit says nothing about where it goes. Give one of: {"line": 7}, {"symbol": "cart"}, or {"anchor": "the exact source line"}.`
172
+ };
173
+ }
174
+
175
+ /**
176
+ * TOOL ENTRY POINT.
177
+ *
178
+ * @param {Record<string, string>} files
179
+ * @param {string} entry File to edit, in the same key form as `files`.
180
+ * @param {Edit[]} [edits] Missing or empty is reported, not thrown.
181
+ * @returns {{ok: boolean, text: string, data: object}}
182
+ */
183
+ export function apply(files, entry, edits) {
184
+ if (!files || !(entry in files)) {
185
+ return { ok: false, text: `no such file "${shortPath(entry)}" in this project`, data: {} };
186
+ }
187
+ if (!Array.isArray(edits) || edits.length === 0) {
188
+ return { ok: false, text: "apply needs a non-empty list of edits", data: {} };
189
+ }
190
+
191
+ const source = files[entry];
192
+ // The symbol table is only needed for `symbol:` targets, and a file that does
193
+ // not compile still has anchors and line numbers. So a compile failure here is
194
+ // not fatal: it only removes the symbol vocabulary.
195
+ const got = symbolsOf(files, entry);
196
+ const symbols = got.ok ? got.symbols.filter((s) => s.file === entry) : [];
197
+
198
+ /** @type {{from: number, to: number, edit: Edit}[]} */
199
+ const resolved = [];
200
+ for (const [i, edit] of edits.entries()) {
201
+ if (!edit?.op || !OPS.has(edit.op)) {
202
+ return {
203
+ ok: false,
204
+ text: `edit ${i + 1}: unknown op "${edit?.op}". Use one of: ${[...OPS].join(", ")}`,
205
+ data: {}
206
+ };
207
+ }
208
+ if (edit.op !== "delete" && typeof edit.text !== "string") {
209
+ return { ok: false, text: `edit ${i + 1}: ${edit.op} needs "text"`, data: {} };
210
+ }
211
+ if (typeof edit.symbol === "string" && !got.ok) {
212
+ return {
213
+ ok: false,
214
+ text: `edit ${i + 1}: cannot use a symbol target because the file does not compile (${got.error}). Fix the error first, or target a line.`,
215
+ data: {}
216
+ };
217
+ }
218
+ const target = resolveTarget(source, edit, symbols);
219
+ if (!target.ok) return { ok: false, text: `edit ${i + 1}: ${target.error}`, data: {} };
220
+ resolved.push({ from: target.from, to: target.to, edit });
221
+ }
222
+
223
+ // Overlap is a caller bug that would otherwise produce a plausible-looking but
224
+ // wrong file, which is the single worst outcome for an automated loop.
225
+ const ordered = [...resolved].sort((a, b) => a.from - b.from);
226
+ for (let i = 1; i < ordered.length; i++) {
227
+ if (ordered[i].from <= ordered[i - 1].to) {
228
+ return {
229
+ ok: false,
230
+ text: `two edits target overlapping lines (${ordered[i - 1].from}-${ordered[i - 1].to} and ${ordered[i].from}-${ordered[i].to}). Combine them into one edit.`,
231
+ data: {}
232
+ };
233
+ }
234
+ }
235
+
236
+ // Descending, so an earlier edit never shifts a later one's line numbers.
237
+ const lines = source.split("\n");
238
+ for (const { from, to, edit } of [...resolved].sort((a, b) => b.from - a.from)) {
239
+ const body = edit.op === "delete" ? [] : (edit.text ?? "").replace(/\n$/, "").split("\n");
240
+ if (edit.op === "replace" || edit.op === "delete")
241
+ lines.splice(from - 1, to - from + 1, ...body);
242
+ else if (edit.op === "insert_after") lines.splice(to, 0, ...body);
243
+ else lines.splice(from - 1, 0, ...body);
244
+ }
245
+
246
+ const content = lines.join("\n");
247
+ const changed = countChanged(source, content);
248
+ return {
249
+ ok: true,
250
+ text: `applied ${resolved.length} edit${resolved.length === 1 ? "" : "s"} to ${shortPath(entry)} (${changed} line${changed === 1 ? "" : "s"} differ)`,
251
+ data: { content, files: { ...files, [entry]: content }, edits: resolved.length, changed }
252
+ };
253
+ }
254
+
255
+ /**
256
+ * How many lines differ, as a blunt blast-radius signal for the caller.
257
+ * @param {string} a
258
+ * @param {string} b
259
+ * @returns {number}
260
+ */
261
+ function countChanged(a, b) {
262
+ const x = a.split("\n");
263
+ const y = b.split("\n");
264
+ let same = 0;
265
+ const pool = [...y];
266
+ for (const line of x) {
267
+ const i = pool.indexOf(line);
268
+ if (i !== -1) {
269
+ same++;
270
+ pool.splice(i, 1);
271
+ }
272
+ }
273
+ return Math.max(x.length, y.length) - same;
274
+ }
@@ -0,0 +1,171 @@
1
+ // ---------------------------------------------------------------------------
2
+ // TOOL: grammar(categories)
3
+ //
4
+ // Hands back only the cheatsheet rows an edit actually needs, plus the closed
5
+ // enum of action tokens when the edit involves an action.
6
+ //
7
+ // Why this is a tool rather than a fixed prompt block. Measured against small
8
+ // local models, the cheatsheet behaves as a load-bearing runtime asset and not
9
+ // as documentation: revising it moved a 1.5B model by 22 points, and a single
10
+ // bad example line was copied verbatim into four consecutive attempts. A model
11
+ // shown only the rows it needs has fewer wrong rows available to copy, and
12
+ // spends fewer tokens getting them.
13
+ //
14
+ // Everything here is derived from `directiveCatalog()`, which the compiler
15
+ // generates from its own tables. Nothing is hand-transcribed, so a directive
16
+ // that changes in the compiler changes here in the same commit or the drift
17
+ // guard in the tests fails.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { CATALOG_ACTION_TOKENS, directiveCatalog } from "../catalog.js";
21
+
22
+ /**
23
+ * Every directive name, grouped into the category a person would ask for.
24
+ *
25
+ * Deliberately exhaustive and deliberately hand-maintained: `tools.test.mjs`
26
+ * asserts that this covers the catalog exactly, so adding a directive to the
27
+ * compiler without categorising it here fails a test rather than silently
28
+ * dropping the directive out of every grammar slice.
29
+ *
30
+ * The names match `errorAreas` in the catalog, so a compile error in area
31
+ * "loops" can be answered with `grammar(["loops"])` without a second mapping.
32
+ */
33
+ export const CATEGORIES = {
34
+ state: [":state", ":store", ":computed", ":theme"],
35
+ loops: ["@loop"],
36
+ actions: [":button", ":effect", ":every"],
37
+ forms: [
38
+ ":form",
39
+ ":input",
40
+ ":textarea",
41
+ ":select",
42
+ ":checkbox",
43
+ ":radio",
44
+ ":submit",
45
+ ":bind",
46
+ ":slider"
47
+ ],
48
+ fetch: [":fetch"],
49
+ structure: ["@include", ":::", ":if", ":carousel"],
50
+ media: [":video", ":audio", ":embed"]
51
+ };
52
+
53
+ /** Categories that also need the loop clause list and loop variables. */
54
+ const NEEDS_LOOP_EXTRAS = new Set(["loops"]);
55
+ /** Categories that also need the closed action-token enum. */
56
+ const NEEDS_ACTION_TOKENS = new Set(["actions"]);
57
+ /** Categories whose expressions use the comparison operators. */
58
+ const NEEDS_PREDICATES = new Set(["loops", "structure", "state"]);
59
+
60
+ /** @returns {string[]} Every category name, for callers that want to enumerate. */
61
+ export function categoryNames() {
62
+ return Object.keys(CATEGORIES);
63
+ }
64
+
65
+ /**
66
+ * One directive rendered as the model should write it.
67
+ *
68
+ * Syntax line then a concrete example, because small models copy `[optional]`
69
+ * bracket notation into the file verbatim. The example is always a real
70
+ * compilable line.
71
+ *
72
+ * @param {{name: string, syntax: string, description: string, example: string}} d
73
+ * @returns {string}
74
+ */
75
+ function renderDirective(d) {
76
+ const lines = [`${d.name} ${d.syntax}`, ` ${d.description}`];
77
+ if (d.example && d.example !== d.syntax) lines.push(` e.g. ${d.example}`);
78
+ return lines.join("\n");
79
+ }
80
+
81
+ /**
82
+ * TOOL ENTRY POINT.
83
+ *
84
+ * @param {string[]} [categories] Category names. Omitted or empty means all of
85
+ * them, which is the whole cheatsheet and is the thing this tool exists to
86
+ * avoid sending.
87
+ * Failures use the same {ok, text, data} shape as every other tool. It used to
88
+ * answer {ok: false, error}, which `Session` never reads, so an unknown category
89
+ * handed the model an undefined message.
90
+ *
91
+ * @returns {{ok: boolean, text: string, data: object}}
92
+ */
93
+ export function grammar(categories) {
94
+ const all = categoryNames();
95
+ const want = !categories || categories.length === 0 ? all : categories;
96
+
97
+ const unknown = want.filter((c) => !all.includes(c));
98
+ if (unknown.length) {
99
+ return {
100
+ ok: false,
101
+ text: `unknown categor${unknown.length === 1 ? "y" : "ies"} ${unknown.map((u) => `"${u}"`).join(", ")}. Available: ${all.join(", ")}`,
102
+ data: { code: "TOOL_ARGS" }
103
+ };
104
+ }
105
+
106
+ const catalog = directiveCatalog();
107
+ const byName = new Map(catalog.directives.map((d) => [d.name, d]));
108
+ const wanted = want.flatMap((c) => CATEGORIES[/** @type {keyof CATEGORIES} */ (c)] ?? []);
109
+ const directives = /** @type {NonNullable<ReturnType<typeof byName.get>>[]} */ (
110
+ wanted.map((n) => byName.get(n)).filter(Boolean)
111
+ );
112
+
113
+ const sections = [directives.map(renderDirective).join("\n\n")];
114
+ // Widened up front: the optional sections below add their own keys, and an
115
+ // inferred literal type would reject each one.
116
+ /** @type {{categories: string[], directives: string[], loopClauses?: string[],
117
+ * actionTokens?: string[], predicateOps?: string[]}} */
118
+ const data = { categories: want, directives: directives.map((d) => d.name) };
119
+
120
+ if (want.some((c) => NEEDS_LOOP_EXTRAS.has(c))) {
121
+ sections.push(
122
+ "Loop clauses, all optional, in this exact order after `into <item>`:\n" +
123
+ catalog.loopClauses.map((c) => ` ${c.name} ${c.syntax ?? ""}`.trimEnd()).join("\n") +
124
+ "\nInside a loop body you may also use: " +
125
+ catalog.loopVariables.map((v) => `{ ${v.name} }`).join(" ")
126
+ );
127
+ data.loopClauses = catalog.loopClauses.map((c) => c.name);
128
+ }
129
+
130
+ if (want.some((c) => NEEDS_ACTION_TOKENS.has(c))) {
131
+ sections.push(
132
+ "Action tokens. This list is closed; anything else is a compile error:\n" +
133
+ catalog.actionOps.map((a) => ` ${a.syntax} ${a.description}`).join("\n")
134
+ );
135
+ data.actionTokens = [...CATALOG_ACTION_TOKENS];
136
+ }
137
+
138
+ if (want.some((c) => NEEDS_PREDICATES.has(c))) {
139
+ sections.push(
140
+ "Comparison operators for `where` and `:if`: " +
141
+ catalog.predicateOps.map((o) => o.name).join(" ") +
142
+ `\nJoin conditions with: ${catalog.predicateJoiners.join(" ")}`
143
+ );
144
+ data.predicateOps = catalog.predicateOps.map((o) => o.name);
145
+ }
146
+
147
+ return { ok: true, text: sections.join("\n\n"), data };
148
+ }
149
+
150
+ /**
151
+ * How much a slice saves against sending everything.
152
+ *
153
+ * The plan claims filtering the cheatsheet is both a context saving and a
154
+ * hallucination guard. The saving half is measurable, so it gets measured
155
+ * rather than asserted.
156
+ *
157
+ * @param {string[]} categories
158
+ * @returns {{sliceChars: number, fullChars: number, saved: number, savedPct: number}}
159
+ */
160
+ export function grammarCost(categories) {
161
+ const slice = grammar(categories);
162
+ const full = grammar();
163
+ const sliceChars = slice.ok ? slice.text.length : 0;
164
+ const fullChars = full.ok ? full.text.length : 0;
165
+ return {
166
+ sliceChars,
167
+ fullChars,
168
+ saved: fullChars - sliceChars,
169
+ savedPct: fullChars ? Math.round((100 * (fullChars - sliceChars)) / fullChars) : 0
170
+ };
171
+ }
@@ -0,0 +1,170 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The tool surface, as one dispatch point.
3
+ //
4
+ // Six tools, all derived from the compiler rather than written beside it, all
5
+ // answering in text a model can nearly paste.
6
+ //
7
+ // grammar(categories) only the cheatsheet rows this edit needs
8
+ // outline(entry) what is in the page, with line numbers
9
+ // refs(name) every declare / write / read of a symbol
10
+ // deps(entry) what this page pulls in, and what pulls it in
11
+ // apply(entry, edits) targeted edits against a snapshot
12
+ // validate(entry) compile it for real, or say exactly what broke
13
+ //
14
+ // A `Session` holds the project files so a model never has to restate them, and
15
+ // so `apply` and `outline` are guaranteed to be looking at the same snapshot.
16
+ // Everything is in memory: a full compile is milliseconds, which is what makes
17
+ // "validate after every edit" affordable rather than aspirational.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { apply } from "./apply.js";
21
+ import { grammar } from "./grammar.js";
22
+ import { deps, outline, refs } from "./outline.js";
23
+ import { parseToolCall } from "./parse.js";
24
+ import { validate } from "./validate.js";
25
+
26
+ export { apply, deps, grammar, outline, parseToolCall, refs, validate };
27
+
28
+ /**
29
+ * The tool list as a model should be told about it: name, when to reach for it,
30
+ * and the exact argument shape. Kept here rather than in a prompt file so the
31
+ * description can never drift from the dispatcher.
32
+ */
33
+ export const TOOL_SPECS = [
34
+ {
35
+ name: "outline",
36
+ args: "{}",
37
+ use: "List everything declared in the page with its line number. Call this FIRST; every other tool's targets come from it."
38
+ },
39
+ {
40
+ name: "refs",
41
+ args: '{ "name": "cart" }',
42
+ use: "Find every place a symbol is declared, written, or read. Call this before changing a symbol, to see what else moves."
43
+ },
44
+ {
45
+ name: "deps",
46
+ args: "{}",
47
+ use: "What this page includes, and which other pages include it."
48
+ },
49
+ {
50
+ name: "grammar",
51
+ args: '{ "categories": ["state", "actions"] }',
52
+ use: "Get the exact syntax for one area. Categories: state, loops, actions, forms, fetch, structure, media."
53
+ },
54
+ {
55
+ name: "apply",
56
+ args: '{ "edits": [{ "op": "replace", "line": 7, "text": ":store cart = [] persist" }] }',
57
+ use: 'Make an edit. Say WHERE with one of "line" (from outline), "symbol" (e.g. "state:cart"), or "anchor" (exact source text). Ops: replace, insert_after, insert_before, delete.'
58
+ },
59
+ {
60
+ name: "validate",
61
+ args: "{}",
62
+ use: "Compile the page for real. Do this after every apply."
63
+ }
64
+ ];
65
+
66
+ /**
67
+ * A working copy of a project plus the tools that operate on it.
68
+ *
69
+ * Edits are staged in the session, not written to disk, so a caller can apply,
70
+ * validate, and roll back without touching a filesystem. `history` exists so a
71
+ * run can be scored later on how many calls it took, which is the number that
72
+ * decides whether the tool surface is worth its context budget.
73
+ */
74
+ export class Session {
75
+ /**
76
+ * @param {Record<string, string>} files Project-relative path → contents.
77
+ * @param {string} entry The file being edited.
78
+ */
79
+ constructor(files, entry) {
80
+ this.files = { ...files };
81
+ this.entry = entry;
82
+ this.original = files[entry];
83
+ /** @type {{tool: string, args: object, ok: boolean}[]} */
84
+ this.history = [];
85
+ }
86
+
87
+ /**
88
+ * Dispatch one tool call.
89
+ *
90
+ * An unknown tool name is answered with the list rather than an exception,
91
+ * because a model that guessed a name can recover from a list and cannot
92
+ * recover from a stack trace.
93
+ *
94
+ * @param {string} tool
95
+ * @param {object} [args]
96
+ * @returns {{ok: boolean, text: string, data?: object}}
97
+ */
98
+ call(tool, args = {}) {
99
+ const result = this.#dispatch(tool, args);
100
+ this.history.push({ tool, args, ok: result.ok });
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * The union of every argument any tool takes. Declaring it beats `object`,
106
+ * which type-checked nothing and let a renamed field through silently.
107
+ *
108
+ * @typedef {{entry?: string, name?: string, categories?: string[],
109
+ * edits?: object[]}} ToolArgs
110
+ *
111
+ * @param {string} tool
112
+ * @param {ToolArgs} args
113
+ * @returns {{ok: boolean, text: string, data?: object}}
114
+ */
115
+ #dispatch(tool, args) {
116
+ switch (tool) {
117
+ case "outline":
118
+ return outline(this.files, args.entry ?? this.entry);
119
+ case "refs":
120
+ return refs(this.files, args.entry ?? this.entry, args.name);
121
+ case "deps":
122
+ return deps(this.files, args.entry ?? this.entry);
123
+ case "grammar":
124
+ return grammar(args.categories);
125
+ case "validate":
126
+ return validate(this.files, args.entry ?? this.entry);
127
+ case "apply": {
128
+ const target = args.entry ?? this.entry;
129
+ const result = apply(this.files, target, args.edits);
130
+ // Staging on success is what keeps outline and apply on one snapshot.
131
+ if (result.ok)
132
+ this.files = /** @type {{files: Record<string, string>}} */ (result.data).files;
133
+ return result;
134
+ }
135
+ default:
136
+ return {
137
+ ok: false,
138
+ text: `no tool called "${tool}". Available: ${TOOL_SPECS.map((t) => t.name).join(", ")}`
139
+ };
140
+ }
141
+ }
142
+
143
+ /** @returns {string} The current content of the file under edit. */
144
+ content() {
145
+ return this.files[this.entry];
146
+ }
147
+
148
+ /** Discard all staged edits. @returns {void} */
149
+ reset() {
150
+ this.files = { ...this.files, [this.entry]: this.original };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The tool list rendered for a system prompt.
156
+ *
157
+ * Same discipline as the cheatsheet: concrete argument examples, never
158
+ * `[optional]` bracket notation, because small models copy schematic notation
159
+ * into their output verbatim.
160
+ *
161
+ * @returns {string}
162
+ */
163
+ export function toolPrompt() {
164
+ return [
165
+ "You have these tools. Call one at a time, as a single JSON object:",
166
+ '{"tool": "outline", "args": {}}',
167
+ "",
168
+ ...TOOL_SPECS.map((t) => `${t.name} args: ${t.args}\n ${t.use}`)
169
+ ].join("\n");
170
+ }