@jefuriiij/synthra 0.1.21 → 0.1.23

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,87 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.1.23] — 2026-06-06
11
+
12
+ ### Added
13
+
14
+ - **Dashboard token-log dedupe can now be disabled via `SYN_DASHBOARD_DEDUPE=0`.**
15
+ By default, `delta.ts` deduplicates `token_log.jsonl` entries that share the
16
+ same project, usage totals, and second-rounded timestamp — collapsing the
17
+ duplicate writes that a co-installed AI tool's Stop hook may produce. Set
18
+ `SYN_DASHBOARD_DEDUPE=0` (also accepts `off` or `false`) to see every raw
19
+ entry. Useful when debugging multi-tool coexistence or auditing raw log data.
20
+
21
+ - **Graph schema-migration check on load.** A new `SCHEMA_VERSION` constant is
22
+ exported from `src/graph/types.ts` and stamped into `info_graph.json` by
23
+ `buildGraph`. On server start, `http.ts` compares the stored graph's
24
+ `schema_version` to the current constant; if they differ it triggers an
25
+ automatic one-time rescan instead of serving an incompatible graph. No
26
+ behavior change today — all graphs are v1 and schema_version matches — but
27
+ this is the forward-safety mechanism for future schema bumps so existing
28
+ graphs are never silently misread.
29
+
30
+ ### Fixed
31
+
32
+ - **JS/TS parser now captures member-assigned functions** (`exports.handler = fn`,
33
+ `module.exports.route = () => {}`, `this.x = () => {}`). Previously these
34
+ CommonJS/member-export patterns were invisible to the query, so modules that
35
+ exclusively use this style extracted zero symbols and degraded to whole-file
36
+ reads via `graph_read`. A member-assignment capture has been added to both
37
+ `JS_QUERY` and `TS_QUERY` in `src/scanner/parsers/typescript.ts`. Note: a
38
+ pure-wiring `server.js` whose only structure is anonymous inline-callback
39
+ arguments (e.g. `io.use(...)` / `socket.on(event, fn)`) is genuinely
40
+ symbol-less — that is correct, and the gate's symbol-hit guard already
41
+ prevents blocking such files.
42
+
43
+ ### Changed
44
+
45
+ - **Policy block v4 → v5.** Adds a "large file — pull the symbol, don't
46
+ chunk" nudge to address recurring dogfood friction: on large files Claude
47
+ was reading successive line-range chunks instead of fetching the specific
48
+ symbol via `graph_read("file::symbol")`. The v5 block now explicitly
49
+ instructs: when a file is large, use `graph_read("file/path.ts::SymbolName")`
50
+ to pull the symbol directly rather than reading successive line-range chunks.
51
+ `POLICY_VERSION` bumped `4 → 5`; existing v4 blocks auto-upgrade on the
52
+ next `syn .` run.
53
+
54
+ ---
55
+
56
+ ## [0.1.22] — 2026-06-06
57
+
58
+ ### Fixed
59
+
60
+ - **`graph_read` now resolves shortened file paths (path-suffix fallback).** Previously
61
+ `graph_read` performed an exact `path === target` match only. Passing a shortened path
62
+ like `appsettings.json` returned "file not found" even when
63
+ `connectwarev2/.../appsettings.json` was indexed. A new `resolveFileTarget` helper (now
64
+ exported) tries an exact match first; on a miss it looks for a unique path-suffix match
65
+ and serves that file; if multiple files share the suffix it reports them as ambiguous with
66
+ candidate paths rather than guessing. Symbol lookups use the resolved path. No API or
67
+ protocol change. Roadmap item #11.
68
+
69
+ - **Gate content-keyword relaxation now intersects file contents, not just file paths.**
70
+ The Moat's recent-activity relaxation previously matched query tokens against the paths of
71
+ recently-touched files only. A query like `Grep "login"` would not relax on a recent save
72
+ of `auth.ts` unless the word "login" appeared in the path. Now the relaxation also checks
73
+ the recently-touched file's graph-node keywords (its indexed content), so a recent save
74
+ relaxes a Grep whenever the file *contains* the queried term — not just when the path
75
+ matches it. Completes roadmap item #3.
76
+
77
+ ### Changed
78
+
79
+ - **Dashboard Projects card shows a first-run hint in the empty state.** When no projects
80
+ have run `syn .` yet, the Projects card now displays "No projects yet — run `syn .` in a
81
+ project to start" instead of a blank card. The Recent-turns card already carried this
82
+ hint; Projects now matches it. Roadmap item #10.
83
+
84
+ - **`bin` path normalization (chore).** Ran `npm pkg fix` to normalize `bin` entries from
85
+ `./bin/syn` to `bin/syn`. Silences the cosmetic publish warnings
86
+ (`"bin[syn]" script name was cleaned`). `syn` and `synthra` still resolve to the same
87
+ entry point. Roadmap item #4.
88
+
89
+ ---
90
+
10
91
  ## [0.1.21] — 2026-06-06
11
92
 
12
93
  ### Added
package/dist/cli/index.js CHANGED
@@ -18,15 +18,15 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.1.21",
21
+ version: "0.1.23",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
25
25
  description: "Local context engine for AI coding assistants \u2014 graph-based context, branch-aware memory, real-time human-activity awareness, deterministic Grep/Glob gating, and a live token dashboard.",
26
26
  type: "module",
27
27
  bin: {
28
- syn: "./bin/syn",
29
- synthra: "./bin/syn"
28
+ syn: "bin/syn",
29
+ synthra: "bin/syn"
30
30
  },
31
31
  scripts: {
32
32
  build: "tsup",
@@ -297,13 +297,18 @@ function summarize(p) {
297
297
  models
298
298
  };
299
299
  }
300
+ function dedupeEnabled() {
301
+ const v = process.env.SYN_DASHBOARD_DEDUPE;
302
+ return v !== "0" && v !== "off" && v !== "false";
303
+ }
300
304
  async function loadProjectFiles(path, name, lastSeen) {
301
305
  const paths = resolvePaths(path);
302
306
  const [rawTokens, gates] = await Promise.all([
303
307
  readJsonl(paths.tokenLog),
304
308
  readJsonl(paths.gateLog)
305
309
  ]);
306
- return { path, name, last_seen: lastSeen, tokens: dedupeTokens(rawTokens), gates };
310
+ const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;
311
+ return { path, name, last_seen: lastSeen, tokens, gates };
307
312
  }
308
313
  function dedupeTokens(entries) {
309
314
  const score2 = (model) => {
@@ -1075,7 +1080,7 @@ var public_default = `<!doctype html>
1075
1080
  const el = $('#proj-chart');
1076
1081
  el.innerHTML = '';
1077
1082
  if (!projects.length) {
1078
- el.innerHTML = '<div class="empty">No projects yet.</div>';
1083
+ el.innerHTML = '<div class="empty">No projects yet \u2014 run <code>syn .</code> in a project to start.</div>';
1079
1084
  return;
1080
1085
  }
1081
1086
  const ranked = [...projects].sort((a, b) => (b.total_turns || 0) - (a.total_turns || 0));
@@ -1401,33 +1406,33 @@ exit 0
1401
1406
  var pre_tool_use_default = '# PreToolUse hook \u2014 Windows PowerShell.\n# THE MOAT (improvement #1). Reads the tool call from stdin (JSON), POSTs it\n# to /gate, and if the server says "block" emits a JSON deny-decision to\n# stdout. Claude Code reads stdout JSON to enforce the decision.\n# Always exits 0; failure-to-reach-server leaves Claude untouched.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$raw = [Console]::In.ReadToEnd()\nif (-not $raw) { exit 0 }\n\ntry {\n $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop\n} catch {\n exit 0\n}\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\n$payload = @{\n tool_name = $hookInput.tool_name\n tool_input = $hookInput.tool_input\n} | ConvertTo-Json -Depth 10 -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/gate" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n} catch {\n exit 0\n}\n\nif ($resp.decision -eq "block") {\n $denyJson = @{\n hookSpecificOutput = @{\n hookEventName = "PreToolUse"\n permissionDecision = "deny"\n permissionDecisionReason = $resp.reason\n }\n } | ConvertTo-Json -Depth 5 -Compress\n Write-Output $denyJson\n}\nexit 0\n';
1402
1407
 
1403
1408
  // src/hooks/scripts/pre-tool-use.sh
1404
- var pre_tool_use_default2 = `#!/usr/bin/env bash
1405
- # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
1406
- # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.
1407
- # Always exits 0; server failures leave Claude untouched.
1408
-
1409
- set +e
1410
-
1411
- PORT_FILE="$PWD/.synthra-graph/mcp_port"
1412
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1413
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1414
- if [ -z "$PORT" ]; then exit 0; fi
1415
-
1416
- INPUT=$(cat 2>/dev/null)
1417
- if [ -z "$INPUT" ]; then exit 0; fi
1418
-
1419
- RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1420
- --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)
1421
-
1422
- case "$RESP" in
1423
- *'"decision":"block"'*)
1424
- REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')
1425
- cat <<EOF
1426
- {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}
1427
- EOF
1428
- ;;
1429
- esac
1430
- exit 0
1409
+ var pre_tool_use_default2 = `#!/usr/bin/env bash\r
1410
+ # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns\r
1411
+ # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.\r
1412
+ # Always exits 0; server failures leave Claude untouched.\r
1413
+ \r
1414
+ set +e\r
1415
+ \r
1416
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1417
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1418
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1419
+ if [ -z "$PORT" ]; then exit 0; fi\r
1420
+ \r
1421
+ INPUT=$(cat 2>/dev/null)\r
1422
+ if [ -z "$INPUT" ]; then exit 0; fi\r
1423
+ \r
1424
+ RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1425
+ --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)\r
1426
+ \r
1427
+ case "$RESP" in\r
1428
+ *'"decision":"block"'*)\r
1429
+ REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')\r
1430
+ cat <<EOF\r
1431
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}\r
1432
+ EOF\r
1433
+ ;;\r
1434
+ esac\r
1435
+ exit 0\r
1431
1436
  `;
1432
1437
 
1433
1438
  // src/hooks/scripts/prime.ps1
@@ -1958,6 +1963,9 @@ import { resolve } from "path";
1958
1963
  // src/scanner/extract.ts
1959
1964
  import { dirname as dirname4, join as join6, posix } from "path";
1960
1965
 
1966
+ // src/graph/types.ts
1967
+ var SCHEMA_VERSION2 = 1;
1968
+
1961
1969
  // src/scanner/hash.ts
1962
1970
  import { createHash } from "crypto";
1963
1971
  function fileHash(content) {
@@ -2294,7 +2302,7 @@ async function buildGraph(root, parsed) {
2294
2302
  nodes,
2295
2303
  edges,
2296
2304
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2297
- schema_version: 1
2305
+ schema_version: SCHEMA_VERSION2
2298
2306
  };
2299
2307
  }
2300
2308
  function buildSymbolIndex(graph) {
@@ -2828,6 +2836,7 @@ var TS_QUERY = `
2828
2836
  (enum_declaration name: (identifier) @enum.name) @enum
2829
2837
  (method_definition name: (property_identifier) @method.name) @method
2830
2838
  (lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
2839
+ (assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
2831
2840
  (import_statement source: (string) @import)
2832
2841
  `;
2833
2842
  var JS_QUERY = `
@@ -2835,6 +2844,7 @@ var JS_QUERY = `
2835
2844
  (class_declaration name: (identifier) @class.name) @class
2836
2845
  (method_definition name: (property_identifier) @method.name) @method
2837
2846
  (lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
2847
+ (assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
2838
2848
  (import_statement source: (string) @import)
2839
2849
  (call_expression function: (identifier) @_require_fn arguments: (arguments . (string) @require_source))
2840
2850
  `;
@@ -2859,7 +2869,7 @@ function shapeFromCaptures(captures) {
2859
2869
  const name = captures.get(`${k}.name`);
2860
2870
  return decl && name ? { decl, name, kind: sk } : null;
2861
2871
  };
2862
- return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function");
2872
+ return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function") ?? findDecl("member-fn", "function");
2863
2873
  }
2864
2874
  async function parseTypeScript(f, source) {
2865
2875
  const grammar = grammarFor(f.ext);
@@ -3267,7 +3277,7 @@ import { basename as basename4 } from "path";
3267
3277
  // src/hooks/claude-md.ts
3268
3278
  import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3269
3279
  import { basename as basename3, dirname as dirname6 } from "path";
3270
- var POLICY_VERSION = 4;
3280
+ var POLICY_VERSION = 5;
3271
3281
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3272
3282
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3273
3283
  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;
@@ -3335,6 +3345,10 @@ function policyBlock() {
3335
3345
  " reads should be rare \u2014 only when you genuinely need the full file.",
3336
3346
  "- If `graph_continue`'s `Files` list contains a `::` entry, pass it",
3337
3347
  " verbatim to `graph_read`.",
3348
+ "- **Large file?** Don't read it in successive line-range chunks \u2014 call",
3349
+ ' `graph_continue` or `graph_read("file::symbol")` to pull the one symbol',
3350
+ " you need. Chunked whole-file Reads are exactly the cost `graph_read`",
3351
+ " exists to avoid.",
3338
3352
  "",
3339
3353
  "### Editing a file",
3340
3354
  "",
@@ -3892,7 +3906,7 @@ async function writeContextMd(path, ctx) {
3892
3906
  // src/memory/context-store.ts
3893
3907
  import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
3894
3908
  import { dirname as dirname8 } from "path";
3895
- var SCHEMA_VERSION2 = 1;
3909
+ var SCHEMA_VERSION3 = 1;
3896
3910
  async function readEntries(path) {
3897
3911
  try {
3898
3912
  const raw = await readFile13(path, "utf8");
@@ -3904,7 +3918,7 @@ async function readEntries(path) {
3904
3918
  }
3905
3919
  async function writeEntries(path, entries) {
3906
3920
  await mkdir7(dirname8(path), { recursive: true });
3907
- const store = { schema_version: SCHEMA_VERSION2, entries };
3921
+ const store = { schema_version: SCHEMA_VERSION3, entries };
3908
3922
  await writeFile7(path, JSON.stringify(store, null, 2) + "\n", "utf8");
3909
3923
  }
3910
3924
  async function appendEntry(path, entry) {
@@ -4438,15 +4452,33 @@ Reason: ${retrieval.reason}
4438
4452
  return textContent(`${header}
4439
4453
  ${packed.text}`);
4440
4454
  }
4455
+ function resolveFileTarget(graph, filePath) {
4456
+ const files = graph.nodes.filter((n) => n.kind === "file");
4457
+ const exact = files.find((n) => n.path === filePath);
4458
+ if (exact) return { node: exact };
4459
+ const suffix = "/" + filePath;
4460
+ const matches = files.filter((n) => n.path.endsWith(suffix));
4461
+ if (matches.length === 1) return { node: matches[0] };
4462
+ if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
4463
+ return { none: true };
4464
+ }
4441
4465
  function graphRead(args, ctx) {
4442
4466
  const target = typeof args?.target === "string" ? args.target : "";
4443
4467
  if (!target) return errorContent("graph_read: 'target' (string) is required");
4444
4468
  const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
4445
4469
  const filePath = (rawFile ?? "").trim();
4446
- const fileNode = ctx.graph.nodes.find(
4447
- (n) => n.kind === "file" && n.path === filePath
4448
- );
4449
- if (!fileNode) return errorContent(`graph_read: file not found in graph: ${filePath}`);
4470
+ const resolved = resolveFileTarget(ctx.graph, filePath);
4471
+ if ("ambiguous" in resolved) {
4472
+ const shown = resolved.ambiguous.slice(0, 5).join(", ");
4473
+ const more = resolved.ambiguous.length > 5 ? ", \u2026" : "";
4474
+ return errorContent(
4475
+ `graph_read: '${filePath}' matches multiple files (${shown}${more}). Pass a longer path.`
4476
+ );
4477
+ }
4478
+ if ("none" in resolved) {
4479
+ return errorContent(`graph_read: file not found in graph: ${filePath}`);
4480
+ }
4481
+ const fileNode = resolved.node;
4450
4482
  if (!symbolName) {
4451
4483
  return textContent(`# ${fileNode.path}
4452
4484
 
@@ -4454,10 +4486,10 @@ ${fileNode.content}`);
4454
4486
  }
4455
4487
  const cleanSym = symbolName.trim();
4456
4488
  const symbol = ctx.graph.nodes.find(
4457
- (n) => n.kind === "symbol" && n.file === filePath && n.name === cleanSym
4489
+ (n) => n.kind === "symbol" && n.file === fileNode.path && n.name === cleanSym
4458
4490
  );
4459
4491
  if (!symbol) {
4460
- return errorContent(`graph_read: symbol '${cleanSym}' not found in ${filePath}`);
4492
+ return errorContent(`graph_read: symbol '${cleanSym}' not found in ${fileNode.path}`);
4461
4493
  }
4462
4494
  const lines = fileNode.content.split(/\r?\n/);
4463
4495
  const body = lines.slice(symbol.start_line - 1, symbol.end_line).join("\n");
@@ -4623,16 +4655,32 @@ function looksLikeNonSymbolQuery(pattern) {
4623
4655
  }
4624
4656
  return false;
4625
4657
  }
4626
- function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
4658
+ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
4659
+ if (recentPaths.length === 0) return [];
4660
+ const recent = new Set(recentPaths);
4661
+ const keywordsByPath = /* @__PURE__ */ new Map();
4662
+ for (const n of graph.nodes) {
4663
+ if (n.kind === "file" && recent.has(n.path)) keywordsByPath.set(n.path, n.keywords);
4664
+ }
4627
4665
  const matches = [];
4628
4666
  for (const path of recentPaths) {
4629
4667
  const lower = path.toLowerCase();
4668
+ let matched = false;
4630
4669
  for (const t of queryTokens) {
4631
4670
  if (lower.includes(t)) {
4632
- matches.push(path);
4671
+ matched = true;
4633
4672
  break;
4634
4673
  }
4635
4674
  }
4675
+ if (!matched) {
4676
+ for (const kw of keywordsByPath.get(path) ?? []) {
4677
+ if (queryTokens.has(kw)) {
4678
+ matched = true;
4679
+ break;
4680
+ }
4681
+ }
4682
+ }
4683
+ if (matched) matches.push(path);
4636
4684
  }
4637
4685
  return matches;
4638
4686
  }
@@ -4683,7 +4731,7 @@ async function handleGate(req, ctx) {
4683
4731
  }
4684
4732
  const qTokens = new Set(tokenizeQuery(query));
4685
4733
  const recentPaths = ctx.activity.recentFilePaths(RECENT_ACTIVITY_WINDOW_MS);
4686
- const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens);
4734
+ const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens, ctx.graph);
4687
4735
  if (overlap.length > 0) {
4688
4736
  const res2 = {
4689
4737
  decision: "allow",
@@ -4770,10 +4818,20 @@ ${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools
4770
4818
  // src/server/http.ts
4771
4819
  async function loadContext(paths) {
4772
4820
  try {
4773
- const [graph, symbolIndex] = await Promise.all([
4821
+ let [graph, symbolIndex] = await Promise.all([
4774
4822
  readGraph(paths.infoGraph),
4775
4823
  readSymbolIndex(paths.symbolIndex)
4776
4824
  ]);
4825
+ if (graph.schema_version !== SCHEMA_VERSION2) {
4826
+ log.info(
4827
+ `graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION2} \u2014 rescanning\u2026`
4828
+ );
4829
+ await scanProject(paths.projectRoot, { silent: true });
4830
+ [graph, symbolIndex] = await Promise.all([
4831
+ readGraph(paths.infoGraph),
4832
+ readSymbolIndex(paths.symbolIndex)
4833
+ ]);
4834
+ }
4777
4835
  const activity = new ActivityStore(paths.activityLog);
4778
4836
  return { paths, graph, symbolIndex, activity };
4779
4837
  } catch (err2) {