@graview/tools 0.1.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.
Files changed (111) hide show
  1. package/LICENSE +96 -0
  2. package/README.md +47 -0
  3. package/dist/agent/adapters.d.ts +43 -0
  4. package/dist/agent/adapters.d.ts.map +1 -0
  5. package/dist/agent/adapters.js +52 -0
  6. package/dist/agent/adapters.js.map +1 -0
  7. package/dist/agent/tools.d.ts +105 -0
  8. package/dist/agent/tools.d.ts.map +1 -0
  9. package/dist/agent/tools.js +311 -0
  10. package/dist/agent/tools.js.map +1 -0
  11. package/dist/cli.d.ts +37 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +306 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/conversation.d.ts +108 -0
  16. package/dist/conversation.d.ts.map +1 -0
  17. package/dist/conversation.js +698 -0
  18. package/dist/conversation.js.map +1 -0
  19. package/dist/decide.d.ts +43 -0
  20. package/dist/decide.d.ts.map +1 -0
  21. package/dist/decide.js +125 -0
  22. package/dist/decide.js.map +1 -0
  23. package/dist/derive.d.ts +84 -0
  24. package/dist/derive.d.ts.map +1 -0
  25. package/dist/derive.js +302 -0
  26. package/dist/derive.js.map +1 -0
  27. package/dist/edit.d.ts +70 -0
  28. package/dist/edit.d.ts.map +1 -0
  29. package/dist/edit.js +97 -0
  30. package/dist/edit.js.map +1 -0
  31. package/dist/figure.d.ts +53 -0
  32. package/dist/figure.d.ts.map +1 -0
  33. package/dist/figure.js +86 -0
  34. package/dist/figure.js.map +1 -0
  35. package/dist/index.d.ts +44 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +31 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/intelligence.d.ts +132 -0
  40. package/dist/intelligence.d.ts.map +1 -0
  41. package/dist/intelligence.js +463 -0
  42. package/dist/intelligence.js.map +1 -0
  43. package/dist/local.d.ts +192 -0
  44. package/dist/local.d.ts.map +1 -0
  45. package/dist/local.js +365 -0
  46. package/dist/local.js.map +1 -0
  47. package/dist/loop.d.ts +84 -0
  48. package/dist/loop.d.ts.map +1 -0
  49. package/dist/loop.js +173 -0
  50. package/dist/loop.js.map +1 -0
  51. package/dist/mcp-stdio.d.ts +38 -0
  52. package/dist/mcp-stdio.d.ts.map +1 -0
  53. package/dist/mcp-stdio.js +83 -0
  54. package/dist/mcp-stdio.js.map +1 -0
  55. package/dist/pins.d.ts +29 -0
  56. package/dist/pins.d.ts.map +1 -0
  57. package/dist/pins.js +60 -0
  58. package/dist/pins.js.map +1 -0
  59. package/dist/plan.d.ts +150 -0
  60. package/dist/plan.d.ts.map +1 -0
  61. package/dist/plan.js +303 -0
  62. package/dist/plan.js.map +1 -0
  63. package/dist/providers/insight.d.ts +14 -0
  64. package/dist/providers/insight.d.ts.map +1 -0
  65. package/dist/providers/insight.js +81 -0
  66. package/dist/providers/insight.js.map +1 -0
  67. package/dist/providers/invariant.d.ts +14 -0
  68. package/dist/providers/invariant.d.ts.map +1 -0
  69. package/dist/providers/invariant.js +123 -0
  70. package/dist/providers/invariant.js.map +1 -0
  71. package/dist/providers/jev.d.ts +115 -0
  72. package/dist/providers/jev.d.ts.map +1 -0
  73. package/dist/providers/jev.js +148 -0
  74. package/dist/providers/jev.js.map +1 -0
  75. package/dist/providers/lens.d.ts +24 -0
  76. package/dist/providers/lens.d.ts.map +1 -0
  77. package/dist/providers/lens.js +36 -0
  78. package/dist/providers/lens.js.map +1 -0
  79. package/dist/providers/llm.d.ts +25 -0
  80. package/dist/providers/llm.d.ts.map +1 -0
  81. package/dist/providers/llm.js +44 -0
  82. package/dist/providers/llm.js.map +1 -0
  83. package/dist/providers/schema.d.ts +12 -0
  84. package/dist/providers/schema.d.ts.map +1 -0
  85. package/dist/providers/schema.js +508 -0
  86. package/dist/providers/schema.js.map +1 -0
  87. package/dist/providers/structure.d.ts +14 -0
  88. package/dist/providers/structure.d.ts.map +1 -0
  89. package/dist/providers/structure.js +211 -0
  90. package/dist/providers/structure.js.map +1 -0
  91. package/dist/questions.d.ts +162 -0
  92. package/dist/questions.d.ts.map +1 -0
  93. package/dist/questions.js +316 -0
  94. package/dist/questions.js.map +1 -0
  95. package/dist/run.d.ts +202 -0
  96. package/dist/run.d.ts.map +1 -0
  97. package/dist/run.js +453 -0
  98. package/dist/run.js.map +1 -0
  99. package/dist/space.d.ts +50 -0
  100. package/dist/space.d.ts.map +1 -0
  101. package/dist/space.js +55 -0
  102. package/dist/space.js.map +1 -0
  103. package/dist/types.d.ts +122 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/usage.d.ts +10 -0
  108. package/dist/usage.d.ts.map +1 -0
  109. package/dist/usage.js +59 -0
  110. package/dist/usage.js.map +1 -0
  111. package/package.json +61 -0
@@ -0,0 +1,698 @@
1
+ import { formFields, humaniseField, nounOf, tellApart, labelOf, readableFields, search, violationsTouching, withArticle, } from "@graview/core";
2
+ import { droppedProposals, firstJsonObject, resolveProposal, validateProposals, } from "./intelligence.js";
3
+ const sentence = (parts) => parts.filter(Boolean).join(" ");
4
+ /** The words around a bare name that still only ask about it: "tell me about the School run". */
5
+ const ASKING_WORDS = new Set(["tell", "me", "about", "what", "how", "show", "describe", "the", "a", "an", "and", "please"]);
6
+ /*
7
+ * The words around what is being looked for: "where is the van", "anything
8
+ * about moving". Dropped before the search, so the matcher is handed the
9
+ * thing and not the asking.
10
+ */
11
+ const LOOKING_WORDS = new Set([
12
+ ...ASKING_WORDS,
13
+ "where", "is", "are", "was", "find", "any", "anything", "something", "things", "called", "named",
14
+ "which", "who", "of", "for", "with", "to", "in", "on", "i", "my", "do", "does", "have", "has",
15
+ "can", "you", "it", "that", "this", "there", "we", "our", "got", "be",
16
+ ]);
17
+ /** "a person", "a person and a date" — a list a person would say out loud. */
18
+ const withList = (words) => words.length <= 1
19
+ ? withArticle(words[0] ?? "something")
20
+ : `${words.slice(0, -1).map(withArticle).join(", ")} and ${withArticle(words[words.length - 1])}`;
21
+ /**
22
+ * The graph answers for itself. Deterministic, keyless, derived:
23
+ * - "what's wrong / broken / problems" → the standing, with ready repairs;
24
+ * - a node named in the message (or selected) → its facts, its trouble,
25
+ * and that trouble's repairs;
26
+ * - a mutation's own title phrased in the message → that call, proposed
27
+ * with what can honestly be filled and nothing guessed;
28
+ * - anything else → the shape of the graph and how to ask.
29
+ */
30
+ export function graphResponder(options = {}) {
31
+ return async (store, text, context = {}) => {
32
+ const asked = text.toLowerCase();
33
+ const name = (node) => labelOf(store.schema.tryDefinition(node.kind), node);
34
+ /*
35
+ * REFERENTS: the selection first — "this" means what is selected — then
36
+ * any node whose label appears in the message, longest label first so
37
+ * "Morning school run" wins over "run".
38
+ */
39
+ const referents = [];
40
+ /*
41
+ * Matching is TOKEN-ALIGNED and punctuation-blind, or names fail for
42
+ * the dumbest reasons: a child stored as "child2" must be found by
43
+ * "child 2", and "child2's nap" by "child 2 nap". Both sides tokenize
44
+ * on non-alphanumerics; a label matches when its squeezed form equals
45
+ * some run of adjacent message tokens joined — token alignment is what
46
+ * keeps "Bo" out of "elbow".
47
+ */
48
+ const tokens = asked.split(/[^a-z0-9]+/).filter(Boolean);
49
+ const runs = new Set();
50
+ for (let start = 0; start < tokens.length; start++) {
51
+ let joined = "";
52
+ for (let end = start; end < Math.min(tokens.length, start + 6); end++) {
53
+ joined += tokens[end];
54
+ runs.add(joined);
55
+ }
56
+ }
57
+ const squeeze = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "");
58
+ const byLabel = [...store.graph.allNodes()]
59
+ .map((node) => ({ node, label: squeeze(name(node)) }))
60
+ .filter(({ label }) => label.length >= 2 && runs.has(label))
61
+ .sort((a, b) => b.label.length - a.label.length);
62
+ /*
63
+ * A NAME SEVERAL THINGS SHARE NAMES NONE OF THEM. "Tell me about the
64
+ * 2026 Tesla Model Y Performance" on a lot with three was answered
65
+ * with the first one found and said nothing about the other two; the
66
+ * words find them all below, each told apart.
67
+ */
68
+ const shared = new Map();
69
+ for (const { label } of byLabel)
70
+ shared.set(label, (shared.get(label) ?? 0) + 1);
71
+ for (const { node, label } of byLabel) {
72
+ if ((shared.get(label) ?? 0) > 1)
73
+ continue;
74
+ if (!referents.some((held) => held.id === node.id))
75
+ referents.push(node);
76
+ }
77
+ /*
78
+ * The SELECTION comes after anything the message NAMED. "This" means
79
+ * what is selected — but a question that says "left back" out loud is
80
+ * about left back, and a standing selection answering it instead was
81
+ * the seat confidently describing the wrong thing.
82
+ */
83
+ for (const id of context.selection ?? []) {
84
+ const node = store.graph.getNode(id);
85
+ if (node && !referents.some((held) => held.id === node.id))
86
+ referents.push(node);
87
+ }
88
+ const violations = store.violations();
89
+ const readyRepairs = (subset = violations) => subset.flatMap((violation) => {
90
+ const repair = violation.repairs.find((candidate) => !candidate.missing?.length);
91
+ return repair
92
+ ? [{ mutation: repair.mutation, args: { ...repair.args }, why: violation.message }]
93
+ : [];
94
+ });
95
+ // ------------------------------------------------------- the standing
96
+ if (/\b(wrong|broken|problem|violat|standing)\b/.test(asked)) {
97
+ if (violations.length === 0) {
98
+ return { say: "Nothing is broken — every declared rule holds.", proposals: [], grounded: true };
99
+ }
100
+ /*
101
+ * THE SENTENCE MATCHES WHAT IS ACTUALLY BELOW IT.
102
+ *
103
+ * "The repairs below come from the rules themselves" was said
104
+ * unconditionally, and `readyRepairs` drops every repair that still
105
+ * needs an argument chosen — so a rule whose repair asks for one thing
106
+ * ("hand it to someone": which someone is the decision the rule cannot
107
+ * make) produced a seat promising repairs under an empty list. Naming
108
+ * what the repair still wants is the honest answer, and it is the same
109
+ * answer the phrased-mutation branch below already gives.
110
+ */
111
+ const offered = validateProposals(store, readyRepairs().slice(0, 4));
112
+ /*
113
+ * And what it wants is said the way a picker is named — by the KIND it
114
+ * would pick, not by the argument's identifier. Otherwise the seat
115
+ * asks for "a handler" where the strip and the routed face both say
116
+ * "a person".
117
+ */
118
+ const asked = (mutation, field) => {
119
+ const declared = store.allMutations().find((candidate) => candidate.name === mutation);
120
+ const spec = declared
121
+ ? formFields(declared.input).find((candidate) => candidate.name === field)
122
+ : undefined;
123
+ return spec?.control === "node" && !spec.kinds.includes("*")
124
+ ? spec.kinds.map((kind) => nounOf(store.schema.tryDefinition(kind), kind)).join(" or ")
125
+ : humaniseField(field).toLowerCase();
126
+ };
127
+ const wants = [
128
+ ...new Set(violations
129
+ .flatMap((violation) => violation.repairs)
130
+ .flatMap((repair) => (repair.missing ?? []).map((field) => asked(repair.mutation, field)))),
131
+ ];
132
+ return {
133
+ say: sentence([
134
+ `${violations.length} ${violations.length === 1 ? "problem" : "problems"}:`,
135
+ violations
136
+ .slice(0, 4)
137
+ .map((violation) => violation.message)
138
+ .join("; ") + (violations.length > 4 ? "…" : "."),
139
+ offered.length > 0
140
+ ? "The repairs below come from the rules themselves."
141
+ : wants.length > 0
142
+ ? `The rules name a way to fix ${violations.length === 1 ? "it" : "these"}, but it needs ${withList(wants)} chosen — select the record and its own actions will ask.`
143
+ : "No rule here names a way to fix it.",
144
+ ]),
145
+ proposals: offered,
146
+ grounded: true,
147
+ };
148
+ }
149
+ // -------------------------------------------- a mutation, said in words
150
+ /*
151
+ * A QUESTION IS NEVER A CHANGE. "what depends on Pay the deposit?"
152
+ * carries the title of an act ("Depends on") and the name of a record,
153
+ * and was answered with a proposal to RUN that act on the record — an
154
+ * apply button under a question, which is the graph answering wrongly
155
+ * rather than not at all. A sentence that asks is answered from the
156
+ * graph below; only a sentence that says does anything.
157
+ */
158
+ const question = /\?\s*$/.test(text) ||
159
+ /^\s*(what|who|whom|whose|which|when|where|why|how|is|are|was|were|does|do|did|can|could|should|would|will|has|have)\b/.test(asked);
160
+ const phrased = question
161
+ ? undefined
162
+ : store.allMutations().find((mutation) => {
163
+ const title = (mutation.title ?? mutation.name).toLowerCase();
164
+ return title.length > 3 && asked.includes(title);
165
+ });
166
+ if (phrased) {
167
+ const args = {};
168
+ const missing = [];
169
+ const quoted = text.match(/"([^"]+)"/)?.[1];
170
+ // Each thing named fills ONE blank: the same record in both ends of a
171
+ // tie is an act that cannot act, and never what was said.
172
+ const unused = [...referents];
173
+ for (const field of formFields(phrased.input)) {
174
+ const value = answerFrom(field, unused, quoted, options.today, asked);
175
+ if (value !== undefined) {
176
+ args[field.name] = value;
177
+ const at = unused.findIndex((node) => node.id === value);
178
+ if (at >= 0)
179
+ unused.splice(at, 1);
180
+ }
181
+ else if (!field.optional)
182
+ missing.push(field.name);
183
+ }
184
+ /*
185
+ * A READING, NOT A FACT. This branch matched an act's title in a
186
+ * sentence and filled what it honestly could — right often enough to
187
+ * be the floor, and not so right that a model should be kept out of
188
+ * it. Marked grounded, it stopped the ladder dead: a person with a
189
+ * model chosen still got the pattern-matcher's single act out of a
190
+ * sentence that described three.
191
+ */
192
+ if (missing.length === 0) {
193
+ return {
194
+ say: `I can do that. Review it below.`,
195
+ proposals: validateProposals(store, [
196
+ { mutation: phrased.name, args, why: `you asked in words` },
197
+ ]),
198
+ };
199
+ }
200
+ return {
201
+ say: `"${phrased.title ?? phrased.name}" needs ${missing.join(", ")} — name the ${missing.length === 1 ? "thing" : "things"} (or select ${missing.length === 1 ? "it" : "them"}) and ask again.`,
202
+ proposals: [],
203
+ };
204
+ }
205
+ /*
206
+ * ------------------------------------------- when / who, from the graph
207
+ *
208
+ * The declarations already answer these. WHEN: field roles name which
209
+ * fields are a thing's start, end and day, and display.format says how
210
+ * to speak them. WHO: an edge whose own description says "who…" IS the
211
+ * who-relation — "who does the run", "who is there" — so following it
212
+ * from the right node answers without knowing what a run is. The right
213
+ * node is chosen honestly: the referent itself when it is timed, else
214
+ * the referent's neighbour that best matches the question's remaining
215
+ * words and any day named.
216
+ */
217
+ const DAY_WORDS = {
218
+ mon: "mon", monday: "mon", tue: "tue", tues: "tue", tuesday: "tue",
219
+ wed: "wed", wednesday: "wed", thu: "thu", thur: "thu", thurs: "thu", thursday: "thu",
220
+ fri: "fri", friday: "fri", sat: "sat", saturday: "sat", sun: "sun", sunday: "sun",
221
+ };
222
+ const askedDay = tokens.map((token) => DAY_WORDS[token]).find(Boolean);
223
+ const formatOf = (kind, field, value) => {
224
+ const format = store.schema.tryDefinition(kind)?.display?.format?.[field];
225
+ return format ? format(value) : String(value);
226
+ };
227
+ const timing = (node) => {
228
+ const roles = store.schema.tryDefinition(node.kind)?.fieldRoles;
229
+ if (!roles?.["start"])
230
+ return null;
231
+ const start = node[roles["start"]];
232
+ const end = roles["end"] ? node[roles["end"]] : undefined;
233
+ const day = roles["day"] ? node[roles["day"]] : roles["days"] ? node[roles["days"]] : undefined;
234
+ if (start === undefined && end === undefined)
235
+ return null;
236
+ const said = [
237
+ start !== undefined ? formatOf(node.kind, roles["start"], start) : null,
238
+ end !== undefined && roles["end"] ? `–${formatOf(node.kind, roles["end"], end)}` : null,
239
+ Array.isArray(day) ? ` on ${day.join(", ")}` : day !== undefined ? ` on ${String(day)}` : null,
240
+ ]
241
+ .filter(Boolean)
242
+ .join("");
243
+ return said || null;
244
+ };
245
+ const neighboursOf = (id) => {
246
+ const out = [];
247
+ for (const edge of store.graph.allEdges()) {
248
+ const other = edge.from === id ? edge.to : edge.to === id ? edge.from : null;
249
+ if (!other)
250
+ continue;
251
+ const found = store.graph.getNode(other);
252
+ if (found && !out.some((held) => held.id === found.id))
253
+ out.push(found);
254
+ }
255
+ return out;
256
+ };
257
+ /**
258
+ * The question's subjects, best first: the referent's neighbours ranked
259
+ * by how many of the question's words their name shares and — when a
260
+ * day is named — how exactly they sit on that day. An exact day FIELD
261
+ * outranks a days array that merely contains it: "tuesday" means the
262
+ * Tuesday one, not everything that also happens on Tuesdays.
263
+ */
264
+ const subjectsFor = (wantTimed) => {
265
+ const first = referents[0];
266
+ if (!first)
267
+ return [];
268
+ const scored = [];
269
+ for (const candidate of neighboursOf(first.id)) {
270
+ let score = 0;
271
+ const squeezed = squeeze(name(candidate));
272
+ for (const token of tokens) {
273
+ if (token.length > 2 && squeezed.includes(token))
274
+ score += 1;
275
+ }
276
+ if (askedDay) {
277
+ const roles = store.schema.tryDefinition(candidate.kind)?.fieldRoles;
278
+ const day = roles?.["day"] ? candidate[roles["day"]] : undefined;
279
+ const days = roles?.["days"] ? candidate[roles["days"]] : undefined;
280
+ if (day === askedDay)
281
+ score += 4;
282
+ else if (Array.isArray(days) && days.includes(askedDay))
283
+ score += 1;
284
+ }
285
+ if (wantTimed && !timing(candidate))
286
+ continue;
287
+ if (score > 0)
288
+ scored.push({ node: candidate, score });
289
+ }
290
+ const ranked = scored.sort((a, b) => b.score - a.score).map((held) => held.node);
291
+ if (wantTimed && timing(first))
292
+ ranked.unshift(first);
293
+ else if (!wantTimed)
294
+ ranked.push(first);
295
+ return ranked;
296
+ };
297
+ if (referents.length > 0 && /\bwhen\b/.test(asked)) {
298
+ const subject = subjectsFor(true)[0] ?? null;
299
+ const said = subject ? timing(subject) : null;
300
+ if (subject && said) {
301
+ return {
302
+ say: `${name(subject)} runs ${said}.`,
303
+ proposals: validateProposals(store, readyRepairs(violationsTouching(violations, [subject.id])).slice(0, 3)),
304
+ grounded: true,
305
+ };
306
+ }
307
+ }
308
+ if (referents.length > 0 && /\bwho(m|se)?\b/.test(asked)) {
309
+ /*
310
+ * A subject that cannot answer "who" is the wrong subject: walk the
311
+ * ranked candidates until one actually has who-edges. The edge's own
312
+ * description is the sentence — and its declared `inverse` ("who is
313
+ * there") counts as a who-sentence read from the other end.
314
+ */
315
+ for (const subject of subjectsFor(false)) {
316
+ const parts = [];
317
+ const said = new Set();
318
+ for (const definition of store.schema.definitions) {
319
+ for (const [edgeKind, spec] of Object.entries(definition.edges)) {
320
+ /*
321
+ * Whoever declared the edge, the who-things are the nodes on
322
+ * the OTHER side of the subject — in-neighbours when the edge
323
+ * points at the subject, out-neighbours when it points away —
324
+ * and the sentence is whichever declared line says "who".
325
+ */
326
+ const forward = /\bwho\b/i.test(spec.description ?? "");
327
+ const backward = /\bwho\b/i.test(spec.inverse ?? "");
328
+ if ((!forward && !backward) || said.has(edgeKind))
329
+ continue;
330
+ said.add(edgeKind);
331
+ const others = [
332
+ ...store.graph.in(subject.id, edgeKind),
333
+ ...store.graph.out(subject.id, edgeKind),
334
+ ];
335
+ if (others.length === 0)
336
+ continue;
337
+ const sentence = forward ? spec.description : spec.inverse;
338
+ parts.push(`${sentence}: ${others.map((other) => name(other)).join(", ")}`);
339
+ }
340
+ }
341
+ if (parts.length > 0) {
342
+ return {
343
+ say: `${name(subject)} — ${parts.join("; ")}.`,
344
+ proposals: validateProposals(store, readyRepairs(violationsTouching(violations, [subject.id])).slice(0, 3)),
345
+ grounded: true,
346
+ };
347
+ }
348
+ }
349
+ }
350
+ // ------------------------------------------------------ a named thing
351
+ if (referents.length > 0) {
352
+ const node = referents[0];
353
+ /*
354
+ * NAMING A THING IS NOT ASKING ABOUT IT. "Erin tends that plot" names
355
+ * Erin and says a change; answered as a fact — "Erin — a gardener.
356
+ * Connected to nothing yet." — and marked grounded, it kept the model
357
+ * from ever reading it, and the seat described the gardener it had
358
+ * just been told to connect. The description is still the floor's
359
+ * best answer, but only a question, or the bare name, is a FACT.
360
+ */
361
+ const own = new Set(name(node).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
362
+ const bare = tokens.every((token) => own.has(token) || ASKING_WORDS.has(token));
363
+ const asking = question || bare;
364
+ const definition = store.schema.tryDefinition(node.kind);
365
+ /*
366
+ * Each fact as it reads on its own, its LABEL lower-cased to sit in
367
+ * brackets — "explicit: no", "year 2026" — and never its value: "tesla"
368
+ * is a make somebody spelt with a capital. A bare value says what it
369
+ * is, "make Tesla", "phone (555) 298-1878", or a VIN reads as noise.
370
+ */
371
+ const quietly = (label) => (label === label.toUpperCase() ? label : label.charAt(0).toLowerCase() + label.slice(1));
372
+ const facts = readableFields(node, definition, { limit: 3 })
373
+ .map((field) => field.alone.startsWith(field.label) ? quietly(field.label) + field.alone.slice(field.label.length) : `${quietly(field.label)} ${field.alone}`)
374
+ .join(", ");
375
+ const touching = violationsTouching(violations, [node.id]);
376
+ /*
377
+ * The relations, in the declarations' own words — "where they can
378
+ * play: Goalkeeper, Left back" answers "what about Bo" the way a
379
+ * person would, instead of a degree count.
380
+ */
381
+ const groups = new Map();
382
+ for (const edge of [...store.graph.allEdges()]) {
383
+ const direction = edge.from === node.id ? "out" : edge.to === node.id ? "in" : null;
384
+ if (!direction)
385
+ continue;
386
+ const other = store.graph.getNode(direction === "out" ? edge.to : edge.from);
387
+ if (!other)
388
+ continue;
389
+ const key = `${edge.kind}|${direction}`;
390
+ if (!groups.has(key)) {
391
+ /*
392
+ * Read from the end you are standing on. An edge is declared on
393
+ * the kind at its `from` end, so its `description` is the reading
394
+ * from there and its `inverse` the reading from the `to` end —
395
+ * whichever kind this node is. "What is Ada seeing to?" was
396
+ * answered "who is seeing to it: Pay the deposit", the item's
397
+ * words in the person's mouth, because the caption was chosen by
398
+ * which KIND declared the edge rather than by which END the node
399
+ * is at.
400
+ */
401
+ let said;
402
+ for (const definition of store.schema.definitions) {
403
+ const spec = definition.edges[edge.kind];
404
+ if (!spec)
405
+ continue;
406
+ said = direction === "out" ? spec.description : (spec.inverse ?? spec.description);
407
+ if (said)
408
+ break;
409
+ }
410
+ groups.set(key, { sentence: said ?? edge.kind.replace(/-/g, " "), names: [] });
411
+ }
412
+ const group = groups.get(key);
413
+ if (group.names.length < 6)
414
+ group.names.push(name(other));
415
+ }
416
+ const related = [...groups.values()]
417
+ .slice(0, 4)
418
+ // Each is a sentence of its own, so it starts like one: "The releases it is on: Blue Hour."
419
+ .map((group) => `${group.sentence.charAt(0).toUpperCase()}${group.sentence.slice(1)}: ${group.names.join(", ")}`)
420
+ .join(". ");
421
+ return {
422
+ say: sentence([
423
+ `${name(node)} — ${withArticle(nounOf(store.schema.tryDefinition(node.kind), node.kind))}${facts ? ` (${facts})` : ""}.`,
424
+ related ? `${related}.` : "Connected to nothing yet.",
425
+ touching.length > 0
426
+ ? `Trouble: ${touching.map((violation) => violation.message).join("; ")}.`
427
+ : "Nothing about it is broken.",
428
+ ]),
429
+ proposals: validateProposals(store, readyRepairs(touching).slice(0, 3)),
430
+ ...(asking ? { grounded: true } : {}),
431
+ };
432
+ }
433
+ // --------------------------------------------------- what the words find
434
+ /*
435
+ * NO ACT AND NO FACT, BUT THE WORDS FIND THINGS. "Where is the van" names
436
+ * nothing by its whole name and asks for no change — and the Find box
437
+ * would have answered it at once. The same matcher, the same seat's
438
+ * view: the reply is the strip in prose, each thing a press. Not a
439
+ * grounded answer — a model may yet read the sentence better.
440
+ */
441
+ {
442
+ const words = asked.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 0 && !LOOKING_WORDS.has(word));
443
+ if (words.length > 0) {
444
+ const found = search(store, words.join(" "), {
445
+ ...(context.principal ? { principal: context.principal } : {}),
446
+ ...(context.selection ? { from: context.selection } : {}),
447
+ ...(options.today ? { today: options.today } : {}),
448
+ limit: 6,
449
+ });
450
+ const picks = found.hits.filter((hit) => hit.about === "node");
451
+ if (picks.length > 0) {
452
+ // Two of one name are told apart: "2025 Subaru Outback Base · VIN 3VP1…" (the W-095 rule, in prose).
453
+ const apart = tellApart(picks.flatMap((hit) => {
454
+ const node = store.graph.getNode(hit.id);
455
+ return node ? [node] : [];
456
+ }), (kind) => store.schema.tryDefinition(kind));
457
+ const said = picks.map((hit) => `${hit.label}${apart.has(hit.id) ? ` · ${apart.get(hit.id)}` : ""} (${nounOf(store.schema.tryDefinition(hit.kind), hit.kind)}${hit.why.field === "label" ? "" : `, ${hit.why.reading.toLowerCase()}: ${hit.why.fragment}`})`);
458
+ const how = found.total === 1 ? "One thing is" : `${numberWord(found.total)} things are`;
459
+ return {
460
+ say: `${how} called “${found.words}” — ${said.join("; ")}${found.total > picks.length ? `; and ${found.total - picks.length} more` : ""}.`,
461
+ proposals: [],
462
+ picks,
463
+ };
464
+ }
465
+ }
466
+ }
467
+ // ------------------------------------------------------------ the shape
468
+ const counts = store.schema.kinds
469
+ .filter((kind) => !store.modules.disabledKinds.has(kind))
470
+ .map((kind) => {
471
+ const plural = store.schema.tryDefinition(kind)?.plural ?? `${kind}s`;
472
+ return `${store.graph.nodesOfKind(kind).length} ${plural}`;
473
+ })
474
+ .join(", ");
475
+ return {
476
+ say: sentence([
477
+ `This graph holds ${counts}.`,
478
+ `Ask about anything by name, ask what's wrong, or say a change in its own words —`,
479
+ `like "${store.allMutations()[0]?.title ?? "an action"}".`,
480
+ ]),
481
+ proposals: [],
482
+ };
483
+ };
484
+ }
485
+ const NUMBER_WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
486
+ const numberWord = (count) => NUMBER_WORDS[count] ?? String(count);
487
+ /** One honest answer for one form field, or undefined — never a guess. */
488
+ function answerFrom(field, referents, quoted, today,
489
+ /** The sentence, lower-cased: a choice is answered by its own word in it. */
490
+ asked = "") {
491
+ switch (field.control) {
492
+ case "node": {
493
+ const match = referents.find((node) => field.kinds.includes("*") || field.kinds.includes(node.kind));
494
+ return match?.id;
495
+ }
496
+ case "text":
497
+ return quoted;
498
+ case "date": {
499
+ const day = today ?? new Date().toISOString().slice(0, 10);
500
+ // A sentence that names no hour still has to give one when the act asks for a time of day.
501
+ return field.time ? `${day}T09:00` : day;
502
+ }
503
+ /*
504
+ * A CHOICE IS ITS OWN WORD. "Give a role keeper to Sam" names the role
505
+ * the way a person would, and a closed set of options is the one kind
506
+ * of argument a sentence can settle exactly: the option that appears
507
+ * as a whole word is the answer; two of them, or none, is no answer.
508
+ */
509
+ case "choice": {
510
+ const words = new Set(asked.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
511
+ const named = (field.options ?? []).filter((option) => option
512
+ .toLowerCase()
513
+ .split(/[^a-z0-9]+/)
514
+ .filter(Boolean)
515
+ .every((part) => words.has(part)));
516
+ return named.length === 1 ? named[0] : undefined;
517
+ }
518
+ default:
519
+ return undefined;
520
+ }
521
+ }
522
+ /**
523
+ * A model holds the conversation, through the same one-function seam and
524
+ * the same validation gate as every other model use. The reply contract is
525
+ * JSON — something to say, calls to propose — and an unparseable answer
526
+ * degrades to words with no proposals rather than to guesses.
527
+ */
528
+ export function llmResponder(options) {
529
+ return async (store, text, context = {}) => {
530
+ /*
531
+ * EACH ACT WITH WHAT IT TAKES. Listed by name and description alone, a
532
+ * model proposed `add-plot` with no label and no beds, and the person
533
+ * was handed a form for what they had just said in words. The same
534
+ * form fields the menu asks with, said as a signature.
535
+ */
536
+ const argument = (field) => {
537
+ const type = field.control === "node"
538
+ ? `name of ${field.kinds.includes("*") ? "anything" : field.kinds.join(" or ")}`
539
+ : field.control === "choice"
540
+ ? (field.options ?? []).map((option) => JSON.stringify(option)).join(" | ")
541
+ : field.control === "date"
542
+ ? field.time
543
+ ? "YYYY-MM-DDTHH:MM"
544
+ : "YYYY-MM-DD"
545
+ : field.control === "text" || field.control === "number" || field.control === "boolean"
546
+ ? field.control
547
+ : "value";
548
+ return `${field.name}${field.optional ? "?" : ""}: ${type}`;
549
+ };
550
+ const mutations = store
551
+ .allMutations()
552
+ .map((mutation) => `- ${mutation.name}(${formFields(mutation.input).map(argument).join(", ")}): ${mutation.description ?? mutation.title ?? ""}`)
553
+ .join("\n");
554
+ /*
555
+ * WHAT IS ACTUALLY IN HERE, BY NAME.
556
+ *
557
+ * A model that cannot see the names cannot use them: asked to add a
558
+ * field to Meal it answers `{"kind": "Meal"}` and hopes, because the
559
+ * prompt listed the acts and never the things. A bounded sample per
560
+ * kind is enough to name anything a sentence is likely to mention, and
561
+ * `resolveProposal` turns whichever name it picks into the id.
562
+ */
563
+ const shape = store.schema.kinds
564
+ .filter((kind) => !store.modules.disabledKinds.has(kind))
565
+ .map((kind) => {
566
+ const definition = store.schema.tryDefinition(kind);
567
+ const members = store.graph.nodesOfKind(kind);
568
+ const names = members
569
+ .slice(0, 12)
570
+ .map((node) => labelOf(definition, node))
571
+ .join(", ");
572
+ return `- ${kind} (${definition?.plural ?? `${kind}s`}, ${members.length}): ${names}${members.length > 12 ? ", …" : ""}`;
573
+ })
574
+ .join("\n");
575
+ /*
576
+ * HOW THINGS ARE CONNECTED. A model told "move it to Erin" can only
577
+ * answer from connections it can see; a bounded list is enough for the
578
+ * graphs a conversation is held over.
579
+ */
580
+ const nameOf = (id) => {
581
+ const node = store.graph.getNode(id);
582
+ return node ? labelOf(store.schema.tryDefinition(node.kind), node) : id;
583
+ };
584
+ const edges = [...store.graph.allEdges()];
585
+ const connections = edges
586
+ .slice(0, 40)
587
+ .map((edge) => `- ${nameOf(edge.from)} —${edge.kind}→ ${nameOf(edge.to)}`)
588
+ .join("\n");
589
+ const selected = (context.selection ?? [])
590
+ .map((id) => {
591
+ const node = store.graph.getNode(id);
592
+ return node ? `${id} (${labelOf(store.schema.tryDefinition(node.kind), node)})` : id;
593
+ })
594
+ .join(", ");
595
+ const history = (context.history ?? [])
596
+ .slice(-8)
597
+ .map((turn) => `${turn.role === "person" ? "Person" : "You"}: ${turn.text}`)
598
+ .join("\n");
599
+ const trouble = store
600
+ .violations()
601
+ .map((violation) => `- ${violation.message}`)
602
+ .join("\n");
603
+ /*
604
+ * The floor's reading, offered as a starting point rather than a rule.
605
+ * "Keep it, correct it, or split it" is the instruction that turns one
606
+ * loose sentence into the two or three acts it actually described.
607
+ */
608
+ const reading = (context.reading ?? [])
609
+ .map((proposal) => `- ${proposal.mutation} ${JSON.stringify(proposal.args)}`)
610
+ .join("\n");
611
+ const prompt = [
612
+ [
613
+ "You are the seat of a typed context graph. You answer questions about it and turn requests for change into proposals of its declared mutations, which the person reviews and applies.",
614
+ "- Fill every argument a mutation takes. Refer to things by their exact name as listed below.",
615
+ "- When a request needs something that does not exist yet, propose creating it first, then refer to it by the name you gave it. Choose sensible names and numbers rather than leaving them out.",
616
+ '- "it", "that" and "this" mean what the conversation or the selection points at.',
617
+ '- "say" is one short sentence in plain words. Do not restate the proposals in it; they are shown beneath it.',
618
+ "- A question gets an answer and no proposals.",
619
+ ].join("\n"),
620
+ `Today is ${new Date().toISOString().slice(0, 10)}.`,
621
+ `Mutations:\n${mutations}`,
622
+ shape ? `What is in the graph now:\n${shape}` : "",
623
+ connections ? `How they are connected:\n${connections}${edges.length > 40 ? "\n- …" : ""}` : "",
624
+ reading
625
+ ? `A first reading of this request, worked out from the graph:\n${reading}\nKeep it, correct it, or split it into several — one proposal per distinct change the person described.`
626
+ : "Where a request describes several changes, answer with several proposals — one per distinct change.",
627
+ trouble ? `Currently broken:\n${trouble}` : "Nothing is broken.",
628
+ selected ? `Selected right now (what "this" means): ${selected}` : "Nothing is selected.",
629
+ history ? `Conversation so far:\n${history}` : "",
630
+ `Person: ${text}`,
631
+ 'Answer ONLY JSON: {"say": string, "proposals": [{"mutation": string, "args": object, "why": string}]}.',
632
+ ]
633
+ .filter(Boolean)
634
+ .join("\n\n");
635
+ const answer = await options.complete(prompt);
636
+ const read = firstJsonObject(answer);
637
+ /*
638
+ * A bare array IS the list of proposals — the shape a model reaches for
639
+ * about as often as the one it was asked for.
640
+ */
641
+ const parsed = (Array.isArray(read) ? { proposals: read } : read);
642
+ /*
643
+ * A PERSON IS NEVER SHOWN THE PLUMBING.
644
+ *
645
+ * The contract is JSON, and the old fallback put the raw answer in the
646
+ * bubble when it would not parse — so a model that closed one brace too
647
+ * many produced a chat message reading `{"say": "Yes", "proposals":
648
+ * [{"mutation": "add-field", …}]}}`. `firstJsonObject` reads the object
649
+ * the model meant; where there is no object at all, a model that was
650
+ * asked for JSON and wrote prose is answering in the wrong shape, and
651
+ * saying so is better than pasting it.
652
+ */
653
+ if (!parsed || typeof parsed !== "object") {
654
+ const prose = answer.trim();
655
+ const looksLikeJson = prose.startsWith("{") || prose.startsWith("[");
656
+ return {
657
+ say: prose.length > 0 && !looksLikeJson
658
+ ? prose
659
+ : "The model answered in a shape I could not read. Ask again, or try a different one from the gear.",
660
+ proposals: [],
661
+ };
662
+ }
663
+ /*
664
+ * And a model names things the way a person does — "Meal", not
665
+ * `declared:meal` — so a label that means exactly one node is read as
666
+ * that node before the gate sees it.
667
+ */
668
+ const offered = (parsed.proposals ?? []).map((proposal) => resolveProposal(store, proposal));
669
+ const kept = validateProposals(store, offered, options.may);
670
+ /*
671
+ * WHAT THE GATE TOOK OUT IS SAID OUT LOUD. A model that answers "Sure"
672
+ * and names an act this app does not have used to leave a sentence with
673
+ * nothing under it: no proposal, no refusal, no way to tell whether the
674
+ * seat had understood. Silence is the one answer that cannot be acted
675
+ * on.
676
+ */
677
+ const dropped = droppedProposals(store, offered, options.may);
678
+ const unknown = dropped.filter((one) => one.why === "unknown").map((one) => one.proposal.mutation);
679
+ const barred = dropped.filter((one) => one.why === "not-allowed").map((one) => one.proposal.mutation);
680
+ const aside = [
681
+ unknown.length > 0
682
+ ? `It also suggested ${unknown.map((name) => `"${name}"`).join(", ")}, which this app has no act for.`
683
+ : "",
684
+ barred.length > 0
685
+ ? `${barred.map((name) => `"${name}"`).join(", ")} is not something this seat may run.`
686
+ : "",
687
+ kept.length === 0 && offered.length > 0 ? "Nothing it suggested can be applied here." : "",
688
+ ]
689
+ .filter(Boolean)
690
+ .join(" ");
691
+ const said = typeof parsed.say === "string" && parsed.say.length > 0 ? parsed.say : "";
692
+ return {
693
+ say: [said, aside].filter(Boolean).join(" ") || "…",
694
+ proposals: kept,
695
+ };
696
+ };
697
+ }
698
+ //# sourceMappingURL=conversation.js.map