@polycode-projects/the-mechanical-code-talker 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ // src/router/resolver.mjs — Stage 1 of the capability router (PLAN_CAPABILITY_ROUTER.md):
2
+ // THE RESOLVER. Turn a request into a SELECTED registry capability with bound
3
+ // arguments, by unification + backward chaining over capabilities-as-facts
4
+ // (a mini-Datalog/SLD step, exactly the "open-condition satisfaction" the plan
5
+ // names). Deterministic, no-LLM, glass-box: every choice is provable.
6
+ //
7
+ // THE CORE is the ask-kind -> capability MAPPING. A request becomes an epistemic
8
+ // GOAL `(knows <topic> ?of)`; each capability's add-effect declares which topic
9
+ // it achieves (registry.mjs `knows(topic, of)`); backward chaining finds the
10
+ // capability whose add-list unifies with the goal and binds ?of to the request's
11
+ // object term. Three fact sources feed the SAME backward-chaining step:
12
+ //
13
+ // 1. THE COMMAND REGISTER (server-http.mjs `selectTool`, the terse verbs
14
+ // "describe X" / "callers X" / "untested") — exact + unambiguous, tried
15
+ // FIRST because a terse command mis-parses through the NL grammar (e.g.
16
+ // "callees Widget.render" keyword-spots to shape:reverse/kind:calls, which
17
+ // would wrongly route to callers). A literal command verb is ground truth.
18
+ // 2. THE NL PARSE (ask.mjs `parseQuery` -> {shape, kind, entityType, object}) —
19
+ // the relational grammar ("which functions call X", "what does X export").
20
+ // NL_INTENTS maps a {shape,kind} to the epistemic TOPIC; backwardChain maps
21
+ // the topic to the capability. This is the Stage-1 deliverable proper.
22
+ // 3. IMPERATIVE INTENT FRAMES (Stage 2, this module's FRAMES table) — curated
23
+ // phrasings the relational grammar does not carry ("blast radius of X",
24
+ // "who calls X", "search for X", "the call edges of X", "explain X"): a
25
+ // regex -> {topic, arg}. Same backward chaining (topic -> capability), same
26
+ // resolveObject binding. This is the surface that lifts NL reach above the
27
+ // command register: it reaches tmct_calls (the raw call-edge dump — a grain
28
+ // the relational "call" verb collides with) via an EXPLICIT edge-dump frame,
29
+ // and it rescues a request whose NL parse selected an OUT-OF-SET capability
30
+ // by re-selecting a DECLARED one (resolveOne falls through to the frame).
31
+ //
32
+ // ENTITY BINDING is DELEGATED to `resolveObject` (ask.mjs — the tiered lemma/
33
+ // fuzzy binding oracle with honest ambiguity). This module NEVER re-implements
34
+ // resolution: the registry's `resolves(param, as)` precondition maps exactly to
35
+ // a resolveObject call, and an ambiguous / no-match term is an HONEST REFUSE,
36
+ // never a guess. On any no-fit -> refuse.
37
+ //
38
+ // Pure-ish: mapParse/mapFrame/backwardChain/commandCapability are pure; resolveOne
39
+ // is async only because it consults ctx.resolve (the graph binding oracle) and
40
+ // ctx.dispatch (executes the grounded call). No network, no Date.now.
41
+
42
+ import { parseQuery } from "../ask.mjs";
43
+ import { selectTool } from "../server-http.mjs";
44
+ import {
45
+ capabilities, capabilityByName, preconditionsOf, effectsOf, PRECOND,
46
+ } from "./registry.mjs";
47
+ import { hallucinationsIn } from "../../agentbench/grade.mjs";
48
+
49
+ // ---- the ask-kind -> epistemic-topic MAPPING (the Stage-1 core) --------------
50
+ // Keyed `${shape}:${kind}` off parseQuery's simple-clause output. The VALUE is
51
+ // the epistemic TOPIC a capability's add-effect must achieve; backwardChain then
52
+ // unifies that topic with the registry (capabilities-as-facts). Every entry's
53
+ // topic MUST be achievable by exactly one registered capability (the bidirectional
54
+ // conformance test proves it). `arg` is the parameter grain the object binds to.
55
+ export const NL_INTENTS = Object.freeze({
56
+ "reverse:calls": { topic: "callers", arg: "symbol" }, // "which fns call X" -> callers of X
57
+ "forward:calls": { topic: "callees", arg: "symbol" }, // "what does X call" -> callees of X
58
+ "reverse:tests": { topic: "tests", arg: "symbol" }, // "which tests cover X"
59
+ "forward:tests": { topic: "tests", arg: "symbol" }, // "what tests X"
60
+ "reverse:inherits": { topic: "subclasses", arg: "class" }, // "which classes extend X"
61
+ "forward:reexports": { topic: "exports", arg: "module" }, // "what does X export"
62
+ "forward:contains": { topic: "members", arg: "class" }, // "what does X contain" -> members of X
63
+ "when:touches": { topic: "history", arg: "symbol" }, // "when did X change" -> commit history
64
+ "forward:cochange": { topic: "cochanges", arg: "symbol" }, // "what changes with X"
65
+ "reverse:cochange": { topic: "cochanges", arg: "symbol" },
66
+ });
67
+
68
+ // ---- ask-vocab RELATION kinds with NO capability — the HONEST ceiling --------
69
+ // Every ask-vocab.mjs RELATIONS key must be either mapped (above) or listed here
70
+ // with a reason (the bidirectional conformance test enforces the partition — no
71
+ // silent gap). These are relations tmct's grammar SPEAKS but the read-only
72
+ // graph-query registry has no operator for: routing them anywhere would be a
73
+ // mis-route, so the resolver REFUSES (never a guess).
74
+ export const UNMAPPED_KINDS = Object.freeze({
75
+ imports: "no importer/imports query tool in the registry (there is no tmct_imports); refusing beats mis-routing to calls",
76
+ uses: "a query-side UNION (imports+calls+callsSymbol) with no single capability; a router that must emit ONE call cannot honour it — refuse",
77
+ defines: "Module->symbol `defines` has no dedicated capability (tmct_members is Class-scoped `contains`); refuse rather than answer a different grain",
78
+ });
79
+
80
+ // ---- capabilities the NL surface cannot reach today (named, not accidental) ---
81
+ // A declared capability with no NL/command/frame path is a ROUTING GAP. The
82
+ // conformance test FAILS on an untagged gap; a genuinely-unreachable cap must be
83
+ // tagged HERE with the Stage it needs, so the ceiling is honest rather than a
84
+ // silent low-completion refuse. (Coordinator reinforcement 2.)
85
+ //
86
+ // EMPTY as of Stage 2. tmct_calls — the raw call-edge dump that USED to sit here —
87
+ // is now reached by a DEDICATED imperative frame keyed on the "call edges / call
88
+ // graph / outgoing calls of X" phrasings the relational grammar does NOT carry
89
+ // (see FRAMES below). The collision the old tag named is real, so the frame does
90
+ // NOT touch the relational "call" verb (that still routes callers/callees); it
91
+ // opens a SECOND, distinct surface that names the edge-dump grain explicitly. With
92
+ // it every declared capability is NL/command/frame-reachable — the ceiling is
93
+ // genuinely empty, not a silenced gap. (The conformance test enforces both
94
+ // directions: an over-claimed tag would now fail, since tmct_calls IS reachable.)
95
+ export const NOT_NL_REACHABLE = Object.freeze({});
96
+
97
+ // ---- imperative intent FRAMES (Stage 2 — fills what the relational grammar and
98
+ // the command register both miss). regex -> { topic, arg | noArg }. `arg` names
99
+ // the parameter grain; the entity is pulled by extractEntity (or, for search, the
100
+ // residual query text). Ordered: first match wins. Every frame's topic is
101
+ // backward-chained to a capability just like an NL intent, so the frame table
102
+ // adds PHRASINGS, never a new routing path. ----
103
+ export const FRAMES = Object.freeze([
104
+ { re: /\buntested\b|\bwithout\s+(?:a\s+)?tests?\b|\bhas\s+no\s+tests?\b|\bneeds?\s+(?:a\s+)?tests?\b/i, topic: "untested", noArg: true },
105
+ { re: /\bblast\s*radius\b|\bimpacts?\b|\bimpacted\b|what\s+(?:a\s+)?change.*(?:reach|affect|touch)|what\s+(?:depends?\s+on|dependents?)\b/i, topic: "impact", arg: "module" },
106
+ // tmct_calls (Stage 2 — the reachability win): the RAW call-edge dump, a grain
107
+ // the relational "call" verb collides with (which routes callers/callees). This
108
+ // frame does NOT use the bare verb — it keys on the EXPLICIT edge-dump nouns
109
+ // ("call edges", "call graph", "outgoing calls of X") the relational grammar
110
+ // never emits, so it opens a distinct surface without touching callers/callees.
111
+ // FIRST so its explicit phrasing wins before the callees/callers verb frames.
112
+ { re: /\bcall[\s-]*edges?\b|\bcall[\s-]*graph\b|\boutgoing\s+calls?\b|\bcall[\s-]*sites?\s+(?:of|in|out|from)\b/i, topic: "calls", arg: "symbol" },
113
+ { re: /\bcallees?\b|wh(?:at|o)\s+does\s+\S+\s+call\b/i, topic: "callees", arg: "symbol" },
114
+ { re: /\bcallers?\b|who\s+calls\b|what\s+calls\b/i, topic: "callers", arg: "symbol" },
115
+ { re: /\btests?\b.*\b(?:for|cover|covering|of)\b|which\s+tests?\b|covers?\b|covered\b|test\s+coverage\b/i, topic: "tests", arg: "symbol" },
116
+ { re: /\bcochang|change[- ]coupl/i, topic: "cochanges", arg: "symbol" },
117
+ { re: /\bexports?\b|\bpublic\s+api\b/i, topic: "exports", arg: "module" },
118
+ { re: /\bsubclasses?\b|children\s+of\b|\bextends?\b/i, topic: "subclasses", arg: "class" },
119
+ { re: /\bmembers?\b|\bmethods?\s+of\b|\battributes?\s+of\b/i, topic: "members", arg: "class" },
120
+ { re: /\bhistory\b|who\s+changed\b|commits?\s+(?:that\s+)?touch/i, topic: "history", arg: "symbol" },
121
+ { re: /\bsignature\b/i, topic: "signature", arg: "symbol" },
122
+ { re: /\bdescribe\b|\bexplain\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
123
+ { re: /\bsearch\b|\bfind\b|look\s+for\b/i, topic: "matches", arg: "query" },
124
+ ]);
125
+
126
+ // ---- backward chaining (the SLD/Datalog step) --------------------------------
127
+
128
+ /** Backward-chain a goal `(knows <topic> ?of)` to the registered capability whose
129
+ * add-effect ACHIEVES it. Pure over the registry; returns the capability or null.
130
+ * This is the whole selection primitive — a capability is chosen ONLY because its
131
+ * declared effect unifies with the request's epistemic goal, never by name. */
132
+ export function backwardChain(topic) {
133
+ for (const cap of capabilities()) {
134
+ if (effectsOf(cap.name).add.some((e) => e.topic === topic)) return cap;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ // The stopword set for the imperative-frame entity extractor (Stage-2 slot
140
+ // filling). Deliberately generous: a wrong pick is caught by the resolveObject
141
+ // miss -> honest refuse, never emitted.
142
+ const STOP = new Set([
143
+ "what", "whats", "which", "who", "whom", "does", "do", "did", "is", "are", "the", "a", "an",
144
+ "of", "for", "to", "in", "on", "by", "me", "us", "tell", "about", "show", "list", "give", "get",
145
+ "assess", "check", "then", "and", "or", "instead", "if", "end", "up", "its", "it", "this", "that",
146
+ "call", "calls", "called", "caller", "callers", "callee", "callees", "test", "tests", "tested",
147
+ "cover", "covers", "covering", "describe", "description", "define", "definition", "impact",
148
+ "member", "members", "method", "methods", "attribute", "attributes", "subclass", "subclasses",
149
+ "export", "exports", "history", "commit", "commits", "signature", "search", "find", "look",
150
+ "module", "modules", "class", "classes", "function", "functions", "symbol", "symbols",
151
+ "untested", "blast", "radius", "change", "changes", "changing", "reach", "reaches", "affect", "affects",
152
+ // Stage-2 edge-dump + imperative-verb tokens (tmct_calls frame + explain/outgoing
153
+ // phrasings): none names an entity, so keep them out of the slot-filler's pool.
154
+ "explain", "edge", "edges", "graph", "outgoing", "site", "sites", "invoke", "invokes", "run", "runs", "execute", "executes",
155
+ ]);
156
+
157
+ /** Pull one entity token from a request (imperative-frame slot-filling). Prefer a
158
+ * path/dotted/CamelCase token, else the last non-stopword identifier. "" if none. */
159
+ export function extractEntity(request) {
160
+ const tokens = String(request).match(/[A-Za-z_][A-Za-z0-9_./-]*/g) || [];
161
+ const pool = tokens.filter((t) => !STOP.has(t.toLowerCase()));
162
+ const strong = pool.filter((t) => /[./]/.test(t) || /^[A-Z][a-z]/.test(t) || /\.[a-z]+$/.test(t));
163
+ const pick = strong.length ? strong : pool;
164
+ return pick.length ? pick[pick.length - 1] : "";
165
+ }
166
+
167
+ /** The residual search query: strip the leading search/find verb + a filler "for". */
168
+ function searchQuery(request) {
169
+ return String(request).replace(/^\s*(?:search|find|look\s+for|look)\s+(?:for\s+)?/i, "").trim();
170
+ }
171
+
172
+ // ---- request -> capability + raw term (PURE, no binding yet) -----------------
173
+
174
+ /** Map a parseQuery simple-clause parse to a capability selection, or a refusal,
175
+ * or null (not an NL shape this resolver routes). Returns one of:
176
+ * { name, arg, term, topic, source:"nl", why:[...] } — a selection
177
+ * { refuse:true, reason } — an honest no-fit
178
+ * null — parse absent / not simple
179
+ * Backward-chains topic -> capability so selection is provable, never by name. */
180
+ export function mapParse(parse) {
181
+ if (!parse || parse.node || !parse.shape) return null; // compositional/absent -> not here
182
+ const { shape, kind } = parse;
183
+ // yes/no + locational/vocabulary shapes have no read-only query operator
184
+ if (shape === "ask") return { refuse: true, reason: "a yes/no `does X <verb> Y` question has no capability that emits it (the registry answers WHAT, not WHETHER)" };
185
+ if (shape === "where" || shape === "meta" || shape === "mentions") {
186
+ return { refuse: true, reason: `the "${shape}" shape (location/vocabulary/prose) has no read-only graph-query capability` };
187
+ }
188
+ const intent = NL_INTENTS[`${shape}:${kind}`];
189
+ if (!intent) {
190
+ const reason = UNMAPPED_KINDS[kind] || `no capability maps the ${shape}/${kind} intent`;
191
+ return { refuse: true, reason };
192
+ }
193
+ const cap = backwardChain(intent.topic);
194
+ if (!cap) return { refuse: true, reason: `backward chaining found no capability achieving (knows ${intent.topic})` };
195
+ return {
196
+ name: cap.name, arg: intent.arg, term: String(parse.object || "").trim(), topic: intent.topic, source: "nl",
197
+ why: [`parse ${shape}/${kind} => goal (knows ${intent.topic} ?${intent.arg})`, `backward-chain => ${cap.name} (its add-effect achieves ${intent.topic})`],
198
+ };
199
+ }
200
+
201
+ /** Match an imperative FRAME. Returns { name, arg|noArg, term, topic, source:"frame", why }
202
+ * or null. Backward-chains the frame's topic to a capability. */
203
+ export function mapFrame(request) {
204
+ for (const f of FRAMES) {
205
+ if (!f.re.test(request)) continue;
206
+ const cap = backwardChain(f.topic);
207
+ if (!cap) continue;
208
+ if (f.noArg) return { name: cap.name, noArg: true, topic: f.topic, source: "frame", why: [`imperative frame => goal (knows ${f.topic})`, `backward-chain => ${cap.name}`] };
209
+ const term = f.arg === "query" ? searchQuery(request) : extractEntity(request);
210
+ return { name: cap.name, arg: f.arg, term, topic: f.topic, source: "frame", why: [`imperative frame => goal (knows ${f.topic} ?${f.arg})`, `backward-chain => ${cap.name}`] };
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /** The command register (server-http.mjs selectTool) filtered to the registry.
216
+ * Returns { name, input, source:"command", why } or null. selectTool binds the
217
+ * arg from the terse command; we re-clean a search query (its raw join keeps a
218
+ * stray "for"). Never reaches outside the declared set. */
219
+ export function commandCapability(request, declaredNames) {
220
+ const sel = selectTool(request, new Set(declaredNames));
221
+ if (!sel || !capabilityByName(sel.name)) return null;
222
+ const input = { ...sel.input };
223
+ if (sel.name === "tmct_search" && input.query != null) input.query = searchQuery(request) || String(input.query);
224
+ return { name: sel.name, input, source: "command", why: [`command register: "${String(request).trim().split(/\s+/)[0]}" => ${sel.name}`] };
225
+ }
226
+
227
+ // ---- the full single-call resolver (async — binds + grounds) -----------------
228
+
229
+ /** Build the glass-box proof chain for a grounded single call: its preconditions
230
+ * (graphLoaded + resolves(param) with the BOUND value + any-present) then the
231
+ * epistemic add-effect. Dispatch has SUCCEEDED, so `resolves` steps are ok. */
232
+ export function proofFor(name, input) {
233
+ const steps = [];
234
+ for (const pre of preconditionsOf(name)) {
235
+ if (pre.pred === PRECOND.graphLoaded) steps.push({ step: "precondition", pred: pre.pred, ok: true });
236
+ else if (pre.pred === PRECOND.resolves) steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: input[pre.param] ?? null, ok: true });
237
+ else if (pre.pred === PRECOND.anyPresent) steps.push({ step: "precondition", pred: pre.pred, params: pre.params, ok: pre.params.some((k) => input[k]) });
238
+ }
239
+ for (const eff of effectsOf(name).add) steps.push({ step: "effect", pred: eff.pred, topic: eff.topic, of: eff.of });
240
+ return steps;
241
+ }
242
+
243
+ const REFUSE = (why) => ({ selected: null, refused: true, reason: why });
244
+
245
+ /** Select a capability for a request and BIND its arguments — the full resolver.
246
+ * Order: command register -> NL parse -> imperative frame. On a bound selection
247
+ * it delegates entity binding to ctx.resolve (resolveObject) and, unless
248
+ * `execute:false`, grounds it via ctx.dispatch. Returns
249
+ * { selected:{name,input}, proof, why, resolved, observed? } — a grounded call
250
+ * { selected:null, refused:true, reason } — an honest refusal
251
+ * NEVER emits an ungrounded / ambiguous / undeclared call. */
252
+ export async function resolveOne(request, declaredNames, ctx, { execute = true } = {}) {
253
+ const declared = new Set(declaredNames);
254
+
255
+ // 1. command register (exact) 2. NL parse 3. imperative frame. An NL-parse
256
+ // REFUSAL is NOT terminal: a shape with no relational operator ("what is the
257
+ // impact of X", "list what covers X") can still be an imperative FRAME hit,
258
+ // so we fall through and only surface the NL reason if the frame misses too.
259
+ let pick = commandCapability(request, declared);
260
+ let nlRefuse = null;
261
+ let nlUndeclared = null;
262
+ if (!pick) {
263
+ const mapped = mapParse(parseQuery(request));
264
+ if (mapped && !mapped.refuse) {
265
+ // An NL parse that selects a DECLARED capability is the Stage-1 answer. One
266
+ // that selects an OUT-OF-SET capability is NOT terminal (Stage-2 widening):
267
+ // an imperative FRAME may still reach a DECLARED capability for the SAME
268
+ // request (e.g. keyword-spot mis-routes "outgoing calls of X" toward an
269
+ // out-of-set callers/callees, but the calls-frame reaches the declared
270
+ // tmct_calls). Hold the out-of-set name and fall through; surface it only if
271
+ // the frame misses too. This can only turn a refuse into a grounded DECLARED
272
+ // call — the declared/hallucination gates below still apply, never a guess.
273
+ if (declared.has(mapped.name)) pick = mapped;
274
+ else nlUndeclared = mapped.name;
275
+ } else if (mapped && mapped.refuse) nlRefuse = mapped.reason;
276
+ }
277
+ if (!pick) pick = mapFrame(request);
278
+ if (!pick) {
279
+ if (nlRefuse) return REFUSE(nlRefuse);
280
+ if (nlUndeclared) return REFUSE(`selected ${nlUndeclared} but it is not in the declared toolset`);
281
+ return REFUSE("no command, NL parse, or imperative frame selects a capability");
282
+ }
283
+ let why = pick.why ?? [];
284
+ if (!declared.has(pick.name)) return REFUSE(`selected ${pick.name} but it is not in the declared toolset`);
285
+
286
+ // build the bound input. A command pick already carries a bound input; an NL /
287
+ // frame pick carries a raw term that we BIND via resolveObject (the oracle).
288
+ let input = pick.input ? { ...pick.input } : {};
289
+ let resolved = null;
290
+ if (!pick.input && !pick.noArg) {
291
+ const term = String(pick.term || "").trim();
292
+ if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
293
+ // DELEGATE binding to resolveObject — the resolves(param,as) precondition.
294
+ const r = ctx.resolve ? ctx.resolve(term) : { match: { label: term }, ambiguous: false };
295
+ if (!r || !r.match) return REFUSE(`"${term}" does not resolve to any graph entity (honest miss)`);
296
+ if (r.ambiguous) return REFUSE(`"${term}" is ambiguous (${[r.match, ...(r.candidates || [])].slice(0, 4).map((m) => m.label).join(", ")}) — narrow it`);
297
+ resolved = r.match;
298
+ input = { [pick.arg]: r.match.label };
299
+ why = [...why, `resolveObject: "${term}" => ${r.match.label} (${r.match.class || "?"}, tier ${r.tier})`];
300
+ }
301
+
302
+ const call = { name: pick.name, input };
303
+ // the same zero-hallucination gate the grader enforces — self-check before emit.
304
+ const problems = hallucinationsIn(call, [...declared]);
305
+ if (problems.length) return REFUSE(`bound call did not validate: ${problems.map((p) => p.reason).join(",")}`);
306
+
307
+ // ground it: a ToolError (unresolvable entity) is an honest miss -> refuse.
308
+ if (execute && ctx.dispatch) {
309
+ const res = await ctx.dispatch(pick.name, input);
310
+ if (!res.ok) return REFUSE(`unresolvable at dispatch: ${res.error}`);
311
+ return { selected: call, proof: proofFor(pick.name, input), why, resolved: res.resolved ?? resolved, observed: String(res.text ?? "").slice(0, 240) };
312
+ }
313
+ return { selected: call, proof: proofFor(pick.name, input), why, resolved };
314
+ }
315
+
316
+ // ---- reachability (used by the bidirectional conformance test + docs) ---------
317
+
318
+ /** The epistemic topics some NL intent or imperative frame can reach. */
319
+ export function nlReachableTopics() {
320
+ const topics = new Set();
321
+ for (const v of Object.values(NL_INTENTS)) topics.add(v.topic);
322
+ for (const f of FRAMES) topics.add(f.topic);
323
+ return topics;
324
+ }
325
+
326
+ /** Every capability an NL intent or frame can reach (by name), via backwardChain. */
327
+ export function reachableCapabilityNames() {
328
+ const names = new Set();
329
+ for (const topic of nlReachableTopics()) { const c = backwardChain(topic); if (c) names.add(c.name); }
330
+ return names;
331
+ }
@@ -0,0 +1,296 @@
1
+ // server-http.mjs — `tmct serve`: an Anthropic Messages API-compatible HTTP
2
+ // endpoint (POST /v1/messages) over tmct's existing zero-model engine.
3
+ //
4
+ // This is Phase A of the capability-router plan (PLAN_CAPABILITY_ROUTER.md): the
5
+ // COMMON INTERFACE a tool-loop client (Claude Code) already speaks. It is a
6
+ // deterministic serialization/HTTP shim — NO model, ever. A request carries
7
+ // { model, messages[], tools[], max_tokens, system? }; a response is a message
8
+ // with `content` blocks (text and/or tool_use) and a `stop_reason`:
9
+ //
10
+ // - TEXT ANSWER (stop_reason "end_turn"): the latest user text is run through
11
+ // runTurn (src/chat.mjs) over the configured graph — the same cited,
12
+ // read-only answer the chat surface gives. Emitted when no tools are
13
+ // declared, or when nothing maps to a declared graph-query tool.
14
+ // - TOOL_USE (stop_reason "tool_use"): when tools[] are declared and the
15
+ // request maps to a declared graph-query tool, a { type:"tool_use", id,
16
+ // name, input } block is emitted — `name`+`input` are backed by dispatchTool
17
+ // (src/server.mjs). The caller executes it and returns a tool_result block;
18
+ // the next request closes the loop with an end_turn text answer.
19
+ //
20
+ // bedrock-meter-pluggable: every response's `usage` is { input_tokens: 0,
21
+ // output_tokens: 0 } — tmct is the $0 floor, priced as free by the meter.
22
+ //
23
+ // NOTE: src/server.mjs is the TOOL-DISPATCH layer (dispatchTool), NOT an HTTP
24
+ // server; this module is the HTTP surface and imports that layer's exports.
25
+
26
+ import { createServer } from "node:http";
27
+ import { runTurn, COMMANDS, asBareCommand, isConversational } from "./chat.mjs";
28
+ import { TOOLS } from "./server.mjs";
29
+ import { parseEntities } from "./codegraph.mjs";
30
+ import { uuidv7 } from "./uuid.mjs";
31
+ import * as defaultSource from "./source.mjs";
32
+
33
+ /** The zero usage every response carries — the meter prices tmct as the $0 floor. */
34
+ const ZERO_USAGE = { input_tokens: 0, output_tokens: 0 };
35
+
36
+ /** The tmct tools dispatchTool can back (the set the shim will emit a tool_use for).
37
+ * A declared tool outside this set is ignored for emission (the request falls
38
+ * through to a text answer) — the shim never emits a call it cannot ground. The
39
+ * COMMANDS map (chat.mjs) names the richer graph tools; TOOLS names the hot
40
+ * catalog. Their union is what dispatchTool serves. */
41
+ const BACKED_TOOLS = new Set([
42
+ ...TOOLS.map((t) => t.name),
43
+ ...Object.values(COMMANDS).map((s) => s.tool),
44
+ ]);
45
+
46
+ /** Flatten a message's `content` (a string OR a content-block array) into plain
47
+ * text — concatenating the `text` blocks. Non-text blocks are ignored here. */
48
+ function textOfContent(content) {
49
+ if (typeof content === "string") return content;
50
+ if (!Array.isArray(content)) return "";
51
+ return content
52
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
53
+ .map((b) => b.text)
54
+ .join("\n")
55
+ .trim();
56
+ }
57
+
58
+ /** The last message with the given role, or null. */
59
+ function lastMessageOfRole(messages, role) {
60
+ if (!Array.isArray(messages)) return null;
61
+ for (let i = messages.length - 1; i >= 0; i--) {
62
+ if (messages[i] && messages[i].role === role) return messages[i];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** The first tool_result block in a message's content array, or null. The caller
68
+ * returns one of these after executing a tool_use — its presence means the loop
69
+ * is closing and we answer with end_turn. */
70
+ function firstToolResult(message) {
71
+ const content = message && message.content;
72
+ if (!Array.isArray(content)) return null;
73
+ return content.find((b) => b && b.type === "tool_result") || null;
74
+ }
75
+
76
+ /** Render a tool_result block's `content` (string OR block array OR arbitrary
77
+ * value) back to text — what the caller reported when it executed the tool. */
78
+ function toolResultText(block) {
79
+ const c = block && block.content;
80
+ if (typeof c === "string") return c;
81
+ if (Array.isArray(c)) return textOfContent(c);
82
+ if (c == null) return "";
83
+ try { return JSON.stringify(c); } catch { return String(c); }
84
+ }
85
+
86
+ /**
87
+ * Decide whether a user turn maps to a DECLARED, dispatch-backed graph-query
88
+ * tool, and bind its arguments. Deterministic, in-ethos (no NL guessing beyond
89
+ * the chat surface's own command routing):
90
+ *
91
+ * 1. A slash/bare command that names a tmct tool ("describe X", "/callers X",
92
+ * "untested") → that tool with its argument bound from the exact arg key the
93
+ * dispatchTool switch reads (COMMANDS in chat.mjs). Only when the tool is
94
+ * declared by the caller.
95
+ * 2. Otherwise, a non-conversational structural question → tmct_ask{query:…},
96
+ * when tmct_ask is declared. Small-talk (isConversational) never emits a
97
+ * call — it falls through to a text answer.
98
+ *
99
+ * Returns { name, input } or null (→ answer as text).
100
+ */
101
+ export function selectTool(text, declaredNames) {
102
+ const t = String(text || "").trim();
103
+ if (!t) return null;
104
+
105
+ // 1. explicit command form → a specific tool, argument bound
106
+ const cmdLine = t.startsWith("/") ? t : asBareCommand(t);
107
+ if (cmdLine) {
108
+ const [first, ...restTok] = cmdLine.replace(/^\//, "").split(/\s+/);
109
+ const spec = COMMANDS[String(first).toLowerCase()];
110
+ if (spec && declaredNames.has(spec.tool) && BACKED_TOOLS.has(spec.tool)) {
111
+ const input = {};
112
+ if (spec.arg) {
113
+ const val = restTok.join(" ").trim();
114
+ if (val) input[spec.arg] = val;
115
+ // an entity command with no argument can't bind a call — fall through
116
+ else if (!spec.optional) return askFallback(t, declaredNames);
117
+ }
118
+ return { name: spec.tool, input };
119
+ }
120
+ }
121
+
122
+ // 2. structural question → tmct_ask, unless it's small-talk
123
+ return askFallback(t, declaredNames);
124
+ }
125
+
126
+ /** The tmct_ask fallback: emit tmct_ask{query} for a non-conversational line when
127
+ * the caller declared tmct_ask; otherwise null (→ text answer). */
128
+ function askFallback(text, declaredNames) {
129
+ if (declaredNames.has("tmct_ask") && BACKED_TOOLS.has("tmct_ask") && !isConversational(text)) {
130
+ return { name: "tmct_ask", input: { query: text } };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /** Build the assistant message envelope shared by every branch. */
136
+ function assistantMessage(model, content, stopReason) {
137
+ return {
138
+ id: `msg_${uuidv7().replace(/-/g, "")}`,
139
+ type: "message",
140
+ role: "assistant",
141
+ model: model || "tmct",
142
+ content,
143
+ stop_reason: stopReason,
144
+ stop_sequence: null,
145
+ usage: { ...ZERO_USAGE },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Produce the Messages-API response for one request body. Pure over its inputs
151
+ * (the loaded graph + config), so it is unit-testable without a socket.
152
+ * - a returned tool_result → end_turn text (relay the tool's output)
153
+ * - a mapped, declared graph tool → tool_use
154
+ * - otherwise → end_turn text via runTurn
155
+ */
156
+ export async function respondToMessages(body, { config, graph, source = defaultSource } = {}) {
157
+ const { model, messages, tools } = body || {};
158
+ const declaredNames = new Set(
159
+ (Array.isArray(tools) ? tools : []).map((t) => t && t.name).filter(Boolean),
160
+ );
161
+
162
+ // Closing the loop: the caller executed our tool_use and returned a
163
+ // tool_result. Relay it as the final, cited answer with end_turn.
164
+ const lastUser = lastMessageOfRole(messages, "user");
165
+ const tr = firstToolResult(lastUser);
166
+ if (tr) {
167
+ const text = toolResultText(tr) || "(the tool returned no output)";
168
+ return assistantMessage(model, [{ type: "text", text }], "end_turn");
169
+ }
170
+
171
+ const userText = textOfContent(lastUser && lastUser.content);
172
+
173
+ // tool_use emission: a declared, dispatch-backed graph tool the request maps to.
174
+ if (declaredNames.size) {
175
+ const sel = selectTool(userText, declaredNames);
176
+ if (sel) {
177
+ const block = {
178
+ type: "tool_use",
179
+ id: `toolu_${uuidv7().replace(/-/g, "")}`,
180
+ name: sel.name,
181
+ input: sel.input,
182
+ };
183
+ return assistantMessage(model, [block], "tool_use");
184
+ }
185
+ }
186
+
187
+ // text answer: the cited, read-only answer the chat surface gives. memoryDir is
188
+ // null so the endpoint is PURE — no session artifacts, no writes, deterministic.
189
+ const { answer } = await runTurn(userText, { config, graph, source, memoryDir: null });
190
+ return assistantMessage(model, [{ type: "text", text: answer }], "end_turn");
191
+ }
192
+
193
+ /** Self-description payload (GET /) — lets a routing target discover the endpoint
194
+ * and the tools tmct can back with just an HTTP GET. */
195
+ function describe(config) {
196
+ return {
197
+ service: "tmct",
198
+ description: "Anthropic Messages API-compatible, deterministic, no-LLM graph router (the $0 floor).",
199
+ endpoint: { method: "POST", path: "/v1/messages" },
200
+ graph: config && config.graphFile,
201
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema })),
202
+ usage_pricing: ZERO_USAGE,
203
+ };
204
+ }
205
+
206
+ async function readBody(req, limit = 5 * 1024 * 1024) {
207
+ return new Promise((resolve, reject) => {
208
+ const chunks = [];
209
+ let size = 0;
210
+ req.on("data", (c) => {
211
+ size += c.length;
212
+ if (size > limit) { reject(new Error("request body too large")); req.destroy(); return; }
213
+ chunks.push(c);
214
+ });
215
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
216
+ req.on("error", reject);
217
+ });
218
+ }
219
+
220
+ function sendJson(res, status, obj) {
221
+ const payload = JSON.stringify(obj);
222
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
223
+ res.end(payload);
224
+ }
225
+
226
+ /** An Anthropic-style error envelope. */
227
+ function sendError(res, status, type, message) {
228
+ sendJson(res, status, { type: "error", error: { type, message } });
229
+ }
230
+
231
+ /**
232
+ * Start the HTTP server. Loads the graph once (tolerant: a missing artifact is
233
+ * the empty bootstrap graph, never an error). Returns { server, url, host, port,
234
+ * config, close } — `close()` shuts the socket cleanly (no hanging handles).
235
+ *
236
+ * config — { graphFile } (build via configFor(repoPath) in bin/tmct.mjs)
237
+ * host — bind address (default 127.0.0.1)
238
+ * port — TCP port; 0 picks an ephemeral port (tests)
239
+ */
240
+ export async function startServer({ config, host = "127.0.0.1", port = 0, source = defaultSource } = {}) {
241
+ if (!config || !config.graphFile) throw new Error("startServer requires config.graphFile");
242
+ // Load the graph once, up front. A missing artifact loads as the empty
243
+ // bootstrap graph — runTurn tolerates it (an honest empty/orienting answer).
244
+ const graph = parseEntities(await source.fetchEntities(config));
245
+
246
+ const server = createServer(async (req, res) => {
247
+ try {
248
+ const url = new URL(req.url, "http://localhost");
249
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/v1/models")) {
250
+ sendJson(res, 200, describe(config));
251
+ return;
252
+ }
253
+ if (url.pathname !== "/v1/messages") {
254
+ sendError(res, 404, "not_found_error", `no route ${req.method} ${url.pathname}`);
255
+ return;
256
+ }
257
+ if (req.method !== "POST") {
258
+ sendError(res, 405, "invalid_request_error", "POST /v1/messages");
259
+ return;
260
+ }
261
+ let body;
262
+ try {
263
+ body = JSON.parse((await readBody(req)) || "{}");
264
+ } catch {
265
+ sendError(res, 400, "invalid_request_error", "request body is not valid JSON");
266
+ return;
267
+ }
268
+ if (!body || !Array.isArray(body.messages)) {
269
+ sendError(res, 400, "invalid_request_error", "`messages` array is required");
270
+ return;
271
+ }
272
+ const out = await respondToMessages(body, { config, graph, source });
273
+ sendJson(res, 200, out);
274
+ } catch (e) {
275
+ sendError(res, 500, "api_error", e && e.message ? e.message : String(e));
276
+ }
277
+ });
278
+
279
+ await new Promise((resolve, reject) => {
280
+ server.once("error", reject);
281
+ server.listen(port, host, () => { server.removeListener("error", reject); resolve(); });
282
+ });
283
+
284
+ const addr = server.address();
285
+ const boundPort = typeof addr === "object" && addr ? addr.port : port;
286
+ const url = `http://${host}:${boundPort}`;
287
+
288
+ return {
289
+ server,
290
+ host,
291
+ port: boundPort,
292
+ url,
293
+ config,
294
+ close: () => new Promise((resolve) => server.close(() => resolve())),
295
+ };
296
+ }
package/src/syllogise.mjs CHANGED
Binary file