@polycode-projects/the-mechanical-code-talker 2.11.6 → 2.11.9

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.
@@ -15,9 +15,13 @@
15
15
  // HTTP surface.
16
16
 
17
17
  import { createServer } from "node:http";
18
- import { runTurn, selectTool } from "../../services/chat.mjs";
19
- import { TOOLS } from "../../tools/server.mjs";
18
+ import { runTurn, selectTool, capabilityPlanDeps } from "../../services/chat.mjs";
19
+ import { TOOLS, dispatchTool } from "../../tools/server.mjs";
20
+ import { runCapabilityPlan, buildCapabilityPlanCtx, declaredCapabilityNames } from "../../domain/router/drive.mjs";
21
+ import { isCapability } from "../../domain/router/registry.mjs";
22
+ import { hallucinationsIn } from "../../domain/router/call-validator.mjs";
20
23
  import { parseEntities } from "../../domain/codegraph.mjs";
24
+ import { ToolError } from "../../adapters/config.mjs";
21
25
  import { uuidv7 } from "../../adapters/uuid.mjs";
22
26
  import * as defaultSource from "../../adapters/source.mjs";
23
27
 
@@ -83,9 +87,58 @@ function assistantMessage(model, content, stopReason) {
83
87
  };
84
88
  }
85
89
 
90
+ /** The first tool_use block of a transcript that ENDS on an assistant message —
91
+ * a caller PROPOSING a call for tmct to validate and (if clean) run. Returns
92
+ * { name, input, id, rest } (rest = any further tool_use blocks in the same
93
+ * message, noted in the reply but not executed) or null. A transcript that ends
94
+ * user-role with a tool_result is the loop-closing shape, not a proposal, so
95
+ * this returns null for it — its final message is not an assistant message. */
96
+ function proposedToolUse(messages) {
97
+ if (!Array.isArray(messages) || messages.length === 0) return null;
98
+ const last = messages[messages.length - 1];
99
+ if (!last || last.role !== "assistant" || !Array.isArray(last.content)) return null;
100
+ const uses = last.content.filter((b) => b && b.type === "tool_use" && typeof b.name === "string");
101
+ if (!uses.length) return null;
102
+ const [first, ...rest] = uses;
103
+ return {
104
+ name: first.name,
105
+ input: first.input && typeof first.input === "object" ? first.input : {},
106
+ id: first.id,
107
+ rest,
108
+ };
109
+ }
110
+
111
+ /** One human line for a hallucination-taxonomy problem, its reason in parentheses
112
+ * so a caller can grep the machine reason off the prose. */
113
+ function describeProblem(p, name) {
114
+ switch (p.reason) {
115
+ case "unknown-tool": return `"${p.detail}" is not a capability I know (unknown-tool)`;
116
+ case "undeclared": return `"${p.detail}" is a real capability but was not declared in this request (undeclared)`;
117
+ case "unknown-arg": return `${p.detail} is not an argument ${name} accepts (unknown-arg)`;
118
+ case "missing-arg": return `${p.detail} (missing-arg)`;
119
+ default: return `${p.detail} (${p.reason})`;
120
+ }
121
+ }
122
+
123
+ /** The tmct capabilities a request declared — what tmct will actually validate
124
+ * and execute, as named in a hand-back / refusal so the caller can see the set. */
125
+ function declaredCapabilityList(declaredNames) {
126
+ const caps = [...declaredNames].filter(isCapability);
127
+ return caps.length ? caps.join(", ") : "(none)";
128
+ }
129
+
130
+ /** Append the "N further calls not processed" note (when a proposal carried more
131
+ * than one tool_use block) to a reply text. */
132
+ function withRestNote(text, rest) {
133
+ if (!rest.length) return text;
134
+ return `${text}\n(${rest.length} further proposed call(s) in the same message were not processed: ${rest.map((b) => b.name).join(", ")}.)`;
135
+ }
136
+
86
137
  /**
87
138
  * Produce the Messages-API response for one request body. Pure over its inputs
88
139
  * (the loaded graph + config), so it is unit-testable without a socket.
140
+ * - a caller-PROPOSED tool_use (transcript ends on an assistant tool_use) →
141
+ * validate with hallucinationsIn, then execute (end_turn) or refuse
89
142
  * - a returned tool_result → end_turn text (relay the tool's output)
90
143
  * - a mapped, declared graph tool → tool_use
91
144
  * - otherwise → end_turn text via runTurn
@@ -96,6 +149,57 @@ export async function respondToMessages(body, { config, graph, source = defaultS
96
149
  (Array.isArray(tools) ? tools : []).map((t) => t && t.name).filter(Boolean),
97
150
  );
98
151
 
152
+ // A caller-proposed call: validate it against the registry + the declared set
153
+ // before anything runs, then execute a clean call or refuse a hallucinated one
154
+ // with the taxonomy's reason. Checked FIRST — a proposal ends on an assistant
155
+ // tool_use, so the loop-closing tool_result branch below (final user turn)
156
+ // never fires for it, and this never fires for a loop close.
157
+ const proposal = proposedToolUse(messages);
158
+ if (proposal) {
159
+ const { name, input, rest } = proposal;
160
+
161
+ // A tool the caller declared that is NOT a tmct capability is the caller's
162
+ // OWN tool — hand it back honestly rather than burning the taxonomy's
163
+ // unknown-tool (which stays reserved for a genuinely invented name).
164
+ if (declaredNames.has(name) && !isCapability(name)) {
165
+ const text = `"${name}" is your own tool, not a tmct capability — tmct validates and executes only tmct capabilities (${declaredCapabilityList(declaredNames)}). Nothing was executed.`;
166
+ return assistantMessage(model, [{ type: "text", text: withRestNote(text, rest) }], "end_turn");
167
+ }
168
+
169
+ const problems = hallucinationsIn({ name, input }, [...declaredNames]);
170
+ if (problems.length) {
171
+ const lines = problems.map((p) => `refusing the proposed call — ${describeProblem(p, name)}.`);
172
+ lines.push(`Nothing was executed; declared capabilities: ${declaredCapabilityList(declaredNames)}.`);
173
+ const msg = assistantMessage(model, [{ type: "text", text: withRestNote(lines.join("\n"), rest) }], "refusal");
174
+ msg.tmct_checked_call = { name, input, problems };
175
+ return msg;
176
+ }
177
+
178
+ // Clean. A taught: record is simulated over the taught rules only — never a
179
+ // dispatchable tool — so decline honestly rather than dispatch an "unknown
180
+ // tool" error under a call that actually validated.
181
+ if (name.startsWith("taught:")) {
182
+ const text = `"${name}" is a taught action — simulated over the taught rules, not a dispatchable tool — so nothing was executed. Run it in chat with "next".`;
183
+ const msg = assistantMessage(model, [{ type: "text", text: withRestNote(text, rest) }], "end_turn");
184
+ msg.tmct_checked_call = { name, input, problems: [] };
185
+ return msg;
186
+ }
187
+ try {
188
+ const out = await dispatchTool(name, input, { config, source });
189
+ const msg = assistantMessage(model, [{ type: "text", text: withRestNote(out, rest) }], "end_turn");
190
+ msg.tmct_checked_call = { name, input, problems: [] };
191
+ return msg;
192
+ } catch (e) {
193
+ if (!(e instanceof ToolError)) throw e;
194
+ // A well-formed call that grounded nothing is an honest MISS, not a
195
+ // refusal — the taxonomy is clean; the graph simply had no answer.
196
+ const text = `the proposed call was well-formed but grounded nothing: ${e.message}`;
197
+ const msg = assistantMessage(model, [{ type: "text", text: withRestNote(text, rest) }], "end_turn");
198
+ msg.tmct_checked_call = { name, input, problems: [] };
199
+ return msg;
200
+ }
201
+ }
202
+
99
203
  // Closing the loop: the caller executed our tool_use and returned a
100
204
  // tool_result. Relay it as the final, cited answer with end_turn.
101
205
  const lastUser = lastMessageOfRole(messages, "user");
@@ -127,6 +231,44 @@ export async function respondToMessages(body, { config, graph, source = defaultS
127
231
  return assistantMessage(model, [{ type: "text", text: answer }], "end_turn");
128
232
  }
129
233
 
234
+ /** The capability names a /v1/plan request restricts its plan to (its `tools`
235
+ * array of names or `{name}` objects), and any that are not registered
236
+ * capabilities — the route rejects an `unknown` list with a 400 before the loop
237
+ * ever runs. A request with no `tools` plans over every registered capability. */
238
+ function planToolNames(body) {
239
+ const declared = declaredCapabilityNames();
240
+ if (!Array.isArray(body?.tools)) return { tools: declared, declared, unknown: [] };
241
+ const tools = body.tools.map((t) => (typeof t === "string" ? t : t && t.name)).filter(Boolean);
242
+ const unknown = tools.filter((t) => !declared.includes(t));
243
+ return { tools, declared, unknown };
244
+ }
245
+
246
+ /**
247
+ * Produce the /v1/plan response for one request body — the capability router
248
+ * (runCapabilityPlan) over HTTP, the same loop result the library export and the
249
+ * `tmct plan --json` CLI already return. Assumes a validated body (the route
250
+ * rejects a missing `request` or an unknown tool name with a 400 first).
251
+ * - refused → { request, ...loopResult } — an in-band honest "no plan found",
252
+ * not a protocol error (still HTTP 200)
253
+ * - grounded → { request, ...loopResult, usage } with $0 usage
254
+ *
255
+ * Per-request taught-action registrations ride ctx.disposers and are unregistered
256
+ * in the finally, so a second request re-reads the store instead of colliding on
257
+ * an already-registered taught capability name.
258
+ */
259
+ export async function respondToPlan(body, { config, graph, memoryDir = null, source = defaultSource } = {}) {
260
+ const request = typeof body?.request === "string" ? body.request.trim() : "";
261
+ const { tools } = planToolNames(body);
262
+ const ctx = await buildCapabilityPlanCtx({ ...capabilityPlanDeps(), config, source, graph, memoryDir });
263
+ try {
264
+ const result = await runCapabilityPlan(request, tools, ctx);
265
+ if (result.refused) return { request, ...result };
266
+ return { request, ...result, usage: { ...ZERO_USAGE } };
267
+ } finally {
268
+ for (const dispose of ctx.disposers || []) dispose();
269
+ }
270
+ }
271
+
130
272
  /** Self-description payload (GET /) — lets a routing target discover the endpoint
131
273
  * and the tools tmct can back with just an HTTP GET. */
132
274
  function describe(config) {
@@ -134,6 +276,7 @@ function describe(config) {
134
276
  service: "tmct",
135
277
  description: "Anthropic Messages API-compatible, deterministic, no-LLM graph router (the $0 floor).",
136
278
  endpoint: { method: "POST", path: "/v1/messages" },
279
+ plan_endpoint: { method: "POST", path: "/v1/plan" },
137
280
  graph: config && config.graphFile,
138
281
  tools: TOOLS.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema })),
139
282
  usage_pricing: ZERO_USAGE,
@@ -174,7 +317,7 @@ function sendError(res, status, type, message) {
174
317
  * host — bind address (default 127.0.0.1)
175
318
  * port — TCP port; 0 picks an ephemeral port (tests)
176
319
  */
177
- export async function startServer({ config, host = "127.0.0.1", port = 0, source = defaultSource } = {}) {
320
+ export async function startServer({ config, host = "127.0.0.1", port = 0, source = defaultSource, memoryDir = null } = {}) {
178
321
  if (!config || !config.graphFile) throw new Error("startServer requires config.graphFile");
179
322
  // Load the graph once, up front. A missing artifact loads as the empty
180
323
  // bootstrap graph — runTurn tolerates it (an honest empty/orienting answer).
@@ -187,6 +330,32 @@ export async function startServer({ config, host = "127.0.0.1", port = 0, source
187
330
  sendJson(res, 200, describe(config));
188
331
  return;
189
332
  }
333
+ if (url.pathname === "/v1/plan") {
334
+ if (req.method !== "POST") {
335
+ sendError(res, 405, "invalid_request_error", "POST /v1/plan");
336
+ return;
337
+ }
338
+ let body;
339
+ try {
340
+ body = JSON.parse((await readBody(req)) || "{}");
341
+ } catch {
342
+ sendError(res, 400, "invalid_request_error", "request body is not valid JSON");
343
+ return;
344
+ }
345
+ const request = typeof body?.request === "string" ? body.request.trim() : "";
346
+ if (!request) {
347
+ sendError(res, 400, "invalid_request_error", "`request` is required and must be a non-empty string");
348
+ return;
349
+ }
350
+ const { unknown, declared } = planToolNames(body);
351
+ if (unknown.length) {
352
+ sendError(res, 400, "invalid_request_error", `unknown tools name(s): ${unknown.join(", ")}; registered capabilities: ${declared.join(", ")}`);
353
+ return;
354
+ }
355
+ const out = await respondToPlan(body, { config, graph, memoryDir, source });
356
+ sendJson(res, 200, out);
357
+ return;
358
+ }
190
359
  if (url.pathname !== "/v1/messages") {
191
360
  sendError(res, 404, "not_found_error", `no route ${req.method} ${url.pathname}`);
192
361
  return;
@@ -179,4 +179,24 @@ export async function exportFactsJsonl(memoryDir) {
179
179
  return serializeFactsJsonl(await loadMemory(memoryDir));
180
180
  }
181
181
 
182
- globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider, normFactTerm, vocabExampleHint, memoryStats, openPersistedStore, exportFactsJsonl, splitSentences: splitSentencesPreservingPaths };
182
+ /**
183
+ * Every fact a "research <topic>" run has stored so far, in storage order —
184
+ * the exposure the "researched this session" panel needs and runTurn's
185
+ * result doesn't otherwise carry (a research turn's own record reports a
186
+ * per-topic FACT COUNT, research.mjs's own researchSnapshot, never the
187
+ * triples themselves). Reads the same provenance tags memoryStats already
188
+ * folds (readFactRows' `provenance`, the ' | '-joined compat string),
189
+ * filtered to the `research:<topicKey>@<depth>` prefix research.mjs's own
190
+ * `researchProvenanceTag` stamps every research-sourced fact with, rather
191
+ * than a second ingest-path computation — so this can never list a fact
192
+ * research didn't actually store.
193
+ */
194
+ export async function researchedFactRows(memoryDir) {
195
+ const memory = await loadMemory(memoryDir);
196
+ const rows = readFactRows(memory);
197
+ return rows
198
+ .filter((row) => String(row.provenance || "").split(" | ").some((tag) => tag.startsWith("research:")))
199
+ .map((row) => ({ subject: row.subject, predicate: row.predicate, object: row.object }));
200
+ }
201
+
202
+ globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider, normFactTerm, vocabExampleHint, memoryStats, openPersistedStore, exportFactsJsonl, researchedFactRows, splitSentences: splitSentencesPreservingPaths };