@jefuriiij/synthra 0.14.1 → 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;
@@ -2769,7 +2779,8 @@ async function rememberEntry(paths, input) {
2769
2779
  content: input.text,
2770
2780
  tags: input.tags ?? [],
2771
2781
  files: input.files ?? [],
2772
- date: (/* @__PURE__ */ new Date()).toISOString()
2782
+ date: (/* @__PURE__ */ new Date()).toISOString(),
2783
+ ...input.anchors && input.anchors.length > 0 ? { anchors: input.anchors } : {}
2773
2784
  };
2774
2785
  await appendEntry(active.paths.contextStore, entry);
2775
2786
  const entries = await readEntries(active.paths.contextStore);
@@ -2988,6 +2999,378 @@ async function pack(files, opts) {
2988
2999
  };
2989
3000
  }
2990
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
+
2991
3374
  // src/server/mcp.ts
2992
3375
  var PROTOCOL_VERSION = "2024-11-05";
2993
3376
  var SERVER_INFO = { name: "synthra", version: "0.0.1" };
@@ -3071,7 +3454,7 @@ var TOOLS = [
3071
3454
  files: {
3072
3455
  type: "array",
3073
3456
  items: { type: "string" },
3074
- description: "Optional project-relative file paths this entry relates to."
3457
+ description: "Optional project-relative file paths this entry relates to. Linked files also anchor the entry: recall flags it 'possibly stale' if they change, and graph_read of those files surfaces it automatically."
3075
3458
  }
3076
3459
  },
3077
3460
  required: ["text", "kind"]
@@ -3176,6 +3559,17 @@ var TOOLS = [
3176
3559
  },
3177
3560
  required: ["from", "to"]
3178
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
+ }
3179
3573
  }
3180
3574
  ];
3181
3575
  async function callTool(name, args, ctx) {
@@ -3204,6 +3598,8 @@ async function callTool(name, args, ctx) {
3204
3598
  return duplicateSymbols(args, ctx);
3205
3599
  case "call_path":
3206
3600
  return callPath(args, ctx);
3601
+ case "route_task":
3602
+ return routeTask(args, ctx);
3207
3603
  default:
3208
3604
  return errorContent(`Unknown tool: ${name}`);
3209
3605
  }
@@ -3360,6 +3756,17 @@ function testsCoveringLine(graph, filePaths) {
3360
3756
  const omitted = tests.length - shown.length;
3361
3757
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3362
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
+ }
3363
3770
  function resolveSymbolArg(ctx, arg) {
3364
3771
  const a = arg.trim();
3365
3772
  if (a.includes("::")) {
@@ -3613,9 +4020,28 @@ async function graphContinue(args, ctx) {
3613
4020
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
3614
4021
  Reason: ${retrieval.reason}
3615
4022
  `;
3616
- return textContent(`${header}
4023
+ const remembered = matchRememberedFacts(query, retrieval.files, await safeRecallAll(ctx), ctx);
4024
+ return textContent(`${header}${remembered}
3617
4025
  ${packed.text}`);
3618
4026
  }
4027
+ function matchRememberedFacts(query, retrievedFiles, entries, ctx) {
4028
+ if (entries.length === 0) return "";
4029
+ const qTokens = new Set(tokenizeQuery(query));
4030
+ const retrievedPaths = new Set(retrievedFiles.map((f) => f.path));
4031
+ const scored = entries.map((e) => {
4032
+ let score2 = 0;
4033
+ for (const t of tokenizeQuery(`${e.content} ${e.tags.join(" ")}`)) {
4034
+ if (qTokens.has(t)) score2 += 1;
4035
+ }
4036
+ if (e.files.some((f) => retrievedPaths.has(f)) || e.anchors?.some((a) => retrievedPaths.has(a.path))) {
4037
+ score2 += 2;
4038
+ }
4039
+ return { e, score: score2 };
4040
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, PACK_FACTS_MAX);
4041
+ if (scored.length === 0) return "";
4042
+ return `${scored.map((x) => `Remembered: ${factLine(x.e, ctx.graph).slice(2)}`).join("\n")}
4043
+ `;
4044
+ }
3619
4045
  function resolveFileTarget(graph, filePath) {
3620
4046
  const files = graph.nodes.filter((n) => n.kind === "file");
3621
4047
  const exact = files.find((n) => n.path === filePath);
@@ -3678,10 +4104,10 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
3678
4104
  let cUsed = used + sep3 + head.length;
3679
4105
  for (const c of callers.slice(0, DEPS_MAX_CALLERS)) {
3680
4106
  const part = `${c.name} \u2192 ${c.file}`;
3681
- const join7 = shown.length > 0 ? 3 : 0;
3682
- if (cUsed + join7 + part.length > maxChars) break;
4107
+ const join8 = shown.length > 0 ? 3 : 0;
4108
+ if (cUsed + join8 + part.length > maxChars) break;
3683
4109
  shown.push(part);
3684
- cUsed += join7 + part.length;
4110
+ cUsed += join8 + part.length;
3685
4111
  }
3686
4112
  if (lines.length > 0) lines.push("");
3687
4113
  if (shown.length > 0) {
@@ -3708,6 +4134,43 @@ function buildTestsFooter(symbol, graph) {
3708
4134
  if (isLikelyEntry(symbol.file)) return "";
3709
4135
  return "Tests: none linked to this file.";
3710
4136
  }
4137
+ var FACTS_MAX = 3;
4138
+ var FACTS_CONTENT_MAX = 160;
4139
+ var PACK_FACTS_MAX = 2;
4140
+ function staleAnchorPaths(entry, graph) {
4141
+ if (!entry.anchors || entry.anchors.length === 0) return [];
4142
+ const hashByPath = /* @__PURE__ */ new Map();
4143
+ for (const n of graph.nodes) if (n.kind === "file") hashByPath.set(n.path, n.file_hash);
4144
+ return entry.anchors.filter((a) => hashByPath.get(a.path) !== a.hash).map((a) => a.path);
4145
+ }
4146
+ function factLine(entry, graph) {
4147
+ const content = entry.content.length > FACTS_CONTENT_MAX ? `${entry.content.slice(0, FACTS_CONTENT_MAX - 1)}\u2026` : entry.content;
4148
+ const date = entry.date ? ` (${entry.date.slice(0, 10)})` : "";
4149
+ const stale = staleAnchorPaths(entry, graph);
4150
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale[0]} changed since stored` : "";
4151
+ return `- [${entry.type}] ${content}${date}${staleNote}`;
4152
+ }
4153
+ function entryLinksFile(entry, filePath) {
4154
+ if (entry.anchors?.some((a) => a.path === filePath)) return true;
4155
+ return entry.files.some((f) => f === filePath || filePath.endsWith(`/${f}`));
4156
+ }
4157
+ function buildFactsFooter(filePath, entries, graph) {
4158
+ const linked = entries.filter((e) => entryLinksFile(e, filePath));
4159
+ if (linked.length === 0) return "";
4160
+ const newestFirst = linked.slice().reverse();
4161
+ const shown = newestFirst.slice(0, FACTS_MAX);
4162
+ const omitted = newestFirst.length - shown.length;
4163
+ const lines = ["\u{1F4CC} Remembered for this file:", ...shown.map((e) => factLine(e, graph))];
4164
+ if (omitted > 0) lines.push(`\u2026+${omitted} more \u2014 mcp__synthra__context_recall()`);
4165
+ return lines.join("\n");
4166
+ }
4167
+ async function safeRecallAll(ctx) {
4168
+ try {
4169
+ return (await recallEntries(ctx.paths, {})).entries;
4170
+ } catch {
4171
+ return [];
4172
+ }
4173
+ }
3711
4174
  async function graphRead(args, ctx) {
3712
4175
  const target = typeof args?.target === "string" ? args.target : "";
3713
4176
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -3726,10 +4189,15 @@ async function graphRead(args, ctx) {
3726
4189
  }
3727
4190
  const fileNode = resolved.node;
3728
4191
  await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
4192
+ const facts = buildFactsFooter(fileNode.path, await safeRecallAll(ctx), ctx.graph);
4193
+ const factsBlock = facts ? `
4194
+
4195
+ ---
4196
+ ${facts}` : "";
3729
4197
  if (!symbolName) {
3730
4198
  return textContent(`# ${fileNode.path}
3731
4199
 
3732
- ${fileNode.content}`);
4200
+ ${fileNode.content}${factsBlock}`);
3733
4201
  }
3734
4202
  const cleanSym = symbolName.trim();
3735
4203
  const symbol = ctx.graph.nodes.find(
@@ -3759,7 +4227,7 @@ ${tests}` : "";
3759
4227
  return textContent(
3760
4228
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
3761
4229
 
3762
- ${body}${depsBlock}${testsBlock}${editHint}`
4230
+ ${body}${depsBlock}${testsBlock}${factsBlock}${editHint}`
3763
4231
  );
3764
4232
  }
3765
4233
  var editedFiles = /* @__PURE__ */ new Set();
@@ -3794,16 +4262,26 @@ async function contextRemember(args, ctx) {
3794
4262
  }
3795
4263
  const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
3796
4264
  const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
4265
+ const anchors = [];
4266
+ for (const f of files) {
4267
+ const resolved = resolveFileTarget(ctx.graph, f);
4268
+ if ("node" in resolved) {
4269
+ anchors.push({ path: resolved.node.path, hash: resolved.node.file_hash });
4270
+ }
4271
+ }
3797
4272
  const result = await rememberEntry(ctx.paths, {
3798
4273
  text,
3799
4274
  kind: kindRaw,
3800
4275
  tags,
3801
- files
4276
+ files,
4277
+ anchors
3802
4278
  });
4279
+ const anchorNote = anchors.length ? `
4280
+ Anchored to ${anchors.length} file(s) \u2014 recall will flag this entry if they change.` : "";
3803
4281
  return textContent(
3804
4282
  `Remembered ${result.entry.type} on branch '${result.branch}'.
3805
4283
  Stored: ${result.storePath}
3806
- CONTEXT.md refreshed: ${result.contextMdPath}`
4284
+ CONTEXT.md refreshed: ${result.contextMdPath}${anchorNote}`
3807
4285
  );
3808
4286
  }
3809
4287
  var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
@@ -3838,15 +4316,17 @@ async function contextRecall(args, ctx) {
3838
4316
  const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
3839
4317
  for (const e of result.entries) {
3840
4318
  const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
3841
- lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}`);
4319
+ const stale = staleAnchorPaths(e, ctx.graph);
4320
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale.join(", ")} changed since stored` : "";
4321
+ lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}${staleNote}`);
3842
4322
  if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
3843
4323
  }
3844
4324
  return textContent(lines.join("\n"));
3845
4325
  }
3846
4326
  async function logToolCall(ctx, tool) {
3847
4327
  try {
3848
- await mkdir8(dirname9(ctx.paths.toolLog), { recursive: true });
3849
- await appendFile3(
4328
+ await mkdir9(dirname11(ctx.paths.toolLog), { recursive: true });
4329
+ await appendFile4(
3850
4330
  ctx.paths.toolLog,
3851
4331
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
3852
4332
  "utf8"
@@ -4058,12 +4538,12 @@ async function getChangedLineRanges(projectRoot, sinceRef) {
4058
4538
  }
4059
4539
 
4060
4540
  // src/memory/session.ts
4061
- import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
4062
- 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";
4063
4543
  var SESSION_SCHEMA_VERSION = 2;
4064
4544
  async function readSession(path) {
4065
4545
  try {
4066
- const raw = await readFile13(path, "utf8");
4546
+ const raw = await readFile14(path, "utf8");
4067
4547
  const parsed = JSON.parse(raw);
4068
4548
  if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
4069
4549
  return parsed;
@@ -4072,7 +4552,7 @@ async function readSession(path) {
4072
4552
  }
4073
4553
  }
4074
4554
  async function writeSession(path, state) {
4075
- await mkdir9(dirname10(path), { recursive: true });
4555
+ await mkdir10(dirname12(path), { recursive: true });
4076
4556
  await writeFile8(path, JSON.stringify(state, null, 2) + "\n", "utf8");
4077
4557
  }
4078
4558
 
@@ -4120,12 +4600,12 @@ async function handleContextUpdate(req, ctx) {
4120
4600
  }
4121
4601
 
4122
4602
  // src/server/routes/gate.ts
4123
- import { appendFile as appendFile5, mkdir as mkdir11 } from "fs/promises";
4124
- import { dirname as dirname12 } from "path";
4603
+ import { appendFile as appendFile6, mkdir as mkdir12 } from "fs/promises";
4604
+ import { dirname as dirname14 } from "path";
4125
4605
 
4126
4606
  // src/server/routes/bash-observe.ts
4127
- import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
4128
- import { dirname as dirname11 } from "path";
4607
+ import { appendFile as appendFile5, mkdir as mkdir11 } from "fs/promises";
4608
+ import { dirname as dirname13 } from "path";
4129
4609
 
4130
4610
  // src/server/routes/query-heuristics.ts
4131
4611
  function looksLikeNonSymbolQuery(pattern) {
@@ -4284,7 +4764,7 @@ var CMD_MAX = 300;
4284
4764
  var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
4285
4765
  async function logObservation(ctx, exp, confidence, avoidable, command) {
4286
4766
  try {
4287
- await mkdir10(dirname11(ctx.paths.bashLog), { recursive: true });
4767
+ await mkdir11(dirname13(ctx.paths.bashLog), { recursive: true });
4288
4768
  const entry = {
4289
4769
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4290
4770
  kind: exp.kind,
@@ -4294,7 +4774,7 @@ async function logObservation(ctx, exp, confidence, avoidable, command) {
4294
4774
  avoidable,
4295
4775
  command: trunc(command, CMD_MAX)
4296
4776
  };
4297
- await appendFile4(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
4777
+ await appendFile5(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
4298
4778
  } catch {
4299
4779
  }
4300
4780
  }
@@ -4364,7 +4844,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
4364
4844
  var LOG_REASON_MAX_CHARS = 240;
4365
4845
  async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
4366
4846
  try {
4367
- await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
4847
+ await mkdir12(dirname14(ctx.paths.gateLog), { recursive: true });
4368
4848
  const entry = {
4369
4849
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4370
4850
  tool: toolName,
@@ -4373,7 +4853,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
4373
4853
  reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
4374
4854
  ...hintChars === void 0 ? {} : { hint_chars: hintChars }
4375
4855
  };
4376
- await appendFile5(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4856
+ await appendFile6(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4377
4857
  } catch {
4378
4858
  }
4379
4859
  }
@@ -4496,16 +4976,16 @@ async function handleGate(req, ctx) {
4496
4976
  }
4497
4977
 
4498
4978
  // src/server/routes/log.ts
4499
- import { appendFile as appendFile6, mkdir as mkdir12 } from "fs/promises";
4500
- import { dirname as dirname13 } from "path";
4979
+ import { appendFile as appendFile7, mkdir as mkdir13 } from "fs/promises";
4980
+ import { dirname as dirname15 } from "path";
4501
4981
  async function handleLog(entry, ctx) {
4502
4982
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
4503
4983
  throw new Error("log: input_tokens and output_tokens (number) are required");
4504
4984
  }
4505
4985
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
4506
4986
  const record = { ...entry, written_at };
4507
- await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
4508
- 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");
4509
4989
  return { ok: true, written_at };
4510
4990
  }
4511
4991
 
@@ -4699,6 +5179,10 @@ function buildApp(ctx, port) {
4699
5179
  const body = await c.req.json().catch(() => ({}));
4700
5180
  return c.json(await handleGate(body, ctx));
4701
5181
  });
5182
+ app.post("/route", async (c) => {
5183
+ const body = await c.req.json().catch(() => ({}));
5184
+ return c.json(await handleRoute(body, ctx));
5185
+ });
4702
5186
  app.get("/activity", async (c) => {
4703
5187
  const sinceParam = c.req.query("since");
4704
5188
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;