@konneal/engine 0.2.135 → 0.2.137

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.
@@ -627,7 +627,12 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
627
627
  let understanding: any = null;
628
628
  // the loose redirect-offer family's gate (refusal.ts): knowledge-seek
629
629
  // questions only, uncited answers only — a conversational turn's
630
- // scope vocabulary is the answer itself
630
+ // scope vocabulary is the answer itself. "Cited" is the ANSWER TEXT
631
+ // carrying citation markers, NEVER the prompt's hit count: retrieval
632
+ // always returns passages (the model just declines to cite them on a
633
+ // refusal) — gating on usedHits left every refusal "cited" and the
634
+ // wall dark through three deploys (2026-10-11)
635
+ const answerCites = (t: string) => /\[[^\]]+\]/.test(t);
631
636
  const refusalScope = (cited: boolean) => ({ cited, conversational: understanding?.intent === "conversational" });
632
637
  // a query naming a model node (/req/…, /term/…) is node-SCOPED: its
633
638
  // ── Option C: optimistic parallel retrieval ──
@@ -1421,28 +1426,52 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1421
1426
  // the tool call — one reader, one tool, no parallel extraction racing a
1422
1427
  // second vision pass that drifts hour to hour. The old probe dropped the
1423
1428
  // image-bearing message, which is why the private bridge had to exist.
1424
- if (multiTurn && /certificate|certified|certification/i.test(q.query) && env.DB) {
1429
+ if (multiTurn && env.DB) {
1425
1430
  try {
1426
1431
  const imageTurn = typeof withImage === "string";
1427
1432
  if (imageTurn) mark("Reading the photograph to identify the instrument");
1428
- const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}\n\n${imageTurn ? "A photograph of the instrument is attached after this message. Transcribe the maker, model and any printed certificate number you can read FROM THE PHOTOGRAPH ITSELF, then emit the TOOL line with those tokens as the query — never invented, and never tokens taken from any register content elsewhere in the context (a register row's holder is not the plate's maker; observed live 2026-10-04)." : ""}\n\n${q.query}`.replace("\n\n\n\n", "\n\n") }];
1429
- if (imageTurn) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1430
- const probe = await (env as any).AI.run(model, {
1431
- messages: probeMessages,
1432
- max_tokens: 600,
1433
- reasoning_effort: imageTurn ? "high" : "low",
1434
- });
1435
- const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1436
- probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1437
- const call = parseToolCall(probeText);
1438
- if (call) {
1439
- mark(`Searching the certificate register for “${String(call.args?.query ?? "")}”`);
1433
+ // the loop v2 (the owner's 2026-10-12 direction): the MODEL decides
1434
+ // whether and what to query — no intent regex arms it. Three
1435
+ // bounded rounds; each result joins the messages ATTRIBUTED and the
1436
+ // next probe re-reads them (the model composes db.find predicates
1437
+ // over the published schema and corrects from schema-named
1438
+ // errors). Cross-turn memory: the conversation history above
1439
+ // already carries the prior answers (which quote their tool
1440
+ // results) — a follow-up reuses them instead of re-querying.
1441
+ // Native function-calling rides alongside the line protocol: when
1442
+ // the binding returns tool_calls, they dispatch identically.
1443
+ for (let round = 0; round < 3; round++) {
1444
+ const imageNote = imageTurn && round === 0
1445
+ ? "A photograph of the instrument is attached after this message. Transcribe the maker, model and any printed certificate number you can read FROM THE PHOTOGRAPH ITSELF, then emit the TOOL line with those tokens as the query — never invented, and never tokens taken from any register content elsewhere in the context (a register row's holder is not the plate's maker; observed live 2026-10-04)."
1446
+ : "";
1447
+ const continueNote = round > 0
1448
+ ? "The tool results above are in hand. Answer now without a TOOL line, or issue exactly one more TOOL line if one more query is genuinely needed."
1449
+ : "";
1450
+ const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: [TOOL_DECLARATION, imageNote, continueNote, q.query].filter(Boolean).join("\n\n") }];
1451
+ if (imageTurn && round === 0) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1452
+ const probe = await (env as any).AI.run(model, {
1453
+ messages: probeMessages,
1454
+ max_tokens: 700,
1455
+ reasoning_effort: imageTurn && round === 0 ? "high" : "low",
1456
+ });
1457
+ const probeText = typeof probe?.response === "string" ? probe?.response : probe?.choices?.[0]?.message?.content ?? "";
1458
+ probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1459
+ const nativeCall = Array.isArray((probe as any)?.tool_calls) ? (probe as any).tool_calls[0]?.function : null;
1460
+ const call = nativeCall
1461
+ ? {
1462
+ name: String(nativeCall.name ?? ""),
1463
+ args: (() => {
1464
+ try { return typeof nativeCall.arguments === "string" ? JSON.parse(nativeCall.arguments) : (nativeCall.arguments ?? {}); } catch { return {}; }
1465
+ })(),
1466
+ }
1467
+ : parseToolCall(probeText);
1468
+ if (!call || !call.name) break; // the model chose to answer
1440
1469
  const result = await runTool(env, call);
1441
- if (result) {
1442
- messages.push({ role: "system", content: toolNote(result) });
1443
- pendingTools.push(result);
1444
- console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
1445
- }
1470
+ if (!result) break;
1471
+ mark(`${result.name} — ${result.query}`.slice(0, 140));
1472
+ messages.push({ role: "system", content: toolNote(result) });
1473
+ pendingTools.push(result);
1474
+ console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
1446
1475
  }
1447
1476
  } catch {
1448
1477
  /* the loop is optional — the answer proceeds without the tool */
@@ -1541,7 +1570,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1541
1570
  }
1542
1571
  // the misplaced-pin wall rides the streamed path too: a
1543
1572
  // CITED stream is not a refusal
1544
- const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full, refusalScope(usedHits.length > 0)), true) : canonicalRefusal(full, refusalScope(false));
1573
+ const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full, refusalScope(answerCites(full))), true) : canonicalRefusal(full, refusalScope(false));
1545
1574
  // answer contract v2: validate [[u:]] refs, resolve typed blocks
1546
1575
  const c2 = canonical0.includes(refusalAnswer())
1547
1576
  ? { text: canonical0, blocks: [], dropped: [] as string[] }
@@ -1582,7 +1611,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1582
1611
  if (streamed.violations.length > 0) {
1583
1612
  console.log("anchors:", streamed.violations.length, "of", streamed.total, "unverified — not caching");
1584
1613
  }
1585
- if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && cacheableAnswer(canonical, refusalScope(usedHits.length > 0)) && !ablate) {
1614
+ if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && cacheableAnswer(canonical, refusalScope(answerCites(canonical))) && !ablate) {
1586
1615
  ctx.waitUntil(
1587
1616
  env.CACHE.put(exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt))), JSON.stringify({ answer: canonical, citations: cites, model, query_hash: queryHash, ...(sourceQuality ? { source_quality: sourceQuality, confidence_note: streamedNote || qualityNote(sourceQuality) } : {}), ...(streamedEntailment ? { entailment: streamedEntailment } : {}) }), { expirationTtl: LIMITS.cacheTtlSec }),
1588
1617
  );
@@ -1645,7 +1674,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1645
1674
  );
1646
1675
  answer = await generateOnce(env, MODELS.fallback, flat, routeEffort(routed.route));
1647
1676
  }
1648
- if (answer) answer = canonicalRefusal(answer, refusalScope(usedHits.length > 0));
1677
+ if (answer) answer = canonicalRefusal(answer, refusalScope(answerCites(answer)));
1649
1678
  // the misplaced-pin wall: a CITED answer is not a refusal — excise
1650
1679
  // the pinned sentence if the model dropped it in as a disclaimer
1651
1680
  // (measured on chip-scoped asks: correct cited answers carrying the
@@ -1688,7 +1717,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1688
1717
  generateRetries += 1;
1689
1718
  const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], routeEffort(routed.route));
1690
1719
  if (corrected) {
1691
- const correctedAnswer = canonicalRefusal(corrected, refusalScope(usedHits.length > 0));
1720
+ const correctedAnswer = canonicalRefusal(corrected, refusalScope(answerCites(corrected)));
1692
1721
  const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h: Hit) => h.text));
1693
1722
  const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
1694
1723
  if (retryAnchors.violations.length < anchors.violations.length || (!retryRetyped && retyped) || (unreferenced && correctedAnswer.includes("[[u:"))) {
@@ -1733,7 +1762,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1733
1762
  const retryAnswer = await generateOnce(env, model, retryMessages, routeEffort(routed.route));
1734
1763
  // the answer now comes from the retry passages — citations must follow
1735
1764
  if (retryAnswer) {
1736
- answer = canonicalRefusal(retryAnswer, refusalScope(retryRetrieve.hits.length > 0));
1765
+ answer = canonicalRefusal(retryAnswer, refusalScope(answerCites(retryAnswer)));
1737
1766
  used = retryUsed;
1738
1767
  }
1739
1768
  }
@@ -1785,7 +1814,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1785
1814
  messages[messages.length - 1],
1786
1815
  ], effort);
1787
1816
  if (regen && !regen.includes(refusalAnswer())) {
1788
- answer = canonicalRefusal(regen, refusalScope(true));
1817
+ answer = canonicalRefusal(regen, refusalScope(answerCites(regen)));
1789
1818
  console.log("blind-answer: regeneration grounded");
1790
1819
  }
1791
1820
  }
@@ -1811,7 +1840,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1811
1840
  }
1812
1841
  }
1813
1842
  const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: entailmentNote || qualityNote(jsonQuality), ...(entailment ? { entailment } : {}), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...(registerBlock ? [registerBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), ...(ablate ? { ablate } : {}), route: { route: routed.route, features: routed.features } };
1814
- const cacheable = !contextual && !declaredCtx && !ablate && cacheableAnswer(answer, refusalScope(usedHits.length > 0)) && finalAnchors.violations.length === 0;
1843
+ const cacheable = !contextual && !declaredCtx && !ablate && cacheableAnswer(answer, refusalScope(answerCites(answer))) && finalAnchors.violations.length === 0;
1815
1844
  if (cacheable) {
1816
1845
  const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
1817
1846
  ctx.waitUntil(env.CACHE.put(ck, JSON.stringify(out), { expirationTtl: LIMITS.cacheTtlSec }));
@@ -0,0 +1,343 @@
1
+ // The data-plane grammar (the owner's 2026-10-12 direction, "the model
2
+ // decides what and how to look for data"): instead of pre-enumerated
3
+ // operation menus, the agent gets the PUBLISHED SCHEMA and composes
4
+ // typed predicates over it. db.schema answers "what is queryable";
5
+ // db.find answers the question. The engine compiles predicates to
6
+ // parameterized SQL — the model never writes SQL, identifiers never
7
+ // interpolate from model input, values always bind. BIRD-INTERACT
8
+ // (2026) grounding: interactive data questions collapse without schema
9
+ // linkage, so every validation error NAMES the valid columns.
10
+
11
+ export interface CatalogColumn {
12
+ name: string;
13
+ type: "text" | "number";
14
+ description: string;
15
+ /** ordering/comparison compiles CAST(col AS INTEGER) — textual columns
16
+ * carrying numeric content (certificates.year is TEXT in D1) */
17
+ numeric_cast?: boolean;
18
+ /** excluded from the default select (heavy bodies); still selectable by name */
19
+ wide?: boolean;
20
+ }
21
+
22
+ export interface CatalogTable {
23
+ name: string;
24
+ description: string;
25
+ columns: CatalogColumn[];
26
+ default_select: string[];
27
+ }
28
+
29
+ // PUBLISHED tables only — public corpus data. Everything holding user
30
+ // or operational state (api_keys, queries, spend, feedback, memories,
31
+ // projects, project_files, conversations, messages, shared_conversations,
32
+ // conversation_entities, attachments, model_plane_meta, ask retries and
33
+ // usage caches) is deliberately absent: db.find cannot reach it.
34
+ export const DB_CATALOG: CatalogTable[] = [
35
+ {
36
+ name: "certificates",
37
+ description:
38
+ "The certificate register (a snapshot, not live certification status): one row per issued certificate across the recommendation families.",
39
+ columns: [
40
+ { name: "num", type: "text", description: "the printed certificate number, e.g. R60/2021-A-NL1-26.12 (family/prefix per recommendation)" },
41
+ { name: "family", type: "text", description: "the recommendation family number, e.g. '60' for R 60" },
42
+ { name: "holder", type: "text", description: "the certificate holder (manufacturer)" },
43
+ { name: "model", type: "text", description: "the instrument model designation" },
44
+ { name: "year", type: "number", description: "the issue year", numeric_cast: true },
45
+ { name: "status", type: "text", description: "Valid | Suspended | Withdrawn" },
46
+ { name: "pdf_key", type: "text", description: "the /cert-pdf path suffix when the PDF is on file, else empty" },
47
+ ],
48
+ default_select: ["num", "family", "holder", "model", "year", "status", "pdf_key"],
49
+ },
50
+ {
51
+ name: "documents",
52
+ description:
53
+ "The document identity registry: every publication edition with its family, part, and derived edition status (an edition with a successor is superseded regardless of any copied status field).",
54
+ columns: [
55
+ { name: "canonical_id", type: "text", description: "doc:<family>-<edition> slug" },
56
+ { name: "docidentifier", type: "text", description: "the full identifier with edition" },
57
+ { name: "family", type: "text", description: "the family key, e.g. R-60" },
58
+ { name: "part", type: "text", description: "1 | 2 | 3 | A | annexes | sup | empty" },
59
+ { name: "edition", type: "number", description: "the edition year", numeric_cast: true },
60
+ { name: "status", type: "text", description: "in-force | superseded | withdrawn (as recorded)" },
61
+ { name: "derived_status", type: "text", description: "the successor-edge derivation result" },
62
+ { name: "active", type: "number", description: "1 = the active edition of its family+part" },
63
+ { name: "superseded_by", type: "text", description: "the successor's canonical_id, else empty" },
64
+ { name: "title", type: "text", description: "the publication title" },
65
+ ],
66
+ default_select: ["canonical_id", "docidentifier", "family", "part", "edition", "derived_status", "active", "superseded_by", "title"],
67
+ },
68
+ {
69
+ name: "graph_nodes",
70
+ description: "The public citation-graph nodes: documents, families, and vocabulary concepts.",
71
+ columns: [
72
+ { name: "id", type: "text", description: "doc:<identifier-slug> | family:<number> | concept:<id>" },
73
+ { name: "kind", type: "text", description: "doc | family | concept" },
74
+ { name: "label", type: "text", description: "the display label" },
75
+ ],
76
+ default_select: ["id", "kind", "label"],
77
+ },
78
+ {
79
+ name: "graph_edges",
80
+ description: "The public citation-graph edges between nodes.",
81
+ columns: [
82
+ { name: "src", type: "text", description: "the source node id" },
83
+ { name: "dst", type: "text", description: "the destination node id" },
84
+ { name: "kind", type: "text", description: "part_of | variant_of | successor | amends | defines" },
85
+ ],
86
+ default_select: ["src", "kind", "dst"],
87
+ },
88
+ {
89
+ name: "model_nodes",
90
+ description:
91
+ "The Recommendation model plane (per-standard requirement/conformance graphs): the machine-readable nodes behind the verdict engine.",
92
+ columns: [
93
+ { name: "standard", type: "text", description: "the standard key, e.g. r60" },
94
+ { name: "node_id", type: "text", description: "/req/metrological/mpe" },
95
+ { name: "kind", type: "text", description: "requirement | conformance_test | term | constraint | characteristic | state_machine | dimension" },
96
+ { name: "name", type: "text", description: "the node's display name" },
97
+ { name: "clause_doc", type: "text", description: "the provenance document urn" },
98
+ { name: "clause_ref", type: "text", description: "the clause inside it, e.g. 5.3.2" },
99
+ { name: "content", type: "text", description: "the node's JSON projection (the checks, thresholds, parameters)", wide: true },
100
+ { name: "content_hash", type: "text", description: "sha256 of content" },
101
+ ],
102
+ default_select: ["standard", "node_id", "kind", "name", "clause_doc", "clause_ref"],
103
+ },
104
+ {
105
+ name: "unit_payloads",
106
+ description: "Typed retrievable units (tables, formulas, figures, terms) with their producer payloads.",
107
+ columns: [
108
+ { name: "unit_id", type: "text", description: "u:table-1" },
109
+ { name: "doc_id", type: "text", description: "the owning document id" },
110
+ { name: "docidentifier", type: "text", description: "the publication identifier with part" },
111
+ { name: "edition", type: "text", description: "the edition year" },
112
+ { name: "clause_anchor", type: "text", description: "the clause the unit lives in" },
113
+ { name: "type", type: "text", description: "table | formula | figure | term | requirement" },
114
+ { name: "payload", type: "text", description: "the producer payload JSON (the table's rows, the formula's math)", wide: true },
115
+ ],
116
+ default_select: ["unit_id", "doc_id", "docidentifier", "edition", "clause_anchor", "type"],
117
+ },
118
+ {
119
+ name: "chunks",
120
+ description:
121
+ "The indexed corpus chunks (one row per clause-level passage) — use for exact metadata questions; semantic search is NOT this tool's job.",
122
+ columns: [
123
+ { name: "id", type: "text", description: "the chunk id" },
124
+ { name: "doc_id", type: "text", description: "the source document slug" },
125
+ { name: "docidentifier", type: "text", description: "the publication identifier with part" },
126
+ { name: "doctype", type: "text", description: "R | D | B | G | E" },
127
+ { name: "doc_number", type: "text", description: "60" },
128
+ { name: "edition", type: "text", description: "the edition year" },
129
+ { name: "language", type: "text", description: "en | fr | …" },
130
+ { name: "clause_anchor", type: "text", description: "the clause anchor" },
131
+ { name: "clause_title", type: "text", description: "the clause title" },
132
+ { name: "status", type: "text", description: "in-force | superseded" },
133
+ { name: "superseded_by", type: "text", description: "the superseding docidentifier, else empty" },
134
+ { name: "corpus", type: "text", description: "clean | dirty" },
135
+ { name: "tier", type: "text", description: "the quality tier" },
136
+ { name: "text", type: "text", description: "the passage body (wide — select only when needed)", wide: true },
137
+ { name: "fts_text", type: "text", description: "context preamble + body (wide)", wide: true },
138
+ ],
139
+ default_select: ["id", "doc_id", "docidentifier", "doctype", "doc_number", "edition", "language", "clause_anchor", "clause_title", "status", "corpus"],
140
+ },
141
+ ];
142
+
143
+ export function publishedTable(name: string): CatalogTable | undefined {
144
+ return DB_CATALOG.find((t) => t.name === name);
145
+ }
146
+
147
+ // ── the predicate compiler (pure; unit-tested) ────────────────────────
148
+
149
+ export const FIND_OPS = ["eq", "ne", "lt", "le", "gt", "ge", "in", "like", "between"] as const;
150
+ export type FindOp = (typeof FIND_OPS)[number];
151
+
152
+ export interface FindPredicate {
153
+ col: string;
154
+ op: FindOp;
155
+ value: unknown;
156
+ }
157
+
158
+ export interface FindArgs {
159
+ table: string;
160
+ where?: FindPredicate[];
161
+ select?: string[];
162
+ order_by?: { col: string; dir?: "asc" | "desc" };
163
+ limit?: number;
164
+ group_by?: string[];
165
+ agg?: { fn: "count" | "sum" | "avg" | "min" | "max"; col?: string };
166
+ }
167
+
168
+ export type Compiled =
169
+ | { ok: true; sql: string; params: unknown[]; echo: string }
170
+ | { ok: false; error: string };
171
+
172
+ const MAX_LIMIT = 25;
173
+ const DEFAULT_LIMIT = 10;
174
+
175
+ const NUMERIC_OPS: FindOp[] = ["lt", "le", "gt", "ge", "between"];
176
+
177
+ /** Compile validated identifiers + bound parameters. Returns a
178
+ * schema-linked error (naming the valid columns) on any mismatch —
179
+ * the model corrects from the error, the loop continues. */
180
+ export function compileFind(args: FindArgs): Compiled {
181
+ const table = publishedTable(String(args?.table ?? ""));
182
+ if (!table) {
183
+ return {
184
+ ok: false,
185
+ error: `unknown table "${String(args?.table ?? "")}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
186
+ };
187
+ }
188
+ const colNames = table.columns.map((c) => c.name);
189
+ const colByName = new Map(table.columns.map((c) => [c.name, c]));
190
+ const params: unknown[] = [];
191
+ const phrases: string[] = [];
192
+ const p = (n: number) => `?${n}`;
193
+
194
+ const colRef = (col: string): string => {
195
+ const c = colByName.get(col)!;
196
+ return c.numeric_cast ? `CAST(${col} AS INTEGER)` : col;
197
+ };
198
+
199
+ for (const w of Array.isArray(args?.where) ? args.where : []) {
200
+ const col = String(w?.col ?? "");
201
+ const colDef = colByName.get(col);
202
+ if (!colDef) {
203
+ return {
204
+ ok: false,
205
+ error: `table "${table.name}" has no column "${col}". Valid columns: ${colNames.join(", ")}.`,
206
+ };
207
+ }
208
+ const op = String(w?.op ?? "") as FindOp;
209
+ if (!FIND_OPS.includes(op)) {
210
+ return { ok: false, error: `unknown op "${op}". Valid ops: ${FIND_OPS.join(", ")}.` };
211
+ }
212
+ const v = w?.value;
213
+ if (NUMERIC_OPS.includes(op) && !colDef.numeric_cast && colDef.type !== "number") {
214
+ return { ok: false, error: `op "${op}" needs a numeric column; "${col}" is ${colDef.type}.` };
215
+ }
216
+ const ref = colRef(col);
217
+ if (op === "in") {
218
+ const list = Array.isArray(v) ? v.map((x) => String(x)) : [String(v)];
219
+ if (!list.length) return { ok: false, error: `op "in" needs a non-empty value list.` };
220
+ const holders = list.map((x) => p(params.push(x))).join(", ");
221
+ phrases.push(`${ref} IN (${holders})`);
222
+ } else if (op === "between") {
223
+ const pair = Array.isArray(v) ? v : [undefined, undefined];
224
+ if (pair.length !== 2) return { ok: false, error: `op "between" needs a two-element value [lo, hi].` };
225
+ phrases.push(`${ref} BETWEEN ${p(params.push(pair[0]))} AND ${p(params.push(pair[1]))}`);
226
+ } else if (op === "like") {
227
+ phrases.push(`${ref} LIKE ${p(params.push(String(v)))}`);
228
+ } else {
229
+ const sqlOp = { eq: "=", ne: "!=", lt: "<", le: "<=", gt: ">", ge: ">=" }[op as "eq" | "ne" | "lt" | "le" | "gt" | "ge"];
230
+ phrases.push(`${ref} ${sqlOp} ${p(params.push(v))}`);
231
+ }
232
+ }
233
+
234
+ const select = (Array.isArray(args?.select) && args.select.length ? args.select : table.default_select).map(String);
235
+ for (const s of select) {
236
+ if (!colNames.includes(s)) {
237
+ return { ok: false, error: `table "${table.name}" has no column "${s}". Valid columns: ${colNames.join(", ")}.` };
238
+ }
239
+ }
240
+
241
+ let head: string;
242
+ if (args?.agg?.fn) {
243
+ const fn = String(args.agg.fn) as "count" | "sum" | "avg" | "min" | "max";
244
+ if (!["count", "sum", "avg", "min", "max"].includes(fn)) {
245
+ return { ok: false, error: `unknown agg fn "${fn}". Valid: count, sum, avg, min, max.` };
246
+ }
247
+ let aggExpr = "COUNT(*)";
248
+ if (fn !== "count") {
249
+ const acol = String(args.agg.col ?? "");
250
+ const adef = colByName.get(acol);
251
+ if (!adef) return { ok: false, error: `agg column "${acol}" is not a column of "${table.name}". Valid columns: ${colNames.join(", ")}.` };
252
+ aggExpr = `${fn.toUpperCase()}(${colRef(acol)})`;
253
+ }
254
+ head = `SELECT ${args?.group_by?.length ? `${(args.group_by as string[]).map(String).join(", ")}, ` : ""}${aggExpr} AS agg FROM ${table.name}`;
255
+ for (const g of args?.group_by ?? []) {
256
+ if (!colNames.includes(String(g))) {
257
+ return { ok: false, error: `table "${table.name}" has no column "${String(g)}". Valid columns: ${colNames.join(", ")}.` };
258
+ }
259
+ }
260
+ } else {
261
+ head = `SELECT ${select.join(", ")} FROM ${table.name}`;
262
+ }
263
+
264
+ const where = phrases.length ? ` WHERE ${phrases.join(" AND ")}` : "";
265
+ let tail = "";
266
+ if (args?.group_by?.length) tail += ` GROUP BY ${(args.group_by as string[]).map(String).join(", ")}`;
267
+ if (args?.order_by?.col) {
268
+ const ocol = String(args.order_by.col);
269
+ if (!colNames.includes(ocol)) {
270
+ return { ok: false, error: `table "${table.name}" has no column "${ocol}". Valid columns: ${colNames.join(", ")}.` };
271
+ }
272
+ const dir = String(args.order_by.dir ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
273
+ tail += ` ORDER BY ${colRef(ocol)} ${dir}`;
274
+ }
275
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Number(args?.limit ?? DEFAULT_LIMIT) || DEFAULT_LIMIT));
276
+ tail += ` LIMIT ${limit}`;
277
+
278
+ const echo = [
279
+ `db.find ${table.name}`,
280
+ args?.where?.length ? `where ${args.where.map((w) => `${w.col} ${w.op} ${JSON.stringify(w.value ?? null)}`).join(" AND ")}` : "all rows",
281
+ args?.agg?.fn ? `${args.agg.fn}${args.agg.col ? ` ${args.agg.col}` : ""}` : "",
282
+ ]
283
+ .filter(Boolean)
284
+ .join(" · ");
285
+
286
+ return {
287
+ ok: true,
288
+ sql: `${head}${where}${tail}`,
289
+ params,
290
+ echo,
291
+ };
292
+ }
293
+
294
+ /** The db.find handler: compile, run on D1, format deterministically.
295
+ * Every output carries the predicate echo — the answer attributes the
296
+ * rows to the query that produced them. */
297
+ export async function runFind(env: any, args: FindArgs): Promise<{ name: string; query: string; output: string } | null> {
298
+ const compiled = compileFind(args);
299
+ if (!compiled.ok) {
300
+ return {
301
+ name: "db.find",
302
+ query: String(args?.table ?? ""),
303
+ output: `The query was rejected: ${compiled.error} Correct the predicates and call again.`,
304
+ };
305
+ }
306
+ try {
307
+ const { portStore } = await import("./env.ts");
308
+ const res = await portStore(env).prepare(compiled.sql).bind(...compiled.params).all();
309
+ const rows = (res?.results ?? []) as Record<string, unknown>[];
310
+ const body = rows.length
311
+ ? rows.map((r) => JSON.stringify(r)).join("\n")
312
+ : "No rows matched. Either the predicates are too narrow or the data is absent — state which, and consider widening.";
313
+ const aggNote = args?.agg?.fn ? " (aggregate)" : "";
314
+ return { name: "db.find", query: compiled.echo, output: `${compiled.echo}${aggNote} → ${rows.length} row(s):\n${body}` };
315
+ } catch (e: any) {
316
+ return { name: "db.find", query: compiled.echo, output: `The query failed to run: ${String(e?.message ?? e).slice(0, 200)}` };
317
+ }
318
+ }
319
+
320
+ /** The db.schema handler: the published map, one table or the catalog. */
321
+ export function runSchema(table?: string): { name: string; query: string; output: string } {
322
+ const tables = table ? [publishedTable(String(table))].filter(Boolean) : DB_CATALOG;
323
+ if (!tables.length) {
324
+ return {
325
+ name: "db.schema",
326
+ query: String(table),
327
+ output: `Unknown table "${table}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
328
+ };
329
+ }
330
+ const output = (tables as CatalogTable[])
331
+ .map((t) => {
332
+ const cols = t.columns
333
+ .map((c) => ` ${c.name} (${c.type}${c.numeric_cast ? ", numeric-cast" : ""}${c.wide ? ", wide" : ""}) — ${c.description}`)
334
+ .join("\n");
335
+ return `${t.name} — ${t.description}\n${cols}\n default select: ${t.default_select.join(", ")}`;
336
+ })
337
+ .join("\n\n");
338
+ return {
339
+ name: "db.schema",
340
+ query: table ? String(table) : "catalog",
341
+ output: `Published tables (compose TOOL db.find with where: [{col, op, value}]; ops: ${FIND_OPS.join(" ")}; values bind safely; like accepts % and _ wildcards):\n\n${output}`,
342
+ };
343
+ }
@@ -164,7 +164,7 @@ async function run(
164
164
  telemetry(env, ctx, "member", "research", MODELS.research, false, 0, queryHash, q.lang);
165
165
  throw new Error("The generation model is unavailable; please retry.");
166
166
  }
167
- answer = canonicalRefusal(answer, { cited: usedHits.length > 0, conversational: false });
167
+ answer = canonicalRefusal(answer, { cited: /\[[^\]]+\]/.test(answer), conversational: false });
168
168
  const anchors = checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
169
169
  if (anchors.violations.length) console.log("research anchors:", anchors.violations.length, "unverified");
170
170
  const out = {
@@ -17,19 +17,21 @@ export interface ToolResult {
17
17
  output: string;
18
18
  }
19
19
 
20
- /** The strict protocol: a line `TOOL certificates.search {"query": "…"}`.
21
- * Plain regex parse — no JSON mode needed, tolerant of surrounding
22
- * prose (the model may talk around the call). */
20
+ /** The strict protocol: a line `TOOL <name> {…json…}`. Line-anchored,
21
+ * case-sensitive (prose never trips it); the args are the WHOLE rest
22
+ * of the line parsed as JSON — nested objects (db.find predicates)
23
+ * are legal, and a malformed or unterminated object is not a call. */
23
24
  export function parseToolCall(text: string): ToolCall | null {
24
- const m = /^TOOL\s+([a-z_.]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? "")); // line-anchored, case-sensitive: prose never trips it
25
+ const m = /^TOOL\s+([a-z_.]+)[ \t]*(\{.*)?$/m.exec(String(text ?? ""));
25
26
  if (!m) return null;
26
- let args: Record<string, unknown> = {};
27
- if (m[2]) {
28
- try { args = JSON.parse(m[2]); } catch { return null; }
29
- } else if (/^TOOL\s+[a-z_.]+\s*\{/m.test(String(text ?? ""))) {
30
- return null; // a malformed call (opened, never closed) is not a call
27
+ const rest = (m[2] ?? "").trim();
28
+ if (!rest) return { name: m[1].toLowerCase(), args: {} };
29
+ if (!rest.startsWith("{")) return null;
30
+ try {
31
+ return { name: m[1].toLowerCase(), args: JSON.parse(rest) };
32
+ } catch {
33
+ return null;
31
34
  }
32
- return { name: m[1].toLowerCase(), args };
33
35
  }
34
36
 
35
37
  // ── the tool registry (OCP, the owner's 2026-10-04 direction) ────────
@@ -65,19 +67,63 @@ const certificatesSearch: ToolSpec = {
65
67
  };
66
68
 
67
69
  import { unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert } from "./tools-content.ts";
70
+ import { runFind, runSchema } from "./dbtools.ts";
71
+
72
+ // the data-plane grammar (dbtools.ts): the model composes typed
73
+ // predicates over the PUBLISHED schema — no operation menus, no raw SQL
74
+ const dbSchema: ToolSpec = {
75
+ name: "db.schema",
76
+ description:
77
+ "The published data map: every queryable table (the certificate register, the document registry, the citation graph, the model plane, typed units, the corpus chunks) with its columns, types, and meanings. Call this first whenever a question turns on structured data; then compose db.find.",
78
+ params: [{ key: "table", required: false, description: "one table's detail; omit for the whole catalog" }],
79
+ audiences: ["agent", "mcp"],
80
+ handler: async (_env, args) => {
81
+ const t = args?.table ? String(args.table) : undefined;
82
+ return runSchema(t);
83
+ },
84
+ };
85
+
86
+ const dbFind: ToolSpec = {
87
+ name: "db.find",
88
+ description:
89
+ "Query a published table with typed predicates you compose: where: [{col, op, value}] with ops eq|ne|lt|le|gt|ge|in|like|between, optional select, order_by {col, dir}, limit (≤25), group_by + agg {fn: count|sum|avg|min|max, col}. Values bind safely; identifiers validate against the schema (errors name the valid columns). Returns the matching rows or aggregate with the query echoed.",
90
+ params: [
91
+ { key: "table", required: true, description: "a published table (db.schema lists them)" },
92
+ { key: "where", required: false, description: "[{col, op, value}] predicates, ANDed" },
93
+ { key: "select", required: false, description: "columns to return; default per table" },
94
+ { key: "order_by", required: false, description: "{col, dir: asc|desc}" },
95
+ { key: "limit", required: false, description: "row cap, ≤25" },
96
+ { key: "agg", required: false, description: "{fn, col?} aggregate instead of rows; with group_by: [cols]" },
97
+ ],
98
+ audiences: ["agent", "mcp"],
99
+ handler: async (env, args) => {
100
+ const findArgs = {
101
+ table: String(args?.table ?? ""),
102
+ where: Array.isArray(args?.where) ? args.where : [],
103
+ select: Array.isArray(args?.select) ? args.select : undefined,
104
+ order_by: args?.order_by && typeof args.order_by === "object" ? (args.order_by as { col: string; dir?: "asc" | "desc" }) : undefined,
105
+ limit: args?.limit != null ? Number(args.limit) : undefined,
106
+ group_by: Array.isArray(args?.group_by) ? args.group_by : undefined,
107
+ agg: args?.agg && typeof args.agg === "object" ? (args.agg as { fn: "count" | "sum" | "avg" | "min" | "max"; col?: string }) : undefined,
108
+ };
109
+ if (!findArgs.table) return null;
110
+ return runFind(env, findArgs);
111
+ },
112
+ };
113
+
114
+ export const TOOLS_REGISTRY: ToolSpec[] = [dbSchema, dbFind, certificatesSearch, unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert];
68
115
 
69
- export const TOOLS_REGISTRY: ToolSpec[] = [certificatesSearch, unitsGet, graphCites, docsSection, glossaryLookup, documentsFamily, licensedSection, verdictEvaluate, conditionsCheck, bibEntry, unitsLookup, unitsConvert];
70
116
 
71
117
  /** The agent loop's protocol declaration, GENERATED from the registry —
72
118
  * the model's tool vocabulary is always the registry's, never a
73
119
  * hand-maintained literal. */
74
120
  export const TOOL_DECLARATION = [
75
- "You may use one tool before answering, by writing a single line:",
121
+ "You may query data before answering, by writing ONE line per call:",
76
122
  ...TOOLS_REGISTRY.filter((t) => t.audiences.includes("agent")).map((t) => {
77
- const shape = `{${t.params.map((p) => `"${p.key}": "<${p.description}>"`).join(", ")}}`;
123
+ const shape = `{${t.params.map((p) => `"${p.key}": <${p.description}>`).join(", ")}}`;
78
124
  return `TOOL ${t.name} ${shape}`;
79
125
  }),
80
- "The worker runs it and returns the result attributed — phrase the tool's result as what it returned, with the searched string visible in your answer. Use a tool when the question turns on what it answers (including from a photograph). If you do not need it, answer directly without the line.",
126
+ "The worker runs each call and returns the result attributed; you may issue up to three calls across the turns, one per turn, each on its own single line (compact JSON, no line breaks). Structured questions — counts, latest, filters, per-holder or per-year breakdowns over the register, document editions, model-plane requirements — start from TOOL db.schema (it lists the queryable tables and their columns), then compose TOOL db.find predicates. Each result arrives attributed: phrase it as what the tool returned, with your query visible in the answer. When you have enough, answer WITHOUT a TOOL line; if no tool helps, answer directly.",
81
127
  ].join("\n");
82
128
 
83
129
  /** Dispatch by LOOKUP — a tool name is never hard-coded at a call site.