@jefuriiij/synthra 0.12.0 → 0.13.1

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.
@@ -1649,7 +1649,17 @@ var DEFAULT_IGNORE = [
1649
1649
  ".mypy_cache/",
1650
1650
  ".ruff_cache/",
1651
1651
  // .NET
1652
- "obj/"
1652
+ "obj/",
1653
+ // Generated / minified bundles — no readable symbols, so indexing them only
1654
+ // pollutes retrieval (a markup query like `nav|menu|toggle` spuriously matches
1655
+ // a symbol inside vendored plugin JS → a useless Moat block) and bloats the
1656
+ // graph. Committed bootstrap/swiper-style plugin JS is the common offender.
1657
+ "*.min.js",
1658
+ "*.min.cjs",
1659
+ "*.min.mjs",
1660
+ "*.min.css",
1661
+ "*.bundle.js",
1662
+ "*-min.js"
1653
1663
  ];
1654
1664
  var BINARY_EXTS = /* @__PURE__ */ new Set([
1655
1665
  ".png",
@@ -3153,6 +3163,19 @@ var TOOLS = [
3153
3163
  limit: { type: "number", description: "Cap on returned names. Default 30." }
3154
3164
  }
3155
3165
  }
3166
+ },
3167
+ {
3168
+ name: "call_path",
3169
+ 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.",
3170
+ inputSchema: {
3171
+ type: "object",
3172
+ properties: {
3173
+ from: { type: "string", description: "Starting symbol ('file::symbol' or unique name)." },
3174
+ to: { type: "string", description: "Target symbol ('file::symbol' or unique name)." },
3175
+ depth: { type: "number", description: "Max call hops to search. Default 6." }
3176
+ },
3177
+ required: ["from", "to"]
3178
+ }
3156
3179
  }
3157
3180
  ];
3158
3181
  async function callTool(name, args, ctx) {
@@ -3179,6 +3202,8 @@ async function callTool(name, args, ctx) {
3179
3202
  return findSymbol(args, ctx);
3180
3203
  case "duplicate_symbols":
3181
3204
  return duplicateSymbols(args, ctx);
3205
+ case "call_path":
3206
+ return callPath(args, ctx);
3182
3207
  default:
3183
3208
  return errorContent(`Unknown tool: ${name}`);
3184
3209
  }
@@ -3335,6 +3360,97 @@ function testsCoveringLine(graph, filePaths) {
3335
3360
  const omitted = tests.length - shown.length;
3336
3361
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3337
3362
  }
3363
+ function resolveSymbolArg(ctx, arg) {
3364
+ const a = arg.trim();
3365
+ if (a.includes("::")) {
3366
+ const [rawFile, rawSym] = a.split("::", 2);
3367
+ const resolved = resolveFileTarget(ctx.graph, (rawFile ?? "").trim());
3368
+ if (!("node" in resolved)) return null;
3369
+ const name = (rawSym ?? "").trim();
3370
+ return ctx.graph.nodes.find(
3371
+ (n) => n.kind === "symbol" && n.file === resolved.node.path && n.name === name
3372
+ ) ?? null;
3373
+ }
3374
+ const matches = ctx.graph.nodes.filter(
3375
+ (n) => n.kind === "symbol" && n.name === a
3376
+ );
3377
+ return matches.length === 1 ? matches[0] : null;
3378
+ }
3379
+ function callPath(args, ctx) {
3380
+ const fromArg = typeof args?.from === "string" ? args.from : "";
3381
+ const toArg = typeof args?.to === "string" ? args.to : "";
3382
+ const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 6;
3383
+ if (!fromArg.trim() || !toArg.trim()) {
3384
+ return errorContent("call_path: 'from' and 'to' (strings) are required");
3385
+ }
3386
+ const from = resolveSymbolArg(ctx, fromArg);
3387
+ if (!from) {
3388
+ return errorContent(
3389
+ `call_path: could not resolve 'from': ${fromArg} (use file::symbol if the name is ambiguous)`
3390
+ );
3391
+ }
3392
+ const to = resolveSymbolArg(ctx, toArg);
3393
+ if (!to) {
3394
+ return errorContent(
3395
+ `call_path: could not resolve 'to': ${toArg} (use file::symbol if the name is ambiguous)`
3396
+ );
3397
+ }
3398
+ if (from.id === to.id) {
3399
+ return textContent(`# call_path
3400
+
3401
+ \`${from.name}\` and \`${to.name}\` are the same symbol.`);
3402
+ }
3403
+ const calleesBy = /* @__PURE__ */ new Map();
3404
+ for (const e of ctx.graph.edges) {
3405
+ if (e.kind !== "calls" || e.from === e.to) continue;
3406
+ (calleesBy.get(e.from) ?? calleesBy.set(e.from, []).get(e.from)).push(e.to);
3407
+ }
3408
+ const symById = /* @__PURE__ */ new Map();
3409
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
3410
+ const prevOf = /* @__PURE__ */ new Map();
3411
+ const visited = /* @__PURE__ */ new Set([from.id]);
3412
+ let frontier = [from.id];
3413
+ let found = false;
3414
+ for (let d = 0; d < maxDepth && !found && frontier.length > 0; d++) {
3415
+ const next = [];
3416
+ for (const cur2 of frontier) {
3417
+ for (const nb of calleesBy.get(cur2) ?? []) {
3418
+ if (visited.has(nb)) continue;
3419
+ visited.add(nb);
3420
+ prevOf.set(nb, cur2);
3421
+ if (nb === to.id) {
3422
+ found = true;
3423
+ break;
3424
+ }
3425
+ next.push(nb);
3426
+ }
3427
+ if (found) break;
3428
+ }
3429
+ frontier = next;
3430
+ }
3431
+ if (!found) {
3432
+ return textContent(
3433
+ `# call_path: ${from.name} \u2192 ${to.name}
3434
+
3435
+ _(no call path found within depth ${maxDepth})_`
3436
+ );
3437
+ }
3438
+ const chain = [];
3439
+ let cur = to.id;
3440
+ while (cur !== void 0) {
3441
+ chain.unshift(cur);
3442
+ if (cur === from.id) break;
3443
+ cur = prevOf.get(cur);
3444
+ }
3445
+ const syms = chain.map((id) => symById.get(id)).filter((s) => !!s);
3446
+ const hops = syms.length - 1;
3447
+ const rendered = syms.map((s) => `\`${s.name}\` (${s.file}:${s.start_line})`).join("\n \u2192 ");
3448
+ return textContent(
3449
+ `# call_path: ${from.name} \u2192 ${to.name} (${hops} hop${hops === 1 ? "" : "s"})
3450
+
3451
+ ${rendered}`
3452
+ );
3453
+ }
3338
3454
  var LIKELY_ENTRY_PATTERNS = [
3339
3455
  /(?:^|\/)main\.[a-z0-9_]+$/i,
3340
3456
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -3900,11 +4016,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
3900
4016
  return [];
3901
4017
  }
3902
4018
  }
4019
+ async function getHeadSha(projectRoot) {
4020
+ try {
4021
+ const { stdout } = await execFileAsync3("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
4022
+ return stdout.trim();
4023
+ } catch {
4024
+ return "";
4025
+ }
4026
+ }
4027
+ function parseDiffHunks(stdout) {
4028
+ const out = /* @__PURE__ */ new Map();
4029
+ let current = null;
4030
+ for (const line of stdout.split("\n")) {
4031
+ if (line.startsWith("+++ ")) {
4032
+ const p = line.slice(4).trim();
4033
+ current = p === "/dev/null" ? null : p.replace(/^b\//, "");
4034
+ } else if (current && line.startsWith("@@")) {
4035
+ const m = /\+(\d+)(?:,(\d+))?/.exec(line);
4036
+ if (!m) continue;
4037
+ const start = Number(m[1]);
4038
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
4039
+ const end = count === 0 ? start : start + count - 1;
4040
+ const list = out.get(current) ?? [];
4041
+ list.push([start, end]);
4042
+ out.set(current, list);
4043
+ }
4044
+ }
4045
+ return out;
4046
+ }
4047
+ async function getChangedLineRanges(projectRoot, sinceRef) {
4048
+ if (!sinceRef) return /* @__PURE__ */ new Map();
4049
+ try {
4050
+ const { stdout } = await execFileAsync3("git", ["diff", "-U0", "--no-color", sinceRef, "--"], {
4051
+ cwd: projectRoot,
4052
+ maxBuffer: 16 * 1024 * 1024
4053
+ });
4054
+ return parseDiffHunks(stdout);
4055
+ } catch {
4056
+ return /* @__PURE__ */ new Map();
4057
+ }
4058
+ }
3903
4059
 
3904
4060
  // src/memory/session.ts
3905
4061
  import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
3906
4062
  import { dirname as dirname10 } from "path";
3907
- var SESSION_SCHEMA_VERSION = 1;
4063
+ var SESSION_SCHEMA_VERSION = 2;
3908
4064
  async function readSession(path) {
3909
4065
  try {
3910
4066
  const raw = await readFile13(path, "utf8");
@@ -3933,6 +4089,7 @@ async function captureSnapshot(ctx, branchOverride) {
3933
4089
  for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
3934
4090
  const prev = await readSession(ctx.paths.sessionState);
3935
4091
  const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
4092
+ const headSha = await getHeadSha(ctx.paths.projectRoot);
3936
4093
  const snapshot = {
3937
4094
  schema_version: SESSION_SCHEMA_VERSION,
3938
4095
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3943,7 +4100,8 @@ async function captureSnapshot(ctx, branchOverride) {
3943
4100
  tasks: tasks.entries.map((e) => e.content),
3944
4101
  decisions: decisions.entries.map((e) => e.content),
3945
4102
  next: next.entries.map((e) => e.content)
3946
- }
4103
+ },
4104
+ headSha
3947
4105
  };
3948
4106
  await writeSession(ctx.paths.sessionState, snapshot);
3949
4107
  }
@@ -4391,6 +4549,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
4391
4549
  var MAX_FILES = 15;
4392
4550
  var MAX_COMMITS2 = 5;
4393
4551
  var MAX_BULLETS2 = 3;
4552
+ var MAX_CHANGED_SYMBOLS = 20;
4553
+ var CHANGED_SIG_MAX = 100;
4554
+ function changedSymbols(ranges, graph) {
4555
+ if (ranges.size === 0) return [];
4556
+ const hits = [];
4557
+ for (const n of graph.nodes) {
4558
+ if (n.kind !== "symbol") continue;
4559
+ const rs = ranges.get(n.file);
4560
+ if (!rs) continue;
4561
+ if (rs.some(([a, b]) => a <= n.end_line && b >= n.start_line)) hits.push(n);
4562
+ }
4563
+ hits.sort((a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1);
4564
+ return hits;
4565
+ }
4566
+ function changedSymbolsSection(ranges, graph) {
4567
+ const hits = changedSymbols(ranges, graph);
4568
+ if (hits.length === 0) return [];
4569
+ const shown = hits.slice(0, MAX_CHANGED_SYMBOLS);
4570
+ const lines = ["", "### Changed symbols (since last session)"];
4571
+ for (const s of shown) {
4572
+ lines.push(
4573
+ `- \`${s.file}::${s.name}\` (${s.symbol_kind}) \u2014 ${s.signature.trim().slice(0, CHANGED_SIG_MAX)}`
4574
+ );
4575
+ }
4576
+ const more = hits.length - shown.length;
4577
+ if (more > 0) lines.push(`_(+${more} more)_`);
4578
+ return lines;
4579
+ }
4394
4580
  function legacyPrimer(ctx) {
4395
4581
  const g = ctx.graph;
4396
4582
  return `Synthra context loaded for ${g.root}.
@@ -4401,7 +4587,7 @@ function hasContent(snap) {
4401
4587
  snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
4402
4588
  );
4403
4589
  }
4404
- function buildResumeDigest(snap, branchNow) {
4590
+ function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
4405
4591
  const plural = (n) => n === 1 ? "" : "s";
4406
4592
  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)`;
4407
4593
  const essential = [head];
@@ -4422,7 +4608,7 @@ function buildResumeDigest(snap, branchNow) {
4422
4608
  essential.push("", "### Recent decisions");
4423
4609
  for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
4424
4610
  }
4425
- const extra = [];
4611
+ const extra = [...changedSymbolLines];
4426
4612
  if (snap.recentCommits.length) {
4427
4613
  extra.push("", "### Recent commits");
4428
4614
  for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
@@ -4449,7 +4635,12 @@ async function handlePrime(ctx, port) {
4449
4635
  return { primer: legacy, port };
4450
4636
  }
4451
4637
  const branchNow = await currentBranch(ctx.paths.projectRoot);
4452
- const digest = buildResumeDigest(snap, branchNow);
4638
+ let changedSymbolLines = [];
4639
+ if (snap.headSha) {
4640
+ const ranges = await getChangedLineRanges(ctx.paths.projectRoot, snap.headSha);
4641
+ changedSymbolLines = changedSymbolsSection(ranges, ctx.graph);
4642
+ }
4643
+ const digest = buildResumeDigest(snap, branchNow, changedSymbolLines);
4453
4644
  return { primer: `${digest}
4454
4645
 
4455
4646
  ---