@jefuriiij/synthra 0.12.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.
@@ -3153,6 +3153,19 @@ var TOOLS = [
3153
3153
  limit: { type: "number", description: "Cap on returned names. Default 30." }
3154
3154
  }
3155
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
+ }
3156
3169
  }
3157
3170
  ];
3158
3171
  async function callTool(name, args, ctx) {
@@ -3179,6 +3192,8 @@ async function callTool(name, args, ctx) {
3179
3192
  return findSymbol(args, ctx);
3180
3193
  case "duplicate_symbols":
3181
3194
  return duplicateSymbols(args, ctx);
3195
+ case "call_path":
3196
+ return callPath(args, ctx);
3182
3197
  default:
3183
3198
  return errorContent(`Unknown tool: ${name}`);
3184
3199
  }
@@ -3335,6 +3350,97 @@ function testsCoveringLine(graph, filePaths) {
3335
3350
  const omitted = tests.length - shown.length;
3336
3351
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
3337
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
+ }
3338
3444
  var LIKELY_ENTRY_PATTERNS = [
3339
3445
  /(?:^|\/)main\.[a-z0-9_]+$/i,
3340
3446
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -3900,11 +4006,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
3900
4006
  return [];
3901
4007
  }
3902
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
+ }
3903
4049
 
3904
4050
  // src/memory/session.ts
3905
4051
  import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile8 } from "fs/promises";
3906
4052
  import { dirname as dirname10 } from "path";
3907
- var SESSION_SCHEMA_VERSION = 1;
4053
+ var SESSION_SCHEMA_VERSION = 2;
3908
4054
  async function readSession(path) {
3909
4055
  try {
3910
4056
  const raw = await readFile13(path, "utf8");
@@ -3933,6 +4079,7 @@ async function captureSnapshot(ctx, branchOverride) {
3933
4079
  for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
3934
4080
  const prev = await readSession(ctx.paths.sessionState);
3935
4081
  const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
4082
+ const headSha = await getHeadSha(ctx.paths.projectRoot);
3936
4083
  const snapshot = {
3937
4084
  schema_version: SESSION_SCHEMA_VERSION,
3938
4085
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3943,7 +4090,8 @@ async function captureSnapshot(ctx, branchOverride) {
3943
4090
  tasks: tasks.entries.map((e) => e.content),
3944
4091
  decisions: decisions.entries.map((e) => e.content),
3945
4092
  next: next.entries.map((e) => e.content)
3946
- }
4093
+ },
4094
+ headSha
3947
4095
  };
3948
4096
  await writeSession(ctx.paths.sessionState, snapshot);
3949
4097
  }
@@ -4391,6 +4539,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
4391
4539
  var MAX_FILES = 15;
4392
4540
  var MAX_COMMITS2 = 5;
4393
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
+ }
4394
4570
  function legacyPrimer(ctx) {
4395
4571
  const g = ctx.graph;
4396
4572
  return `Synthra context loaded for ${g.root}.
@@ -4401,7 +4577,7 @@ function hasContent(snap) {
4401
4577
  snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
4402
4578
  );
4403
4579
  }
4404
- function buildResumeDigest(snap, branchNow) {
4580
+ function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
4405
4581
  const plural = (n) => n === 1 ? "" : "s";
4406
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)`;
4407
4583
  const essential = [head];
@@ -4422,7 +4598,7 @@ function buildResumeDigest(snap, branchNow) {
4422
4598
  essential.push("", "### Recent decisions");
4423
4599
  for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
4424
4600
  }
4425
- const extra = [];
4601
+ const extra = [...changedSymbolLines];
4426
4602
  if (snap.recentCommits.length) {
4427
4603
  extra.push("", "### Recent commits");
4428
4604
  for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
@@ -4449,7 +4625,12 @@ async function handlePrime(ctx, port) {
4449
4625
  return { primer: legacy, port };
4450
4626
  }
4451
4627
  const branchNow = await currentBranch(ctx.paths.projectRoot);
4452
- 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);
4453
4634
  return { primer: `${digest}
4454
4635
 
4455
4636
  ---