@jefuriiij/synthra 0.3.1 → 0.4.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,29 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.4.0] — 2026-06-10
11
+
12
+ ### Changed
13
+
14
+ - **The Moat's block messages now deliver the answer, not just directions.**
15
+ When the gate blocks a Grep/Glob, the deny reason used to name the relevant
16
+ file paths — and agents responded by Reading those files whole, erasing the
17
+ savings the block was meant to create. The block message now carries
18
+ copy-pasteable `mcp__synthra__graph_read("file::symbol")` targets with
19
+ one-line signatures for the query's best-matching symbols (~300 tokens,
20
+ signatures only), plus a `graph_continue` pointer for the full pack. The
21
+ cheap path is now the path of least resistance. Budget tunable via
22
+ `SYN_GATE_HINT_CHARS` (default 1200 chars). Gate decisions are unchanged —
23
+ only the message got smarter.
24
+ - **Policy v7 — full namespaced tool names.** Agents wasted tool-discovery
25
+ round-trips searching for short names like `graph_continue` that don't
26
+ resolve. The CLAUDE.md policy block now states the `mcp__synthra__` namespace
27
+ requirement up front, provides a ready ToolSearch `select:` line for the
28
+ graph tools, and uses the full form in every invocation example. Existing
29
+ policy blocks upgrade automatically on the next `syn .`.
30
+
31
+ ---
32
+
10
33
  ## [0.3.1] — 2026-06-09
11
34
 
12
35
  ### Changed
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.3.1",
21
+ version: "0.4.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3732,7 +3732,7 @@ import { basename as basename4 } from "path";
3732
3732
  // src/hooks/claude-md.ts
3733
3733
  import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3734
3734
  import { basename as basename3, dirname as dirname8 } from "path";
3735
- var POLICY_VERSION = 6;
3735
+ var POLICY_VERSION = 7;
3736
3736
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3737
3737
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3738
3738
  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;
@@ -3748,9 +3748,12 @@ function policyBlock() {
3748
3748
  "",
3749
3749
  "> **Tool namespace.** Synthra's MCP tools are exposed as",
3750
3750
  "> `mcp__synthra__graph_continue`, `mcp__synthra__graph_read`, and",
3751
- "> `mcp__synthra__graph_register_edit`. Below they are referred to by",
3752
- "> their short names (`graph_continue` etc.) for readability \u2014 use the",
3753
- "> full namespaced form when actually invoking them.",
3751
+ "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
3752
+ "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
3753
+ "> If the tools are deferred, load their schemas with ToolSearch:",
3754
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
3755
+ "> Below, short names (`graph_continue` etc.) appear in prose for",
3756
+ "> readability only.",
3754
3757
  "",
3755
3758
  "### Tools",
3756
3759
  "",
@@ -3779,8 +3782,8 @@ function policyBlock() {
3779
3782
  " test, docs, cleanup, commit)",
3780
3783
  "- The task is pure text (commit message, explanation, summary)",
3781
3784
  "",
3782
- 'If skipping, go directly to `graph_read("file.ts::symbol")` on what',
3783
- "you already know.",
3785
+ "If skipping, go directly to",
3786
+ '`mcp__synthra__graph_read("file.ts::symbol")` on what you already know.',
3784
3787
  "",
3785
3788
  "### Confidence caps",
3786
3789
  "",
@@ -3789,8 +3792,8 @@ function policyBlock() {
3789
3792
  "- **`Confidence: high`** \u2192 Stop. Do NOT Grep, Glob, or further explore",
3790
3793
  " for this query. The graph already has it.",
3791
3794
  "- **`Confidence: medium`** \u2192 Read the listed `Files` directly via",
3792
- ' `graph_read("file::symbol")` *before* trying Grep. The graph has',
3793
- " narrowed the search space \u2014 use it, don't bypass it.",
3795
+ ' `mcp__synthra__graph_read("file::symbol")` *before* trying Grep. The',
3796
+ " graph has narrowed the search space \u2014 use it, don't bypass it.",
3794
3797
  "- **`Confidence: low`** \u2192 You may use Grep / Glob, but the PreToolUse",
3795
3798
  " hook may still block redundant calls.",
3796
3799
  "",
@@ -3801,8 +3804,9 @@ function policyBlock() {
3801
3804
  "- If `graph_continue`'s `Files` list contains a `::` entry, pass it",
3802
3805
  " verbatim to `graph_read`.",
3803
3806
  "- **Large file?** Don't read it in successive line-range chunks \u2014 call",
3804
- ' `graph_continue` or `graph_read("file::symbol")` to pull the one symbol',
3805
- " you need. Chunked whole-file Reads are exactly the cost `graph_read`",
3807
+ " `mcp__synthra__graph_continue` or",
3808
+ ' `mcp__synthra__graph_read("file::symbol")` to pull the one symbol you',
3809
+ " need. Chunked whole-file Reads are exactly the cost `graph_read`",
3806
3810
  " exists to avoid.",
3807
3811
  "",
3808
3812
  "### Editing a file",
@@ -3830,9 +3834,10 @@ function policyBlock() {
3830
3834
  "decisions carried over from the previous session. **Trust it.** It is the",
3831
3835
  "cheapest possible orientation: do NOT re-run `graph_continue` or Grep just",
3832
3836
  'to rediscover "what were we doing / what changed" \u2014 that work is already',
3833
- 'done. For the concrete next steps, `context_recall({kind:"next"})` returns',
3834
- "them verbatim. Only reach for fresh retrieval when the task moves beyond",
3835
- "what the digest covers.",
3837
+ "done. For the concrete next steps,",
3838
+ '`mcp__synthra__context_recall({kind:"next"})` returns them verbatim. Only',
3839
+ "reach for fresh retrieval when the task moves beyond what the digest",
3840
+ "covers.",
3836
3841
  "",
3837
3842
  "### Session-end resume note",
3838
3843
  "",
@@ -5335,6 +5340,32 @@ async function handleContextUpdate(req, ctx) {
5335
5340
  // src/server/routes/gate.ts
5336
5341
  import { appendFile as appendFile4, mkdir as mkdir12 } from "fs/promises";
5337
5342
  import { dirname as dirname13 } from "path";
5343
+
5344
+ // src/shared/config.ts
5345
+ function num(name, fallback) {
5346
+ const v = process.env[name];
5347
+ if (!v) return fallback;
5348
+ const n = Number(v);
5349
+ return Number.isFinite(n) ? n : fallback;
5350
+ }
5351
+ function str(name, fallback) {
5352
+ return process.env[name] ?? fallback;
5353
+ }
5354
+ function loadConfig() {
5355
+ return {
5356
+ hardMaxReadChars: num("SYN_HARD_MAX_READ_CHARS", 4e3),
5357
+ gateHintMaxChars: num("SYN_GATE_HINT_CHARS", 1200),
5358
+ turnReadBudgetChars: num("SYN_TURN_READ_BUDGET_CHARS", 18e3),
5359
+ fallbackMaxCallsPerTurn: num("SYN_FALLBACK_MAX_CALLS_PER_TURN", 1),
5360
+ retrieveCacheTtlSec: num("SYN_RETRIEVE_CACHE_TTL_SEC", 900),
5361
+ mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
5362
+ dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
5363
+ logLevel: str("SYN_LOG_LEVEL", "info"),
5364
+ claudeBin: str("SYN_CLAUDE_BIN", "claude")
5365
+ };
5366
+ }
5367
+
5368
+ // src/server/routes/gate.ts
5338
5369
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
5339
5370
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
5340
5371
  function extractQuery(toolName, input) {
@@ -5388,7 +5419,8 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
5388
5419
  }
5389
5420
  return matches;
5390
5421
  }
5391
- async function logDecision(ctx, toolName, query, decision, reason) {
5422
+ var LOG_REASON_MAX_CHARS = 240;
5423
+ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
5392
5424
  try {
5393
5425
  await mkdir12(dirname13(ctx.paths.gateLog), { recursive: true });
5394
5426
  const entry = {
@@ -5396,12 +5428,70 @@ async function logDecision(ctx, toolName, query, decision, reason) {
5396
5428
  tool: toolName,
5397
5429
  decision,
5398
5430
  query,
5399
- reason
5431
+ reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
5432
+ ...hintChars === void 0 ? {} : { hint_chars: hintChars }
5400
5433
  };
5401
5434
  await appendFile4(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5402
5435
  } catch {
5403
5436
  }
5404
5437
  }
5438
+ var SIG_LINE_MAX_CHARS = 140;
5439
+ function scoreSymbolName(name, qTokens) {
5440
+ const lower = name.toLowerCase();
5441
+ let score2 = 0;
5442
+ for (const t of qTokens) {
5443
+ if (t === lower) score2 += 3;
5444
+ else if (t.length >= 3 && lower.includes(t)) score2 += 1;
5445
+ }
5446
+ return score2;
5447
+ }
5448
+ function buildBlockHint(query, retrieval, graph, toolName, maxChars = loadConfig().gateHintMaxChars) {
5449
+ const topFiles = retrieval.files.slice(0, 3);
5450
+ const topPaths = new Set(topFiles.map((f) => f.path));
5451
+ const symsByFile = /* @__PURE__ */ new Map();
5452
+ for (const n of graph.nodes) {
5453
+ if (n.kind !== "symbol" || !topPaths.has(n.file)) continue;
5454
+ const list = symsByFile.get(n.file);
5455
+ if (list) list.push(n);
5456
+ else symsByFile.set(n.file, [n]);
5457
+ }
5458
+ const qTokens = tokenizeQuery(query);
5459
+ const entries = [];
5460
+ for (const f of topFiles) {
5461
+ const syms = (symsByFile.get(f.path) ?? []).slice().sort((a, b) => a.start_line - b.start_line);
5462
+ if (syms.length === 0) {
5463
+ entries.push(`\u2022 mcp__synthra__graph_read("${f.path}")`);
5464
+ continue;
5465
+ }
5466
+ const scored = syms.map((s) => ({ s, score: scoreSymbolName(s.name, qTokens) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score);
5467
+ const picks = scored.length > 0 ? scored.slice(0, 2).map((x) => x.s) : syms.slice(0, 1);
5468
+ for (const s of picks) {
5469
+ const sig = `L${s.start_line}: ${s.signature.trim()}`;
5470
+ const sigLine = sig.length > SIG_LINE_MAX_CHARS ? `${sig.slice(0, SIG_LINE_MAX_CHARS - 1)}\u2026` : sig;
5471
+ entries.push(`\u2022 mcp__synthra__graph_read("${f.path}::${s.name}")
5472
+ ${sigLine}`);
5473
+ }
5474
+ }
5475
+ const header = `Synthra blocked this ${toolName} \u2014 ${retrieval.confidence}-confidence context for "${query}" already exists.
5476
+ Read symbols directly (~50 tokens each) instead of whole files:
5477
+ `;
5478
+ const footer = `
5479
+ Full pack: mcp__synthra__graph_continue("${query}")`;
5480
+ const parts = [];
5481
+ let used = header.length + footer.length + 1;
5482
+ for (const e of entries) {
5483
+ if (used + e.length + 1 > maxChars) break;
5484
+ parts.push(e);
5485
+ used += e.length + 1;
5486
+ }
5487
+ if (parts.length === 0) {
5488
+ const top = topFiles.map((f) => f.path).join(", ");
5489
+ return `Synthra has ${retrieval.confidence}-confidence context for "${query}" (top files: ${top}). Use mcp__synthra__graph_continue("${query}") instead of ${toolName}, or read a specific file/symbol with mcp__synthra__graph_read.`;
5490
+ }
5491
+ return `${header}
5492
+ ${parts.join("\n")}
5493
+ ${footer}`;
5494
+ }
5405
5495
  async function handleGate(req, ctx) {
5406
5496
  if (!req?.tool_name || typeof req.tool_name !== "string") {
5407
5497
  return { decision: "allow", reason: "no tool_name" };
@@ -5452,12 +5542,9 @@ async function handleGate(req, ctx) {
5452
5542
  await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
5453
5543
  return res2;
5454
5544
  }
5455
- const top = retrieval.files.slice(0, 3).map((f) => f.path).join(", ");
5456
- const res = {
5457
- decision: "block",
5458
- reason: `Synthra has ${retrieval.confidence}-confidence context for "${query}" (top files: ${top}). Use the \`graph_continue\` MCP tool with this query instead of ${req.tool_name}, or read a specific file/symbol with \`graph_read\`.`
5459
- };
5460
- await logDecision(ctx, req.tool_name, query, res.decision, res.reason);
5545
+ const hint = buildBlockHint(query, retrieval, ctx.graph, req.tool_name);
5546
+ const res = { decision: "block", reason: hint };
5547
+ await logDecision(ctx, req.tool_name, query, res.decision, hint, hint.length);
5461
5548
  return res;
5462
5549
  }
5463
5550
 
@@ -5702,29 +5789,6 @@ async function startServer(paths, options = {}) {
5702
5789
  };
5703
5790
  }
5704
5791
 
5705
- // src/shared/config.ts
5706
- function num(name, fallback) {
5707
- const v = process.env[name];
5708
- if (!v) return fallback;
5709
- const n = Number(v);
5710
- return Number.isFinite(n) ? n : fallback;
5711
- }
5712
- function str(name, fallback) {
5713
- return process.env[name] ?? fallback;
5714
- }
5715
- function loadConfig() {
5716
- return {
5717
- hardMaxReadChars: num("SYN_HARD_MAX_READ_CHARS", 4e3),
5718
- turnReadBudgetChars: num("SYN_TURN_READ_BUDGET_CHARS", 18e3),
5719
- fallbackMaxCallsPerTurn: num("SYN_FALLBACK_MAX_CALLS_PER_TURN", 1),
5720
- retrieveCacheTtlSec: num("SYN_RETRIEVE_CACHE_TTL_SEC", 900),
5721
- mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
5722
- dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
5723
- logLevel: str("SYN_LOG_LEVEL", "info"),
5724
- claudeBin: str("SYN_CLAUDE_BIN", "claude")
5725
- };
5726
- }
5727
-
5728
5792
  // src/cli/session-discovery.ts
5729
5793
  import { readdir as readdir2, stat as stat3 } from "fs/promises";
5730
5794
  import { homedir as homedir2 } from "os";