@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ 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
+
32
+ ## [0.15.0] — 2026-07-02
33
+
34
+ ### Added
35
+
36
+ - **Your remembered context now talks back.** Synthra's second brain was
37
+ write-only — decisions and gotchas went in via `context_remember` and never
38
+ resurfaced. Now they appear exactly where they're relevant:
39
+ - `graph_read` of a file (or a symbol in it) shows a `📌 Remembered for this
40
+ file` block with the entries linked to that file.
41
+ - `graph_continue` packs include `Remembered:` lines for entries matching the
42
+ query.
43
+ - **Memories know when they might be wrong.** Entries linked to files are now
44
+ *anchored* to those files' content hashes at capture time. When the code
45
+ changes afterwards (tracked live by auto-reindex), every surfacing of that
46
+ entry — graph_read, graph_continue, context_recall — carries
47
+ `⚠ possibly stale — <file> changed since stored`. Old entries without anchors
48
+ keep working and are never flagged; the shared context-store format is
49
+ unchanged (additive optional field).
50
+
51
+ ---
52
+
10
53
  ## [0.14.1] — 2026-07-02
11
54
 
12
55
  ### 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.14.1",
21
+ version: "0.16.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -517,6 +517,7 @@ function resolvePaths(projectRoot) {
517
517
  tokenLog: join2(graphDir, "token_log.jsonl"),
518
518
  gateLog: join2(graphDir, "gate_log.jsonl"),
519
519
  bashLog: join2(graphDir, "bash_log.jsonl"),
520
+ routeLog: join2(graphDir, "route_log.jsonl"),
520
521
  toolLog: join2(graphDir, "tool_log.jsonl"),
521
522
  accessLog: join2(graphDir, "access_log.jsonl"),
522
523
  learnStore: join2(graphDir, "learn_store.json"),
@@ -1062,6 +1063,44 @@ fi
1062
1063
  exit 0
1063
1064
  `;
1064
1065
 
1066
+ // src/hooks/scripts/route.ps1
1067
+ var route_default = '# UserPromptSubmit hook \u2014 Windows PowerShell.\n# THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,\n# and prints the routing hint (if any) to stdout \u2014 Claude Code injects\n# UserPromptSubmit stdout into the conversation as added context.\n# Always exits 0; any failure leaves the prompt untouched.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$raw = [Console]::In.ReadToEnd()\nif (-not $raw) { exit 0 }\n\ntry {\n $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop\n} catch {\n exit 0\n}\nif (-not $hookInput.prompt) { exit 0 }\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\n$payload = @{ prompt = $hookInput.prompt } | ConvertTo-Json -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/route" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n if ($resp.hint) { Write-Output $resp.hint }\n} catch {\n # silent on failure \u2014 the prompt proceeds without a routing hint\n}\nexit 0\n';
1068
+
1069
+ // src/hooks/scripts/route.sh
1070
+ var route_default2 = `#!/usr/bin/env bash
1071
+ # UserPromptSubmit hook \u2014 bash.
1072
+ # THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,
1073
+ # and prints the routing hint (if any) to stdout \u2014 Claude Code injects
1074
+ # UserPromptSubmit stdout into the conversation as added context.
1075
+ # Always exits 0; requires \`jq\` (silently no-ops without it, like the other hooks).
1076
+
1077
+ set +e
1078
+
1079
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"
1080
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1081
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1082
+ if [ -z "$PORT" ]; then exit 0; fi
1083
+
1084
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1085
+
1086
+ INPUT=$(cat 2>/dev/null)
1087
+ if [ -z "$INPUT" ]; then exit 0; fi
1088
+
1089
+ # Re-emit only the prompt field as the request body (jq handles escaping).
1090
+ BODY=$(printf '%s' "$INPUT" | jq -c '{prompt: (.prompt // empty)}' 2>/dev/null)
1091
+ if [ -z "$BODY" ] || [ "$BODY" = "{}" ]; then exit 0; fi
1092
+
1093
+ HINT=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1094
+ --data "$BODY" "http://127.0.0.1:$PORT/route" 2>/dev/null \\
1095
+ | jq -r '.hint // empty' 2>/dev/null \\
1096
+ | head -c 2000)
1097
+
1098
+ if [ -n "$HINT" ]; then
1099
+ printf '%s\\n' "$HINT"
1100
+ fi
1101
+ exit 0
1102
+ `;
1103
+
1065
1104
  // src/hooks/scripts/stop.ps1
1066
1105
  var stop_default = `# Stop hook \u2014 Windows PowerShell.\r
1067
1106
  # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
@@ -1226,7 +1265,9 @@ var SCRIPTS = [
1226
1265
  sh: pre_tool_use_default2
1227
1266
  },
1228
1267
  { event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
1229
- { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
1268
+ { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 },
1269
+ // The Dispatcher — per-prompt routing hint (best-fit agent/skill/model).
1270
+ { event: "UserPromptSubmit", baseName: "synthra-route", ps1: route_default, sh: route_default2 }
1230
1271
  ];
1231
1272
  function commandFor(scriptPath) {
1232
1273
  if (process.platform === "win32") {
@@ -3046,7 +3087,7 @@ import { basename as basename5 } from "path";
3046
3087
  // src/hooks/claude-md.ts
3047
3088
  import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
3048
3089
  import { basename as basename4, dirname as dirname9 } from "path";
3049
- var POLICY_VERSION = 8;
3090
+ var POLICY_VERSION = 9;
3050
3091
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3051
3092
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3052
3093
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
@@ -3068,7 +3109,7 @@ function policyBlock() {
3068
3109
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
3069
3110
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
3070
3111
  "> If the tools are deferred, load their schemas with ToolSearch:",
3071
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
3112
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol,mcp__synthra__route_task`.",
3072
3113
  "> Below, short names (`graph_continue` etc.) appear in prose for",
3073
3114
  "> readability only.",
3074
3115
  "",
@@ -3086,6 +3127,11 @@ function policyBlock() {
3086
3127
  " util, or function, call this to check whether one already exists. If it",
3087
3128
  " returns matches, reuse or extend them instead of re-implementing; only",
3088
3129
  ' "no match \u2014 safe to create" means it is genuinely new.',
3130
+ "- **`route_task(task)`** \u2014 **delegate-first**: before starting a multi-step",
3131
+ " implementation task, ask which installed subagent/skill fits it. Plan on",
3132
+ " the primary model, then hand execution to the recommended subagent on a",
3133
+ " cheaper model (sonnet \u2248 5\xD7 cheaper than opus). Synthra may also inject a",
3134
+ " `[Synthra route]` hint on your prompt \u2014 treat it the same way.",
3089
3135
  "",
3090
3136
  "### When to call `graph_continue` \u2014 and when to skip",
3091
3137
  "",
@@ -3473,6 +3519,10 @@ function loadConfig() {
3473
3519
  // the terminal bypass of the Moat can be measured. Never blocks. Opt out
3474
3520
  // with SYN_NO_BASH_OBSERVE.
3475
3521
  bashObserve: !process.env.SYN_NO_BASH_OBSERVE,
3522
+ // The Dispatcher: per-prompt routing hints (best-fit agent/skill/model).
3523
+ // Silent unless the top agent clears routeMinScore. SYN_NO_ROUTE disables.
3524
+ route: !process.env.SYN_NO_ROUTE,
3525
+ routeMinScore: num("SYN_ROUTE_MIN_SCORE", 3),
3476
3526
  mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
3477
3527
  dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
3478
3528
  logLevel: str("SYN_LOG_LEVEL", "info"),
@@ -3481,8 +3531,8 @@ function loadConfig() {
3481
3531
  }
3482
3532
 
3483
3533
  // src/server/mcp.ts
3484
- import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
3485
- import { dirname as dirname12 } from "path";
3534
+ import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
3535
+ import { dirname as dirname13 } from "path";
3486
3536
 
3487
3537
  // src/graph/rank.ts
3488
3538
  var KW_BASE_WEIGHT = 2;
@@ -3882,7 +3932,8 @@ async function rememberEntry(paths, input) {
3882
3932
  content: input.text,
3883
3933
  tags: input.tags ?? [],
3884
3934
  files: input.files ?? [],
3885
- date: (/* @__PURE__ */ new Date()).toISOString()
3935
+ date: (/* @__PURE__ */ new Date()).toISOString(),
3936
+ ...input.anchors && input.anchors.length > 0 ? { anchors: input.anchors } : {}
3886
3937
  };
3887
3938
  await appendEntry(active.paths.contextStore, entry);
3888
3939
  const entries = await readEntries(active.paths.contextStore);
@@ -4101,6 +4152,158 @@ async function pack(files, opts) {
4101
4152
  };
4102
4153
  }
4103
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
+
4104
4307
  // src/server/mcp.ts
4105
4308
  var PROTOCOL_VERSION = "2024-11-05";
4106
4309
  var SERVER_INFO = { name: "synthra", version: "0.0.1" };
@@ -4184,7 +4387,7 @@ var TOOLS = [
4184
4387
  files: {
4185
4388
  type: "array",
4186
4389
  items: { type: "string" },
4187
- description: "Optional project-relative file paths this entry relates to."
4390
+ 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."
4188
4391
  }
4189
4392
  },
4190
4393
  required: ["text", "kind"]
@@ -4289,6 +4492,17 @@ var TOOLS = [
4289
4492
  },
4290
4493
  required: ["from", "to"]
4291
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
+ }
4292
4506
  }
4293
4507
  ];
4294
4508
  async function callTool(name, args, ctx) {
@@ -4317,6 +4531,8 @@ async function callTool(name, args, ctx) {
4317
4531
  return duplicateSymbols(args, ctx);
4318
4532
  case "call_path":
4319
4533
  return callPath(args, ctx);
4534
+ case "route_task":
4535
+ return routeTask(args, ctx);
4320
4536
  default:
4321
4537
  return errorContent(`Unknown tool: ${name}`);
4322
4538
  }
@@ -4473,6 +4689,17 @@ function testsCoveringLine(graph, filePaths) {
4473
4689
  const omitted = tests.length - shown.length;
4474
4690
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4475
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
+ }
4476
4703
  function resolveSymbolArg(ctx, arg) {
4477
4704
  const a = arg.trim();
4478
4705
  if (a.includes("::")) {
@@ -4726,9 +4953,28 @@ async function graphContinue(args, ctx) {
4726
4953
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
4727
4954
  Reason: ${retrieval.reason}
4728
4955
  `;
4729
- return textContent(`${header}
4956
+ const remembered = matchRememberedFacts(query, retrieval.files, await safeRecallAll(ctx), ctx);
4957
+ return textContent(`${header}${remembered}
4730
4958
  ${packed.text}`);
4731
4959
  }
4960
+ function matchRememberedFacts(query, retrievedFiles, entries, ctx) {
4961
+ if (entries.length === 0) return "";
4962
+ const qTokens = new Set(tokenizeQuery(query));
4963
+ const retrievedPaths = new Set(retrievedFiles.map((f) => f.path));
4964
+ const scored = entries.map((e) => {
4965
+ let score2 = 0;
4966
+ for (const t of tokenizeQuery(`${e.content} ${e.tags.join(" ")}`)) {
4967
+ if (qTokens.has(t)) score2 += 1;
4968
+ }
4969
+ if (e.files.some((f) => retrievedPaths.has(f)) || e.anchors?.some((a) => retrievedPaths.has(a.path))) {
4970
+ score2 += 2;
4971
+ }
4972
+ return { e, score: score2 };
4973
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, PACK_FACTS_MAX);
4974
+ if (scored.length === 0) return "";
4975
+ return `${scored.map((x) => `Remembered: ${factLine(x.e, ctx.graph).slice(2)}`).join("\n")}
4976
+ `;
4977
+ }
4732
4978
  function resolveFileTarget(graph, filePath) {
4733
4979
  const files = graph.nodes.filter((n) => n.kind === "file");
4734
4980
  const exact = files.find((n) => n.path === filePath);
@@ -4821,6 +5067,43 @@ function buildTestsFooter(symbol, graph) {
4821
5067
  if (isLikelyEntry(symbol.file)) return "";
4822
5068
  return "Tests: none linked to this file.";
4823
5069
  }
5070
+ var FACTS_MAX = 3;
5071
+ var FACTS_CONTENT_MAX = 160;
5072
+ var PACK_FACTS_MAX = 2;
5073
+ function staleAnchorPaths(entry, graph) {
5074
+ if (!entry.anchors || entry.anchors.length === 0) return [];
5075
+ const hashByPath = /* @__PURE__ */ new Map();
5076
+ for (const n of graph.nodes) if (n.kind === "file") hashByPath.set(n.path, n.file_hash);
5077
+ return entry.anchors.filter((a) => hashByPath.get(a.path) !== a.hash).map((a) => a.path);
5078
+ }
5079
+ function factLine(entry, graph) {
5080
+ const content = entry.content.length > FACTS_CONTENT_MAX ? `${entry.content.slice(0, FACTS_CONTENT_MAX - 1)}\u2026` : entry.content;
5081
+ const date = entry.date ? ` (${entry.date.slice(0, 10)})` : "";
5082
+ const stale = staleAnchorPaths(entry, graph);
5083
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale[0]} changed since stored` : "";
5084
+ return `- [${entry.type}] ${content}${date}${staleNote}`;
5085
+ }
5086
+ function entryLinksFile(entry, filePath) {
5087
+ if (entry.anchors?.some((a) => a.path === filePath)) return true;
5088
+ return entry.files.some((f) => f === filePath || filePath.endsWith(`/${f}`));
5089
+ }
5090
+ function buildFactsFooter(filePath, entries, graph) {
5091
+ const linked = entries.filter((e) => entryLinksFile(e, filePath));
5092
+ if (linked.length === 0) return "";
5093
+ const newestFirst = linked.slice().reverse();
5094
+ const shown = newestFirst.slice(0, FACTS_MAX);
5095
+ const omitted = newestFirst.length - shown.length;
5096
+ const lines = ["\u{1F4CC} Remembered for this file:", ...shown.map((e) => factLine(e, graph))];
5097
+ if (omitted > 0) lines.push(`\u2026+${omitted} more \u2014 mcp__synthra__context_recall()`);
5098
+ return lines.join("\n");
5099
+ }
5100
+ async function safeRecallAll(ctx) {
5101
+ try {
5102
+ return (await recallEntries(ctx.paths, {})).entries;
5103
+ } catch {
5104
+ return [];
5105
+ }
5106
+ }
4824
5107
  async function graphRead(args, ctx) {
4825
5108
  const target = typeof args?.target === "string" ? args.target : "";
4826
5109
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -4839,10 +5122,15 @@ async function graphRead(args, ctx) {
4839
5122
  }
4840
5123
  const fileNode = resolved.node;
4841
5124
  await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
5125
+ const facts = buildFactsFooter(fileNode.path, await safeRecallAll(ctx), ctx.graph);
5126
+ const factsBlock = facts ? `
5127
+
5128
+ ---
5129
+ ${facts}` : "";
4842
5130
  if (!symbolName) {
4843
5131
  return textContent(`# ${fileNode.path}
4844
5132
 
4845
- ${fileNode.content}`);
5133
+ ${fileNode.content}${factsBlock}`);
4846
5134
  }
4847
5135
  const cleanSym = symbolName.trim();
4848
5136
  const symbol = ctx.graph.nodes.find(
@@ -4872,7 +5160,7 @@ ${tests}` : "";
4872
5160
  return textContent(
4873
5161
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
4874
5162
 
4875
- ${body}${depsBlock}${testsBlock}${editHint}`
5163
+ ${body}${depsBlock}${testsBlock}${factsBlock}${editHint}`
4876
5164
  );
4877
5165
  }
4878
5166
  var editedFiles = /* @__PURE__ */ new Set();
@@ -4907,16 +5195,26 @@ async function contextRemember(args, ctx) {
4907
5195
  }
4908
5196
  const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
4909
5197
  const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
5198
+ const anchors = [];
5199
+ for (const f of files) {
5200
+ const resolved = resolveFileTarget(ctx.graph, f);
5201
+ if ("node" in resolved) {
5202
+ anchors.push({ path: resolved.node.path, hash: resolved.node.file_hash });
5203
+ }
5204
+ }
4910
5205
  const result = await rememberEntry(ctx.paths, {
4911
5206
  text,
4912
5207
  kind: kindRaw,
4913
5208
  tags,
4914
- files
5209
+ files,
5210
+ anchors
4915
5211
  });
5212
+ const anchorNote = anchors.length ? `
5213
+ Anchored to ${anchors.length} file(s) \u2014 recall will flag this entry if they change.` : "";
4916
5214
  return textContent(
4917
5215
  `Remembered ${result.entry.type} on branch '${result.branch}'.
4918
5216
  Stored: ${result.storePath}
4919
- CONTEXT.md refreshed: ${result.contextMdPath}`
5217
+ CONTEXT.md refreshed: ${result.contextMdPath}${anchorNote}`
4920
5218
  );
4921
5219
  }
4922
5220
  var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
@@ -4951,15 +5249,17 @@ async function contextRecall(args, ctx) {
4951
5249
  const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
4952
5250
  for (const e of result.entries) {
4953
5251
  const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
4954
- lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}`);
5252
+ const stale = staleAnchorPaths(e, ctx.graph);
5253
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale.join(", ")} changed since stored` : "";
5254
+ lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}${staleNote}`);
4955
5255
  if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
4956
5256
  }
4957
5257
  return textContent(lines.join("\n"));
4958
5258
  }
4959
5259
  async function logToolCall(ctx, tool) {
4960
5260
  try {
4961
- await mkdir10(dirname12(ctx.paths.toolLog), { recursive: true });
4962
- await appendFile3(
5261
+ await mkdir11(dirname13(ctx.paths.toolLog), { recursive: true });
5262
+ await appendFile4(
4963
5263
  ctx.paths.toolLog,
4964
5264
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
4965
5265
  "utf8"
@@ -5152,8 +5452,8 @@ async function getChangedLineRanges(projectRoot, sinceRef) {
5152
5452
  }
5153
5453
 
5154
5454
  // src/memory/session.ts
5155
- import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
5156
- import { dirname as dirname13 } from "path";
5455
+ import { mkdir as mkdir12, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
5456
+ import { dirname as dirname14 } from "path";
5157
5457
  var SESSION_SCHEMA_VERSION = 2;
5158
5458
  async function readSession(path) {
5159
5459
  try {
@@ -5166,7 +5466,7 @@ async function readSession(path) {
5166
5466
  }
5167
5467
  }
5168
5468
  async function writeSession(path, state) {
5169
- await mkdir11(dirname13(path), { recursive: true });
5469
+ await mkdir12(dirname14(path), { recursive: true });
5170
5470
  await writeFile10(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5171
5471
  }
5172
5472
 
@@ -5214,12 +5514,12 @@ async function handleContextUpdate(req, ctx) {
5214
5514
  }
5215
5515
 
5216
5516
  // src/server/routes/gate.ts
5217
- import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
5218
- import { dirname as dirname15 } from "path";
5517
+ import { appendFile as appendFile6, mkdir as mkdir14 } from "fs/promises";
5518
+ import { dirname as dirname16 } from "path";
5219
5519
 
5220
5520
  // src/server/routes/bash-observe.ts
5221
- import { appendFile as appendFile4, mkdir as mkdir12 } from "fs/promises";
5222
- import { dirname as dirname14 } from "path";
5521
+ import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
5522
+ import { dirname as dirname15 } from "path";
5223
5523
 
5224
5524
  // src/server/routes/query-heuristics.ts
5225
5525
  function looksLikeNonSymbolQuery(pattern) {
@@ -5378,7 +5678,7 @@ var CMD_MAX = 300;
5378
5678
  var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
5379
5679
  async function logObservation(ctx, exp, confidence, avoidable, command) {
5380
5680
  try {
5381
- await mkdir12(dirname14(ctx.paths.bashLog), { recursive: true });
5681
+ await mkdir13(dirname15(ctx.paths.bashLog), { recursive: true });
5382
5682
  const entry = {
5383
5683
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5384
5684
  kind: exp.kind,
@@ -5388,7 +5688,7 @@ async function logObservation(ctx, exp, confidence, avoidable, command) {
5388
5688
  avoidable,
5389
5689
  command: trunc(command, CMD_MAX)
5390
5690
  };
5391
- await appendFile4(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
5691
+ await appendFile5(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
5392
5692
  } catch {
5393
5693
  }
5394
5694
  }
@@ -5458,7 +5758,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
5458
5758
  var LOG_REASON_MAX_CHARS = 240;
5459
5759
  async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
5460
5760
  try {
5461
- await mkdir13(dirname15(ctx.paths.gateLog), { recursive: true });
5761
+ await mkdir14(dirname16(ctx.paths.gateLog), { recursive: true });
5462
5762
  const entry = {
5463
5763
  ts: (/* @__PURE__ */ new Date()).toISOString(),
5464
5764
  tool: toolName,
@@ -5467,7 +5767,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
5467
5767
  reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
5468
5768
  ...hintChars === void 0 ? {} : { hint_chars: hintChars }
5469
5769
  };
5470
- await appendFile5(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5770
+ await appendFile6(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5471
5771
  } catch {
5472
5772
  }
5473
5773
  }
@@ -5590,16 +5890,16 @@ async function handleGate(req, ctx) {
5590
5890
  }
5591
5891
 
5592
5892
  // src/server/routes/log.ts
5593
- import { appendFile as appendFile6, mkdir as mkdir14 } from "fs/promises";
5594
- import { dirname as dirname16 } from "path";
5893
+ import { appendFile as appendFile7, mkdir as mkdir15 } from "fs/promises";
5894
+ import { dirname as dirname17 } from "path";
5595
5895
  async function handleLog(entry, ctx) {
5596
5896
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
5597
5897
  throw new Error("log: input_tokens and output_tokens (number) are required");
5598
5898
  }
5599
5899
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
5600
5900
  const record = { ...entry, written_at };
5601
- await mkdir14(dirname16(ctx.paths.tokenLog), { recursive: true });
5602
- await appendFile6(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5901
+ await mkdir15(dirname17(ctx.paths.tokenLog), { recursive: true });
5902
+ await appendFile7(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5603
5903
  return { ok: true, written_at };
5604
5904
  }
5605
5905
 
@@ -5793,6 +6093,10 @@ function buildApp(ctx, port) {
5793
6093
  const body = await c.req.json().catch(() => ({}));
5794
6094
  return c.json(await handleGate(body, ctx));
5795
6095
  });
6096
+ app.post("/route", async (c) => {
6097
+ const body = await c.req.json().catch(() => ({}));
6098
+ return c.json(await handleRoute(body, ctx));
6099
+ });
5796
6100
  app.get("/activity", async (c) => {
5797
6101
  const sinceParam = c.req.query("since");
5798
6102
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
@@ -6099,7 +6403,7 @@ import { readFile as readFile20, readdir as readdir4, rm, rmdir, stat as stat5,
6099
6403
  import { basename as basename6, join as join13, resolve as resolve4 } from "path";
6100
6404
 
6101
6405
  // src/cli/self-update.ts
6102
- import { mkdir as mkdir15, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6406
+ import { mkdir as mkdir16, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6103
6407
  import { homedir as homedir4 } from "os";
6104
6408
  import { join as join12 } from "path";
6105
6409
  import { createInterface } from "readline/promises";
@@ -6166,7 +6470,7 @@ async function readLastSeen() {
6166
6470
  }
6167
6471
  async function writeLastSeen(version) {
6168
6472
  try {
6169
- await mkdir15(SYNTHRA_DIR, { recursive: true });
6473
+ await mkdir16(SYNTHRA_DIR, { recursive: true });
6170
6474
  const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
6171
6475
  await writeFile12(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
6172
6476
  } catch {
@@ -6336,7 +6640,8 @@ var HOOK_BASENAMES = [
6336
6640
  "synthra-prime",
6337
6641
  "synthra-pre-tool-use",
6338
6642
  "synthra-pre-compact",
6339
- "synthra-stop"
6643
+ "synthra-stop",
6644
+ "synthra-route"
6340
6645
  ];
6341
6646
  async function exists3(path) {
6342
6647
  try {