@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 +22 -0
- package/dist/cli/index.js +257 -26
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +2 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +443 -33
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 =
|
|
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
|
|
3485
|
-
import { dirname as
|
|
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
|
|
5036
|
-
await
|
|
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
|
|
5230
|
-
import { dirname as
|
|
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
|
|
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
|
|
5292
|
-
import { dirname as
|
|
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
|
|
5296
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5668
|
-
import { dirname as
|
|
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
|
|
5676
|
-
await
|
|
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
|
|
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
|
|
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 {
|