@konneal/engine 0.2.56 → 0.2.59

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.
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-ZVMQHCNL.js";
3
+ } from "./chunk-TZOED2YR.js";
4
4
  import "./chunk-TPV5GPY2.js";
5
5
  import "./chunk-SPKPH54J.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-LVZTWUVJ.js";
8
8
  import "./chunk-2PCUAJJO.js";
9
+ import "./chunk-FHOOFPJW.js";
9
10
  import "./chunk-3FYJM7LH.js";
10
11
  export {
11
12
  handleAsk
@@ -0,0 +1,19 @@
1
+ import {
2
+ buildRegisterQuery,
3
+ certificateLinks,
4
+ isRegisterShaped,
5
+ queryFamily,
6
+ registerNote,
7
+ registerTokens,
8
+ searchRegister
9
+ } from "./chunk-FHOOFPJW.js";
10
+ import "./chunk-3FYJM7LH.js";
11
+ export {
12
+ buildRegisterQuery,
13
+ certificateLinks,
14
+ isRegisterShaped,
15
+ queryFamily,
16
+ registerNote,
17
+ registerTokens,
18
+ searchRegister
19
+ };
@@ -5,6 +5,7 @@ export interface RegisterRow {
5
5
  model: string;
6
6
  year: string;
7
7
  status: string;
8
+ pdf_key?: string | null;
8
9
  }
9
10
  /** A question is register-shaped when it asks about certification
10
11
  * standing of an identifiable model or holder — the deterministic
@@ -37,3 +38,7 @@ export declare function searchRegister(db: any, query: string): Promise<{
37
38
  tokens: string[];
38
39
  } | null>;
39
40
  export declare function registerNote(rows: RegisterRow[]): string;
41
+ /** Each certificate's location rides the note as a link when its PDF
42
+ * is in the R2 plane (TODO 7) — the user gets the document, not just
43
+ * the fact. */
44
+ export declare function certificateLinks(rows: RegisterRow[]): string;
@@ -0,0 +1,133 @@
1
+ import {
2
+ P
3
+ } from "./chunk-3FYJM7LH.js";
4
+
5
+ // workers/worker_public/src/certificates.ts
6
+ function isRegisterShaped(query) {
7
+ return /\bcertif(ied|icates?|ication)s?\b/i.test(query) && /\b(is|are|still|currently|valid|status|suspended|revoked|was|were|have|has|hold|holds|possess|carry|got)\b/i.test(query);
8
+ }
9
+ function queryFamily(query) {
10
+ const m = /\b([RDMB])\s?-?(\d{2,3})\b/i.exec(query);
11
+ return m ? `${m[1].toUpperCase()}${m[2].padStart(2, "0")}` : null;
12
+ }
13
+ function registerTokens(query) {
14
+ const stop = /* @__PURE__ */ new Set([
15
+ "the",
16
+ "a",
17
+ "an",
18
+ "is",
19
+ "are",
20
+ "was",
21
+ "were",
22
+ "still",
23
+ "currently",
24
+ "in",
25
+ "on",
26
+ "for",
27
+ "of",
28
+ "and",
29
+ "or",
30
+ "certificate",
31
+ "certificates",
32
+ "certified",
33
+ "certification",
34
+ "register",
35
+ "status",
36
+ "valid",
37
+ "suspended",
38
+ "revoked",
39
+ "recommendation",
40
+ "per",
41
+ "under",
42
+ "by",
43
+ "with",
44
+ "what",
45
+ "which",
46
+ "who",
47
+ "does",
48
+ "do",
49
+ "load",
50
+ "cell",
51
+ "model",
52
+ "manufacturer",
53
+ "holder",
54
+ "company",
55
+ "r",
56
+ "how",
57
+ "to",
58
+ "list",
59
+ "show"
60
+ ]);
61
+ const tokens = [];
62
+ const publisherWord = P().publisher.name.toLowerCase();
63
+ for (const w of query.split(/[^A-Za-z0-9&-]+/)) {
64
+ if (w.length < 2) continue;
65
+ if (stop.has(w.toLowerCase())) continue;
66
+ if (w.toLowerCase() === publisherWord) continue;
67
+ if (/^\d+$/.test(w) && w.length < 2) continue;
68
+ if (!tokens.includes(w)) tokens.push(w);
69
+ }
70
+ return tokens.slice(0, 6);
71
+ }
72
+ function buildRegisterQuery(tokens) {
73
+ const params = [];
74
+ const clauses = tokens.map((t) => {
75
+ if (/^\d+$/.test(t)) {
76
+ const padded = `% ${t.toLowerCase()} %`;
77
+ params.push(t, t, padded);
78
+ const n1 = params.length - 2;
79
+ const n2 = params.length;
80
+ return `(model = ?${n1} OR num = ?${n1} OR (' ' || REPLACE(REPLACE(REPLACE(LOWER(model), '-', ' '), ',', ' '), '.', ' ') || ' ') LIKE ?${n2})`;
81
+ }
82
+ params.push(`%${t}%`);
83
+ const n = params.length;
84
+ return `(holder LIKE ?${n} OR model LIKE ?${n} OR num LIKE ?${n})`;
85
+ });
86
+ return {
87
+ sql: `SELECT num, family, holder, model, year, status, pdf_key FROM certificates WHERE ${clauses.join(" OR ")} LIMIT 6`,
88
+ params
89
+ };
90
+ }
91
+ async function searchRegister(db, query) {
92
+ if (!isRegisterShaped(query)) return null;
93
+ const tokens = registerTokens(query);
94
+ if (!tokens.length) return null;
95
+ const built = buildRegisterQuery(tokens);
96
+ let { sql, params } = built;
97
+ const family = queryFamily(query);
98
+ if (family) {
99
+ sql = sql.replace(" WHERE ", " WHERE family = ?0 AND ");
100
+ params.unshift(family.replace(/^[A-Za-z]+/, ""));
101
+ }
102
+ try {
103
+ const res = await db.prepare(sql).bind(...params).all();
104
+ return { rows: res.results ?? [], tokens };
105
+ } catch {
106
+ return { rows: [], tokens };
107
+ }
108
+ }
109
+ function registerNote(rows) {
110
+ if (!rows.length) {
111
+ return `Certificate register: NO certificate matching the asked holder or model appears in the ${P().publisher.name}-CS register snapshot. State plainly that no such certificate is in this register, and that the register is a snapshot rather than the live certification status.`;
112
+ }
113
+ const lines = rows.map((r) => `- ${r.num}: holder ${r.holder}, model "${r.model}"${r.year ? `, issued ${r.year}` : ""} \u2014 status ${r.status}`);
114
+ return [
115
+ "Certificate register \u2014 the following certificates matched the asked holder or model. Quote the certificate number, the holder and the status verbatim; the register is a snapshot, so qualify any statement about current certification accordingly.",
116
+ ...lines
117
+ ].join("\n");
118
+ }
119
+ function certificateLinks(rows) {
120
+ const links = rows.filter((r) => r.pdf_key).map((r) => `- ${r.num}: https://www.oimlsmart.org/cert-pdf/${r.pdf_key}`);
121
+ return links.length ? `The certificates' documents:
122
+ ${links.join("\n")}` : "";
123
+ }
124
+
125
+ export {
126
+ isRegisterShaped,
127
+ queryFamily,
128
+ registerTokens,
129
+ buildRegisterQuery,
130
+ searchRegister,
131
+ registerNote,
132
+ certificateLinks
133
+ };
@@ -64,6 +64,11 @@ import {
64
64
  roleModel,
65
65
  sha256Hex
66
66
  } from "./chunk-2PCUAJJO.js";
67
+ import {
68
+ certificateLinks,
69
+ registerNote,
70
+ searchRegister
71
+ } from "./chunk-FHOOFPJW.js";
67
72
  import {
68
73
  P
69
74
  } from "./chunk-3FYJM7LH.js";
@@ -1864,119 +1869,37 @@ function exactCacheKey(indexVersion, gen, ns, queryHash) {
1864
1869
  return `a:${indexVersion}:g${gen}:${ns}:${queryHash}`;
1865
1870
  }
1866
1871
 
1867
- // workers/worker_public/src/certificates.ts
1868
- function isRegisterShaped(query) {
1869
- return /\bcertif(ied|icates?|ication)s?\b/i.test(query) && /\b(is|are|still|currently|valid|status|suspended|revoked|was|were|have|has|hold|holds|possess|carry|got)\b/i.test(query);
1870
- }
1871
- function queryFamily(query) {
1872
- const m = /\b([RDMB])\s?-?(\d{2,3})\b/i.exec(query);
1873
- return m ? `${m[1].toUpperCase()}${m[2].padStart(2, "0")}` : null;
1874
- }
1875
- function registerTokens(query) {
1876
- const stop = /* @__PURE__ */ new Set([
1877
- "the",
1878
- "a",
1879
- "an",
1880
- "is",
1881
- "are",
1882
- "was",
1883
- "were",
1884
- "still",
1885
- "currently",
1886
- "in",
1887
- "on",
1888
- "for",
1889
- "of",
1890
- "and",
1891
- "or",
1892
- "certificate",
1893
- "certificates",
1894
- "certified",
1895
- "certification",
1896
- "register",
1897
- "status",
1898
- "valid",
1899
- "suspended",
1900
- "revoked",
1901
- "recommendation",
1902
- "per",
1903
- "under",
1904
- "by",
1905
- "with",
1906
- "what",
1907
- "which",
1908
- "who",
1909
- "does",
1910
- "do",
1911
- "load",
1912
- "cell",
1913
- "model",
1914
- "manufacturer",
1915
- "holder",
1916
- "company",
1917
- "r",
1918
- "how",
1919
- "to",
1920
- "list",
1921
- "show"
1922
- ]);
1923
- const tokens2 = [];
1924
- const publisherWord = P().publisher.name.toLowerCase();
1925
- for (const w of query.split(/[^A-Za-z0-9&-]+/)) {
1926
- if (w.length < 2) continue;
1927
- if (stop.has(w.toLowerCase())) continue;
1928
- if (w.toLowerCase() === publisherWord) continue;
1929
- if (/^\d+$/.test(w) && w.length < 2) continue;
1930
- if (!tokens2.includes(w)) tokens2.push(w);
1931
- }
1932
- return tokens2.slice(0, 6);
1933
- }
1934
- function buildRegisterQuery(tokens2) {
1935
- const params = [];
1936
- const clauses = tokens2.map((t) => {
1937
- if (/^\d+$/.test(t)) {
1938
- const padded = `% ${t.toLowerCase()} %`;
1939
- params.push(t, t, padded);
1940
- const n1 = params.length - 2;
1941
- const n2 = params.length;
1942
- return `(model = ?${n1} OR num = ?${n1} OR (' ' || REPLACE(REPLACE(REPLACE(LOWER(model), '-', ' '), ',', ' '), '.', ' ') || ' ') LIKE ?${n2})`;
1943
- }
1944
- params.push(`%${t}%`);
1945
- const n = params.length;
1946
- return `(holder LIKE ?${n} OR model LIKE ?${n} OR num LIKE ?${n})`;
1947
- });
1948
- return {
1949
- sql: `SELECT num, family, holder, model, year, status FROM certificates WHERE ${clauses.join(" OR ")} LIMIT 6`,
1950
- params
1951
- };
1952
- }
1953
- async function searchRegister(db, query) {
1954
- if (!isRegisterShaped(query)) return null;
1955
- const tokens2 = registerTokens(query);
1956
- if (!tokens2.length) return null;
1957
- const built = buildRegisterQuery(tokens2);
1958
- let { sql, params } = built;
1959
- const family = queryFamily(query);
1960
- if (family) {
1961
- sql = sql.replace(" WHERE ", " WHERE family = ?0 AND ");
1962
- params.unshift(family);
1963
- }
1964
- try {
1965
- const res = await db.prepare(sql).bind(...params).all();
1966
- return { rows: res.results ?? [], tokens: tokens2 };
1967
- } catch {
1968
- return { rows: [], tokens: tokens2 };
1872
+ // workers/worker_public/src/tools.ts
1873
+ function parseToolCall(text) {
1874
+ const m = /^TOOL\s+([a-z_]+)\s*(\{[^\n}]*\})?/m.exec(String(text ?? ""));
1875
+ if (!m) return null;
1876
+ let args = {};
1877
+ if (m[2]) {
1878
+ try {
1879
+ args = JSON.parse(m[2]);
1880
+ } catch {
1881
+ return null;
1882
+ }
1883
+ } else if (/^TOOL\s+[a-z_]+\s*\{/m.test(String(text ?? ""))) {
1884
+ return null;
1969
1885
  }
1886
+ return { name: m[1].toLowerCase(), args };
1970
1887
  }
1971
- function registerNote(rows) {
1972
- if (!rows.length) {
1973
- return `Certificate register: NO certificate matching the asked holder or model appears in the ${P().publisher.name}-CS register snapshot. State plainly that no such certificate is in this register, and that the register is a snapshot rather than the live certification status.`;
1974
- }
1975
- const lines = rows.map((r) => `- ${r.num}: holder ${r.holder}, model "${r.model}"${r.year ? `, issued ${r.year}` : ""} \u2014 status ${r.status}`);
1976
- return [
1977
- "Certificate register \u2014 the following certificates matched the asked holder or model. Quote the certificate number, the holder and the status verbatim; the register is a snapshot, so qualify any statement about current certification accordingly.",
1978
- ...lines
1979
- ].join("\n");
1888
+ var TOOL_DECLARATION = `You may use one tool before answering, by writing a single line:
1889
+ TOOL register_search {"query": "manufacturer and model to look up"}
1890
+ The worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known \u2014 including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.`;
1891
+ async function runTool(db, call) {
1892
+ if (call.name !== "register_search") return null;
1893
+ const query = String(call.args?.query ?? "").trim().slice(0, 160);
1894
+ if (!query) return null;
1895
+ const { searchRegister: searchRegister2, registerNote: registerNote2 } = await import("./certificates-CUGV2FIW.js");
1896
+ const reg = await searchRegister2(db, query);
1897
+ const output = reg?.rows?.length ? registerNote2(reg.rows) : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
1898
+ return { name: call.name, query, output };
1899
+ }
1900
+ function toolNote(r) {
1901
+ return `The ${r.name} tool returned, for the query "${r.query}":
1902
+ ${r.output}`;
1980
1903
  }
1981
1904
 
1982
1905
  // workers/worker_public/prompts/nameplate.md
@@ -2391,7 +2314,7 @@ async function handleAsk(env, ctx, req, tier, key) {
2391
2314
  }
2392
2315
  }
2393
2316
  console.log("register-search:", JSON.stringify({ shaped: !!register, tokens: register?.tokens ?? null, rows: register?.rows?.length ?? null, first: register?.rows?.[0]?.num ?? null }));
2394
- const regNote = register ? registerNote(register.rows) : void 0;
2317
+ const regNote = register ? [registerNote(register.rows), certificateLinks(register.rows)].filter(Boolean).join("\n") : void 0;
2395
2318
  if (understanding?.intent === "conversational") {
2396
2319
  const queryHash2 = await sha256Hex(q.query);
2397
2320
  const messages2 = [
@@ -2743,6 +2666,27 @@ Answer account questions from these records ONLY: name the record when you use i
2743
2666
  budget
2744
2667
  );
2745
2668
  await attachFigureImages(env, messages, usedHits, q.query);
2669
+ if (/certificate|certified|certification/i.test(q.query) && env.DB) {
2670
+ try {
2671
+ const probe = await env.AI.run(model, {
2672
+ messages: [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}
2673
+
2674
+ ${q.query}` }],
2675
+ max_tokens: 300,
2676
+ reasoning_effort: "low"
2677
+ });
2678
+ const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
2679
+ const call = parseToolCall(probeText);
2680
+ if (call) {
2681
+ const result = await runTool(env.DB, call);
2682
+ if (result) {
2683
+ messages.push({ role: "system", content: toolNote(result) });
2684
+ console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
2685
+ }
2686
+ }
2687
+ } catch {
2688
+ }
2689
+ }
2746
2690
  if (withImage) {
2747
2691
  const wantsMarkings = /\b(mark(ing|ings)?|nameplate|label|inscription|engrav|sticker|plate)\b|certificat/i.test(q.query);
2748
2692
  const last = messages[messages.length - 1];
@@ -0,0 +1,18 @@
1
+ export interface ToolCall {
2
+ name: string;
3
+ args: Record<string, unknown>;
4
+ }
5
+ export interface ToolResult {
6
+ name: string;
7
+ query: string;
8
+ output: string;
9
+ }
10
+ /** The strict protocol: a line `TOOL register_search {"query": "…"}`.
11
+ * Plain regex parse — no JSON mode needed, tolerant of surrounding
12
+ * prose (the model may talk around the call). */
13
+ export declare function parseToolCall(text: string): ToolCall | null;
14
+ export declare const TOOL_DECLARATION = "You may use one tool before answering, by writing a single line:\nTOOL register_search {\"query\": \"manufacturer and model to look up\"}\nThe worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known \u2014 including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.";
15
+ export declare function runTool(db: any, call: ToolCall): Promise<ToolResult | null>;
16
+ /** The attributed injection: the answer model sees what the tool
17
+ * returned, for the string it asked — never a standing claim. */
18
+ export declare function toolNote(r: ToolResult): string;
@@ -1,6 +1,3 @@
1
- import {
2
- handleSearch
3
- } from "../../chunk-FLNX4O7C.js";
4
1
  import {
5
2
  bindModelNode,
6
3
  checkQuoteAnchors,
@@ -9,7 +6,10 @@ import {
9
6
  modelGroundingBlock,
10
7
  scoreFaithfulness,
11
8
  scoreJudge
12
- } from "../../chunk-ZVMQHCNL.js";
9
+ } from "../../chunk-TZOED2YR.js";
10
+ import {
11
+ handleSearch
12
+ } from "../../chunk-FLNX4O7C.js";
13
13
  import {
14
14
  buildMessages,
15
15
  cfBlobs,
@@ -63,6 +63,7 @@ import {
63
63
  sha256Hex,
64
64
  today
65
65
  } from "../../chunk-2PCUAJJO.js";
66
+ import "../../chunk-FHOOFPJW.js";
66
67
  import {
67
68
  P,
68
69
  setProfile
@@ -958,7 +959,7 @@ async function handleMcp(env, ctx, req, tier, key) {
958
959
  // stream:false forces the JSON lane (anon defaults to SSE)
959
960
  body: JSON.stringify({ ...args, stream: false })
960
961
  });
961
- const res = name === "ask" ? await (await import("../../ask-5XMD22SJ.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
962
+ const res = name === "ask" ? await (await import("../../ask-7J4A3PSP.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
962
963
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
963
964
  });
964
965
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.56",
30
+ "version": "0.2.59",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -39,7 +39,8 @@ export type { Env };
39
39
  import { json, err, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
40
40
  import { clientIp, checkQuota, telemetry } from "./quota";
41
41
  import { graphExpand, editionNote } from "./graph";
42
- import { registerNote, searchRegister } from "./certificates";
42
+ import { registerNote, searchRegister, certificateLinks } from "./certificates";
43
+ import { TOOL_DECLARATION, parseToolCall, runTool, toolNote } from "./tools";
43
44
  import { extractNameplate, nameplateRegisterQuery } from "./nameplate.ts";
44
45
  import { P } from "./profile.ts";
45
46
 
@@ -636,7 +637,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
636
637
  }
637
638
  }
638
639
  console.log("register-search:", JSON.stringify({ shaped: !!register, tokens: register?.tokens ?? null, rows: register?.rows?.length ?? null, first: register?.rows?.[0]?.num ?? null }));
639
- const regNote = register ? registerNote(register.rows) : undefined;
640
+ const regNote = register ? [registerNote(register.rows), certificateLinks(register.rows)].filter(Boolean).join("\n") : undefined;
640
641
 
641
642
  // Conversational route, decided by query UNDERSTANDING (any language, any
642
643
  // phrasing) — not string matching. No retrieval: nothing in the corpus
@@ -1147,6 +1148,31 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1147
1148
  budget,
1148
1149
  );
1149
1150
  await attachFigureImages(env, messages, usedHits, q.query);
1151
+ // the agent loop (the owner's 2026-09-28 direction): ONE bounded tool
1152
+ // round, armed only where a tool can help. The model may call
1153
+ // register_search through the strict line protocol; the worker
1154
+ // executes it deterministically and the ATTRIBUTED result joins the
1155
+ // messages — what the tool returned, for the string it asked.
1156
+ if (/certificate|certified|certification/i.test(q.query) && env.DB) {
1157
+ try {
1158
+ const probe = await (env as any).AI.run(model, {
1159
+ messages: [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}\n\n${q.query}` }],
1160
+ max_tokens: 300,
1161
+ reasoning_effort: "low",
1162
+ });
1163
+ const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1164
+ const call = parseToolCall(probeText);
1165
+ if (call) {
1166
+ const result = await runTool(env.DB, call);
1167
+ if (result) {
1168
+ messages.push({ role: "system", content: toolNote(result) });
1169
+ console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
1170
+ }
1171
+ }
1172
+ } catch {
1173
+ /* the loop is optional — the answer proceeds without the tool */
1174
+ }
1175
+ }
1150
1176
  if (withImage) {
1151
1177
  // the user's own image rides on the question message — retrieval stays
1152
1178
  // text-driven; the answer model reads the image as question context.
@@ -15,6 +15,7 @@ export interface RegisterRow {
15
15
  model: string;
16
16
  year: string;
17
17
  status: string;
18
+ pdf_key?: string | null;
18
19
  }
19
20
 
20
21
  /** A question is register-shaped when it asks about certification
@@ -83,7 +84,7 @@ export function buildRegisterQuery(tokens: string[]): { sql: string; params: str
83
84
  return `(holder LIKE ?${n} OR model LIKE ?${n} OR num LIKE ?${n})`;
84
85
  });
85
86
  return {
86
- sql: `SELECT num, family, holder, model, year, status FROM certificates WHERE ${clauses.join(" OR ")} LIMIT 6`,
87
+ sql: `SELECT num, family, holder, model, year, status, pdf_key FROM certificates WHERE ${clauses.join(" OR ")} LIMIT 6`,
87
88
  params,
88
89
  };
89
90
  }
@@ -96,8 +97,10 @@ export async function searchRegister(db: any, query: string): Promise<{ rows: Re
96
97
  let { sql, params } = built;
97
98
  const family = queryFamily(query);
98
99
  if (family) {
100
+ // the register's family column is the DIGITS ONLY ("60", "105") —
101
+ // the letter+padding form ("R60") never matched a single row
99
102
  sql = sql.replace(" WHERE ", " WHERE family = ?0 AND ");
100
- params.unshift(family);
103
+ params.unshift(family.replace(/^[A-Za-z]+/, ""));
101
104
  }
102
105
  try {
103
106
  const res = await db.prepare(sql).bind(...params).all();
@@ -118,3 +121,11 @@ export function registerNote(rows: RegisterRow[]): string {
118
121
  ...lines,
119
122
  ].join("\n");
120
123
  }
124
+ /** Each certificate's location rides the note as a link when its PDF
125
+ * is in the R2 plane (TODO 7) — the user gets the document, not just
126
+ * the fact. */
127
+ export function certificateLinks(rows: RegisterRow[]): string {
128
+ const links = rows.filter((r) => r.pdf_key).map((r) => `- ${r.num}: https://www.oimlsmart.org/cert-pdf/${r.pdf_key}`);
129
+ return links.length ? `The certificates' documents:\n${links.join("\n")}` : "";
130
+ }
131
+
@@ -0,0 +1,55 @@
1
+ // The agent interface's tool plane (the owner's 2026-09-28 direction): a
2
+ // bounded tool loop on the serving path. The model may invoke named
3
+ // tools through a strict line protocol; the worker executes each
4
+ // deterministically and the result rides back ATTRIBUTED — every tool
5
+ // output is phrased as what the tool returned, and the searched string
6
+ // is always visible in the answer. One round per ask, armed only where
7
+ // a tool can help (the certificate intent, v1).
8
+
9
+ export interface ToolCall {
10
+ name: string;
11
+ args: Record<string, unknown>;
12
+ }
13
+
14
+ export interface ToolResult {
15
+ name: string;
16
+ query: string;
17
+ output: string;
18
+ }
19
+
20
+ /** The strict protocol: a line `TOOL register_search {"query": "…"}`.
21
+ * Plain regex parse — no JSON mode needed, tolerant of surrounding
22
+ * prose (the model may talk around the call). */
23
+ 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
+ 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
31
+ }
32
+ return { name: m[1].toLowerCase(), args };
33
+ }
34
+
35
+ export const TOOL_DECLARATION = `You may use one tool before answering, by writing a single line:
36
+ TOOL register_search {"query": "manufacturer and model to look up"}
37
+ The worker runs it against the certificate register (a snapshot) and returns the matching rows, or the exact no-match statement for the string you asked. Use it when the question turns on certification standing and the holder is known — including from a photograph. Then answer, phrasing the tool's result as what it returned (the searched string stays visible in your answer). If you do not need the tool, answer directly without the line.`;
38
+
39
+ export async function runTool(db: any, call: ToolCall): Promise<ToolResult | null> {
40
+ if (call.name !== "register_search") return null;
41
+ const query = String(call.args?.query ?? "").trim().slice(0, 160);
42
+ if (!query) return null;
43
+ const { searchRegister, registerNote } = await import("./certificates");
44
+ const reg = await searchRegister(db, query);
45
+ const output = reg?.rows?.length
46
+ ? registerNote(reg.rows)
47
+ : `No certificate was found for "${query}" in the certificates database (the register snapshot). State this as the search's result, with the searched string visible.`;
48
+ return { name: call.name, query, output };
49
+ }
50
+
51
+ /** The attributed injection: the answer model sees what the tool
52
+ * returned, for the string it asked — never a standing claim. */
53
+ export function toolNote(r: ToolResult): string {
54
+ return `The ${r.name} tool returned, for the query "${r.query}":\n${r.output}`;
55
+ }