@jefuriiij/synthra 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.16.0] — 2026-07-02
11
+
12
+ ### Added
13
+
14
+ - **The Dispatcher — Synthra now routes your tasks.** On every prompt, Synthra
15
+ scores the request against every installed subagent and skill (plus the
16
+ project's language fingerprint) and — when it finds a clear fit — injects a
17
+ one-line hint: *"[Synthra route] This task fits agent 'X' (model: sonnet) +
18
+ skill 'Y'. Plan here first, then delegate execution."* Works for any domain
19
+ your arsenal covers: UI work routes to your frontend agents, security audits
20
+ to your security agents, and so on. Silent when unsure. Disable with
21
+ `SYN_NO_ROUTE`; tune the confidence bar with `SYN_ROUTE_MIN_SCORE`.
22
+ - **`route_task` tool** — the on-demand version: ask which installed
23
+ agent/skill fits a task and which model to run it on; returns ranked
24
+ candidates with match reasons.
25
+ - **Model policy baked in:** plan on the primary model, delegate execution to a
26
+ subagent on a cheaper one (Sonnet is ~5× cheaper than Opus on every rate).
27
+ Agents that pin their own `model:` are respected; everything else defaults to
28
+ a `sonnet` recommendation.
29
+
30
+ ---
31
+
10
32
  ## [0.15.0] — 2026-07-02
11
33
 
12
34
  ### Added
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.15.0",
21
+ version: "0.16.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -517,6 +517,7 @@ function resolvePaths(projectRoot) {
517
517
  tokenLog: join2(graphDir, "token_log.jsonl"),
518
518
  gateLog: join2(graphDir, "gate_log.jsonl"),
519
519
  bashLog: join2(graphDir, "bash_log.jsonl"),
520
+ routeLog: join2(graphDir, "route_log.jsonl"),
520
521
  toolLog: join2(graphDir, "tool_log.jsonl"),
521
522
  accessLog: join2(graphDir, "access_log.jsonl"),
522
523
  learnStore: join2(graphDir, "learn_store.json"),
@@ -1062,6 +1063,44 @@ fi
1062
1063
  exit 0
1063
1064
  `;
1064
1065
 
1066
+ // src/hooks/scripts/route.ps1
1067
+ var route_default = '# UserPromptSubmit hook \u2014 Windows PowerShell.\n# THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,\n# and prints the routing hint (if any) to stdout \u2014 Claude Code injects\n# UserPromptSubmit stdout into the conversation as added context.\n# Always exits 0; any failure leaves the prompt untouched.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$raw = [Console]::In.ReadToEnd()\nif (-not $raw) { exit 0 }\n\ntry {\n $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop\n} catch {\n exit 0\n}\nif (-not $hookInput.prompt) { exit 0 }\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\n$payload = @{ prompt = $hookInput.prompt } | ConvertTo-Json -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/route" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n if ($resp.hint) { Write-Output $resp.hint }\n} catch {\n # silent on failure \u2014 the prompt proceeds without a routing hint\n}\nexit 0\n';
1068
+
1069
+ // src/hooks/scripts/route.sh
1070
+ var route_default2 = `#!/usr/bin/env bash
1071
+ # UserPromptSubmit hook \u2014 bash.
1072
+ # THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,
1073
+ # and prints the routing hint (if any) to stdout \u2014 Claude Code injects
1074
+ # UserPromptSubmit stdout into the conversation as added context.
1075
+ # Always exits 0; requires \`jq\` (silently no-ops without it, like the other hooks).
1076
+
1077
+ set +e
1078
+
1079
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
1080
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1081
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1082
+ if [ -z "$PORT" ]; then exit 0; fi
1083
+
1084
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1085
+
1086
+ INPUT=$(cat 2>/dev/null)
1087
+ if [ -z "$INPUT" ]; then exit 0; fi
1088
+
1089
+ # Re-emit only the prompt field as the request body (jq handles escaping).
1090
+ BODY=$(printf '%s' "$INPUT" | jq -c '{prompt: (.prompt // empty)}' 2>/dev/null)
1091
+ if [ -z "$BODY" ] || [ "$BODY" = "{}" ]; then exit 0; fi
1092
+
1093
+ HINT=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1094
+ --data "$BODY" "http://127.0.0.1:$PORT/route" 2>/dev/null \\
1095
+ | jq -r '.hint // empty' 2>/dev/null \\
1096
+ | head -c 2000)
1097
+
1098
+ if [ -n "$HINT" ]; then
1099
+ printf '%s\\n' "$HINT"
1100
+ fi
1101
+ exit 0
1102
+ `;
1103
+
1065
1104
  // src/hooks/scripts/stop.ps1
1066
1105
  var stop_default = `# Stop hook \u2014 Windows PowerShell.\r
1067
1106
  # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
@@ -1226,7 +1265,9 @@ var SCRIPTS = [
1226
1265
  sh: pre_tool_use_default2
1227
1266
  },
1228
1267
  { event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
1229
- { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
1268
+ { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 },
1269
+ // The Dispatcher — per-prompt routing hint (best-fit agent/skill/model).
1270
+ { event: "UserPromptSubmit", baseName: "synthra-route", ps1: route_default, sh: route_default2 }
1230
1271
  ];
1231
1272
  function commandFor(scriptPath) {
1232
1273
  if (process.platform === "win32") {
@@ -3046,7 +3087,7 @@ import { basename as basename5 } from "path";
3046
3087
  // src/hooks/claude-md.ts
3047
3088
  import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
3048
3089
  import { basename as basename4, dirname as dirname9 } from "path";
3049
- var POLICY_VERSION = 8;
3090
+ var POLICY_VERSION = 9;
3050
3091
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3051
3092
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3052
3093
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
@@ -3068,7 +3109,7 @@ function policyBlock() {
3068
3109
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
3069
3110
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
3070
3111
  "> If the tools are deferred, load their schemas with ToolSearch:",
3071
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
3112
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol,mcp__synthra__route_task`.",
3072
3113
  "> Below, short names (`graph_continue` etc.) appear in prose for",
3073
3114
  "> readability only.",
3074
3115
  "",
@@ -3086,6 +3127,11 @@ function policyBlock() {
3086
3127
  " util, or function, call this to check whether one already exists. If it",
3087
3128
  " returns matches, reuse or extend them instead of re-implementing; only",
3088
3129
  ' "no match \u2014 safe to create" means it is genuinely new.',
3130
+ "- **`route_task(task)`** \u2014 **delegate-first**: before starting a multi-step",
3131
+ " implementation task, ask which installed subagent/skill fits it. Plan on",
3132
+ " the primary model, then hand execution to the recommended subagent on a",
3133
+ " cheaper model (sonnet \u2248 5\xD7 cheaper than opus). Synthra may also inject a",
3134
+ " `[Synthra route]` hint on your prompt \u2014 treat it the same way.",
3089
3135
  "",
3090
3136
  "### When to call `graph_continue` \u2014 and when to skip",
3091
3137
  "",
@@ -3473,6 +3519,10 @@ function loadConfig() {
3473
3519
  // the terminal bypass of the Moat can be measured. Never blocks. Opt out
3474
3520
  // with SYN_NO_BASH_OBSERVE.
3475
3521
  bashObserve: !process.env.SYN_NO_BASH_OBSERVE,
3522
+ // The Dispatcher: per-prompt routing hints (best-fit agent/skill/model).
3523
+ // Silent unless the top agent clears routeMinScore. SYN_NO_ROUTE disables.
3524
+ route: !process.env.SYN_NO_ROUTE,
3525
+ routeMinScore: num("SYN_ROUTE_MIN_SCORE", 3),
3476
3526
  mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
3477
3527
  dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
3478
3528
  logLevel: str("SYN_LOG_LEVEL", "info"),
@@ -3481,8 +3531,8 @@ function loadConfig() {
3481
3531
  }
3482
3532
 
3483
3533
  // src/server/mcp.ts
3484
- import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
3485
- import { dirname as dirname12 } from "path";
3534
+ import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
3535
+ import { dirname as dirname13 } from "path";
3486
3536
 
3487
3537
  // src/graph/rank.ts
3488
3538
  var KW_BASE_WEIGHT = 2;
@@ -4102,6 +4152,158 @@ async function pack(files, opts) {
4102
4152
  };
4103
4153
  }
4104
4154
 
4155
+ // src/server/routes/route-match.ts
4156
+ var ROUTE_MIN_PROMPT_TOKENS = 3;
4157
+ var ROUTE_MAX_AGENTS = 3;
4158
+ var ROUTE_MAX_SKILLS = 2;
4159
+ var FINGERPRINT_BOOST = 2;
4160
+ var NAME_HIT_WEIGHT = 3;
4161
+ var EXT_KEYWORDS = {
4162
+ ".svelte": ["svelte"],
4163
+ ".vue": ["vue"],
4164
+ ".tsx": ["react", "typescript"],
4165
+ ".jsx": ["react"],
4166
+ ".ts": ["typescript"],
4167
+ ".py": ["python"],
4168
+ ".cs": ["csharp", "dotnet"],
4169
+ ".dart": ["flutter", "dart"],
4170
+ ".rs": ["rust"],
4171
+ ".go": ["golang", "go"],
4172
+ ".java": ["java"],
4173
+ ".kt": ["kotlin"],
4174
+ ".php": ["php", "laravel"],
4175
+ ".rb": ["ruby", "rails"],
4176
+ ".hubl": ["hubspot", "hubl"],
4177
+ ".html": ["html", "css"]
4178
+ };
4179
+ function fingerprintKeywords(extCounts) {
4180
+ const total = [...extCounts.values()].reduce((a, b) => a + b, 0);
4181
+ const out = /* @__PURE__ */ new Set();
4182
+ if (total === 0) return out;
4183
+ for (const [ext, count] of extCounts) {
4184
+ if (count / total < 0.2) continue;
4185
+ for (const kw of EXT_KEYWORDS[ext] ?? []) out.add(kw);
4186
+ }
4187
+ return out;
4188
+ }
4189
+ function scoreItem(item, qTokens, fingerprint) {
4190
+ const nameTokens = new Set(tokenizeQuery(item.name));
4191
+ const descTokens = new Set(tokenizeQuery(item.description));
4192
+ let score2 = 0;
4193
+ const hits = [];
4194
+ for (const t of qTokens) {
4195
+ if (nameTokens.has(t)) {
4196
+ score2 += NAME_HIT_WEIGHT;
4197
+ hits.push(t);
4198
+ } else if (descTokens.has(t)) {
4199
+ score2 += 1;
4200
+ hits.push(t);
4201
+ }
4202
+ }
4203
+ for (const kw of fingerprint) {
4204
+ if (nameTokens.has(kw) || descTokens.has(kw)) {
4205
+ score2 += FINGERPRINT_BOOST;
4206
+ break;
4207
+ }
4208
+ }
4209
+ return { item, score: score2, hits };
4210
+ }
4211
+ function scoreArsenal(prompt, arsenal, extCounts, minScore) {
4212
+ const qTokens = new Set(tokenizeQuery(prompt));
4213
+ if (qTokens.size < ROUTE_MIN_PROMPT_TOKENS) {
4214
+ return { confident: false, agents: [], skills: [] };
4215
+ }
4216
+ const fingerprint = fingerprintKeywords(extCounts);
4217
+ const rank = (items) => items.filter((i) => i.enabled !== false).map((i) => scoreItem(i, qTokens, fingerprint)).filter((s) => s.score > 0).sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
4218
+ const agents = rank(arsenal.agents).slice(0, ROUTE_MAX_AGENTS).map((s) => ({
4219
+ name: s.item.name,
4220
+ score: s.score,
4221
+ reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit",
4222
+ model: s.item.meta?.model?.trim() || "sonnet"
4223
+ }));
4224
+ const skills = rank(arsenal.skills).slice(0, ROUTE_MAX_SKILLS).map((s) => ({
4225
+ name: s.item.name,
4226
+ score: s.score,
4227
+ reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit"
4228
+ }));
4229
+ const confident = agents.length > 0 && (agents[0]?.score ?? 0) >= minScore;
4230
+ return { confident, agents, skills };
4231
+ }
4232
+ function renderHint(match) {
4233
+ if (!match.confident || match.agents.length === 0) return "";
4234
+ const a = match.agents[0];
4235
+ const skill = match.skills[0] ? ` + skill '${match.skills[0].name}'` : "";
4236
+ return `[Synthra route] This task fits agent '${a.name}' (model: ${a.model})${skill}. Plan here first, then delegate execution to it - execution on cheaper models cuts cost ~5x.`;
4237
+ }
4238
+ function renderRouteReport(task, match) {
4239
+ const lines = [`# route_task: "${task}"`, ""];
4240
+ if (match.agents.length === 0 && match.skills.length === 0) {
4241
+ lines.push(
4242
+ "No strong match in the installed Arsenal \u2014 proceed yourself (browse the dashboard's Arsenal tab to see what's available)."
4243
+ );
4244
+ return lines.join("\n");
4245
+ }
4246
+ if (match.agents.length > 0) {
4247
+ lines.push(match.confident ? "Recommended agents:" : "Possible agents (low confidence):");
4248
+ for (const a of match.agents) {
4249
+ lines.push(`- \`${a.name}\` (model: ${a.model}) \u2014 score ${a.score}, ${a.reason}`);
4250
+ }
4251
+ }
4252
+ if (match.skills.length > 0) {
4253
+ lines.push("");
4254
+ lines.push("Relevant skills:");
4255
+ for (const s of match.skills) lines.push(`- \`${s.name}\` \u2014 score ${s.score}, ${s.reason}`);
4256
+ }
4257
+ lines.push("");
4258
+ lines.push(
4259
+ "_Model policy: plan on the primary model; delegate execution to a subagent on a cheaper model (sonnet \u2248 5\xD7 cheaper than opus)._"
4260
+ );
4261
+ return lines.join("\n");
4262
+ }
4263
+
4264
+ // src/server/routes/route.ts
4265
+ import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
4266
+ import { dirname as dirname12 } from "path";
4267
+ var defaultDeps = { arsenal: (root) => computeArsenal(root) };
4268
+ function graphExtCounts(ctx) {
4269
+ const counts = /* @__PURE__ */ new Map();
4270
+ for (const n of ctx.graph.nodes) {
4271
+ if (n.kind !== "file") continue;
4272
+ const ext = n.ext;
4273
+ counts.set(ext, (counts.get(ext) ?? 0) + 1);
4274
+ }
4275
+ return counts;
4276
+ }
4277
+ var PROMPT_LOG_MAX = 200;
4278
+ async function logRoute(ctx, prompt, hint) {
4279
+ try {
4280
+ await mkdir10(dirname12(ctx.paths.routeLog), { recursive: true });
4281
+ const entry = {
4282
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4283
+ prompt: prompt.length > PROMPT_LOG_MAX ? `${prompt.slice(0, PROMPT_LOG_MAX)}\u2026` : prompt,
4284
+ routed: hint.length > 0,
4285
+ hint_chars: hint.length
4286
+ };
4287
+ await appendFile3(ctx.paths.routeLog, JSON.stringify(entry) + "\n", "utf8");
4288
+ } catch {
4289
+ }
4290
+ }
4291
+ async function handleRoute(req, ctx, deps = defaultDeps) {
4292
+ const cfg = loadConfig();
4293
+ if (!cfg.route) return { hint: "" };
4294
+ const prompt = typeof req?.prompt === "string" ? req.prompt.trim() : "";
4295
+ if (!prompt) return { hint: "" };
4296
+ try {
4297
+ const arsenal = await deps.arsenal(ctx.paths.projectRoot);
4298
+ const match = scoreArsenal(prompt, arsenal, graphExtCounts(ctx), cfg.routeMinScore);
4299
+ const hint = renderHint(match);
4300
+ await logRoute(ctx, prompt, hint);
4301
+ return { hint };
4302
+ } catch {
4303
+ return { hint: "" };
4304
+ }
4305
+ }
4306
+
4105
4307
  // src/server/mcp.ts
4106
4308
  var PROTOCOL_VERSION = "2024-11-05";
4107
4309
  var SERVER_INFO = { name: "synthra", version: "0.0.1" };
@@ -4290,6 +4492,17 @@ var TOOLS = [
4290
4492
  },
4291
4493
  required: ["from", "to"]
4292
4494
  }
4495
+ },
4496
+ {
4497
+ name: "route_task",
4498
+ description: "Ask Synthra which installed subagent/skill best fits a task, and which model to run it on. Scores the task against every installed agent and skill (plus the project's language fingerprint). Use BEFORE starting a multi-step implementation task: plan on the primary model, then delegate execution to the recommended agent on a cheaper model (sonnet \u2248 5\xD7 cheaper than opus).",
4499
+ inputSchema: {
4500
+ type: "object",
4501
+ properties: {
4502
+ task: { type: "string", description: "The task to route, in a sentence." }
4503
+ },
4504
+ required: ["task"]
4505
+ }
4293
4506
  }
4294
4507
  ];
4295
4508
  async function callTool(name, args, ctx) {
@@ -4318,6 +4531,8 @@ async function callTool(name, args, ctx) {
4318
4531
  return duplicateSymbols(args, ctx);
4319
4532
  case "call_path":
4320
4533
  return callPath(args, ctx);
4534
+ case "route_task":
4535
+ return routeTask(args, ctx);
4321
4536
  default:
4322
4537
  return errorContent(`Unknown tool: ${name}`);
4323
4538
  }
@@ -4474,6 +4689,17 @@ function testsCoveringLine(graph, filePaths) {
4474
4689
  const omitted = tests.length - shown.length;
4475
4690
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4476
4691
  }
4692
+ async function routeTask(args, ctx) {
4693
+ const task = typeof args?.task === "string" ? args.task.trim() : "";
4694
+ if (!task) return errorContent("route_task: 'task' (string) is required");
4695
+ try {
4696
+ const arsenal = await computeArsenal(ctx.paths.projectRoot);
4697
+ const match = scoreArsenal(task, arsenal, graphExtCounts(ctx), loadConfig().routeMinScore);
4698
+ return textContent(renderRouteReport(task, match));
4699
+ } catch (err2) {
4700
+ return errorContent(`route_task: arsenal scan failed \u2014 ${err2.message}`);
4701
+ }
4702
+ }
4477
4703
  function resolveSymbolArg(ctx, arg) {
4478
4704
  const a = arg.trim();
4479
4705
  if (a.includes("::")) {
@@ -5032,8 +5258,8 @@ async function contextRecall(args, ctx) {
5032
5258
  }
5033
5259
  async function logToolCall(ctx, tool) {
5034
5260
  try {
5035
- await mkdir10(dirname12(ctx.paths.toolLog), { recursive: true });
5036
- await appendFile3(
5261
+ await mkdir11(dirname13(ctx.paths.toolLog), { recursive: true });
5262
+ await appendFile4(
5037
5263
  ctx.paths.toolLog,
5038
5264
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
5039
5265
  "utf8"
@@ -5226,8 +5452,8 @@ async function getChangedLineRanges(projectRoot, sinceRef) {
5226
5452
  }
5227
5453
 
5228
5454
  // src/memory/session.ts
5229
- import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
5230
- import { dirname as dirname13 } from "path";
5455
+ import { mkdir as mkdir12, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
5456
+ import { dirname as dirname14 } from "path";
5231
5457
  var SESSION_SCHEMA_VERSION = 2;
5232
5458
  async function readSession(path) {
5233
5459
  try {
@@ -5240,7 +5466,7 @@ async function readSession(path) {
5240
5466
  }
5241
5467
  }
5242
5468
  async function writeSession(path, state) {
5243
- await mkdir11(dirname13(path), { recursive: true });
5469
+ await mkdir12(dirname14(path), { recursive: true });
5244
5470
  await writeFile10(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5245
5471
  }
5246
5472
 
@@ -5288,12 +5514,12 @@ async function handleContextUpdate(req, ctx) {
5288
5514
  }
5289
5515
 
5290
5516
  // src/server/routes/gate.ts
5291
- import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
5292
- import { dirname as dirname15 } from "path";
5517
+ import { appendFile as appendFile6, mkdir as mkdir14 } from "fs/promises";
5518
+ import { dirname as dirname16 } from "path";
5293
5519
 
5294
5520
  // src/server/routes/bash-observe.ts
5295
- import { appendFile as appendFile4, mkdir as mkdir12 } from "fs/promises";
5296
- import { dirname as dirname14 } from "path";
5521
+ import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
5522
+ import { dirname as dirname15 } from "path";
5297
5523
 
5298
5524
  // src/server/routes/query-heuristics.ts
5299
5525
  function looksLikeNonSymbolQuery(pattern) {
@@ -5452,7 +5678,7 @@ var CMD_MAX = 300;
5452
5678
  var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
5453
5679
  async function logObservation(ctx, exp, confidence, avoidable, command) {
5454
5680
  try {
5455
- await mkdir12(dirname14(ctx.paths.bashLog), { recursive: true });
5681
+ await mkdir13(dirname15(ctx.paths.bashLog), { recursive: true });
5456
5682
  const entry = {
5457
5683
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5458
5684
  kind: exp.kind,
@@ -5462,7 +5688,7 @@ async function logObservation(ctx, exp, confidence, avoidable, command) {
5462
5688
  avoidable,
5463
5689
  command: trunc(command, CMD_MAX)
5464
5690
  };
5465
- await appendFile4(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
5691
+ await appendFile5(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
5466
5692
  } catch {
5467
5693
  }
5468
5694
  }
@@ -5532,7 +5758,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
5532
5758
  var LOG_REASON_MAX_CHARS = 240;
5533
5759
  async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
5534
5760
  try {
5535
- await mkdir13(dirname15(ctx.paths.gateLog), { recursive: true });
5761
+ await mkdir14(dirname16(ctx.paths.gateLog), { recursive: true });
5536
5762
  const entry = {
5537
5763
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5538
5764
  tool: toolName,
@@ -5541,7 +5767,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
5541
5767
  reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
5542
5768
  ...hintChars === void 0 ? {} : { hint_chars: hintChars }
5543
5769
  };
5544
- await appendFile5(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5770
+ await appendFile6(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5545
5771
  } catch {
5546
5772
  }
5547
5773
  }
@@ -5664,16 +5890,16 @@ async function handleGate(req, ctx) {
5664
5890
  }
5665
5891
 
5666
5892
  // src/server/routes/log.ts
5667
- import { appendFile as appendFile6, mkdir as mkdir14 } from "fs/promises";
5668
- import { dirname as dirname16 } from "path";
5893
+ import { appendFile as appendFile7, mkdir as mkdir15 } from "fs/promises";
5894
+ import { dirname as dirname17 } from "path";
5669
5895
  async function handleLog(entry, ctx) {
5670
5896
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
5671
5897
  throw new Error("log: input_tokens and output_tokens (number) are required");
5672
5898
  }
5673
5899
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
5674
5900
  const record = { ...entry, written_at };
5675
- await mkdir14(dirname16(ctx.paths.tokenLog), { recursive: true });
5676
- await appendFile6(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5901
+ await mkdir15(dirname17(ctx.paths.tokenLog), { recursive: true });
5902
+ await appendFile7(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5677
5903
  return { ok: true, written_at };
5678
5904
  }
5679
5905
 
@@ -5867,6 +6093,10 @@ function buildApp(ctx, port) {
5867
6093
  const body = await c.req.json().catch(() => ({}));
5868
6094
  return c.json(await handleGate(body, ctx));
5869
6095
  });
6096
+ app.post("/route", async (c) => {
6097
+ const body = await c.req.json().catch(() => ({}));
6098
+ return c.json(await handleRoute(body, ctx));
6099
+ });
5870
6100
  app.get("/activity", async (c) => {
5871
6101
  const sinceParam = c.req.query("since");
5872
6102
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
@@ -6173,7 +6403,7 @@ import { readFile as readFile20, readdir as readdir4, rm, rmdir, stat as stat5,
6173
6403
  import { basename as basename6, join as join13, resolve as resolve4 } from "path";
6174
6404
 
6175
6405
  // src/cli/self-update.ts
6176
- import { mkdir as mkdir15, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6406
+ import { mkdir as mkdir16, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6177
6407
  import { homedir as homedir4 } from "os";
6178
6408
  import { join as join12 } from "path";
6179
6409
  import { createInterface } from "readline/promises";
@@ -6240,7 +6470,7 @@ async function readLastSeen() {
6240
6470
  }
6241
6471
  async function writeLastSeen(version) {
6242
6472
  try {
6243
- await mkdir15(SYNTHRA_DIR, { recursive: true });
6473
+ await mkdir16(SYNTHRA_DIR, { recursive: true });
6244
6474
  const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
6245
6475
  await writeFile12(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
6246
6476
  } catch {
@@ -6410,7 +6640,8 @@ var HOOK_BASENAMES = [
6410
6640
  "synthra-prime",
6411
6641
  "synthra-pre-tool-use",
6412
6642
  "synthra-pre-compact",
6413
- "synthra-stop"
6643
+ "synthra-stop",
6644
+ "synthra-route"
6414
6645
  ];
6415
6646
  async function exists3(path) {
6416
6647
  try {