@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.13.0] — 2026-06-24
11
+
12
+ ### Added
13
+
14
+ - **The resume digest now lists the symbols that changed since your last session.**
15
+ The SessionStart "Since you were last here" primer showed *files* touched; it now
16
+ leads its supporting context with the actual **symbols/signatures** that changed —
17
+ e.g. `src/auth.ts::login (function) — function login(creds: Creds): Promise<...>`.
18
+ Computed from a git diff against the previous session's HEAD (committed **and**
19
+ uncommitted changes), overlapped with the current graph. Best-effort: silently
20
+ omitted in non-git projects.
21
+ - **`call_path(from, to)` — trace control flow.** Returns the shortest chain of
22
+ calls from one symbol to another (`handler → service → repo`), so you can see how
23
+ one symbol reaches another. The forward complement to `blast_radius` (callers).
24
+ Each of `from`/`to` is a `file::symbol` target or a bare symbol name when unique.
25
+
26
+ Both reuse the existing call graph + git — no graph schema change, no new dependencies.
27
+
28
+ ---
29
+
10
30
  ## [0.12.0] — 2026-06-24
11
31
 
12
32
  ### Added
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.12.0",
21
+ version: "0.13.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -4244,6 +4244,19 @@ var TOOLS = [
4244
4244
  limit: { type: "number", description: "Cap on returned names. Default 30." }
4245
4245
  }
4246
4246
  }
4247
+ },
4248
+ {
4249
+ name: "call_path",
4250
+ 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.",
4251
+ inputSchema: {
4252
+ type: "object",
4253
+ properties: {
4254
+ from: { type: "string", description: "Starting symbol ('file::symbol' or unique name)." },
4255
+ to: { type: "string", description: "Target symbol ('file::symbol' or unique name)." },
4256
+ depth: { type: "number", description: "Max call hops to search. Default 6." }
4257
+ },
4258
+ required: ["from", "to"]
4259
+ }
4247
4260
  }
4248
4261
  ];
4249
4262
  async function callTool(name, args, ctx) {
@@ -4270,6 +4283,8 @@ async function callTool(name, args, ctx) {
4270
4283
  return findSymbol(args, ctx);
4271
4284
  case "duplicate_symbols":
4272
4285
  return duplicateSymbols(args, ctx);
4286
+ case "call_path":
4287
+ return callPath(args, ctx);
4273
4288
  default:
4274
4289
  return errorContent(`Unknown tool: ${name}`);
4275
4290
  }
@@ -4426,6 +4441,97 @@ function testsCoveringLine(graph, filePaths) {
4426
4441
  const omitted = tests.length - shown.length;
4427
4442
  return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
4428
4443
  }
4444
+ function resolveSymbolArg(ctx, arg) {
4445
+ const a = arg.trim();
4446
+ if (a.includes("::")) {
4447
+ const [rawFile, rawSym] = a.split("::", 2);
4448
+ const resolved = resolveFileTarget(ctx.graph, (rawFile ?? "").trim());
4449
+ if (!("node" in resolved)) return null;
4450
+ const name = (rawSym ?? "").trim();
4451
+ return ctx.graph.nodes.find(
4452
+ (n) => n.kind === "symbol" && n.file === resolved.node.path && n.name === name
4453
+ ) ?? null;
4454
+ }
4455
+ const matches = ctx.graph.nodes.filter(
4456
+ (n) => n.kind === "symbol" && n.name === a
4457
+ );
4458
+ return matches.length === 1 ? matches[0] : null;
4459
+ }
4460
+ function callPath(args, ctx) {
4461
+ const fromArg = typeof args?.from === "string" ? args.from : "";
4462
+ const toArg = typeof args?.to === "string" ? args.to : "";
4463
+ const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 6;
4464
+ if (!fromArg.trim() || !toArg.trim()) {
4465
+ return errorContent("call_path: 'from' and 'to' (strings) are required");
4466
+ }
4467
+ const from = resolveSymbolArg(ctx, fromArg);
4468
+ if (!from) {
4469
+ return errorContent(
4470
+ `call_path: could not resolve 'from': ${fromArg} (use file::symbol if the name is ambiguous)`
4471
+ );
4472
+ }
4473
+ const to = resolveSymbolArg(ctx, toArg);
4474
+ if (!to) {
4475
+ return errorContent(
4476
+ `call_path: could not resolve 'to': ${toArg} (use file::symbol if the name is ambiguous)`
4477
+ );
4478
+ }
4479
+ if (from.id === to.id) {
4480
+ return textContent(`# call_path
4481
+
4482
+ \`${from.name}\` and \`${to.name}\` are the same symbol.`);
4483
+ }
4484
+ const calleesBy = /* @__PURE__ */ new Map();
4485
+ for (const e of ctx.graph.edges) {
4486
+ if (e.kind !== "calls" || e.from === e.to) continue;
4487
+ (calleesBy.get(e.from) ?? calleesBy.set(e.from, []).get(e.from)).push(e.to);
4488
+ }
4489
+ const symById = /* @__PURE__ */ new Map();
4490
+ for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
4491
+ const prevOf = /* @__PURE__ */ new Map();
4492
+ const visited = /* @__PURE__ */ new Set([from.id]);
4493
+ let frontier = [from.id];
4494
+ let found = false;
4495
+ for (let d = 0; d < maxDepth && !found && frontier.length > 0; d++) {
4496
+ const next = [];
4497
+ for (const cur2 of frontier) {
4498
+ for (const nb of calleesBy.get(cur2) ?? []) {
4499
+ if (visited.has(nb)) continue;
4500
+ visited.add(nb);
4501
+ prevOf.set(nb, cur2);
4502
+ if (nb === to.id) {
4503
+ found = true;
4504
+ break;
4505
+ }
4506
+ next.push(nb);
4507
+ }
4508
+ if (found) break;
4509
+ }
4510
+ frontier = next;
4511
+ }
4512
+ if (!found) {
4513
+ return textContent(
4514
+ `# call_path: ${from.name} \u2192 ${to.name}
4515
+
4516
+ _(no call path found within depth ${maxDepth})_`
4517
+ );
4518
+ }
4519
+ const chain = [];
4520
+ let cur = to.id;
4521
+ while (cur !== void 0) {
4522
+ chain.unshift(cur);
4523
+ if (cur === from.id) break;
4524
+ cur = prevOf.get(cur);
4525
+ }
4526
+ const syms = chain.map((id) => symById.get(id)).filter((s) => !!s);
4527
+ const hops = syms.length - 1;
4528
+ const rendered = syms.map((s) => `\`${s.name}\` (${s.file}:${s.start_line})`).join("\n \u2192 ");
4529
+ return textContent(
4530
+ `# call_path: ${from.name} \u2192 ${to.name} (${hops} hop${hops === 1 ? "" : "s"})
4531
+
4532
+ ${rendered}`
4533
+ );
4534
+ }
4429
4535
  var LIKELY_ENTRY_PATTERNS = [
4430
4536
  /(?:^|\/)main\.[a-z0-9_]+$/i,
4431
4537
  /(?:^|\/)index\.[a-z0-9_]+$/i,
@@ -4972,11 +5078,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
4972
5078
  return [];
4973
5079
  }
4974
5080
  }
5081
+ async function getHeadSha(projectRoot) {
5082
+ try {
5083
+ const { stdout } = await execFileAsync3("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
5084
+ return stdout.trim();
5085
+ } catch {
5086
+ return "";
5087
+ }
5088
+ }
5089
+ function parseDiffHunks(stdout) {
5090
+ const out = /* @__PURE__ */ new Map();
5091
+ let current = null;
5092
+ for (const line of stdout.split("\n")) {
5093
+ if (line.startsWith("+++ ")) {
5094
+ const p = line.slice(4).trim();
5095
+ current = p === "/dev/null" ? null : p.replace(/^b\//, "");
5096
+ } else if (current && line.startsWith("@@")) {
5097
+ const m = /\+(\d+)(?:,(\d+))?/.exec(line);
5098
+ if (!m) continue;
5099
+ const start = Number(m[1]);
5100
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
5101
+ const end = count === 0 ? start : start + count - 1;
5102
+ const list = out.get(current) ?? [];
5103
+ list.push([start, end]);
5104
+ out.set(current, list);
5105
+ }
5106
+ }
5107
+ return out;
5108
+ }
5109
+ async function getChangedLineRanges(projectRoot, sinceRef) {
5110
+ if (!sinceRef) return /* @__PURE__ */ new Map();
5111
+ try {
5112
+ const { stdout } = await execFileAsync3("git", ["diff", "-U0", "--no-color", sinceRef, "--"], {
5113
+ cwd: projectRoot,
5114
+ maxBuffer: 16 * 1024 * 1024
5115
+ });
5116
+ return parseDiffHunks(stdout);
5117
+ } catch {
5118
+ return /* @__PURE__ */ new Map();
5119
+ }
5120
+ }
4975
5121
 
4976
5122
  // src/memory/session.ts
4977
5123
  import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
4978
5124
  import { dirname as dirname13 } from "path";
4979
- var SESSION_SCHEMA_VERSION = 1;
5125
+ var SESSION_SCHEMA_VERSION = 2;
4980
5126
  async function readSession(path) {
4981
5127
  try {
4982
5128
  const raw = await readFile17(path, "utf8");
@@ -5005,6 +5151,7 @@ async function captureSnapshot(ctx, branchOverride) {
5005
5151
  for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
5006
5152
  const prev = await readSession(ctx.paths.sessionState);
5007
5153
  const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
5154
+ const headSha = await getHeadSha(ctx.paths.projectRoot);
5008
5155
  const snapshot = {
5009
5156
  schema_version: SESSION_SCHEMA_VERSION,
5010
5157
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5015,7 +5162,8 @@ async function captureSnapshot(ctx, branchOverride) {
5015
5162
  tasks: tasks.entries.map((e) => e.content),
5016
5163
  decisions: decisions.entries.map((e) => e.content),
5017
5164
  next: next.entries.map((e) => e.content)
5018
- }
5165
+ },
5166
+ headSha
5019
5167
  };
5020
5168
  await writeSession(ctx.paths.sessionState, snapshot);
5021
5169
  }
@@ -5463,6 +5611,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
5463
5611
  var MAX_FILES = 15;
5464
5612
  var MAX_COMMITS2 = 5;
5465
5613
  var MAX_BULLETS2 = 3;
5614
+ var MAX_CHANGED_SYMBOLS = 20;
5615
+ var CHANGED_SIG_MAX = 100;
5616
+ function changedSymbols(ranges, graph) {
5617
+ if (ranges.size === 0) return [];
5618
+ const hits = [];
5619
+ for (const n of graph.nodes) {
5620
+ if (n.kind !== "symbol") continue;
5621
+ const rs = ranges.get(n.file);
5622
+ if (!rs) continue;
5623
+ if (rs.some(([a, b]) => a <= n.end_line && b >= n.start_line)) hits.push(n);
5624
+ }
5625
+ hits.sort((a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1);
5626
+ return hits;
5627
+ }
5628
+ function changedSymbolsSection(ranges, graph) {
5629
+ const hits = changedSymbols(ranges, graph);
5630
+ if (hits.length === 0) return [];
5631
+ const shown = hits.slice(0, MAX_CHANGED_SYMBOLS);
5632
+ const lines = ["", "### Changed symbols (since last session)"];
5633
+ for (const s of shown) {
5634
+ lines.push(
5635
+ `- \`${s.file}::${s.name}\` (${s.symbol_kind}) \u2014 ${s.signature.trim().slice(0, CHANGED_SIG_MAX)}`
5636
+ );
5637
+ }
5638
+ const more = hits.length - shown.length;
5639
+ if (more > 0) lines.push(`_(+${more} more)_`);
5640
+ return lines;
5641
+ }
5466
5642
  function legacyPrimer(ctx) {
5467
5643
  const g = ctx.graph;
5468
5644
  return `Synthra context loaded for ${g.root}.
@@ -5473,7 +5649,7 @@ function hasContent(snap) {
5473
5649
  snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
5474
5650
  );
5475
5651
  }
5476
- function buildResumeDigest(snap, branchNow) {
5652
+ function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
5477
5653
  const plural = (n) => n === 1 ? "" : "s";
5478
5654
  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)`;
5479
5655
  const essential = [head];
@@ -5494,7 +5670,7 @@ function buildResumeDigest(snap, branchNow) {
5494
5670
  essential.push("", "### Recent decisions");
5495
5671
  for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
5496
5672
  }
5497
- const extra = [];
5673
+ const extra = [...changedSymbolLines];
5498
5674
  if (snap.recentCommits.length) {
5499
5675
  extra.push("", "### Recent commits");
5500
5676
  for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
@@ -5521,7 +5697,12 @@ async function handlePrime(ctx, port) {
5521
5697
  return { primer: legacy, port };
5522
5698
  }
5523
5699
  const branchNow = await currentBranch(ctx.paths.projectRoot);
5524
- const digest = buildResumeDigest(snap, branchNow);
5700
+ let changedSymbolLines = [];
5701
+ if (snap.headSha) {
5702
+ const ranges = await getChangedLineRanges(ctx.paths.projectRoot, snap.headSha);
5703
+ changedSymbolLines = changedSymbolsSection(ranges, ctx.graph);
5704
+ }
5705
+ const digest = buildResumeDigest(snap, branchNow, changedSymbolLines);
5525
5706
  return { primer: `${digest}
5526
5707
 
5527
5708
  ---