@jefuriiij/synthra 0.8.1 → 0.9.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,34 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.9.0] — 2026-06-20
11
+
12
+ ### Added
13
+
14
+ - **The graph auto-reindexes edited files mid-session — it never goes stale.**
15
+ Previously the in-memory graph was a snapshot from the last `syn .`: edit a
16
+ file and `graph_read` / `blast_radius` / the dependency footer would keep
17
+ serving the *old* signature, body, and line ranges until the next manual scan.
18
+ Now the running server watches for source changes and, ~1s after edits settle,
19
+ re-runs the incremental scan and hot-swaps the graph in place — so reads always
20
+ reflect what's on disk. The rescan is incremental (only the changed file hits
21
+ tree-sitter; everything else reuses the content-hash parse cache) and debounced
22
+ so a burst of saves coalesces into one rebuild. Tune with
23
+ `SYN_REINDEX_DEBOUNCE_MS` (default `1000`); disable with `SYN_NO_AUTOREINDEX`.
24
+
25
+ ### Fixed
26
+
27
+ - **In-session rescans (auto-reindex and branch-switch) no longer rewrite your
28
+ `CLAUDE.md` / `.gitignore`.** A rescan now skips the bootstrap step — it only
29
+ rebuilds the graph. This also closes a feedback loop the new auto-reindex would
30
+ otherwise hit (rewriting the watched `CLAUDE.md` on every rescan would retrigger
31
+ the watcher endlessly).
32
+ - **`CLAUDE.md` no longer accumulates a blank line on every `syn .`.** The policy
33
+ block patcher is now idempotent — re-running with nothing to change is a true
34
+ no-op instead of appending an empty line above the managed block each time.
35
+
36
+ ---
37
+
10
38
  ## [0.8.1] — 2026-06-16
11
39
 
12
40
  ### Changed
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.8.1",
21
+ version: "0.9.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3167,9 +3167,13 @@ async function patchClaudeMd(path, projectName) {
3167
3167
  return { created: true, updated: false, skipped: false };
3168
3168
  }
3169
3169
  const stripped = existing.replace(ANY_BLOCK_RE, "");
3170
- const hadBlock = stripped !== existing;
3171
- const desired = stripped.endsWith("\n") ? stripped + "\n" + block + "\n" : (stripped.length ? stripped + "\n\n" : "") + block + "\n";
3172
- if (hadBlock && desired === existing) {
3170
+ const base = stripped.replace(/\s+$/, "");
3171
+ const desired = base.length ? `${base}
3172
+
3173
+ ${block}
3174
+ ` : `${block}
3175
+ `;
3176
+ if (desired === existing) {
3173
3177
  return { created: false, updated: false, skipped: true };
3174
3178
  }
3175
3179
  await writeFile6(path, desired, "utf8");
@@ -3268,8 +3272,8 @@ async function scanProject(projectRootRaw, opts = {}) {
3268
3272
  const start = Date.now();
3269
3273
  const verbose = !opts.silent;
3270
3274
  if (verbose) log.info(`scanning ${projectRoot}`);
3271
- const boot = await bootstrap(paths);
3272
- if (verbose) {
3275
+ const boot = opts.skipBootstrap ? null : await bootstrap(paths);
3276
+ if (verbose && boot) {
3273
3277
  if (boot.graphCreated) log.info(" created .synthra-graph/");
3274
3278
  if (boot.contextCreated) log.info(" created .synthra/");
3275
3279
  if (boot.gitignoreUpdated) log.info(" updated .gitignore");
@@ -3379,6 +3383,36 @@ var LearnRuntime = class _LearnRuntime {
3379
3383
  }
3380
3384
  };
3381
3385
 
3386
+ // src/shared/config.ts
3387
+ function num(name, fallback) {
3388
+ const v = process.env[name];
3389
+ if (!v) return fallback;
3390
+ const n = Number(v);
3391
+ return Number.isFinite(n) ? n : fallback;
3392
+ }
3393
+ function str(name, fallback) {
3394
+ return process.env[name] ?? fallback;
3395
+ }
3396
+ function loadConfig() {
3397
+ return {
3398
+ hardMaxReadChars: num("SYN_HARD_MAX_READ_CHARS", 4e3),
3399
+ gateHintMaxChars: num("SYN_GATE_HINT_CHARS", 1200),
3400
+ readDepsMaxChars: num("SYN_READ_DEPS_CHARS", 900),
3401
+ turnReadBudgetChars: num("SYN_TURN_READ_BUDGET_CHARS", 18e3),
3402
+ fallbackMaxCallsPerTurn: num("SYN_FALLBACK_MAX_CALLS_PER_TURN", 1),
3403
+ retrieveCacheTtlSec: num("SYN_RETRIEVE_CACHE_TTL_SEC", 900),
3404
+ // Auto-reindex: re-run the incremental scan + swap the in-memory graph this
3405
+ // many ms after the last source-file change, so graph reads never go stale
3406
+ // mid-session. Set SYN_NO_AUTOREINDEX to disable entirely.
3407
+ reindexDebounceMs: num("SYN_REINDEX_DEBOUNCE_MS", 1e3),
3408
+ autoReindex: !process.env.SYN_NO_AUTOREINDEX,
3409
+ mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
3410
+ dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
3411
+ logLevel: str("SYN_LOG_LEVEL", "info"),
3412
+ claudeBin: str("SYN_CLAUDE_BIN", "claude")
3413
+ };
3414
+ }
3415
+
3382
3416
  // src/server/mcp.ts
3383
3417
  import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
3384
3418
  import { dirname as dirname12 } from "path";
@@ -4000,31 +4034,6 @@ async function pack(files, opts) {
4000
4034
  };
4001
4035
  }
4002
4036
 
4003
- // src/shared/config.ts
4004
- function num(name, fallback) {
4005
- const v = process.env[name];
4006
- if (!v) return fallback;
4007
- const n = Number(v);
4008
- return Number.isFinite(n) ? n : fallback;
4009
- }
4010
- function str(name, fallback) {
4011
- return process.env[name] ?? fallback;
4012
- }
4013
- function loadConfig() {
4014
- return {
4015
- hardMaxReadChars: num("SYN_HARD_MAX_READ_CHARS", 4e3),
4016
- gateHintMaxChars: num("SYN_GATE_HINT_CHARS", 1200),
4017
- readDepsMaxChars: num("SYN_READ_DEPS_CHARS", 900),
4018
- turnReadBudgetChars: num("SYN_TURN_READ_BUDGET_CHARS", 18e3),
4019
- fallbackMaxCallsPerTurn: num("SYN_FALLBACK_MAX_CALLS_PER_TURN", 1),
4020
- retrieveCacheTtlSec: num("SYN_RETRIEVE_CACHE_TTL_SEC", 900),
4021
- mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
4022
- dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
4023
- logLevel: str("SYN_LOG_LEVEL", "info"),
4024
- claudeBin: str("SYN_CLAUDE_BIN", "claude")
4025
- };
4026
- }
4027
-
4028
4037
  // src/server/mcp.ts
4029
4038
  var PROTOCOL_VERSION = "2024-11-05";
4030
4039
  var SERVER_INFO = { name: "synthra", version: "0.0.1" };
@@ -4598,6 +4607,61 @@ async function handleMcpRequest(body, ctx) {
4598
4607
  }
4599
4608
  }
4600
4609
 
4610
+ // src/server/reindex.ts
4611
+ async function rescanAndSwap(ctx, paths, label) {
4612
+ try {
4613
+ await scanProject(paths.projectRoot, { silent: true, skipBootstrap: true });
4614
+ const [graph, symbolIndex] = await Promise.all([
4615
+ readGraph(paths.infoGraph),
4616
+ readSymbolIndex(paths.symbolIndex)
4617
+ ]);
4618
+ ctx.graph = graph;
4619
+ ctx.symbolIndex = symbolIndex;
4620
+ log.info(`reindexed (${label}) \u2014 ${graph.symbol_count} symbols, ${graph.edge_count} edges.`);
4621
+ } catch (err2) {
4622
+ log.warn(`reindex failed (${label}): ${err2.message}`);
4623
+ }
4624
+ }
4625
+ function createReindexer(ctx, paths, opts = {}) {
4626
+ const debounceMs = opts.debounceMs ?? 1e3;
4627
+ const rescan = opts.rescan ?? rescanAndSwap;
4628
+ let timer = null;
4629
+ let running = false;
4630
+ let pending = false;
4631
+ async function run() {
4632
+ if (running) {
4633
+ pending = true;
4634
+ return;
4635
+ }
4636
+ running = true;
4637
+ try {
4638
+ await rescan(ctx, paths, "edit");
4639
+ } finally {
4640
+ running = false;
4641
+ if (pending) {
4642
+ pending = false;
4643
+ void run();
4644
+ }
4645
+ }
4646
+ }
4647
+ return {
4648
+ schedule() {
4649
+ if (timer) clearTimeout(timer);
4650
+ timer = setTimeout(() => {
4651
+ timer = null;
4652
+ void run();
4653
+ }, debounceMs);
4654
+ timer.unref?.();
4655
+ },
4656
+ stop() {
4657
+ if (timer) {
4658
+ clearTimeout(timer);
4659
+ timer = null;
4660
+ }
4661
+ }
4662
+ };
4663
+ }
4664
+
4601
4665
  // src/server/routes/activity.ts
4602
4666
  async function handleActivity(sinceMs, ctx) {
4603
4667
  const events = ctx.activity.getEvents(sinceMs);
@@ -5083,24 +5147,17 @@ async function startServer(paths, options = {}) {
5083
5147
  const app = buildApp(ctx, port);
5084
5148
  const nodeServer = serve2({ fetch: app.fetch, port, hostname: "127.0.0.1" });
5085
5149
  await writeFile11(paths.mcpPort, String(port), "utf8");
5086
- const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
5150
+ const cfg = loadConfig();
5151
+ const reindexer = cfg.autoReindex ? createReindexer(ctx, paths, { debounceMs: cfg.reindexDebounceMs }) : null;
5152
+ const fileWatcher = createFileWatcher(paths.projectRoot, (e) => {
5153
+ void ctx.activity.add(e);
5154
+ reindexer?.schedule();
5155
+ });
5087
5156
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
5088
5157
  await ctx.activity.add(e);
5089
5158
  if (e.kind === "branch-switch") {
5090
- try {
5091
- const to = e.details?.to ?? "unknown";
5092
- log.info(`branch switched to '${to}' \u2014 rebuilding graph\u2026`);
5093
- await scanProject(paths.projectRoot, { silent: true });
5094
- const [g, idx] = await Promise.all([
5095
- readGraph(paths.infoGraph),
5096
- readSymbolIndex(paths.symbolIndex)
5097
- ]);
5098
- ctx.graph = g;
5099
- ctx.symbolIndex = idx;
5100
- log.info(`graph rebuilt for '${to}' (${g.symbol_count} symbols).`);
5101
- } catch (err2) {
5102
- log.warn(`branch rescan failed: ${err2.message}`);
5103
- }
5159
+ const to = e.details?.to ?? "unknown";
5160
+ await rescanAndSwap(ctx, paths, `branch ${to}`);
5104
5161
  }
5105
5162
  });
5106
5163
  try {
@@ -5118,6 +5175,7 @@ async function startServer(paths, options = {}) {
5118
5175
  port,
5119
5176
  url,
5120
5177
  async stop() {
5178
+ reindexer?.stop();
5121
5179
  await fileWatcher.stop().catch(() => void 0);
5122
5180
  await gitWatcher.stop().catch(() => void 0);
5123
5181
  await ctx.learn?.flush().catch(() => void 0);