@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 +20 -0
- package/dist/cli/index.js +187 -6
- 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 +186 -5
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
---
|