@jefuriiij/synthra 0.11.0 → 0.13.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.
@@ -1811,7 +1811,7 @@ import { basename as basename2 } from "path";
1811
1811
  // src/hooks/claude-md.ts
1812
1812
  import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
1813
1813
  import { basename, dirname as dirname5 } from "path";
1814
- var POLICY_VERSION = 7;
1814
+ var POLICY_VERSION = 8;
1815
1815
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
1816
1816
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
1817
1817
  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;
@@ -1830,7 +1830,7 @@ function policyBlock() {
1830
1830
  "> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
1831
1831
  "> in ToolSearch or invocation \u2014 always use the full namespaced form.",
1832
1832
  "> If the tools are deferred, load their schemas with ToolSearch:",
1833
- "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
1833
+ "> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
1834
1834
  "> Below, short names (`graph_continue` etc.) appear in prose for",
1835
1835
  "> readability only.",
1836
1836
  "",
@@ -1844,6 +1844,10 @@ function policyBlock() {
1844
1844
  " symbol is ~50 tokens, reading a whole file is thousands.",
1845
1845
  "- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
1846
1846
  " subsequent turns weight your changes and avoid stale snapshots.",
1847
+ "- **`find_symbol(name)`** \u2014 **reuse-first**: before writing a new helper,",
1848
+ " util, or function, call this to check whether one already exists. If it",
1849
+ " returns matches, reuse or extend them instead of re-implementing; only",
1850
+ ' "no match \u2014 safe to create" means it is genuinely new.',
1847
1851
  "",
1848
1852
  "### When to call `graph_continue` \u2014 and when to skip",
1849
1853
  "",
@@ -3128,6 +3132,40 @@ var TOOLS = [
3128
3132
  limit: { type: "number", description: "Cap on returned files. Default 50." }
3129
3133
  }
3130
3134
  }
3135
+ },
3136
+ {
3137
+ name: "find_symbol",
3138
+ description: "Find existing symbols by name BEFORE writing a new one \u2014 reuse beats re-implementing. Returns exact-name definitions (signatures + graph_read targets) or, if none, similarly-named symbols. 'No symbol matching \u2026 \u2014 safe to create' means it's genuinely new.",
3139
+ inputSchema: {
3140
+ type: "object",
3141
+ properties: {
3142
+ name: { type: "string", description: "Symbol name (or near-name) to look for." }
3143
+ },
3144
+ required: ["name"]
3145
+ }
3146
+ },
3147
+ {
3148
+ name: "duplicate_symbols",
3149
+ description: "List symbol names defined in more than one file (functions/classes/types; methods excluded) \u2014 consolidation candidates for review. Advisory: duplicates may be intentional.",
3150
+ inputSchema: {
3151
+ type: "object",
3152
+ properties: {
3153
+ limit: { type: "number", description: "Cap on returned names. Default 30." }
3154
+ }
3155
+ }
3156
+ },
3157
+ {
3158
+ name: "call_path",
3159
+ description: "Trace how one symbol reaches another through the call graph \u2014 the shortest chain of calls from 'from' to 'to'. Use to understand control flow ('how does this handler end up hitting the DB layer?'). Each of 'from'/'to' is a 'file::symbol' target or a bare symbol name when unique.",
3160
+ inputSchema: {
3161
+ type: "object",
3162
+ properties: {
3163
+ from: { type: "string", description: "Starting symbol ('file::symbol' or unique name)." },
3164
+ to: { type: "string", description: "Target symbol ('file::symbol' or unique name)." },
3165
+ depth: { type: "number", description: "Max call hops to search. Default 6." }
3166
+ },
3167
+ required: ["from", "to"]
3168
+ }
3131
3169
  }
3132
3170
  ];
3133
3171
  async function callTool(name, args, ctx) {
@@ -3150,6 +3188,12 @@ async function callTool(name, args, ctx) {
3150
3188
  return blastRadius(args, ctx);
3151
3189
  case "dead_code":
3152
3190
  return deadCode(args, ctx);
3191
+ case "find_symbol":
3192
+ return findSymbol(args, ctx);
3193
+ case "duplicate_symbols":
3194
+ return duplicateSymbols(args, ctx);
3195
+ case "call_path":
3196
+ return callPath(args, ctx);
3153
3197
  default:
3154
3198
  return errorContent(`Unknown tool: ${name}`);
3155
3199
  }
@@ -3306,6 +3350,97 @@ function testsCoveringLine(graph, filePaths) {
3306
3350
  const omitted = tests.length - shown.length;
3307
3351
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3308
3352
  }
3353
+ function resolveSymbolArg(ctx, arg) {
3354
+ const a = arg.trim();
3355
+ if (a.includes("::")) {
3356
+ const [rawFile, rawSym] = a.split("::", 2);
3357
+ const resolved = resolveFileTarget(ctx.graph, (rawFile ?? "").trim());
3358
+ if (!("node" in resolved)) return null;
3359
+ const name = (rawSym ?? "").trim();
3360
+ return ctx.graph.nodes.find(
3361
+ (n) => n.kind === "symbol" && n.file === resolved.node.path && n.name === name
3362
+ ) ?? null;
3363
+ }
3364
+ const matches = ctx.graph.nodes.filter(
3365
+ (n) => n.kind === "symbol" && n.name === a
3366
+ );
3367
+ return matches.length === 1 ? matches[0] : null;
3368
+ }
3369
+ function callPath(args, ctx) {
3370
+ const fromArg = typeof args?.from === "string" ? args.from : "";
3371
+ const toArg = typeof args?.to === "string" ? args.to : "";
3372
+ const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 6;
3373
+ if (!fromArg.trim() || !toArg.trim()) {
3374
+ return errorContent("call_path: 'from' and 'to' (strings) are required");
3375
+ }
3376
+ const from = resolveSymbolArg(ctx, fromArg);
3377
+ if (!from) {
3378
+ return errorContent(
3379
+ `call_path: could not resolve 'from': ${fromArg} (use file::symbol if the name is ambiguous)`
3380
+ );
3381
+ }
3382
+ const to = resolveSymbolArg(ctx, toArg);
3383
+ if (!to) {
3384
+ return errorContent(
3385
+ `call_path: could not resolve 'to': ${toArg} (use file::symbol if the name is ambiguous)`
3386
+ );
3387
+ }
3388
+ if (from.id === to.id) {
3389
+ return textContent(`# call_path
3390
+
3391
+ \`${from.name}\` and \`${to.name}\` are the same symbol.`);
3392
+ }
3393
+ const calleesBy = /* @__PURE__ */ new Map();
3394
+ for (const e of ctx.graph.edges) {
3395
+ if (e.kind !== "calls" || e.from === e.to) continue;
3396
+ (calleesBy.get(e.from) ?? calleesBy.set(e.from, []).get(e.from)).push(e.to);
3397
+ }
3398
+ const symById = /* @__PURE__ */ new Map();
3399
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
3400
+ const prevOf = /* @__PURE__ */ new Map();
3401
+ const visited = /* @__PURE__ */ new Set([from.id]);
3402
+ let frontier = [from.id];
3403
+ let found = false;
3404
+ for (let d = 0; d < maxDepth && !found && frontier.length > 0; d++) {
3405
+ const next = [];
3406
+ for (const cur2 of frontier) {
3407
+ for (const nb of calleesBy.get(cur2) ?? []) {
3408
+ if (visited.has(nb)) continue;
3409
+ visited.add(nb);
3410
+ prevOf.set(nb, cur2);
3411
+ if (nb === to.id) {
3412
+ found = true;
3413
+ break;
3414
+ }
3415
+ next.push(nb);
3416
+ }
3417
+ if (found) break;
3418
+ }
3419
+ frontier = next;
3420
+ }
3421
+ if (!found) {
3422
+ return textContent(
3423
+ `# call_path: ${from.name} \u2192 ${to.name}
3424
+
3425
+ _(no call path found within depth ${maxDepth})_`
3426
+ );
3427
+ }
3428
+ const chain = [];
3429
+ let cur = to.id;
3430
+ while (cur !== void 0) {
3431
+ chain.unshift(cur);
3432
+ if (cur === from.id) break;
3433
+ cur = prevOf.get(cur);
3434
+ }
3435
+ const syms = chain.map((id) => symById.get(id)).filter((s) => !!s);
3436
+ const hops = syms.length - 1;
3437
+ const rendered = syms.map((s) => `\`${s.name}\` (${s.file}:${s.start_line})`).join("\n \u2192 ");
3438
+ return textContent(
3439
+ `# call_path: ${from.name} \u2192 ${to.name} (${hops} hop${hops === 1 ? "" : "s"})
3440
+
3441
+ ${rendered}`
3442
+ );
3443
+ }
3309
3444
  var LIKELY_ENTRY_PATTERNS = [
3310
3445
  /(?:^|\/)main\.[a-z0-9_]+$/i,
3311
3446
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -3353,6 +3488,107 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
3353
3488
  );
3354
3489
  return textContent(lines.join("\n"));
3355
3490
  }
3491
+ var FIND_MAX = 12;
3492
+ var FIND_SIG_MAX = 140;
3493
+ function symbolEntry(s) {
3494
+ const sig = s.signature.trim().slice(0, FIND_SIG_MAX);
3495
+ return `\u2022 ${sig} \u2192 mcp__synthra__graph_read("${s.file}::${s.name}") [${s.symbol_kind}, L${s.start_line}]`;
3496
+ }
3497
+ var byFileLine = (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1;
3498
+ function findSymbol(args, ctx) {
3499
+ const name = typeof args?.name === "string" ? args.name.trim() : "";
3500
+ if (!name) return errorContent("find_symbol: 'name' (string) is required");
3501
+ const symbols = ctx.graph.nodes.filter((n) => n.kind === "symbol");
3502
+ const lower = name.toLowerCase();
3503
+ const exact = symbols.filter((s) => s.name === name);
3504
+ const exactHits = exact.length > 0 ? exact : symbols.filter((s) => s.name.toLowerCase() === lower);
3505
+ if (exactHits.length > 0) {
3506
+ const sorted = exactHits.slice().sort(byFileLine);
3507
+ const shown2 = sorted.slice(0, FIND_MAX);
3508
+ const omitted2 = sorted.length - shown2.length;
3509
+ const lines2 = [
3510
+ `# find_symbol: "${name}"`,
3511
+ "",
3512
+ `Exact matches (${sorted.length}) \u2014 reuse one of these instead of writing a new one:`,
3513
+ ...shown2.map(symbolEntry)
3514
+ ];
3515
+ if (omitted2 > 0) lines2.push(`\u2026+${omitted2} more`);
3516
+ return textContent(lines2.join("\n"));
3517
+ }
3518
+ const tokens = new Set(tokenizeQuery(name));
3519
+ const scored = symbols.map((s) => {
3520
+ const n = s.name.toLowerCase();
3521
+ let score2 = 0;
3522
+ if (n.includes(lower) || lower.includes(n)) score2 += 2;
3523
+ for (const t of tokens) if (n.includes(t)) score2 += 1;
3524
+ return { s, score: score2 };
3525
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || byFileLine(a.s, b.s));
3526
+ if (scored.length === 0) {
3527
+ return textContent(
3528
+ `# find_symbol: "${name}"
3529
+
3530
+ No symbol matching "${name}" \u2014 safe to create.`
3531
+ );
3532
+ }
3533
+ const shown = scored.slice(0, FIND_MAX);
3534
+ const omitted = scored.length - shown.length;
3535
+ const lines = [
3536
+ `# find_symbol: "${name}"`,
3537
+ "",
3538
+ `No exact match. Similar names (${scored.length}) \u2014 reuse or extend one before writing new:`,
3539
+ ...shown.map((x) => symbolEntry(x.s))
3540
+ ];
3541
+ if (omitted > 0) lines.push(`\u2026+${omitted} more`);
3542
+ return textContent(lines.join("\n"));
3543
+ }
3544
+ var DUP_INCLUDE = /* @__PURE__ */ new Set([
3545
+ "function",
3546
+ "class",
3547
+ "interface",
3548
+ "type",
3549
+ "enum",
3550
+ "const",
3551
+ "component"
3552
+ ]);
3553
+ function duplicateSymbols(args, ctx) {
3554
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 30;
3555
+ const defsByName = /* @__PURE__ */ new Map();
3556
+ const filesByName = /* @__PURE__ */ new Map();
3557
+ for (const n of ctx.graph.nodes) {
3558
+ if (n.kind !== "symbol" || !DUP_INCLUDE.has(n.symbol_kind)) continue;
3559
+ (defsByName.get(n.name) ?? defsByName.set(n.name, []).get(n.name)).push({
3560
+ file: n.file,
3561
+ line: n.start_line
3562
+ });
3563
+ (filesByName.get(n.name) ?? filesByName.set(n.name, /* @__PURE__ */ new Set()).get(n.name)).add(n.file);
3564
+ }
3565
+ const dups = [...defsByName.entries()].filter(([name]) => (filesByName.get(name)?.size ?? 0) >= 2).map(([name, defs]) => ({
3566
+ name,
3567
+ defs: defs.slice().sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)
3568
+ })).sort((a, b) => b.defs.length - a.defs.length || a.name.localeCompare(b.name));
3569
+ if (dups.length === 0) {
3570
+ return textContent(
3571
+ "# Duplicate symbols\n\n_(no top-level symbol name is defined in more than one file)_"
3572
+ );
3573
+ }
3574
+ const shown = dups.slice(0, limit);
3575
+ const lines = [
3576
+ "# Duplicate symbols (consolidation candidates)",
3577
+ "",
3578
+ `${shown.length} of ${dups.length} name(s) defined in multiple files (functions/classes/types; methods excluded):`,
3579
+ ""
3580
+ ];
3581
+ for (const d of shown) {
3582
+ lines.push(
3583
+ `- \`${d.name}\` (${d.defs.length}): ${d.defs.map((x) => `${x.file}:${x.line}`).join(" \xB7 ")}`
3584
+ );
3585
+ }
3586
+ lines.push("");
3587
+ lines.push(
3588
+ "_advisory: the same name in multiple files may be intentional \u2014 verify before consolidating._"
3589
+ );
3590
+ return textContent(lines.join("\n"));
3591
+ }
3356
3592
  async function graphContinue(args, ctx) {
3357
3593
  const query = typeof args?.query === "string" ? args.query : "";
3358
3594
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
@@ -3770,11 +4006,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
3770
4006
  return [];
3771
4007
  }
3772
4008
  }
4009
+ async function getHeadSha(projectRoot) {
4010
+ try {
4011
+ const { stdout } = await execFileAsync3("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
4012
+ return stdout.trim();
4013
+ } catch {
4014
+ return "";
4015
+ }
4016
+ }
4017
+ function parseDiffHunks(stdout) {
4018
+ const out = /* @__PURE__ */ new Map();
4019
+ let current = null;
4020
+ for (const line of stdout.split("\n")) {
4021
+ if (line.startsWith("+++ ")) {
4022
+ const p = line.slice(4).trim();
4023
+ current = p === "/dev/null" ? null : p.replace(/^b\//, "");
4024
+ } else if (current && line.startsWith("@@")) {
4025
+ const m = /\+(\d+)(?:,(\d+))?/.exec(line);
4026
+ if (!m) continue;
4027
+ const start = Number(m[1]);
4028
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
4029
+ const end = count === 0 ? start : start + count - 1;
4030
+ const list = out.get(current) ?? [];
4031
+ list.push([start, end]);
4032
+ out.set(current, list);
4033
+ }
4034
+ }
4035
+ return out;
4036
+ }
4037
+ async function getChangedLineRanges(projectRoot, sinceRef) {
4038
+ if (!sinceRef) return /* @__PURE__ */ new Map();
4039
+ try {
4040
+ const { stdout } = await execFileAsync3("git", ["diff", "-U0", "--no-color", sinceRef, "--"], {
4041
+ cwd: projectRoot,
4042
+ maxBuffer: 16 * 1024 * 1024
4043
+ });
4044
+ return parseDiffHunks(stdout);
4045
+ } catch {
4046
+ return /* @__PURE__ */ new Map();
4047
+ }
4048
+ }
3773
4049
 
3774
4050
  // src/memory/session.ts
3775
4051
  import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
3776
4052
  import { dirname as dirname10 } from "path";
3777
- var SESSION_SCHEMA_VERSION = 1;
4053
+ var SESSION_SCHEMA_VERSION = 2;
3778
4054
  async function readSession(path) {
3779
4055
  try {
3780
4056
  const raw = await readFile13(path, "utf8");
@@ -3803,6 +4079,7 @@ async function captureSnapshot(ctx, branchOverride) {
3803
4079
  for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
3804
4080
  const prev = await readSession(ctx.paths.sessionState);
3805
4081
  const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
4082
+ const headSha = await getHeadSha(ctx.paths.projectRoot);
3806
4083
  const snapshot = {
3807
4084
  schema_version: SESSION_SCHEMA_VERSION,
3808
4085
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3813,7 +4090,8 @@ async function captureSnapshot(ctx, branchOverride) {
3813
4090
  tasks: tasks.entries.map((e) => e.content),
3814
4091
  decisions: decisions.entries.map((e) => e.content),
3815
4092
  next: next.entries.map((e) => e.content)
3816
- }
4093
+ },
4094
+ headSha
3817
4095
  };
3818
4096
  await writeSession(ctx.paths.sessionState, snapshot);
3819
4097
  }
@@ -4261,6 +4539,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
4261
4539
  var MAX_FILES = 15;
4262
4540
  var MAX_COMMITS2 = 5;
4263
4541
  var MAX_BULLETS2 = 3;
4542
+ var MAX_CHANGED_SYMBOLS = 20;
4543
+ var CHANGED_SIG_MAX = 100;
4544
+ function changedSymbols(ranges, graph) {
4545
+ if (ranges.size === 0) return [];
4546
+ const hits = [];
4547
+ for (const n of graph.nodes) {
4548
+ if (n.kind !== "symbol") continue;
4549
+ const rs = ranges.get(n.file);
4550
+ if (!rs) continue;
4551
+ if (rs.some(([a, b]) => a <= n.end_line && b >= n.start_line)) hits.push(n);
4552
+ }
4553
+ hits.sort((a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1);
4554
+ return hits;
4555
+ }
4556
+ function changedSymbolsSection(ranges, graph) {
4557
+ const hits = changedSymbols(ranges, graph);
4558
+ if (hits.length === 0) return [];
4559
+ const shown = hits.slice(0, MAX_CHANGED_SYMBOLS);
4560
+ const lines = ["", "### Changed symbols (since last session)"];
4561
+ for (const s of shown) {
4562
+ lines.push(
4563
+ `- \`${s.file}::${s.name}\` (${s.symbol_kind}) \u2014 ${s.signature.trim().slice(0, CHANGED_SIG_MAX)}`
4564
+ );
4565
+ }
4566
+ const more = hits.length - shown.length;
4567
+ if (more > 0) lines.push(`_(+${more} more)_`);
4568
+ return lines;
4569
+ }
4264
4570
  function legacyPrimer(ctx) {
4265
4571
  const g = ctx.graph;
4266
4572
  return `Synthra context loaded for ${g.root}.
@@ -4271,7 +4577,7 @@ function hasContent(snap) {
4271
4577
  snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
4272
4578
  );
4273
4579
  }
4274
- function buildResumeDigest(snap, branchNow) {
4580
+ function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
4275
4581
  const plural = (n) => n === 1 ? "" : "s";
4276
4582
  const head = `## Since you were last here \u2014 ${snap.branch} (${snap.recentCommits.length} commit${plural(snap.recentCommits.length)}, ${snap.filesTouched.length} file${plural(snap.filesTouched.length)} touched)`;
4277
4583
  const essential = [head];
@@ -4292,7 +4598,7 @@ function buildResumeDigest(snap, branchNow) {
4292
4598
  essential.push("", "### Recent decisions");
4293
4599
  for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
4294
4600
  }
4295
- const extra = [];
4601
+ const extra = [...changedSymbolLines];
4296
4602
  if (snap.recentCommits.length) {
4297
4603
  extra.push("", "### Recent commits");
4298
4604
  for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
@@ -4319,7 +4625,12 @@ async function handlePrime(ctx, port) {
4319
4625
  return { primer: legacy, port };
4320
4626
  }
4321
4627
  const branchNow = await currentBranch(ctx.paths.projectRoot);
4322
- const digest = buildResumeDigest(snap, branchNow);
4628
+ let changedSymbolLines = [];
4629
+ if (snap.headSha) {
4630
+ const ranges = await getChangedLineRanges(ctx.paths.projectRoot, snap.headSha);
4631
+ changedSymbolLines = changedSymbolsSection(ranges, ctx.graph);
4632
+ }
4633
+ const digest = buildResumeDigest(snap, branchNow, changedSymbolLines);
4323
4634
  return { primer: `${digest}
4324
4635
 
4325
4636
  ---