@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.
- package/CHANGELOG.md +40 -0
- package/dist/cli/index.js +319 -8
- 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 +318 -7
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ 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
|
+
|
|
30
|
+
## [0.12.0] — 2026-06-24
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`find_symbol(name)` — reuse before you re-implement.** Before writing a new
|
|
35
|
+
helper, ask Synthra whether one already exists: `find_symbol` returns every
|
|
36
|
+
exact-name definition (with signatures + ready `graph_read` targets), or — if
|
|
37
|
+
there's no exact match — similarly-named symbols to reuse or extend. "No symbol
|
|
38
|
+
matching … — safe to create" is the green light that it's genuinely new. The
|
|
39
|
+
injected policy now nudges the agent to check first.
|
|
40
|
+
- **`duplicate_symbols` — consolidation candidates.** Lists symbol names defined
|
|
41
|
+
in more than one file (functions/classes/types; methods excluded, since shared
|
|
42
|
+
method names are normal). Advisory — duplicates can be intentional; it never
|
|
43
|
+
says "delete."
|
|
44
|
+
|
|
45
|
+
Both are built on the symbol index (exact name lookup) — no false-positive risk,
|
|
46
|
+
no new dependencies.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
10
50
|
## [0.11.0] — 2026-06-24
|
|
11
51
|
|
|
12
52
|
### 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
|
},
|
|
@@ -3017,7 +3017,7 @@ import { basename as basename5 } from "path";
|
|
|
3017
3017
|
// src/hooks/claude-md.ts
|
|
3018
3018
|
import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
|
|
3019
3019
|
import { basename as basename4, dirname as dirname9 } from "path";
|
|
3020
|
-
var POLICY_VERSION =
|
|
3020
|
+
var POLICY_VERSION = 8;
|
|
3021
3021
|
var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
|
|
3022
3022
|
var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
|
|
3023
3023
|
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;
|
|
@@ -3036,7 +3036,7 @@ function policyBlock() {
|
|
|
3036
3036
|
"> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
|
|
3037
3037
|
"> in ToolSearch or invocation \u2014 always use the full namespaced form.",
|
|
3038
3038
|
"> If the tools are deferred, load their schemas with ToolSearch:",
|
|
3039
|
-
"> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit`.",
|
|
3039
|
+
"> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
|
|
3040
3040
|
"> Below, short names (`graph_continue` etc.) appear in prose for",
|
|
3041
3041
|
"> readability only.",
|
|
3042
3042
|
"",
|
|
@@ -3050,6 +3050,10 @@ function policyBlock() {
|
|
|
3050
3050
|
" symbol is ~50 tokens, reading a whole file is thousands.",
|
|
3051
3051
|
"- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
|
|
3052
3052
|
" subsequent turns weight your changes and avoid stale snapshots.",
|
|
3053
|
+
"- **`find_symbol(name)`** \u2014 **reuse-first**: before writing a new helper,",
|
|
3054
|
+
" util, or function, call this to check whether one already exists. If it",
|
|
3055
|
+
" returns matches, reuse or extend them instead of re-implementing; only",
|
|
3056
|
+
' "no match \u2014 safe to create" means it is genuinely new.',
|
|
3053
3057
|
"",
|
|
3054
3058
|
"### When to call `graph_continue` \u2014 and when to skip",
|
|
3055
3059
|
"",
|
|
@@ -4219,6 +4223,40 @@ var TOOLS = [
|
|
|
4219
4223
|
limit: { type: "number", description: "Cap on returned files. Default 50." }
|
|
4220
4224
|
}
|
|
4221
4225
|
}
|
|
4226
|
+
},
|
|
4227
|
+
{
|
|
4228
|
+
name: "find_symbol",
|
|
4229
|
+
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.",
|
|
4230
|
+
inputSchema: {
|
|
4231
|
+
type: "object",
|
|
4232
|
+
properties: {
|
|
4233
|
+
name: { type: "string", description: "Symbol name (or near-name) to look for." }
|
|
4234
|
+
},
|
|
4235
|
+
required: ["name"]
|
|
4236
|
+
}
|
|
4237
|
+
},
|
|
4238
|
+
{
|
|
4239
|
+
name: "duplicate_symbols",
|
|
4240
|
+
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.",
|
|
4241
|
+
inputSchema: {
|
|
4242
|
+
type: "object",
|
|
4243
|
+
properties: {
|
|
4244
|
+
limit: { type: "number", description: "Cap on returned names. Default 30." }
|
|
4245
|
+
}
|
|
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
|
+
}
|
|
4222
4260
|
}
|
|
4223
4261
|
];
|
|
4224
4262
|
async function callTool(name, args, ctx) {
|
|
@@ -4241,6 +4279,12 @@ async function callTool(name, args, ctx) {
|
|
|
4241
4279
|
return blastRadius(args, ctx);
|
|
4242
4280
|
case "dead_code":
|
|
4243
4281
|
return deadCode(args, ctx);
|
|
4282
|
+
case "find_symbol":
|
|
4283
|
+
return findSymbol(args, ctx);
|
|
4284
|
+
case "duplicate_symbols":
|
|
4285
|
+
return duplicateSymbols(args, ctx);
|
|
4286
|
+
case "call_path":
|
|
4287
|
+
return callPath(args, ctx);
|
|
4244
4288
|
default:
|
|
4245
4289
|
return errorContent(`Unknown tool: ${name}`);
|
|
4246
4290
|
}
|
|
@@ -4397,6 +4441,97 @@ function testsCoveringLine(graph, filePaths) {
|
|
|
4397
4441
|
const omitted = tests.length - shown.length;
|
|
4398
4442
|
return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
|
|
4399
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
|
+
}
|
|
4400
4535
|
var LIKELY_ENTRY_PATTERNS = [
|
|
4401
4536
|
/(?:^|\/)main\.[a-z0-9_]+$/i,
|
|
4402
4537
|
/(?:^|\/)index\.[a-z0-9_]+$/i,
|
|
@@ -4444,6 +4579,107 @@ _(no file is unreferenced \u2014 every file is either imported by another, has a
|
|
|
4444
4579
|
);
|
|
4445
4580
|
return textContent(lines.join("\n"));
|
|
4446
4581
|
}
|
|
4582
|
+
var FIND_MAX = 12;
|
|
4583
|
+
var FIND_SIG_MAX = 140;
|
|
4584
|
+
function symbolEntry(s) {
|
|
4585
|
+
const sig = s.signature.trim().slice(0, FIND_SIG_MAX);
|
|
4586
|
+
return `\u2022 ${sig} \u2192 mcp__synthra__graph_read("${s.file}::${s.name}") [${s.symbol_kind}, L${s.start_line}]`;
|
|
4587
|
+
}
|
|
4588
|
+
var byFileLine = (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1;
|
|
4589
|
+
function findSymbol(args, ctx) {
|
|
4590
|
+
const name = typeof args?.name === "string" ? args.name.trim() : "";
|
|
4591
|
+
if (!name) return errorContent("find_symbol: 'name' (string) is required");
|
|
4592
|
+
const symbols = ctx.graph.nodes.filter((n) => n.kind === "symbol");
|
|
4593
|
+
const lower = name.toLowerCase();
|
|
4594
|
+
const exact = symbols.filter((s) => s.name === name);
|
|
4595
|
+
const exactHits = exact.length > 0 ? exact : symbols.filter((s) => s.name.toLowerCase() === lower);
|
|
4596
|
+
if (exactHits.length > 0) {
|
|
4597
|
+
const sorted = exactHits.slice().sort(byFileLine);
|
|
4598
|
+
const shown2 = sorted.slice(0, FIND_MAX);
|
|
4599
|
+
const omitted2 = sorted.length - shown2.length;
|
|
4600
|
+
const lines2 = [
|
|
4601
|
+
`# find_symbol: "${name}"`,
|
|
4602
|
+
"",
|
|
4603
|
+
`Exact matches (${sorted.length}) \u2014 reuse one of these instead of writing a new one:`,
|
|
4604
|
+
...shown2.map(symbolEntry)
|
|
4605
|
+
];
|
|
4606
|
+
if (omitted2 > 0) lines2.push(`\u2026+${omitted2} more`);
|
|
4607
|
+
return textContent(lines2.join("\n"));
|
|
4608
|
+
}
|
|
4609
|
+
const tokens = new Set(tokenizeQuery(name));
|
|
4610
|
+
const scored = symbols.map((s) => {
|
|
4611
|
+
const n = s.name.toLowerCase();
|
|
4612
|
+
let score2 = 0;
|
|
4613
|
+
if (n.includes(lower) || lower.includes(n)) score2 += 2;
|
|
4614
|
+
for (const t of tokens) if (n.includes(t)) score2 += 1;
|
|
4615
|
+
return { s, score: score2 };
|
|
4616
|
+
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || byFileLine(a.s, b.s));
|
|
4617
|
+
if (scored.length === 0) {
|
|
4618
|
+
return textContent(
|
|
4619
|
+
`# find_symbol: "${name}"
|
|
4620
|
+
|
|
4621
|
+
No symbol matching "${name}" \u2014 safe to create.`
|
|
4622
|
+
);
|
|
4623
|
+
}
|
|
4624
|
+
const shown = scored.slice(0, FIND_MAX);
|
|
4625
|
+
const omitted = scored.length - shown.length;
|
|
4626
|
+
const lines = [
|
|
4627
|
+
`# find_symbol: "${name}"`,
|
|
4628
|
+
"",
|
|
4629
|
+
`No exact match. Similar names (${scored.length}) \u2014 reuse or extend one before writing new:`,
|
|
4630
|
+
...shown.map((x) => symbolEntry(x.s))
|
|
4631
|
+
];
|
|
4632
|
+
if (omitted > 0) lines.push(`\u2026+${omitted} more`);
|
|
4633
|
+
return textContent(lines.join("\n"));
|
|
4634
|
+
}
|
|
4635
|
+
var DUP_INCLUDE = /* @__PURE__ */ new Set([
|
|
4636
|
+
"function",
|
|
4637
|
+
"class",
|
|
4638
|
+
"interface",
|
|
4639
|
+
"type",
|
|
4640
|
+
"enum",
|
|
4641
|
+
"const",
|
|
4642
|
+
"component"
|
|
4643
|
+
]);
|
|
4644
|
+
function duplicateSymbols(args, ctx) {
|
|
4645
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 30;
|
|
4646
|
+
const defsByName = /* @__PURE__ */ new Map();
|
|
4647
|
+
const filesByName = /* @__PURE__ */ new Map();
|
|
4648
|
+
for (const n of ctx.graph.nodes) {
|
|
4649
|
+
if (n.kind !== "symbol" || !DUP_INCLUDE.has(n.symbol_kind)) continue;
|
|
4650
|
+
(defsByName.get(n.name) ?? defsByName.set(n.name, []).get(n.name)).push({
|
|
4651
|
+
file: n.file,
|
|
4652
|
+
line: n.start_line
|
|
4653
|
+
});
|
|
4654
|
+
(filesByName.get(n.name) ?? filesByName.set(n.name, /* @__PURE__ */ new Set()).get(n.name)).add(n.file);
|
|
4655
|
+
}
|
|
4656
|
+
const dups = [...defsByName.entries()].filter(([name]) => (filesByName.get(name)?.size ?? 0) >= 2).map(([name, defs]) => ({
|
|
4657
|
+
name,
|
|
4658
|
+
defs: defs.slice().sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)
|
|
4659
|
+
})).sort((a, b) => b.defs.length - a.defs.length || a.name.localeCompare(b.name));
|
|
4660
|
+
if (dups.length === 0) {
|
|
4661
|
+
return textContent(
|
|
4662
|
+
"# Duplicate symbols\n\n_(no top-level symbol name is defined in more than one file)_"
|
|
4663
|
+
);
|
|
4664
|
+
}
|
|
4665
|
+
const shown = dups.slice(0, limit);
|
|
4666
|
+
const lines = [
|
|
4667
|
+
"# Duplicate symbols (consolidation candidates)",
|
|
4668
|
+
"",
|
|
4669
|
+
`${shown.length} of ${dups.length} name(s) defined in multiple files (functions/classes/types; methods excluded):`,
|
|
4670
|
+
""
|
|
4671
|
+
];
|
|
4672
|
+
for (const d of shown) {
|
|
4673
|
+
lines.push(
|
|
4674
|
+
`- \`${d.name}\` (${d.defs.length}): ${d.defs.map((x) => `${x.file}:${x.line}`).join(" \xB7 ")}`
|
|
4675
|
+
);
|
|
4676
|
+
}
|
|
4677
|
+
lines.push("");
|
|
4678
|
+
lines.push(
|
|
4679
|
+
"_advisory: the same name in multiple files may be intentional \u2014 verify before consolidating._"
|
|
4680
|
+
);
|
|
4681
|
+
return textContent(lines.join("\n"));
|
|
4682
|
+
}
|
|
4447
4683
|
async function graphContinue(args, ctx) {
|
|
4448
4684
|
const query = typeof args?.query === "string" ? args.query : "";
|
|
4449
4685
|
if (!query) return errorContent("graph_continue: 'query' (string) is required");
|
|
@@ -4842,11 +5078,51 @@ async function getCommitsSince(projectRoot, sinceIso) {
|
|
|
4842
5078
|
return [];
|
|
4843
5079
|
}
|
|
4844
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
|
+
}
|
|
4845
5121
|
|
|
4846
5122
|
// src/memory/session.ts
|
|
4847
5123
|
import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
|
|
4848
5124
|
import { dirname as dirname13 } from "path";
|
|
4849
|
-
var SESSION_SCHEMA_VERSION =
|
|
5125
|
+
var SESSION_SCHEMA_VERSION = 2;
|
|
4850
5126
|
async function readSession(path) {
|
|
4851
5127
|
try {
|
|
4852
5128
|
const raw = await readFile17(path, "utf8");
|
|
@@ -4875,6 +5151,7 @@ async function captureSnapshot(ctx, branchOverride) {
|
|
|
4875
5151
|
for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
|
|
4876
5152
|
const prev = await readSession(ctx.paths.sessionState);
|
|
4877
5153
|
const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
|
|
5154
|
+
const headSha = await getHeadSha(ctx.paths.projectRoot);
|
|
4878
5155
|
const snapshot = {
|
|
4879
5156
|
schema_version: SESSION_SCHEMA_VERSION,
|
|
4880
5157
|
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -4885,7 +5162,8 @@ async function captureSnapshot(ctx, branchOverride) {
|
|
|
4885
5162
|
tasks: tasks.entries.map((e) => e.content),
|
|
4886
5163
|
decisions: decisions.entries.map((e) => e.content),
|
|
4887
5164
|
next: next.entries.map((e) => e.content)
|
|
4888
|
-
}
|
|
5165
|
+
},
|
|
5166
|
+
headSha
|
|
4889
5167
|
};
|
|
4890
5168
|
await writeSession(ctx.paths.sessionState, snapshot);
|
|
4891
5169
|
}
|
|
@@ -5333,6 +5611,34 @@ var RESUME_PRIMER_MAX_CHARS = 2720;
|
|
|
5333
5611
|
var MAX_FILES = 15;
|
|
5334
5612
|
var MAX_COMMITS2 = 5;
|
|
5335
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
|
+
}
|
|
5336
5642
|
function legacyPrimer(ctx) {
|
|
5337
5643
|
const g = ctx.graph;
|
|
5338
5644
|
return `Synthra context loaded for ${g.root}.
|
|
@@ -5343,7 +5649,7 @@ function hasContent(snap) {
|
|
|
5343
5649
|
snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
|
|
5344
5650
|
);
|
|
5345
5651
|
}
|
|
5346
|
-
function buildResumeDigest(snap, branchNow) {
|
|
5652
|
+
function buildResumeDigest(snap, branchNow, changedSymbolLines = []) {
|
|
5347
5653
|
const plural = (n) => n === 1 ? "" : "s";
|
|
5348
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)`;
|
|
5349
5655
|
const essential = [head];
|
|
@@ -5364,7 +5670,7 @@ function buildResumeDigest(snap, branchNow) {
|
|
|
5364
5670
|
essential.push("", "### Recent decisions");
|
|
5365
5671
|
for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
|
|
5366
5672
|
}
|
|
5367
|
-
const extra = [];
|
|
5673
|
+
const extra = [...changedSymbolLines];
|
|
5368
5674
|
if (snap.recentCommits.length) {
|
|
5369
5675
|
extra.push("", "### Recent commits");
|
|
5370
5676
|
for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
|
|
@@ -5391,7 +5697,12 @@ async function handlePrime(ctx, port) {
|
|
|
5391
5697
|
return { primer: legacy, port };
|
|
5392
5698
|
}
|
|
5393
5699
|
const branchNow = await currentBranch(ctx.paths.projectRoot);
|
|
5394
|
-
|
|
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);
|
|
5395
5706
|
return { primer: `${digest}
|
|
5396
5707
|
|
|
5397
5708
|
---
|