@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.
@@ -1560,16 +1560,16 @@ async function readParseCache(path) {
1560
1560
  return emptyParseCache();
1561
1561
  }
1562
1562
  }
1563
- async function writeParseCache(path, cache) {
1563
+ async function writeParseCache(path, cache2) {
1564
1564
  try {
1565
1565
  await mkdir2(dirname3(path), { recursive: true });
1566
- await writeFile(path, `${JSON.stringify(cache)}
1566
+ await writeFile(path, `${JSON.stringify(cache2)}
1567
1567
  `, "utf8");
1568
1568
  } catch {
1569
1569
  }
1570
1570
  }
1571
1571
  async function incrementalParse(parsable, prev, opts = {}) {
1572
- const cache = emptyParseCache();
1572
+ const cache2 = emptyParseCache();
1573
1573
  const parsed = [];
1574
1574
  let reused = 0;
1575
1575
  let reparsed = 0;
@@ -1591,20 +1591,20 @@ async function incrementalParse(parsable, prev, opts = {}) {
1591
1591
  imports: cached.imports,
1592
1592
  calls: cached.calls
1593
1593
  });
1594
- cache.files[f.relPath] = cached;
1594
+ cache2.files[f.relPath] = cached;
1595
1595
  reused += 1;
1596
1596
  continue;
1597
1597
  }
1598
1598
  try {
1599
1599
  const p = await parseSource(f, source);
1600
1600
  parsed.push(p);
1601
- cache.files[f.relPath] = { hash, symbols: p.symbols, imports: p.imports, calls: p.calls };
1601
+ cache2.files[f.relPath] = { hash, symbols: p.symbols, imports: p.imports, calls: p.calls };
1602
1602
  reparsed += 1;
1603
1603
  } catch {
1604
1604
  parseErrors += 1;
1605
1605
  }
1606
1606
  }
1607
- return { parsed, cache, reused, reparsed, parseErrors };
1607
+ return { parsed, cache: cache2, reused, reparsed, parseErrors };
1608
1608
  }
1609
1609
 
1610
1610
  // src/scanner/walker.ts
@@ -1796,6 +1796,7 @@ function resolvePaths(projectRoot) {
1796
1796
  tokenLog: join5(graphDir, "token_log.jsonl"),
1797
1797
  gateLog: join5(graphDir, "gate_log.jsonl"),
1798
1798
  bashLog: join5(graphDir, "bash_log.jsonl"),
1799
+ routeLog: join5(graphDir, "route_log.jsonl"),
1799
1800
  toolLog: join5(graphDir, "tool_log.jsonl"),
1800
1801
  accessLog: join5(graphDir, "access_log.jsonl"),
1801
1802
  learnStore: join5(graphDir, "learn_store.json"),
@@ -1821,7 +1822,7 @@ import { basename as basename2 } from "path";
1821
1822
  // src/hooks/claude-md.ts
1822
1823
  import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
1823
1824
  import { basename, dirname as dirname5 } from "path";
1824
- var POLICY_VERSION = 8;
1825
+ var POLICY_VERSION = 9;
1825
1826
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
1826
1827
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
1827
1828
  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;
@@ -1840,7 +1841,7 @@ function policyBlock() {
1840
1841
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
1841
1842
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
1842
1843
  "> If the tools are deferred, load their schemas with ToolSearch:",
1843
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
1844
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol,mcp__synthra__route_task`.",
1844
1845
  "> Below, short names (`graph_continue` etc.) appear in prose for",
1845
1846
  "> readability only.",
1846
1847
  "",
@@ -1858,6 +1859,11 @@ function policyBlock() {
1858
1859
  " util, or function, call this to check whether one already exists. If it",
1859
1860
  " returns matches, reuse or extend them instead of re-implementing; only",
1860
1861
  ' "no match \u2014 safe to create" means it is genuinely new.',
1862
+ "- **`route_task(task)`** \u2014 **delegate-first**: before starting a multi-step",
1863
+ " implementation task, ask which installed subagent/skill fits it. Plan on",
1864
+ " the primary model, then hand execution to the recommended subagent on a",
1865
+ " cheaper model (sonnet \u2248 5\xD7 cheaper than opus). Synthra may also inject a",
1866
+ " `[Synthra route]` hint on your prompt \u2014 treat it the same way.",
1861
1867
  "",
1862
1868
  "### When to call `graph_continue` \u2014 and when to skip",
1863
1869
  "",
@@ -2126,7 +2132,7 @@ async function scanProject(projectRootRaw, opts = {}) {
2126
2132
  if (verbose) log.info(` walked ${walked.length} files`);
2127
2133
  const parsable = walked.filter((f) => PARSABLE_EXTS.has(f.ext));
2128
2134
  const prevCache = await readParseCache(paths.parseCache);
2129
- const { parsed, cache, reused, reparsed, parseErrors } = await incrementalParse(
2135
+ const { parsed, cache: cache2, reused, reparsed, parseErrors } = await incrementalParse(
2130
2136
  parsable,
2131
2137
  prevCache,
2132
2138
  { full: opts.full }
@@ -2140,7 +2146,7 @@ async function scanProject(projectRootRaw, opts = {}) {
2140
2146
  const symbolIndex = buildSymbolIndex(graph);
2141
2147
  await writeGraph(paths.infoGraph, graph);
2142
2148
  await writeSymbolIndex(paths.symbolIndex, symbolIndex);
2143
- await writeParseCache(paths.parseCache, cache);
2149
+ await writeParseCache(paths.parseCache, cache2);
2144
2150
  if (verbose) {
2145
2151
  log.info(
2146
2152
  ` wrote ${paths.infoGraph} \u2014 ${graph.symbol_count} symbols, ${graph.edge_count} edges`
@@ -2360,6 +2366,10 @@ function loadConfig() {
2360
2366
  // the terminal bypass of the Moat can be measured. Never blocks. Opt out
2361
2367
  // with SYN_NO_BASH_OBSERVE.
2362
2368
  bashObserve: !process.env.SYN_NO_BASH_OBSERVE,
2369
+ // The Dispatcher: per-prompt routing hints (best-fit agent/skill/model).
2370
+ // Silent unless the top agent clears routeMinScore. SYN_NO_ROUTE disables.
2371
+ route: !process.env.SYN_NO_ROUTE,
2372
+ routeMinScore: num("SYN_ROUTE_MIN_SCORE", 3),
2363
2373
  mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
2364
2374
  dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
2365
2375
  logLevel: str("SYN_LOG_LEVEL", "info"),
@@ -2368,8 +2378,8 @@ function loadConfig() {
2368
2378
  }
2369
2379
 
2370
2380
  // src/server/mcp.ts
2371
- import { appendFile as appendFile3, mkdir as mkdir8 } from "fs/promises";
2372
- import { dirname as dirname9 } from "path";
2381
+ import { appendFile as appendFile4, mkdir as mkdir9 } from "fs/promises";
2382
+ import { dirname as dirname11 } from "path";
2373
2383
 
2374
2384
  // src/graph/rank.ts
2375
2385
  var KW_BASE_WEIGHT = 2;
@@ -2989,6 +2999,378 @@ async function pack(files, opts) {
2989
2999
  };
2990
3000
  }
2991
3001
 
3002
+ // src/dashboard/arsenal.ts
3003
+ import { readFile as readFile13, readdir as readdir2 } from "fs/promises";
3004
+ import { homedir } from "os";
3005
+ import { basename as basename3, dirname as dirname9, join as join7 } from "path";
3006
+ var DESC_MAX = 300;
3007
+ var TOOLS_MAX = 200;
3008
+ async function readText(path) {
3009
+ try {
3010
+ return await readFile13(path, "utf8");
3011
+ } catch {
3012
+ return null;
3013
+ }
3014
+ }
3015
+ async function readJson2(path) {
3016
+ const text = await readText(path);
3017
+ if (text === null) return null;
3018
+ try {
3019
+ return JSON.parse(text);
3020
+ } catch {
3021
+ return null;
3022
+ }
3023
+ }
3024
+ async function listNames(dir) {
3025
+ try {
3026
+ return await readdir2(dir);
3027
+ } catch {
3028
+ return [];
3029
+ }
3030
+ }
3031
+ function clip(s, max) {
3032
+ const t = s.trim();
3033
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
3034
+ }
3035
+ function parseFrontmatter(md) {
3036
+ const m = md.match(/^?\s*---\r?\n([\s\S]*?)\r?\n---/);
3037
+ if (!m) return {};
3038
+ const lines = (m[1] ?? "").split(/\r?\n/);
3039
+ const out = {};
3040
+ for (let i = 0; i < lines.length; i++) {
3041
+ const line = lines[i] ?? "";
3042
+ const kv = line.match(/^([A-Za-z][\w-]*):\s?(.*)$/);
3043
+ if (!kv) continue;
3044
+ const key = kv[1] ?? "";
3045
+ if (!key) continue;
3046
+ let val = kv[2] ?? "";
3047
+ if (val.startsWith('"') && !/[^\\]"\s*$/.test(val.slice(1)) || val === '"') {
3048
+ const buf = [val];
3049
+ while (i + 1 < lines.length && !/"\s*$/.test(buf[buf.length - 1] ?? "")) {
3050
+ i += 1;
3051
+ buf.push(lines[i] ?? "");
3052
+ }
3053
+ val = buf.join(" ");
3054
+ }
3055
+ val = val.trim().replace(/^["']|["']$/g, "").trim();
3056
+ out[key] = val;
3057
+ }
3058
+ return out;
3059
+ }
3060
+ function skillItem(fm, fallbackName, scope, source) {
3061
+ const meta = {};
3062
+ if (fm["argument-hint"]) meta.argument_hint = fm["argument-hint"];
3063
+ if (fm["user-invocable"]) meta.user_invocable = fm["user-invocable"];
3064
+ return {
3065
+ name: fm.name || fallbackName,
3066
+ description: clip(fm.description || "", DESC_MAX),
3067
+ scope,
3068
+ ...source ? { source } : {},
3069
+ ...Object.keys(meta).length ? { meta } : {}
3070
+ };
3071
+ }
3072
+ function agentItem(fm, fallbackName, scope, source) {
3073
+ const meta = {};
3074
+ if (fm.tools) meta.tools = clip(fm.tools, TOOLS_MAX);
3075
+ if (fm.model) meta.model = fm.model;
3076
+ return {
3077
+ name: fm.name || fallbackName,
3078
+ description: clip(fm.description || "", DESC_MAX),
3079
+ scope,
3080
+ ...source ? { source } : {},
3081
+ ...Object.keys(meta).length ? { meta } : {}
3082
+ };
3083
+ }
3084
+ async function scanSkillsDir(dir, scope, source, out) {
3085
+ for (const name of await listNames(dir)) {
3086
+ const md = await readText(join7(dir, name, "SKILL.md"));
3087
+ if (md === null) continue;
3088
+ out.push(skillItem(parseFrontmatter(md), name, scope, source));
3089
+ }
3090
+ }
3091
+ async function scanAgentsDir(dir, scope, source, out) {
3092
+ for (const file of await listNames(dir)) {
3093
+ if (!file.endsWith(".md")) continue;
3094
+ const md = await readText(join7(dir, file));
3095
+ if (md === null) continue;
3096
+ out.push(agentItem(parseFrontmatter(md), basename3(file, ".md"), scope, source));
3097
+ }
3098
+ }
3099
+ function mcpItemsFrom(json, scope, source) {
3100
+ if (!json || typeof json !== "object") return [];
3101
+ const record = json;
3102
+ const servers = record.mcpServers && typeof record.mcpServers === "object" ? record.mcpServers : record;
3103
+ const items = [];
3104
+ for (const [name, raw] of Object.entries(servers)) {
3105
+ if (!raw || typeof raw !== "object") continue;
3106
+ const cfg = raw;
3107
+ const type = typeof cfg.type === "string" ? cfg.type : cfg.command ? "stdio" : "http";
3108
+ const url = typeof cfg.url === "string" ? cfg.url.split("?")[0] : typeof cfg.command === "string" ? cfg.command : "";
3109
+ const meta = { type };
3110
+ if (url) meta.url = url;
3111
+ items.push({ name, description: "", scope, ...source ? { source } : {}, meta });
3112
+ }
3113
+ return items;
3114
+ }
3115
+ var SCOPE_ORDER = { project: 0, personal: 1, plugin: 2 };
3116
+ function sortItems(items) {
3117
+ return items.sort((a, b) => {
3118
+ if (a.scope !== b.scope) return SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope];
3119
+ const sa = a.source ?? "";
3120
+ const sb = b.source ?? "";
3121
+ if (sa !== sb) return sa < sb ? -1 : 1;
3122
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
3123
+ });
3124
+ }
3125
+ var cache = null;
3126
+ var CACHE_TTL_MS = 15e3;
3127
+ async function computeArsenal(projectRoot, homeDir = homedir()) {
3128
+ const key = `${projectRoot}\0${homeDir}`;
3129
+ const now = Date.now();
3130
+ if (cache && cache.key === key && now - cache.at < CACHE_TTL_MS) return cache.data;
3131
+ const homeClaude = join7(homeDir, ".claude");
3132
+ const projClaude = join7(projectRoot, ".claude");
3133
+ const skills = [];
3134
+ const agents = [];
3135
+ const mcp = [];
3136
+ await scanSkillsDir(join7(projClaude, "skills"), "project", void 0, skills);
3137
+ await scanSkillsDir(join7(homeClaude, "skills"), "personal", void 0, skills);
3138
+ await scanAgentsDir(join7(projClaude, "agents"), "project", void 0, agents);
3139
+ await scanAgentsDir(join7(homeClaude, "agents"), "personal", void 0, agents);
3140
+ mcp.push(...mcpItemsFrom(await readJson2(join7(projectRoot, ".mcp.json")), "project", void 0));
3141
+ mcp.push(
3142
+ ...mcpItemsFrom(
3143
+ (await readJson2(join7(homeDir, ".claude.json")))?.mcpServers,
3144
+ "personal",
3145
+ void 0
3146
+ )
3147
+ );
3148
+ const installedRaw = await readJson2(
3149
+ join7(homeClaude, "plugins", "installed_plugins.json")
3150
+ );
3151
+ const pluginsMap = installedRaw?.plugins ?? installedRaw ?? {};
3152
+ const settings = await readJson2(
3153
+ join7(homeClaude, "settings.json")
3154
+ );
3155
+ const enabledMap = settings?.enabledPlugins ?? {};
3156
+ let pluginCount = 0;
3157
+ for (const [pluginKey, entries] of Object.entries(pluginsMap)) {
3158
+ const entry = Array.isArray(entries) ? entries[0] : void 0;
3159
+ if (!entry?.installPath) continue;
3160
+ pluginCount += 1;
3161
+ const pluginName = pluginKey.split("@")[0];
3162
+ const enabled = enabledMap[pluginKey] !== false;
3163
+ const root = entry.installPath;
3164
+ const manifest = await readJson2(
3165
+ join7(root, ".claude-plugin", "plugin.json")
3166
+ );
3167
+ const agentFiles = /* @__PURE__ */ new Set();
3168
+ for (const f of await listNames(join7(root, "agents"))) {
3169
+ if (f.endsWith(".md")) agentFiles.add(join7(root, "agents", f));
3170
+ }
3171
+ for (const rel of manifest?.agents ?? []) agentFiles.add(join7(root, rel));
3172
+ const pAgents = [];
3173
+ for (const file of agentFiles) {
3174
+ const md = await readText(file);
3175
+ if (md !== null)
3176
+ pAgents.push(agentItem(parseFrontmatter(md), basename3(file, ".md"), "plugin", pluginName));
3177
+ }
3178
+ const skillMds = /* @__PURE__ */ new Set();
3179
+ for (const name of await listNames(join7(root, "skills"))) {
3180
+ skillMds.add(join7(root, "skills", name, "SKILL.md"));
3181
+ }
3182
+ for (const rel of manifest?.skills ?? []) {
3183
+ skillMds.add(rel.endsWith(".md") ? join7(root, rel) : join7(root, rel, "SKILL.md"));
3184
+ }
3185
+ const pSkills = [];
3186
+ for (const md of skillMds) {
3187
+ const text = await readText(md);
3188
+ if (text !== null)
3189
+ pSkills.push(
3190
+ skillItem(parseFrontmatter(text), basename3(dirname9(md)), "plugin", pluginName)
3191
+ );
3192
+ }
3193
+ const pMcp = mcpItemsFrom(await readJson2(join7(root, ".mcp.json")), "plugin", pluginName);
3194
+ for (const it of [...pSkills, ...pAgents, ...pMcp]) it.enabled = enabled;
3195
+ skills.push(...pSkills);
3196
+ agents.push(...pAgents);
3197
+ mcp.push(...pMcp);
3198
+ }
3199
+ const seen = /* @__PURE__ */ new Set();
3200
+ const dedupedSkills = skills.filter((s) => {
3201
+ const k = `${s.scope}:${s.source ?? ""}:${s.name}`;
3202
+ if (seen.has(k)) return false;
3203
+ seen.add(k);
3204
+ return true;
3205
+ });
3206
+ const data = {
3207
+ skills: sortItems(dedupedSkills),
3208
+ agents: sortItems(agents),
3209
+ mcp: sortItems(mcp),
3210
+ counts: {
3211
+ skills: dedupedSkills.length,
3212
+ agents: agents.length,
3213
+ mcp: mcp.length,
3214
+ plugins: pluginCount
3215
+ },
3216
+ scanned_at: new Date(now).toISOString()
3217
+ };
3218
+ cache = { key, at: now, data };
3219
+ return data;
3220
+ }
3221
+
3222
+ // src/server/routes/route-match.ts
3223
+ var ROUTE_MIN_PROMPT_TOKENS = 3;
3224
+ var ROUTE_MAX_AGENTS = 3;
3225
+ var ROUTE_MAX_SKILLS = 2;
3226
+ var FINGERPRINT_BOOST = 2;
3227
+ var NAME_HIT_WEIGHT = 3;
3228
+ var EXT_KEYWORDS = {
3229
+ ".svelte": ["svelte"],
3230
+ ".vue": ["vue"],
3231
+ ".tsx": ["react", "typescript"],
3232
+ ".jsx": ["react"],
3233
+ ".ts": ["typescript"],
3234
+ ".py": ["python"],
3235
+ ".cs": ["csharp", "dotnet"],
3236
+ ".dart": ["flutter", "dart"],
3237
+ ".rs": ["rust"],
3238
+ ".go": ["golang", "go"],
3239
+ ".java": ["java"],
3240
+ ".kt": ["kotlin"],
3241
+ ".php": ["php", "laravel"],
3242
+ ".rb": ["ruby", "rails"],
3243
+ ".hubl": ["hubspot", "hubl"],
3244
+ ".html": ["html", "css"]
3245
+ };
3246
+ function fingerprintKeywords(extCounts) {
3247
+ const total = [...extCounts.values()].reduce((a, b) => a + b, 0);
3248
+ const out = /* @__PURE__ */ new Set();
3249
+ if (total === 0) return out;
3250
+ for (const [ext, count] of extCounts) {
3251
+ if (count / total < 0.2) continue;
3252
+ for (const kw of EXT_KEYWORDS[ext] ?? []) out.add(kw);
3253
+ }
3254
+ return out;
3255
+ }
3256
+ function scoreItem(item, qTokens, fingerprint) {
3257
+ const nameTokens = new Set(tokenizeQuery(item.name));
3258
+ const descTokens = new Set(tokenizeQuery(item.description));
3259
+ let score2 = 0;
3260
+ const hits = [];
3261
+ for (const t of qTokens) {
3262
+ if (nameTokens.has(t)) {
3263
+ score2 += NAME_HIT_WEIGHT;
3264
+ hits.push(t);
3265
+ } else if (descTokens.has(t)) {
3266
+ score2 += 1;
3267
+ hits.push(t);
3268
+ }
3269
+ }
3270
+ for (const kw of fingerprint) {
3271
+ if (nameTokens.has(kw) || descTokens.has(kw)) {
3272
+ score2 += FINGERPRINT_BOOST;
3273
+ break;
3274
+ }
3275
+ }
3276
+ return { item, score: score2, hits };
3277
+ }
3278
+ function scoreArsenal(prompt, arsenal, extCounts, minScore) {
3279
+ const qTokens = new Set(tokenizeQuery(prompt));
3280
+ if (qTokens.size < ROUTE_MIN_PROMPT_TOKENS) {
3281
+ return { confident: false, agents: [], skills: [] };
3282
+ }
3283
+ const fingerprint = fingerprintKeywords(extCounts);
3284
+ 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));
3285
+ const agents = rank(arsenal.agents).slice(0, ROUTE_MAX_AGENTS).map((s) => ({
3286
+ name: s.item.name,
3287
+ score: s.score,
3288
+ reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit",
3289
+ model: s.item.meta?.model?.trim() || "sonnet"
3290
+ }));
3291
+ const skills = rank(arsenal.skills).slice(0, ROUTE_MAX_SKILLS).map((s) => ({
3292
+ name: s.item.name,
3293
+ score: s.score,
3294
+ reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit"
3295
+ }));
3296
+ const confident = agents.length > 0 && (agents[0]?.score ?? 0) >= minScore;
3297
+ return { confident, agents, skills };
3298
+ }
3299
+ function renderHint(match) {
3300
+ if (!match.confident || match.agents.length === 0) return "";
3301
+ const a = match.agents[0];
3302
+ const skill = match.skills[0] ? ` + skill '${match.skills[0].name}'` : "";
3303
+ 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.`;
3304
+ }
3305
+ function renderRouteReport(task, match) {
3306
+ const lines = [`# route_task: "${task}"`, ""];
3307
+ if (match.agents.length === 0 && match.skills.length === 0) {
3308
+ lines.push(
3309
+ "No strong match in the installed Arsenal \u2014 proceed yourself (browse the dashboard's Arsenal tab to see what's available)."
3310
+ );
3311
+ return lines.join("\n");
3312
+ }
3313
+ if (match.agents.length > 0) {
3314
+ lines.push(match.confident ? "Recommended agents:" : "Possible agents (low confidence):");
3315
+ for (const a of match.agents) {
3316
+ lines.push(`- \`${a.name}\` (model: ${a.model}) \u2014 score ${a.score}, ${a.reason}`);
3317
+ }
3318
+ }
3319
+ if (match.skills.length > 0) {
3320
+ lines.push("");
3321
+ lines.push("Relevant skills:");
3322
+ for (const s of match.skills) lines.push(`- \`${s.name}\` \u2014 score ${s.score}, ${s.reason}`);
3323
+ }
3324
+ lines.push("");
3325
+ lines.push(
3326
+ "_Model policy: plan on the primary model; delegate execution to a subagent on a cheaper model (sonnet \u2248 5\xD7 cheaper than opus)._"
3327
+ );
3328
+ return lines.join("\n");
3329
+ }
3330
+
3331
+ // src/server/routes/route.ts
3332
+ import { appendFile as appendFile3, mkdir as mkdir8 } from "fs/promises";
3333
+ import { dirname as dirname10 } from "path";
3334
+ var defaultDeps = { arsenal: (root) => computeArsenal(root) };
3335
+ function graphExtCounts(ctx) {
3336
+ const counts = /* @__PURE__ */ new Map();
3337
+ for (const n of ctx.graph.nodes) {
3338
+ if (n.kind !== "file") continue;
3339
+ const ext = n.ext;
3340
+ counts.set(ext, (counts.get(ext) ?? 0) + 1);
3341
+ }
3342
+ return counts;
3343
+ }
3344
+ var PROMPT_LOG_MAX = 200;
3345
+ async function logRoute(ctx, prompt, hint) {
3346
+ try {
3347
+ await mkdir8(dirname10(ctx.paths.routeLog), { recursive: true });
3348
+ const entry = {
3349
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3350
+ prompt: prompt.length > PROMPT_LOG_MAX ? `${prompt.slice(0, PROMPT_LOG_MAX)}\u2026` : prompt,
3351
+ routed: hint.length > 0,
3352
+ hint_chars: hint.length
3353
+ };
3354
+ await appendFile3(ctx.paths.routeLog, JSON.stringify(entry) + "\n", "utf8");
3355
+ } catch {
3356
+ }
3357
+ }
3358
+ async function handleRoute(req, ctx, deps = defaultDeps) {
3359
+ const cfg = loadConfig();
3360
+ if (!cfg.route) return { hint: "" };
3361
+ const prompt = typeof req?.prompt === "string" ? req.prompt.trim() : "";
3362
+ if (!prompt) return { hint: "" };
3363
+ try {
3364
+ const arsenal = await deps.arsenal(ctx.paths.projectRoot);
3365
+ const match = scoreArsenal(prompt, arsenal, graphExtCounts(ctx), cfg.routeMinScore);
3366
+ const hint = renderHint(match);
3367
+ await logRoute(ctx, prompt, hint);
3368
+ return { hint };
3369
+ } catch {
3370
+ return { hint: "" };
3371
+ }
3372
+ }
3373
+
2992
3374
  // src/server/mcp.ts
2993
3375
  var PROTOCOL_VERSION = "2024-11-05";
2994
3376
  var SERVER_INFO = { name: "synthra", version: "0.0.1" };
@@ -3177,6 +3559,17 @@ var TOOLS = [
3177
3559
  },
3178
3560
  required: ["from", "to"]
3179
3561
  }
3562
+ },
3563
+ {
3564
+ name: "route_task",
3565
+ 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).",
3566
+ inputSchema: {
3567
+ type: "object",
3568
+ properties: {
3569
+ task: { type: "string", description: "The task to route, in a sentence." }
3570
+ },
3571
+ required: ["task"]
3572
+ }
3180
3573
  }
3181
3574
  ];
3182
3575
  async function callTool(name, args, ctx) {
@@ -3205,6 +3598,8 @@ async function callTool(name, args, ctx) {
3205
3598
  return duplicateSymbols(args, ctx);
3206
3599
  case "call_path":
3207
3600
  return callPath(args, ctx);
3601
+ case "route_task":
3602
+ return routeTask(args, ctx);
3208
3603
  default:
3209
3604
  return errorContent(`Unknown tool: ${name}`);
3210
3605
  }
@@ -3361,6 +3756,17 @@ function testsCoveringLine(graph, filePaths) {
3361
3756
  const omitted = tests.length - shown.length;
3362
3757
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3363
3758
  }
3759
+ async function routeTask(args, ctx) {
3760
+ const task = typeof args?.task === "string" ? args.task.trim() : "";
3761
+ if (!task) return errorContent("route_task: 'task' (string) is required");
3762
+ try {
3763
+ const arsenal = await computeArsenal(ctx.paths.projectRoot);
3764
+ const match = scoreArsenal(task, arsenal, graphExtCounts(ctx), loadConfig().routeMinScore);
3765
+ return textContent(renderRouteReport(task, match));
3766
+ } catch (err2) {
3767
+ return errorContent(`route_task: arsenal scan failed \u2014 ${err2.message}`);
3768
+ }
3769
+ }
3364
3770
  function resolveSymbolArg(ctx, arg) {
3365
3771
  const a = arg.trim();
3366
3772
  if (a.includes("::")) {
@@ -3698,10 +4104,10 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
3698
4104
  let cUsed = used + sep3 + head.length;
3699
4105
  for (const c of callers.slice(0, DEPS_MAX_CALLERS)) {
3700
4106
  const part = `${c.name} \u2192 ${c.file}`;
3701
- const join7 = shown.length > 0 ? 3 : 0;
3702
- if (cUsed + join7 + part.length > maxChars) break;
4107
+ const join8 = shown.length > 0 ? 3 : 0;
4108
+ if (cUsed + join8 + part.length > maxChars) break;
3703
4109
  shown.push(part);
3704
- cUsed += join7 + part.length;
4110
+ cUsed += join8 + part.length;
3705
4111
  }
3706
4112
  if (lines.length > 0) lines.push("");
3707
4113
  if (shown.length > 0) {
@@ -3919,8 +4325,8 @@ async function contextRecall(args, ctx) {
3919
4325
  }
3920
4326
  async function logToolCall(ctx, tool) {
3921
4327
  try {
3922
- await mkdir8(dirname9(ctx.paths.toolLog), { recursive: true });
3923
- await appendFile3(
4328
+ await mkdir9(dirname11(ctx.paths.toolLog), { recursive: true });
4329
+ await appendFile4(
3924
4330
  ctx.paths.toolLog,
3925
4331
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
3926
4332
  "utf8"
@@ -4132,12 +4538,12 @@ async function getChangedLineRanges(projectRoot, sinceRef) {
4132
4538
  }
4133
4539
 
4134
4540
  // src/memory/session.ts
4135
- import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
4136
- import { dirname as dirname10 } from "path";
4541
+ import { mkdir as mkdir10, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
4542
+ import { dirname as dirname12 } from "path";
4137
4543
  var SESSION_SCHEMA_VERSION = 2;
4138
4544
  async function readSession(path) {
4139
4545
  try {
4140
- const raw = await readFile13(path, "utf8");
4546
+ const raw = await readFile14(path, "utf8");
4141
4547
  const parsed = JSON.parse(raw);
4142
4548
  if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
4143
4549
  return parsed;
@@ -4146,7 +4552,7 @@ async function readSession(path) {
4146
4552
  }
4147
4553
  }
4148
4554
  async function writeSession(path, state) {
4149
- await mkdir9(dirname10(path), { recursive: true });
4555
+ await mkdir10(dirname12(path), { recursive: true });
4150
4556
  await writeFile8(path, JSON.stringify(state, null, 2) + "\n", "utf8");
4151
4557
  }
4152
4558
 
@@ -4194,12 +4600,12 @@ async function handleContextUpdate(req, ctx) {
4194
4600
  }
4195
4601
 
4196
4602
  // src/server/routes/gate.ts
4197
- import { appendFile as appendFile5, mkdir as mkdir11 } from "fs/promises";
4198
- import { dirname as dirname12 } from "path";
4603
+ import { appendFile as appendFile6, mkdir as mkdir12 } from "fs/promises";
4604
+ import { dirname as dirname14 } from "path";
4199
4605
 
4200
4606
  // src/server/routes/bash-observe.ts
4201
- import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
4202
- import { dirname as dirname11 } from "path";
4607
+ import { appendFile as appendFile5, mkdir as mkdir11 } from "fs/promises";
4608
+ import { dirname as dirname13 } from "path";
4203
4609
 
4204
4610
  // src/server/routes/query-heuristics.ts
4205
4611
  function looksLikeNonSymbolQuery(pattern) {
@@ -4358,7 +4764,7 @@ var CMD_MAX = 300;
4358
4764
  var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
4359
4765
  async function logObservation(ctx, exp, confidence, avoidable, command) {
4360
4766
  try {
4361
- await mkdir10(dirname11(ctx.paths.bashLog), { recursive: true });
4767
+ await mkdir11(dirname13(ctx.paths.bashLog), { recursive: true });
4362
4768
  const entry = {
4363
4769
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4364
4770
  kind: exp.kind,
@@ -4368,7 +4774,7 @@ async function logObservation(ctx, exp, confidence, avoidable, command) {
4368
4774
  avoidable,
4369
4775
  command: trunc(command, CMD_MAX)
4370
4776
  };
4371
- await appendFile4(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
4777
+ await appendFile5(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
4372
4778
  } catch {
4373
4779
  }
4374
4780
  }
@@ -4438,7 +4844,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
4438
4844
  var LOG_REASON_MAX_CHARS = 240;
4439
4845
  async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
4440
4846
  try {
4441
- await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
4847
+ await mkdir12(dirname14(ctx.paths.gateLog), { recursive: true });
4442
4848
  const entry = {
4443
4849
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4444
4850
  tool: toolName,
@@ -4447,7 +4853,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
4447
4853
  reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
4448
4854
  ...hintChars === void 0 ? {} : { hint_chars: hintChars }
4449
4855
  };
4450
- await appendFile5(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4856
+ await appendFile6(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4451
4857
  } catch {
4452
4858
  }
4453
4859
  }
@@ -4570,16 +4976,16 @@ async function handleGate(req, ctx) {
4570
4976
  }
4571
4977
 
4572
4978
  // src/server/routes/log.ts
4573
- import { appendFile as appendFile6, mkdir as mkdir12 } from "fs/promises";
4574
- import { dirname as dirname13 } from "path";
4979
+ import { appendFile as appendFile7, mkdir as mkdir13 } from "fs/promises";
4980
+ import { dirname as dirname15 } from "path";
4575
4981
  async function handleLog(entry, ctx) {
4576
4982
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
4577
4983
  throw new Error("log: input_tokens and output_tokens (number) are required");
4578
4984
  }
4579
4985
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
4580
4986
  const record = { ...entry, written_at };
4581
- await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
4582
- await appendFile6(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
4987
+ await mkdir13(dirname15(ctx.paths.tokenLog), { recursive: true });
4988
+ await appendFile7(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
4583
4989
  return { ok: true, written_at };
4584
4990
  }
4585
4991
 
@@ -4773,6 +5179,10 @@ function buildApp(ctx, port) {
4773
5179
  const body = await c.req.json().catch(() => ({}));
4774
5180
  return c.json(await handleGate(body, ctx));
4775
5181
  });
5182
+ app.post("/route", async (c) => {
5183
+ const body = await c.req.json().catch(() => ({}));
5184
+ return c.json(await handleRoute(body, ctx));
5185
+ });
4776
5186
  app.get("/activity", async (c) => {
4777
5187
  const sinceParam = c.req.query("since");
4778
5188
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;