@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 +23 -0
- package/dist/cli/index.js +109 -45
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +108 -21
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 =
|
|
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`.
|
|
3752
|
-
">
|
|
3753
|
-
">
|
|
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
|
-
|
|
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
|
-
' `
|
|
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
|
-
|
|
3805
|
-
"
|
|
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
|
-
|
|
3834
|
-
"them verbatim. Only
|
|
3835
|
-
"what the digest
|
|
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
|
-
|
|
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
|
|
5456
|
-
const res = {
|
|
5457
|
-
|
|
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";
|