@jefuriiij/synthra 0.1.25 → 0.2.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/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.1.25",
21
+ version: "0.2.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -33,7 +33,12 @@ var init_package = __esm({
33
33
  dev: "tsup --watch",
34
34
  test: "vitest run",
35
35
  "test:watch": "vitest",
36
- typecheck: "tsc --noEmit"
36
+ "test:coverage": "vitest run --coverage",
37
+ typecheck: "tsc --noEmit",
38
+ lint: "biome lint .",
39
+ format: "biome format --write .",
40
+ check: "biome check .",
41
+ "check:fix": "biome check --write ."
37
42
  },
38
43
  files: [
39
44
  "dist",
@@ -75,8 +80,10 @@ var init_package = __esm({
75
80
  "web-tree-sitter": "^0.25.10"
76
81
  },
77
82
  devDependencies: {
83
+ "@biomejs/biome": "^2.4.16",
78
84
  "@types/cross-spawn": "^6.0.6",
79
85
  "@types/node": "^25.9.1",
86
+ "@vitest/coverage-v8": "^4.1.8",
80
87
  tsup: "^8.5.1",
81
88
  typescript: "^6.0.3",
82
89
  vitest: "^4.1.7"
@@ -158,6 +165,8 @@ function resolvePaths(projectRoot) {
158
165
  tokenLog: join(graphDir, "token_log.jsonl"),
159
166
  gateLog: join(graphDir, "gate_log.jsonl"),
160
167
  toolLog: join(graphDir, "tool_log.jsonl"),
168
+ accessLog: join(graphDir, "access_log.jsonl"),
169
+ learnStore: join(graphDir, "learn_store.json"),
161
170
  mcpPort: join(graphDir, "mcp_port"),
162
171
  mcpServerLog: join(graphDir, "mcp_server.log"),
163
172
  mcpServerErrLog: join(graphDir, "mcp_server.err.log"),
@@ -374,7 +383,9 @@ async function computeDashboardData(activePaths, recentN = 500) {
374
383
  const loaded = await Promise.all(
375
384
  allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen))
376
385
  );
377
- const projects = loaded.map(summarize).sort((a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens));
386
+ const projects = loaded.map(summarize).sort(
387
+ (a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens)
388
+ );
378
389
  const activeFiles = loaded.find((p) => p.path === activePath) ?? {
379
390
  path: activePath,
380
391
  name: activeName,
@@ -1440,12 +1451,19 @@ if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1440
1451
  PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1441
1452
  if [ -z "$PORT" ]; then exit 0; fi
1442
1453
 
1454
+ # Parse the primer with jq, not sed. The primer now carries a multi-line "Since you
1455
+ # were last here" resume digest with quotes and newlines, so the old greedy sed capture
1456
+ # (.*") both over-ran into the trailing "port" field and broke on inner quotes. jq -r
1457
+ # also decodes JSON escapes, so we print with %s (not %b). No jq \u2192 no primer (matches
1458
+ # prime.sh / stop.sh \u2014 completes the jq migration across all bash hooks).
1459
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1460
+
1443
1461
  PRIMER=$(curl -sS --max-time 3 "http://127.0.0.1:$PORT/prime" 2>/dev/null \\
1444
- | sed -n 's/.*"primer"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p' \\
1462
+ | jq -r '.primer // empty' 2>/dev/null \\
1445
1463
  | head -c 8000)
1446
1464
 
1447
1465
  if [ -n "$PRIMER" ]; then
1448
- printf '%b\\n' "$PRIMER"
1466
+ printf '%s\\n' "$PRIMER"
1449
1467
  fi
1450
1468
  exit 0
1451
1469
  `;
@@ -1454,41 +1472,41 @@ exit 0
1454
1472
  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';
1455
1473
 
1456
1474
  // src/hooks/scripts/pre-tool-use.sh
1457
- var pre_tool_use_default2 = `#!/usr/bin/env bash
1458
- # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
1459
- # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.
1460
- # Always exits 0; server failures leave Claude untouched.
1461
- # Requires \`jq\` to read the gate response; falls back to silent no-op (no
1462
- # enforcement) if absent \u2014 same policy as the Stop/Prime hooks.
1463
-
1464
- set +e
1465
-
1466
- PORT_FILE="$PWD/.synthra-graph/mcp_port"
1467
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1468
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1469
- if [ -z "$PORT" ]; then exit 0; fi
1470
-
1471
- INPUT=$(cat 2>/dev/null)
1472
- if [ -z "$INPUT" ]; then exit 0; fi
1473
-
1474
- RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1475
- --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)
1476
-
1477
- # Parse the gate response with jq, not a greedy sed capture. The block \`reason\`
1478
- # legitimately contains double quotes (it quotes the query, e.g. "login"), so the
1479
- # old sed capture (\\(.*\\)") both over-ran into the trailing JSON fields and, once
1480
- # embedded raw in the heredoc, produced invalid hook output. jq reads each field
1481
- # and re-emits the deny object with correct escaping. (matches stop.sh / prime.sh,
1482
- # jq fix #1.) No jq \u2192 no enforcement; bail silently like the other hooks.
1483
- if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1484
-
1485
- DECISION=$(printf '%s' "$RESP" | jq -r '.decision // empty' 2>/dev/null)
1486
- if [ "$DECISION" = "block" ]; then
1487
- REASON=$(printf '%s' "$RESP" | jq -r '.reason // empty' 2>/dev/null)
1488
- jq -nc --arg r "$REASON" \\
1489
- '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
1490
- fi
1491
- exit 0
1475
+ var pre_tool_use_default2 = `#!/usr/bin/env bash\r
1476
+ # PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns\r
1477
+ # "block", emits the deny-decision JSON to stdout for Claude Code to enforce.\r
1478
+ # Always exits 0; server failures leave Claude untouched.\r
1479
+ # Requires \`jq\` to read the gate response; falls back to silent no-op (no\r
1480
+ # enforcement) if absent \u2014 same policy as the Stop/Prime hooks.\r
1481
+ \r
1482
+ set +e\r
1483
+ \r
1484
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1485
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1486
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1487
+ if [ -z "$PORT" ]; then exit 0; fi\r
1488
+ \r
1489
+ INPUT=$(cat 2>/dev/null)\r
1490
+ if [ -z "$INPUT" ]; then exit 0; fi\r
1491
+ \r
1492
+ RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1493
+ --data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)\r
1494
+ \r
1495
+ # Parse the gate response with jq, not a greedy sed capture. The block \`reason\`\r
1496
+ # legitimately contains double quotes (it quotes the query, e.g. "login"), so the\r
1497
+ # old sed capture (\\(.*\\)") both over-ran into the trailing JSON fields and, once\r
1498
+ # embedded raw in the heredoc, produced invalid hook output. jq reads each field\r
1499
+ # and re-emits the deny object with correct escaping. (matches stop.sh / prime.sh,\r
1500
+ # jq fix #1.) No jq \u2192 no enforcement; bail silently like the other hooks.\r
1501
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi\r
1502
+ \r
1503
+ DECISION=$(printf '%s' "$RESP" | jq -r '.decision // empty' 2>/dev/null)\r
1504
+ if [ "$DECISION" = "block" ]; then\r
1505
+ REASON=$(printf '%s' "$RESP" | jq -r '.reason // empty' 2>/dev/null)\r
1506
+ jq -nc --arg r "$REASON" \\\r
1507
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'\r
1508
+ fi\r
1509
+ exit 0\r
1492
1510
  `;
1493
1511
 
1494
1512
  // src/hooks/scripts/prime.ps1
@@ -1544,159 +1562,165 @@ exit 0
1544
1562
  `;
1545
1563
 
1546
1564
  // src/hooks/scripts/stop.ps1
1547
- var stop_default = `# Stop hook \u2014 Windows PowerShell.
1548
- # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums
1549
- # usage.* token counts across all assistant turns since the last offset, and
1550
- # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid
1551
- # double-counting on session resume.
1552
-
1553
- $ErrorActionPreference = "SilentlyContinue"
1554
-
1555
- $raw = [Console]::In.ReadToEnd()
1556
- if (-not $raw) { exit 0 }
1557
- try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }
1558
-
1559
- $transcript = $hookInput.transcript_path
1560
- if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }
1561
-
1562
- $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"
1563
- if (-not (Test-Path $portFile)) { exit 0 }
1564
- $port = (Get-Content -Path $portFile -Raw).Trim()
1565
- if (-not $port) { exit 0 }
1566
-
1567
- $offsetFile = "$transcript.stopoffset"
1568
- $startOffset = 0
1569
- if (Test-Path $offsetFile) {
1570
- $val = (Get-Content -Path $offsetFile -Raw).Trim()
1571
- if ($val -match '^\\d+$') { $startOffset = [int]$val }
1572
- }
1573
-
1574
- $lines = Get-Content -Path $transcript
1575
- $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""
1576
- $lineNum = 0
1577
- foreach ($line in $lines) {
1578
- $lineNum++
1579
- if ($lineNum -le $startOffset) { continue }
1580
- if (-not $line) { continue }
1581
- try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }
1582
- $usage = $e.message.usage
1583
- if (-not $usage) { continue }
1584
- $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1585
- $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1586
- $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1587
- $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })
1588
- if ($e.message.model) { $model = $e.message.model }
1589
- }
1590
-
1591
- Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII
1592
-
1593
- if ($inT -eq 0 -and $outT -eq 0) { exit 0 }
1594
-
1595
- $payload = @{
1596
- input_tokens = $inT
1597
- output_tokens = $outT
1598
- cache_creation_input_tokens = $cc
1599
- cache_read_input_tokens = $cr
1600
- model = $model
1601
- description = "synthra-stop-hook"
1602
- project = $PWD.Path
1603
- } | ConvertTo-Json -Compress
1604
-
1605
- try {
1606
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`
1607
- -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null
1608
- } catch {
1609
- # silent
1610
- }
1611
-
1612
- # Refresh CONTEXT.md from the branch-scoped store.
1613
- $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress
1614
- try {
1615
- Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`
1616
- -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null
1617
- } catch {
1618
- # silent
1619
- }
1620
- exit 0
1565
+ var stop_default = `# Stop hook \u2014 Windows PowerShell.\r
1566
+ # Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
1567
+ # usage.* token counts across all assistant turns since the last offset, and\r
1568
+ # POSTs the totals to /log. Uses a per-transcript .stopoffset file to avoid\r
1569
+ # double-counting on session resume.\r
1570
+ \r
1571
+ $ErrorActionPreference = "SilentlyContinue"\r
1572
+ \r
1573
+ $raw = [Console]::In.ReadToEnd()\r
1574
+ if (-not $raw) { exit 0 }\r
1575
+ try { $hookInput = $raw | ConvertFrom-Json -ErrorAction Stop } catch { exit 0 }\r
1576
+ \r
1577
+ $transcript = $hookInput.transcript_path\r
1578
+ if (-not $transcript -or -not (Test-Path $transcript)) { exit 0 }\r
1579
+ \r
1580
+ $portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\r
1581
+ if (-not (Test-Path $portFile)) { exit 0 }\r
1582
+ $port = (Get-Content -Path $portFile -Raw).Trim()\r
1583
+ if (-not $port) { exit 0 }\r
1584
+ \r
1585
+ $offsetFile = "$transcript.stopoffset"\r
1586
+ $startOffset = 0\r
1587
+ if (Test-Path $offsetFile) {\r
1588
+ $val = (Get-Content -Path $offsetFile -Raw).Trim()\r
1589
+ if ($val -match '^\\d+$') { $startOffset = [int]$val }\r
1590
+ }\r
1591
+ \r
1592
+ $lines = Get-Content -Path $transcript\r
1593
+ $inT = 0; $outT = 0; $cc = 0; $cr = 0; $model = ""\r
1594
+ $lineNum = 0\r
1595
+ foreach ($line in $lines) {\r
1596
+ $lineNum++\r
1597
+ if ($lineNum -le $startOffset) { continue }\r
1598
+ if (-not $line) { continue }\r
1599
+ try { $e = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue }\r
1600
+ $usage = $e.message.usage\r
1601
+ if (-not $usage) { continue }\r
1602
+ $inT += [int]($usage.input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1603
+ $outT += [int]($usage.output_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1604
+ $cc += [int]($usage.cache_creation_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1605
+ $cr += [int]($usage.cache_read_input_tokens | ForEach-Object { if ($_) { $_ } else { 0 } })\r
1606
+ if ($e.message.model) { $model = $e.message.model }\r
1607
+ }\r
1608
+ \r
1609
+ Set-Content -Path $offsetFile -Value $lineNum -Encoding ASCII\r
1610
+ \r
1611
+ if ($inT -eq 0 -and $outT -eq 0) { exit 0 }\r
1612
+ \r
1613
+ $payload = @{\r
1614
+ input_tokens = $inT\r
1615
+ output_tokens = $outT\r
1616
+ cache_creation_input_tokens = $cc\r
1617
+ cache_read_input_tokens = $cr\r
1618
+ model = $model\r
1619
+ description = "synthra-stop-hook"\r
1620
+ project = $PWD.Path\r
1621
+ } | ConvertTo-Json -Compress\r
1622
+ \r
1623
+ try {\r
1624
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/log" -Method POST \`\r
1625
+ -Body $payload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1626
+ } catch {\r
1627
+ # silent\r
1628
+ }\r
1629
+ \r
1630
+ # Refresh CONTEXT.md from the branch-scoped store.\r
1631
+ $ctxPayload = @{ transcript_path = $transcript } | ConvertTo-Json -Compress\r
1632
+ try {\r
1633
+ Invoke-RestMethod -Uri "http://127.0.0.1:$port/context-update" -Method POST \`\r
1634
+ -Body $ctxPayload -ContentType "application/json" -TimeoutSec 3 | Out-Null\r
1635
+ } catch {\r
1636
+ # silent\r
1637
+ }\r
1638
+ exit 0\r
1621
1639
  `;
1622
1640
 
1623
1641
  // src/hooks/scripts/stop.sh
1624
- var stop_default2 = `#!/usr/bin/env bash
1625
- # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,
1626
- # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.
1627
- # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.
1628
-
1629
- set +e
1630
-
1631
- INPUT=$(cat 2>/dev/null)
1632
- if [ -z "$INPUT" ]; then exit 0; fi
1633
-
1634
- # jq is required for the parsing below \u2014 bail early (silent no-op) if it's absent.
1635
- if ! command -v jq >/dev/null 2>&1; then exit 0; fi
1636
-
1637
- # Extract transcript_path with jq, not sed. A greedy sed capture (\\(.*\\)") grabs the
1638
- # trailing JSON fields after transcript_path and yields a path that doesn't exist, so
1639
- # the -f check below always failed and totals were never POSTed to /log. (issue #1)
1640
- TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
1641
- if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi
1642
-
1643
- PORT_FILE="$PWD/.synthra-graph/mcp_port"
1644
- if [ ! -f "$PORT_FILE" ]; then exit 0; fi
1645
- PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
1646
- if [ -z "$PORT" ]; then exit 0; fi
1647
-
1648
- OFFSET_FILE="\${TRANSCRIPT}.stopoffset"
1649
- START_OFFSET=0
1650
- if [ -f "$OFFSET_FILE" ]; then
1651
- START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')
1652
- case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac
1653
- fi
1654
-
1655
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
1656
- TOTAL_LINES=\${TOTAL_LINES:-0}
1657
-
1658
- if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi
1659
-
1660
- USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\
1661
- | jq -s '
1662
- map(select(.message.usage != null) | .message)
1663
- | reduce .[] as $m (
1664
- {in:0, out:0, cc:0, cr:0, model:""};
1665
- .in += ($m.usage.input_tokens // 0)
1666
- | .out += ($m.usage.output_tokens // 0)
1667
- | .cc += ($m.usage.cache_creation_input_tokens // 0)
1668
- | .cr += ($m.usage.cache_read_input_tokens // 0)
1669
- | .model = ($m.model // .model)
1670
- )
1671
- ' 2>/dev/null)
1672
-
1673
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"
1674
-
1675
- IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')
1676
- OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')
1677
- CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')
1678
- CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')
1679
- MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')
1680
-
1681
- if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi
1682
-
1683
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1684
- --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\
1685
- '{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\
1686
- "http://127.0.0.1:$PORT/log" >/dev/null 2>&1
1687
-
1688
- # Refresh CONTEXT.md from the branch-scoped store.
1689
- curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
1690
- --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\
1691
- "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1
1692
-
1693
- exit 0
1642
+ var stop_default2 = `#!/usr/bin/env bash\r
1643
+ # Stop hook \u2014 bash. Reads transcript JSONL, sums usage.* across new lines,\r
1644
+ # POSTs totals to /log. Uses a .stopoffset file to avoid double-counting.\r
1645
+ # Requires \`jq\` for robust JSON parsing; falls back to silent no-op if absent.\r
1646
+ \r
1647
+ set +e\r
1648
+ \r
1649
+ INPUT=$(cat 2>/dev/null)\r
1650
+ if [ -z "$INPUT" ]; then exit 0; fi\r
1651
+ \r
1652
+ # jq is required for the parsing below \u2014 bail early (silent no-op) if it's absent.\r
1653
+ if ! command -v jq >/dev/null 2>&1; then exit 0; fi\r
1654
+ \r
1655
+ # Extract transcript_path with jq, not sed. A greedy sed capture (\\(.*\\)") grabs the\r
1656
+ # trailing JSON fields after transcript_path and yields a path that doesn't exist, so\r
1657
+ # the -f check below always failed and totals were never POSTed to /log. (issue #1)\r
1658
+ TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)\r
1659
+ if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then exit 0; fi\r
1660
+ \r
1661
+ PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
1662
+ if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
1663
+ PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
1664
+ if [ -z "$PORT" ]; then exit 0; fi\r
1665
+ \r
1666
+ OFFSET_FILE="\${TRANSCRIPT}.stopoffset"\r
1667
+ START_OFFSET=0\r
1668
+ if [ -f "$OFFSET_FILE" ]; then\r
1669
+ START_OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null | tr -d '[:space:]')\r
1670
+ case "$START_OFFSET" in ''|*[!0-9]*) START_OFFSET=0 ;; esac\r
1671
+ fi\r
1672
+ \r
1673
+ TOTAL_LINES=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')\r
1674
+ TOTAL_LINES=\${TOTAL_LINES:-0}\r
1675
+ \r
1676
+ if [ "$TOTAL_LINES" -le "$START_OFFSET" ]; then exit 0; fi\r
1677
+ \r
1678
+ USAGE=$(tail -n +$((START_OFFSET + 1)) "$TRANSCRIPT" 2>/dev/null \\\r
1679
+ | jq -s '\r
1680
+ map(select(.message.usage != null) | .message)\r
1681
+ | reduce .[] as $m (\r
1682
+ {in:0, out:0, cc:0, cr:0, model:""};\r
1683
+ .in += ($m.usage.input_tokens // 0)\r
1684
+ | .out += ($m.usage.output_tokens // 0)\r
1685
+ | .cc += ($m.usage.cache_creation_input_tokens // 0)\r
1686
+ | .cr += ($m.usage.cache_read_input_tokens // 0)\r
1687
+ | .model = ($m.model // .model)\r
1688
+ )\r
1689
+ ' 2>/dev/null)\r
1690
+ \r
1691
+ printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE"\r
1692
+ \r
1693
+ IN=$(printf '%s' "$USAGE" | jq -r '.in // 0')\r
1694
+ OUT=$(printf '%s' "$USAGE" | jq -r '.out // 0')\r
1695
+ CC=$(printf '%s' "$USAGE" | jq -r '.cc // 0')\r
1696
+ CR=$(printf '%s' "$USAGE" | jq -r '.cr // 0')\r
1697
+ MODEL=$(printf '%s' "$USAGE" | jq -r '.model // ""')\r
1698
+ \r
1699
+ if [ "$IN" = "0" ] && [ "$OUT" = "0" ]; then exit 0; fi\r
1700
+ \r
1701
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1702
+ --data "$(jq -nc --argjson i "$IN" --argjson o "$OUT" --argjson cc "$CC" --argjson cr "$CR" --arg m "$MODEL" --arg p "$PWD" \\\r
1703
+ '{input_tokens:$i, output_tokens:$o, cache_creation_input_tokens:$cc, cache_read_input_tokens:$cr, model:$m, description:"synthra-stop-hook", project:$p}')" \\\r
1704
+ "http://127.0.0.1:$PORT/log" >/dev/null 2>&1\r
1705
+ \r
1706
+ # Refresh CONTEXT.md from the branch-scoped store.\r
1707
+ curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
1708
+ --data "$(jq -nc --arg t "$TRANSCRIPT" '{transcript_path:$t}')" \\\r
1709
+ "http://127.0.0.1:$PORT/context-update" >/dev/null 2>&1\r
1710
+ \r
1711
+ exit 0\r
1694
1712
  `;
1695
1713
 
1696
1714
  // src/hooks/installer.ts
1697
1715
  var SCRIPTS = [
1698
1716
  { event: "SessionStart", baseName: "synthra-prime", ps1: prime_default, sh: prime_default2 },
1699
- { event: "PreToolUse", matcher: "Grep|Glob", baseName: "synthra-pre-tool-use", ps1: pre_tool_use_default, sh: pre_tool_use_default2 },
1717
+ {
1718
+ event: "PreToolUse",
1719
+ matcher: "Grep|Glob",
1720
+ baseName: "synthra-pre-tool-use",
1721
+ ps1: pre_tool_use_default,
1722
+ sh: pre_tool_use_default2
1723
+ },
1700
1724
  { event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
1701
1725
  { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
1702
1726
  ];
@@ -1773,7 +1797,7 @@ async function installHooks(paths) {
1773
1797
  // src/server/http.ts
1774
1798
  import { serve as serve2 } from "@hono/node-server";
1775
1799
  import { Hono as Hono2 } from "hono";
1776
- import { writeFile as writeFile8 } from "fs/promises";
1800
+ import { writeFile as writeFile10 } from "fs/promises";
1777
1801
 
1778
1802
  // src/activity/activity-log.ts
1779
1803
  import { appendFile, mkdir as mkdir3 } from "fs/promises";
@@ -2234,7 +2258,20 @@ function extractKeywords(content, _ext) {
2234
2258
  }
2235
2259
 
2236
2260
  // src/scanner/extract.ts
2237
- var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue", ".dart", ".html", ".hubl"];
2261
+ var RESOLVE_EXTS = [
2262
+ ".ts",
2263
+ ".tsx",
2264
+ ".js",
2265
+ ".jsx",
2266
+ ".mjs",
2267
+ ".cjs",
2268
+ ".py",
2269
+ ".svelte",
2270
+ ".vue",
2271
+ ".dart",
2272
+ ".html",
2273
+ ".hubl"
2274
+ ];
2238
2275
  var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py"];
2239
2276
  function fileId(relPath) {
2240
2277
  return `file:${relPath}`;
@@ -3333,7 +3370,7 @@ import { basename as basename4 } from "path";
3333
3370
  // src/hooks/claude-md.ts
3334
3371
  import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
3335
3372
  import { basename as basename3, dirname as dirname6 } from "path";
3336
- var POLICY_VERSION = 5;
3373
+ var POLICY_VERSION = 6;
3337
3374
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3338
3375
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3339
3376
  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;
@@ -3424,6 +3461,17 @@ function policyBlock() {
3424
3461
  "- Don't call `graph_continue` more than once per turn.",
3425
3462
  "- Don't read whole files when a symbol-level read would suffice.",
3426
3463
  "",
3464
+ "### Resuming a session",
3465
+ "",
3466
+ 'At session start the primer may begin with a **"Since you were last here"**',
3467
+ "digest \u2014 recent commits, files touched, open next-steps, and recent",
3468
+ "decisions carried over from the previous session. **Trust it.** It is the",
3469
+ "cheapest possible orientation: do NOT re-run `graph_continue` or Grep just",
3470
+ 'to rediscover "what were we doing / what changed" \u2014 that work is already',
3471
+ 'done. For the concrete next steps, `context_recall({kind:"next"})` returns',
3472
+ "them verbatim. Only reach for fresh retrieval when the task moves beyond",
3473
+ "what the digest covers.",
3474
+ "",
3427
3475
  "### Session-end resume note",
3428
3476
  "",
3429
3477
  `When the user signals they're done (e.g. "bye", "wrap up", "done"),`,
@@ -3602,7 +3650,9 @@ async function scanProject(projectRootRaw, opts = {}) {
3602
3650
  if (boot.gitignoreUpdated) log.info(" updated .gitignore");
3603
3651
  if (boot.claudeMdCreated) {
3604
3652
  log.info(" created CLAUDE.md \u2014 onboarding skeleton for the agent");
3605
- log.info(" \u21B3 fill in Build / Conventions / Decisions (or run /init in Claude to auto-draft)");
3653
+ log.info(
3654
+ " \u21B3 fill in Build / Conventions / Decisions (or run /init in Claude to auto-draft)"
3655
+ );
3606
3656
  } else if (boot.claudeMdUpdated) {
3607
3657
  log.info(" updated CLAUDE.md");
3608
3658
  }
@@ -3650,11 +3700,191 @@ async function scanCommand(rawPath) {
3650
3700
  return scanProject(rawPath);
3651
3701
  }
3652
3702
 
3703
+ // src/learn/store.ts
3704
+ import { appendFile as appendFile2, mkdir as mkdir6, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3705
+ import { dirname as dirname7 } from "path";
3706
+
3707
+ // src/learn/usage.ts
3708
+ var LEARN_SCHEMA_VERSION = 1;
3709
+ var DAY_MS = 24 * 60 * 60 * 1e3;
3710
+ function halfLifeMs() {
3711
+ const env = Number(process.env.SYN_LEARN_HALFLIFE_DAYS);
3712
+ const days = Number.isFinite(env) && env > 0 ? env : 7;
3713
+ return days * DAY_MS;
3714
+ }
3715
+ function weightFor(source) {
3716
+ switch (source) {
3717
+ case "register_edit":
3718
+ return 2;
3719
+ case "read":
3720
+ return 1;
3721
+ default:
3722
+ return 0;
3723
+ }
3724
+ }
3725
+ function emptyStore() {
3726
+ return {
3727
+ schema_version: LEARN_SCHEMA_VERSION,
3728
+ asOf: (/* @__PURE__ */ new Date(0)).toISOString(),
3729
+ files: {}
3730
+ };
3731
+ }
3732
+ function decayFactor(fromTs, toMs, hl) {
3733
+ const fromMs = Date.parse(fromTs);
3734
+ if (!Number.isFinite(fromMs)) return 1;
3735
+ const dt = toMs - fromMs;
3736
+ if (dt <= 0) return 1;
3737
+ return Math.exp(-(Math.LN2 / hl) * dt);
3738
+ }
3739
+ function foldEvent(store, ev) {
3740
+ const w = weightFor(ev.source);
3741
+ if (w <= 0 || !ev.path) return store;
3742
+ const tMs = Date.parse(ev.ts);
3743
+ if (!Number.isFinite(tMs)) return store;
3744
+ const hl = halfLifeMs();
3745
+ const prev = store.files[ev.path];
3746
+ if (prev) {
3747
+ const decayed = prev.decayed * decayFactor(prev.lastTs, tMs, hl) + w;
3748
+ store.files[ev.path] = { count: prev.count + 1, decayed, lastTs: ev.ts };
3749
+ } else {
3750
+ store.files[ev.path] = { count: 1, decayed: w, lastTs: ev.ts };
3751
+ }
3752
+ return store;
3753
+ }
3754
+ function effectiveScores(store, nowMs) {
3755
+ const hl = halfLifeMs();
3756
+ const out = /* @__PURE__ */ new Map();
3757
+ for (const [path, stat6] of Object.entries(store.files)) {
3758
+ const eff = stat6.decayed * decayFactor(stat6.lastTs, nowMs, hl);
3759
+ if (eff > 0.01) out.set(path, eff);
3760
+ }
3761
+ return out;
3762
+ }
3763
+ function recomputeFromLog(events) {
3764
+ const store = emptyStore();
3765
+ for (const ev of events) foldEvent(store, ev);
3766
+ return store;
3767
+ }
3768
+
3769
+ // src/learn/store.ts
3770
+ async function readLearnStore(path) {
3771
+ try {
3772
+ const raw = await readFile11(path, "utf8");
3773
+ const parsed = JSON.parse(raw);
3774
+ if (parsed.schema_version !== LEARN_SCHEMA_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
3775
+ return emptyStore();
3776
+ }
3777
+ return {
3778
+ schema_version: LEARN_SCHEMA_VERSION,
3779
+ asOf: typeof parsed.asOf === "string" ? parsed.asOf : emptyStore().asOf,
3780
+ files: parsed.files
3781
+ };
3782
+ } catch {
3783
+ return emptyStore();
3784
+ }
3785
+ }
3786
+ async function writeLearnStore(path, store) {
3787
+ try {
3788
+ await mkdir6(dirname7(path), { recursive: true });
3789
+ await writeFile6(path, JSON.stringify(store, null, 2) + "\n", "utf8");
3790
+ } catch {
3791
+ }
3792
+ }
3793
+ async function readAccessLog(path) {
3794
+ try {
3795
+ const raw = await readFile11(path, "utf8");
3796
+ const out = [];
3797
+ for (const line of raw.split("\n")) {
3798
+ const t = line.trim();
3799
+ if (!t) continue;
3800
+ try {
3801
+ const ev = JSON.parse(t);
3802
+ if (ev && typeof ev.ts === "string" && typeof ev.path === "string" && typeof ev.source === "string") {
3803
+ out.push(ev);
3804
+ }
3805
+ } catch {
3806
+ }
3807
+ }
3808
+ return out;
3809
+ } catch {
3810
+ return [];
3811
+ }
3812
+ }
3813
+ async function appendAccess(path, ev) {
3814
+ try {
3815
+ await mkdir6(dirname7(path), { recursive: true });
3816
+ await appendFile2(path, JSON.stringify(ev) + "\n", "utf8");
3817
+ } catch {
3818
+ }
3819
+ }
3820
+
3821
+ // src/learn/runtime.ts
3822
+ var PERSIST_DEBOUNCE_MS = 2e3;
3823
+ var LearnRuntime = class _LearnRuntime {
3824
+ constructor(accessLogPath, storePath, store) {
3825
+ this.accessLogPath = accessLogPath;
3826
+ this.storePath = storePath;
3827
+ this.store = store;
3828
+ }
3829
+ accessLogPath;
3830
+ storePath;
3831
+ store;
3832
+ dirty = false;
3833
+ timer = null;
3834
+ /** Load the aggregate from disk; if it's empty but a raw log exists, replay it
3835
+ * (the log is the source of truth). Always succeeds — falls back to empty. */
3836
+ static async load(accessLogPath, storePath) {
3837
+ let store = await readLearnStore(storePath);
3838
+ if (Object.keys(store.files).length === 0) {
3839
+ const events = await readAccessLog(accessLogPath);
3840
+ if (events.length > 0) store = recomputeFromLog(events);
3841
+ }
3842
+ return new _LearnRuntime(accessLogPath, storePath, store);
3843
+ }
3844
+ /** Record an access: append to the durable log + fold into the in-memory
3845
+ * aggregate. Best-effort — never throws into a tool call. */
3846
+ async record(ev) {
3847
+ await appendAccess(this.accessLogPath, ev);
3848
+ foldEvent(this.store, ev);
3849
+ this.schedulePersist();
3850
+ }
3851
+ /** Decayed path→weight map for the ranker, as of now. */
3852
+ effectiveScores(nowMs = Date.now()) {
3853
+ return effectiveScores(this.store, nowMs);
3854
+ }
3855
+ schedulePersist() {
3856
+ this.dirty = true;
3857
+ if (this.timer) return;
3858
+ this.timer = setTimeout(() => {
3859
+ this.timer = null;
3860
+ void this.flush();
3861
+ }, PERSIST_DEBOUNCE_MS);
3862
+ this.timer.unref?.();
3863
+ }
3864
+ /** Persist the aggregate if it changed since the last write. Called on the
3865
+ * debounce and on server shutdown. */
3866
+ async flush() {
3867
+ if (this.timer) {
3868
+ clearTimeout(this.timer);
3869
+ this.timer = null;
3870
+ }
3871
+ if (!this.dirty) return;
3872
+ this.dirty = false;
3873
+ this.store.asOf = (/* @__PURE__ */ new Date()).toISOString();
3874
+ await writeLearnStore(this.storePath, this.store);
3875
+ }
3876
+ };
3877
+
3653
3878
  // src/server/mcp.ts
3654
- import { appendFile as appendFile2, mkdir as mkdir8 } from "fs/promises";
3655
- import { dirname as dirname9 } from "path";
3879
+ import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
3880
+ import { dirname as dirname10 } from "path";
3656
3881
 
3657
3882
  // src/graph/rank.ts
3883
+ var USAGE_BOOST_CAP_DEFAULT = 4;
3884
+ function usageBoostCap() {
3885
+ const env = Number(process.env.SYN_LEARN_BOOST_CAP);
3886
+ return Number.isFinite(env) && env >= 0 ? env : USAGE_BOOST_CAP_DEFAULT;
3887
+ }
3658
3888
  var STOPWORDS2 = /* @__PURE__ */ new Set([
3659
3889
  "a",
3660
3890
  "an",
@@ -3801,6 +4031,21 @@ function scoreFiles(inputs) {
3801
4031
  }
3802
4032
  }
3803
4033
  }
4034
+ const usage = inputs.usageScores;
4035
+ if (usage && usage.size > 0) {
4036
+ let maxU = 0;
4037
+ for (const v of usage.values()) if (v > maxU) maxU = v;
4038
+ if (maxU > 0) {
4039
+ const cap = usageBoostCap();
4040
+ for (const s of scored) {
4041
+ if (s.score <= 0) continue;
4042
+ const u = usage.get(s.file.path) ?? 0;
4043
+ if (u <= 0) continue;
4044
+ s.score += cap * (u / maxU);
4045
+ s.reasons.push(`used\xD7${Math.round(u)}`);
4046
+ }
4047
+ }
4048
+ }
3804
4049
  scored.sort((a, b) => b.score - a.score);
3805
4050
  return scored;
3806
4051
  }
@@ -3809,9 +4054,7 @@ function scoreFiles(inputs) {
3809
4054
  async function retrieve(graph, query, options = {}) {
3810
4055
  const topK = options.topK ?? 12;
3811
4056
  const qTokens = tokenizeQuery(query);
3812
- const allFiles = graph.nodes.filter(
3813
- (n) => n.kind === "file"
3814
- );
4057
+ const allFiles = graph.nodes.filter((n) => n.kind === "file");
3815
4058
  if (allFiles.length === 0 || qTokens.length === 0) {
3816
4059
  return {
3817
4060
  files: [],
@@ -3825,7 +4068,8 @@ async function retrieve(graph, query, options = {}) {
3825
4068
  query,
3826
4069
  graph,
3827
4070
  recentlyEditedPaths: options.recentlyEditedPaths,
3828
- sessionKnownPaths: options.sessionKnownPaths
4071
+ sessionKnownPaths: options.sessionKnownPaths,
4072
+ usageScores: options.usageScores
3829
4073
  };
3830
4074
  const scored = scoreFiles(rankInputs);
3831
4075
  const positive = scored.filter((s) => s.score > 0);
@@ -3858,14 +4102,14 @@ async function retrieve(graph, query, options = {}) {
3858
4102
 
3859
4103
  // src/memory/branches.ts
3860
4104
  import { execFile as execFile2 } from "child_process";
3861
- import { readFile as readFile11 } from "fs/promises";
4105
+ import { readFile as readFile12 } from "fs/promises";
3862
4106
  import { join as join8 } from "path";
3863
4107
  import { promisify as promisify2 } from "util";
3864
4108
  var execFileAsync2 = promisify2(execFile2);
3865
4109
  async function currentBranch(projectRoot) {
3866
4110
  try {
3867
4111
  const headPath = join8(projectRoot, ".git", "HEAD");
3868
- const head = await readFile11(headPath, "utf8");
4112
+ const head = await readFile12(headPath, "utf8");
3869
4113
  const trimmed = head.trim();
3870
4114
  const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
3871
4115
  if (match?.[1]) return match[1];
@@ -3915,8 +4159,8 @@ function resolveBranchPaths(contextDir, branch, isDefault) {
3915
4159
  }
3916
4160
 
3917
4161
  // src/memory/context-md.ts
3918
- import { mkdir as mkdir6, readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
3919
- import { dirname as dirname7 } from "path";
4162
+ import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
4163
+ import { dirname as dirname8 } from "path";
3920
4164
  var MAX_BULLETS = 3;
3921
4165
  function deriveContextMd(entries, branch) {
3922
4166
  const tasks = entries.filter((e) => e.type === "task").reverse();
@@ -3959,17 +4203,17 @@ function formatContextMd(ctx) {
3959
4203
  return lines.join("\n");
3960
4204
  }
3961
4205
  async function writeContextMd(path, ctx) {
3962
- await mkdir6(dirname7(path), { recursive: true });
3963
- await writeFile6(path, formatContextMd(ctx), "utf8");
4206
+ await mkdir7(dirname8(path), { recursive: true });
4207
+ await writeFile7(path, formatContextMd(ctx), "utf8");
3964
4208
  }
3965
4209
 
3966
4210
  // src/memory/context-store.ts
3967
- import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
3968
- import { dirname as dirname8 } from "path";
4211
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
4212
+ import { dirname as dirname9 } from "path";
3969
4213
  var SCHEMA_VERSION3 = 1;
3970
4214
  async function readEntries(path) {
3971
4215
  try {
3972
- const raw = await readFile13(path, "utf8");
4216
+ const raw = await readFile14(path, "utf8");
3973
4217
  const parsed = JSON.parse(raw);
3974
4218
  return Array.isArray(parsed.entries) ? parsed.entries : [];
3975
4219
  } catch {
@@ -3977,9 +4221,9 @@ async function readEntries(path) {
3977
4221
  }
3978
4222
  }
3979
4223
  async function writeEntries(path, entries) {
3980
- await mkdir7(dirname8(path), { recursive: true });
4224
+ await mkdir8(dirname9(path), { recursive: true });
3981
4225
  const store = { schema_version: SCHEMA_VERSION3, entries };
3982
- await writeFile7(path, JSON.stringify(store, null, 2) + "\n", "utf8");
4226
+ await writeFile8(path, JSON.stringify(store, null, 2) + "\n", "utf8");
3983
4227
  }
3984
4228
  async function appendEntry(path, entry) {
3985
4229
  const entries = await readEntries(path);
@@ -4253,7 +4497,10 @@ var TOOLS = [
4253
4497
  inputSchema: {
4254
4498
  type: "object",
4255
4499
  properties: {
4256
- query: { type: "string", description: "Natural-language description of what you're looking for." }
4500
+ query: {
4501
+ type: "string",
4502
+ description: "Natural-language description of what you're looking for."
4503
+ }
4257
4504
  },
4258
4505
  required: ["query"]
4259
4506
  }
@@ -4409,9 +4656,7 @@ function blastRadius(args, ctx) {
4409
4656
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
4410
4657
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
4411
4658
  const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
4412
- const root = ctx.graph.nodes.find(
4413
- (n) => n.kind === "file" && n.path === filePath
4414
- );
4659
+ const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
4415
4660
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4416
4661
  const incoming = /* @__PURE__ */ new Map();
4417
4662
  for (const e of ctx.graph.edges) {
@@ -4458,8 +4703,8 @@ var LIKELY_ENTRY_PATTERNS = [
4458
4703
  /(?:^|\/)index\.[a-z0-9_]+$/i,
4459
4704
  /(?:^|\/)app\.[a-z0-9_]+$/i,
4460
4705
  /(?:^|\/)entry\.[a-z0-9_]+$/i,
4461
- /(?:^|\/)cli[\/.]/i,
4462
- /(?:^|\/)bin[\/.]/i,
4706
+ /(?:^|\/)cli[/.]/i,
4707
+ /(?:^|\/)bin[/.]/i,
4463
4708
  /(?:^|\/)server\.[a-z0-9_]+$/i,
4464
4709
  /\.test\.[a-z0-9_]+$/i,
4465
4710
  /\.spec\.[a-z0-9_]+$/i,
@@ -4505,9 +4750,11 @@ async function graphContinue(args, ctx) {
4505
4750
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
4506
4751
  const retrieval = await retrieve(ctx.graph, query, {
4507
4752
  recentlyEditedPaths: ctx.activity.recentFilePaths(15 * 60 * 1e3),
4508
- sessionKnownPaths: getRegisteredEdits()
4753
+ sessionKnownPaths: getRegisteredEdits(),
4754
+ usageScores: ctx.learn?.effectiveScores()
4509
4755
  });
4510
4756
  const packed = await pack(retrieval.files, { query, graph: ctx.graph });
4757
+ await logAccess(ctx, { ts: nowIso(), path: "", source: "continue", query });
4511
4758
  const header = `Confidence: ${retrieval.confidence}
4512
4759
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
4513
4760
  Reason: ${retrieval.reason}
@@ -4525,7 +4772,7 @@ function resolveFileTarget(graph, filePath) {
4525
4772
  if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
4526
4773
  return { none: true };
4527
4774
  }
4528
- function graphRead(args, ctx) {
4775
+ async function graphRead(args, ctx) {
4529
4776
  const target = typeof args?.target === "string" ? args.target : "";
4530
4777
  if (!target) return errorContent("graph_read: 'target' (string) is required");
4531
4778
  const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
@@ -4542,6 +4789,7 @@ function graphRead(args, ctx) {
4542
4789
  return errorContent(`graph_read: file not found in graph: ${filePath}`);
4543
4790
  }
4544
4791
  const fileNode = resolved.node;
4792
+ await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
4545
4793
  if (!symbolName) {
4546
4794
  return textContent(`# ${fileNode.path}
4547
4795
 
@@ -4563,10 +4811,21 @@ ${body}`
4563
4811
  );
4564
4812
  }
4565
4813
  var editedFiles = /* @__PURE__ */ new Set();
4566
- function graphRegisterEdit(args, _ctx) {
4814
+ async function graphRegisterEdit(args, ctx) {
4567
4815
  const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
4568
- for (const f of files) editedFiles.add(f);
4569
- return textContent(`Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`);
4816
+ for (const f of files) {
4817
+ const file = f;
4818
+ editedFiles.add(file);
4819
+ const resolved = resolveFileTarget(ctx.graph, file);
4820
+ await logAccess(ctx, {
4821
+ ts: nowIso(),
4822
+ path: "node" in resolved ? resolved.node.path : file,
4823
+ source: "register_edit"
4824
+ });
4825
+ }
4826
+ return textContent(
4827
+ `Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`
4828
+ );
4570
4829
  }
4571
4830
  function getRegisteredEdits() {
4572
4831
  return Array.from(editedFiles);
@@ -4602,9 +4861,7 @@ function recentActivity(args, ctx) {
4602
4861
  let events = ctx.activity.getEvents(sinceMs);
4603
4862
  if (limit) events = events.slice(-limit);
4604
4863
  if (events.length === 0) {
4605
- return textContent(
4606
- `No human-activity events since ${new Date(sinceMs).toISOString()}.`
4607
- );
4864
+ return textContent(`No human-activity events since ${new Date(sinceMs).toISOString()}.`);
4608
4865
  }
4609
4866
  const lines = [`# Recent human activity (${events.length} events)`, ""];
4610
4867
  for (const e of events) {
@@ -4636,8 +4893,8 @@ async function contextRecall(args, ctx) {
4636
4893
  }
4637
4894
  async function logToolCall(ctx, tool) {
4638
4895
  try {
4639
- await mkdir8(dirname9(ctx.paths.toolLog), { recursive: true });
4640
- await appendFile2(
4896
+ await mkdir9(dirname10(ctx.paths.toolLog), { recursive: true });
4897
+ await appendFile3(
4641
4898
  ctx.paths.toolLog,
4642
4899
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
4643
4900
  "utf8"
@@ -4645,6 +4902,16 @@ async function logToolCall(ctx, tool) {
4645
4902
  } catch {
4646
4903
  }
4647
4904
  }
4905
+ async function logAccess(ctx, ev) {
4906
+ try {
4907
+ if (ctx.learn) await ctx.learn.record(ev);
4908
+ else await appendAccess(ctx.paths.accessLog, ev);
4909
+ } catch {
4910
+ }
4911
+ }
4912
+ function nowIso() {
4913
+ return (/* @__PURE__ */ new Date()).toISOString();
4914
+ }
4648
4915
  async function handleMcpRequest(body, ctx) {
4649
4916
  if (!body || typeof body !== "object") {
4650
4917
  return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
@@ -4695,9 +4962,87 @@ async function handleActivity(sinceMs, ctx) {
4695
4962
  };
4696
4963
  }
4697
4964
 
4965
+ // src/memory/git-snapshot.ts
4966
+ import { execFile as execFile3 } from "child_process";
4967
+ import { promisify as promisify3 } from "util";
4968
+ var execFileAsync3 = promisify3(execFile3);
4969
+ var MAX_COMMITS = 5;
4970
+ var FIELD = "";
4971
+ async function getCommitsSince(projectRoot, sinceIso) {
4972
+ const args = [
4973
+ "log",
4974
+ `--max-count=${MAX_COMMITS}`,
4975
+ "--no-merges",
4976
+ `--pretty=format:%h${FIELD}%s${FIELD}%aI`
4977
+ ];
4978
+ if (Number.isFinite(Date.parse(sinceIso))) args.push(`--since=${sinceIso}`);
4979
+ try {
4980
+ const { stdout } = await execFileAsync3("git", args, { cwd: projectRoot });
4981
+ const out = [];
4982
+ for (const line of stdout.split("\n")) {
4983
+ const t = line.trim();
4984
+ if (!t) continue;
4985
+ const [hash, message, date] = t.split(FIELD);
4986
+ if (hash && message) out.push({ hash, message, date: date ?? "" });
4987
+ }
4988
+ return out;
4989
+ } catch {
4990
+ return [];
4991
+ }
4992
+ }
4993
+
4994
+ // src/memory/session.ts
4995
+ import { mkdir as mkdir10, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
4996
+ import { dirname as dirname11 } from "path";
4997
+ var SESSION_SCHEMA_VERSION = 1;
4998
+ async function readSession(path) {
4999
+ try {
5000
+ const raw = await readFile15(path, "utf8");
5001
+ const parsed = JSON.parse(raw);
5002
+ if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
5003
+ return parsed;
5004
+ } catch {
5005
+ return null;
5006
+ }
5007
+ }
5008
+ async function writeSession(path, state) {
5009
+ await mkdir10(dirname11(path), { recursive: true });
5010
+ await writeFile9(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5011
+ }
5012
+
4698
5013
  // src/server/routes/context-update.ts
5014
+ var TOUCHED_WINDOW_MS = 24 * 60 * 60 * 1e3;
5015
+ async function captureSnapshot(ctx, branchOverride) {
5016
+ const active = await resolveActiveBranch(ctx.paths, branchOverride);
5017
+ const [tasks, decisions, next] = await Promise.all([
5018
+ recallEntries(ctx.paths, { kind: "task", branch: active.branch, limit: 1 }),
5019
+ recallEntries(ctx.paths, { kind: "decision", branch: active.branch, limit: 3 }),
5020
+ recallEntries(ctx.paths, { kind: "next", branch: active.branch, limit: 3 })
5021
+ ]);
5022
+ const touched = new Set(getRegisteredEdits());
5023
+ for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
5024
+ const prev = await readSession(ctx.paths.sessionState);
5025
+ const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
5026
+ const snapshot = {
5027
+ schema_version: SESSION_SCHEMA_VERSION,
5028
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
5029
+ branch: active.branch,
5030
+ filesTouched: Array.from(touched),
5031
+ recentCommits,
5032
+ summary: {
5033
+ tasks: tasks.entries.map((e) => e.content),
5034
+ decisions: decisions.entries.map((e) => e.content),
5035
+ next: next.entries.map((e) => e.content)
5036
+ }
5037
+ };
5038
+ await writeSession(ctx.paths.sessionState, snapshot);
5039
+ }
4699
5040
  async function handleContextUpdate(req, ctx) {
4700
5041
  const r = await refreshContextMd(ctx.paths, req?.branch);
5042
+ try {
5043
+ await captureSnapshot(ctx, req?.branch);
5044
+ } catch {
5045
+ }
4701
5046
  return {
4702
5047
  updated: true,
4703
5048
  branch: r.branch,
@@ -4707,8 +5052,8 @@ async function handleContextUpdate(req, ctx) {
4707
5052
  }
4708
5053
 
4709
5054
  // src/server/routes/gate.ts
4710
- import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
4711
- import { dirname as dirname10 } from "path";
5055
+ import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
5056
+ import { dirname as dirname12 } from "path";
4712
5057
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
4713
5058
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
4714
5059
  function extractQuery(toolName, input) {
@@ -4764,7 +5109,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
4764
5109
  }
4765
5110
  async function logDecision(ctx, toolName, query, decision, reason) {
4766
5111
  try {
4767
- await mkdir9(dirname10(ctx.paths.gateLog), { recursive: true });
5112
+ await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
4768
5113
  const entry = {
4769
5114
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4770
5115
  tool: toolName,
@@ -4772,7 +5117,7 @@ async function logDecision(ctx, toolName, query, decision, reason) {
4772
5117
  query,
4773
5118
  reason
4774
5119
  };
4775
- await appendFile3(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5120
+ await appendFile4(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4776
5121
  } catch {
4777
5122
  }
4778
5123
  }
@@ -4836,16 +5181,16 @@ async function handleGate(req, ctx) {
4836
5181
  }
4837
5182
 
4838
5183
  // src/server/routes/log.ts
4839
- import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
4840
- import { dirname as dirname11 } from "path";
5184
+ import { appendFile as appendFile5, mkdir as mkdir12 } from "fs/promises";
5185
+ import { dirname as dirname13 } from "path";
4841
5186
  async function handleLog(entry, ctx) {
4842
5187
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
4843
5188
  throw new Error("log: input_tokens and output_tokens (number) are required");
4844
5189
  }
4845
5190
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
4846
5191
  const record = { ...entry, written_at };
4847
- await mkdir10(dirname11(ctx.paths.tokenLog), { recursive: true });
4848
- await appendFile4(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
5192
+ await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
5193
+ await appendFile5(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
4849
5194
  return { ok: true, written_at };
4850
5195
  }
4851
5196
 
@@ -4855,13 +5200,15 @@ async function handlePack(req, ctx) {
4855
5200
  throw new Error("pack: 'query' (string) is required");
4856
5201
  }
4857
5202
  const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
4858
- const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
5203
+ const usageScores = ctx.learn?.effectiveScores();
5204
+ const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths, usageScores });
4859
5205
  const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
4860
5206
  const scored = scoreFiles({
4861
5207
  candidates: allFiles,
4862
5208
  query: req.query,
4863
5209
  graph: ctx.graph,
4864
- recentlyEditedPaths
5210
+ recentlyEditedPaths,
5211
+ usageScores
4865
5212
  });
4866
5213
  const reasons = /* @__PURE__ */ new Map();
4867
5214
  for (const s of scored) {
@@ -4883,14 +5230,74 @@ async function handlePack(req, ctx) {
4883
5230
  }
4884
5231
 
4885
5232
  // src/server/routes/prime.ts
4886
- async function handlePrime(ctx, port) {
5233
+ var RESUME_PRIMER_MAX_CHARS = 2720;
5234
+ var MAX_FILES = 15;
5235
+ var MAX_COMMITS2 = 5;
5236
+ var MAX_BULLETS2 = 3;
5237
+ function legacyPrimer(ctx) {
4887
5238
  const g = ctx.graph;
4888
- const fileCount = g.file_count;
4889
- const symbolCount = g.symbol_count;
4890
- const primer = `Synthra context loaded for ${g.root}.
4891
- ${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.
4892
- (Full primer wired in M3.)`;
4893
- return { primer, port };
5239
+ return `Synthra context loaded for ${g.root}.
5240
+ ${g.file_count} files indexed, ${g.symbol_count} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.`;
5241
+ }
5242
+ function hasContent(snap) {
5243
+ return Boolean(
5244
+ snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
5245
+ );
5246
+ }
5247
+ function buildResumeDigest(snap, branchNow) {
5248
+ const plural = (n) => n === 1 ? "" : "s";
5249
+ 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)`;
5250
+ const essential = [head];
5251
+ if (snap.branch !== branchNow) {
5252
+ essential.push("");
5253
+ essential.push(
5254
+ `_(snapshot was for branch '${snap.branch}'; you're now on '${branchNow}' \u2014 may be stale)_`
5255
+ );
5256
+ }
5257
+ if (snap.summary.tasks[0]) {
5258
+ essential.push("", "### In progress", `- ${snap.summary.tasks[0]}`);
5259
+ }
5260
+ if (snap.summary.next.length) {
5261
+ essential.push("", "### Open next steps");
5262
+ for (const n of snap.summary.next.slice(0, MAX_BULLETS2)) essential.push(`- ${n}`);
5263
+ }
5264
+ if (snap.summary.decisions.length) {
5265
+ essential.push("", "### Recent decisions");
5266
+ for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
5267
+ }
5268
+ const extra = [];
5269
+ if (snap.recentCommits.length) {
5270
+ extra.push("", "### Recent commits");
5271
+ for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
5272
+ const date = c.date ? ` (${c.date.slice(0, 10)})` : "";
5273
+ extra.push(`- \`${c.hash}\` ${c.message}${date}`);
5274
+ }
5275
+ }
5276
+ if (snap.filesTouched.length) {
5277
+ const shown = snap.filesTouched.slice(0, MAX_FILES);
5278
+ const more = snap.filesTouched.length - shown.length;
5279
+ extra.push("", "### Files touched", shown.join(", ") + (more > 0 ? `, +${more} more` : ""));
5280
+ }
5281
+ let out = essential.join("\n");
5282
+ for (const line of extra) {
5283
+ if ((out + "\n" + line).length > RESUME_PRIMER_MAX_CHARS) break;
5284
+ out += "\n" + line;
5285
+ }
5286
+ return (out.length > RESUME_PRIMER_MAX_CHARS ? out.slice(0, RESUME_PRIMER_MAX_CHARS) : out).trimEnd();
5287
+ }
5288
+ async function handlePrime(ctx, port) {
5289
+ const legacy = legacyPrimer(ctx);
5290
+ const snap = await readSession(ctx.paths.sessionState);
5291
+ if (!snap || !hasContent(snap)) {
5292
+ return { primer: legacy, port };
5293
+ }
5294
+ const branchNow = await currentBranch(ctx.paths.projectRoot);
5295
+ const digest = buildResumeDigest(snap, branchNow);
5296
+ return { primer: `${digest}
5297
+
5298
+ ---
5299
+
5300
+ ${legacy}`, port };
4894
5301
  }
4895
5302
 
4896
5303
  // src/server/http.ts
@@ -4901,9 +5308,7 @@ async function loadContext(paths) {
4901
5308
  readSymbolIndex(paths.symbolIndex)
4902
5309
  ]);
4903
5310
  if (graph.schema_version !== SCHEMA_VERSION2) {
4904
- log.info(
4905
- `graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION2} \u2014 rescanning\u2026`
4906
- );
5311
+ log.info(`graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION2} \u2014 rescanning\u2026`);
4907
5312
  await scanProject(paths.projectRoot, { silent: true });
4908
5313
  [graph, symbolIndex] = await Promise.all([
4909
5314
  readGraph(paths.infoGraph),
@@ -4911,7 +5316,8 @@ async function loadContext(paths) {
4911
5316
  ]);
4912
5317
  }
4913
5318
  const activity = new ActivityStore(paths.activityLog);
4914
- return { paths, graph, symbolIndex, activity };
5319
+ const learn = await LearnRuntime.load(paths.accessLog, paths.learnStore);
5320
+ return { paths, graph, symbolIndex, activity, learn };
4915
5321
  } catch (err2) {
4916
5322
  throw new Error(
4917
5323
  `failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
@@ -4948,9 +5354,7 @@ function buildApp(ctx, port) {
4948
5354
  app.get("/activity", async (c) => {
4949
5355
  const sinceParam = c.req.query("since");
4950
5356
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
4951
- return c.json(
4952
- await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
4953
- );
5357
+ return c.json(await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx));
4954
5358
  });
4955
5359
  app.post("/context-update", async (c) => {
4956
5360
  const body = await c.req.json().catch(() => ({}));
@@ -4971,11 +5375,8 @@ async function startServer(paths, options = {}) {
4971
5375
  const port = options.port ?? await findFreePort();
4972
5376
  const app = buildApp(ctx, port);
4973
5377
  const nodeServer = serve2({ fetch: app.fetch, port, hostname: "127.0.0.1" });
4974
- await writeFile8(paths.mcpPort, String(port), "utf8");
4975
- const fileWatcher = createFileWatcher(
4976
- paths.projectRoot,
4977
- (e) => ctx.activity.add(e)
4978
- );
5378
+ await writeFile10(paths.mcpPort, String(port), "utf8");
5379
+ const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
4979
5380
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
4980
5381
  await ctx.activity.add(e);
4981
5382
  if (e.kind === "branch-switch") {
@@ -5012,6 +5413,7 @@ async function startServer(paths, options = {}) {
5012
5413
  async stop() {
5013
5414
  await fileWatcher.stop().catch(() => void 0);
5014
5415
  await gitWatcher.stop().catch(() => void 0);
5416
+ await ctx.learn?.flush().catch(() => void 0);
5015
5417
  await new Promise((resolve6, reject) => {
5016
5418
  nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
5017
5419
  });
@@ -5117,7 +5519,7 @@ async function dashboardCommand(rawPath) {
5117
5519
  }
5118
5520
 
5119
5521
  // src/cli/doctor-command.ts
5120
- import { readFile as readFile14, stat as stat4 } from "fs/promises";
5522
+ import { readFile as readFile16, stat as stat4 } from "fs/promises";
5121
5523
  import { join as join10, resolve as resolve3 } from "path";
5122
5524
  import spawn from "cross-spawn";
5123
5525
  var ICON = { ok: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
@@ -5148,7 +5550,11 @@ async function runDoctorChecks(projectRoot) {
5148
5550
  const checks = [];
5149
5551
  const nodeMajor = Number(process.versions.node.split(".")[0]);
5150
5552
  checks.push(
5151
- nodeMajor >= 18 ? { status: "ok", label: "Node", detail: `v${process.versions.node}` } : { status: "fail", label: "Node", detail: `v${process.versions.node} \u2014 Synthra needs Node >= 18` }
5553
+ nodeMajor >= 18 ? { status: "ok", label: "Node", detail: `v${process.versions.node}` } : {
5554
+ status: "fail",
5555
+ label: "Node",
5556
+ detail: `v${process.versions.node} \u2014 Synthra needs Node >= 18`
5557
+ }
5152
5558
  );
5153
5559
  const hasJq = await binWorks("jq", ["--version"]);
5154
5560
  if (process.platform === "win32") {
@@ -5175,14 +5581,19 @@ async function runDoctorChecks(projectRoot) {
5175
5581
  }
5176
5582
  );
5177
5583
  if (!await exists2(paths.infoGraph)) {
5178
- checks.push({ status: "warn", label: "Graph", detail: "no info_graph.json \u2014 run `syn .` (or `syn scan`) here." });
5584
+ checks.push({
5585
+ status: "warn",
5586
+ label: "Graph",
5587
+ detail: "no info_graph.json \u2014 run `syn .` (or `syn scan`) here."
5588
+ });
5179
5589
  } else {
5180
5590
  try {
5181
- const graph = JSON.parse(await readFile14(paths.infoGraph, "utf8"));
5591
+ const graph = JSON.parse(await readFile16(paths.infoGraph, "utf8"));
5182
5592
  const parts = [`${graph.symbol_count} symbols`, `${graph.file_count} files`];
5183
5593
  let status = "ok";
5184
5594
  const ageMs = Date.now() - Date.parse(graph.generated_at);
5185
- if (Number.isFinite(ageMs)) parts.push(`scanned ${Math.max(0, Math.round(ageMs / 6e4))}m ago`);
5595
+ if (Number.isFinite(ageMs))
5596
+ parts.push(`scanned ${Math.max(0, Math.round(ageMs / 6e4))}m ago`);
5186
5597
  if (graph.schema_version !== SCHEMA_VERSION2) {
5187
5598
  status = "warn";
5188
5599
  parts.push(`schema v${graph.schema_version} \u2260 v${SCHEMA_VERSION2} (auto-rescans on serve)`);
@@ -5193,22 +5604,38 @@ async function runDoctorChecks(projectRoot) {
5193
5604
  }
5194
5605
  checks.push({ status, label: "Graph", detail: parts.join(" \xB7 ") });
5195
5606
  } catch {
5196
- checks.push({ status: "warn", label: "Graph", detail: "info_graph.json unreadable \u2014 re-run `syn scan`." });
5607
+ checks.push({
5608
+ status: "warn",
5609
+ label: "Graph",
5610
+ detail: "info_graph.json unreadable \u2014 re-run `syn scan`."
5611
+ });
5197
5612
  }
5198
5613
  }
5199
5614
  checks.push(
5200
- await exists2(join10(projectRoot, ".mcp.json")) ? { status: "ok", label: "MCP registration", detail: ".mcp.json present (IDE can see graph_* tools)" } : {
5615
+ await exists2(join10(projectRoot, ".mcp.json")) ? {
5616
+ status: "ok",
5617
+ label: "MCP registration",
5618
+ detail: ".mcp.json present (IDE can see graph_* tools)"
5619
+ } : {
5201
5620
  status: "warn",
5202
5621
  label: "MCP registration",
5203
5622
  detail: "no .mcp.json \u2014 the IDE extension won't see Synthra's tools; run `syn .`."
5204
5623
  }
5205
5624
  );
5206
5625
  if (!await exists2(paths.claudeMd)) {
5207
- checks.push({ status: "warn", label: "CLAUDE.md policy", detail: "no CLAUDE.md \u2014 run `syn .` to scaffold + inject the policy block." });
5626
+ checks.push({
5627
+ status: "warn",
5628
+ label: "CLAUDE.md policy",
5629
+ detail: "no CLAUDE.md \u2014 run `syn .` to scaffold + inject the policy block."
5630
+ });
5208
5631
  } else {
5209
- const md = await readFile14(paths.claudeMd, "utf8");
5632
+ const md = await readFile16(paths.claudeMd, "utf8");
5210
5633
  if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
5211
- checks.push({ status: "ok", label: "CLAUDE.md policy", detail: `policy block v${POLICY_VERSION}` });
5634
+ checks.push({
5635
+ status: "ok",
5636
+ label: "CLAUDE.md policy",
5637
+ detail: `policy block v${POLICY_VERSION}`
5638
+ });
5212
5639
  } else {
5213
5640
  const m = md.match(/synthra-policy v(\d+) BEGIN/);
5214
5641
  checks.push({
@@ -5219,11 +5646,19 @@ async function runDoctorChecks(projectRoot) {
5219
5646
  }
5220
5647
  }
5221
5648
  if (!await exists2(paths.claudeSettings)) {
5222
- checks.push({ status: "warn", label: "Hooks", detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks." });
5649
+ checks.push({
5650
+ status: "warn",
5651
+ label: "Hooks",
5652
+ detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks."
5653
+ });
5223
5654
  } else {
5224
- const s = await readFile14(paths.claudeSettings, "utf8");
5655
+ const s = await readFile16(paths.claudeSettings, "utf8");
5225
5656
  checks.push(
5226
- s.includes("synthra-hook=true") ? { status: "ok", label: "Hooks", detail: "registered in .claude/settings.local.json" } : { status: "warn", label: "Hooks", detail: "settings.local.json present but no Synthra hooks \u2014 run `syn .`." }
5657
+ s.includes("synthra-hook=true") ? { status: "ok", label: "Hooks", detail: "registered in .claude/settings.local.json" } : {
5658
+ status: "warn",
5659
+ label: "Hooks",
5660
+ detail: "settings.local.json present but no Synthra hooks \u2014 run `syn .`."
5661
+ }
5227
5662
  );
5228
5663
  }
5229
5664
  return checks;
@@ -5240,12 +5675,14 @@ async function doctorCommand(rawPath) {
5240
5675
  const warn = checks.filter((c) => c.status === "warn").length;
5241
5676
  const fail = checks.filter((c) => c.status === "fail").length;
5242
5677
  log.info("");
5243
- log.info(fail === 0 && warn === 0 ? " All checks passed." : ` ${fail} failed \xB7 ${warn} warning(s).`);
5678
+ log.info(
5679
+ fail === 0 && warn === 0 ? " All checks passed." : ` ${fail} failed \xB7 ${warn} warning(s).`
5680
+ );
5244
5681
  log.info("");
5245
5682
  }
5246
5683
 
5247
5684
  // src/cli/self-update.ts
5248
- import { mkdir as mkdir11, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5685
+ import { mkdir as mkdir13, readFile as readFile17, writeFile as writeFile11 } from "fs/promises";
5249
5686
  import { homedir as homedir3 } from "os";
5250
5687
  import { join as join11 } from "path";
5251
5688
  import { createInterface } from "readline/promises";
@@ -5303,7 +5740,7 @@ async function checkForUpdate() {
5303
5740
  }
5304
5741
  async function readLastSeen() {
5305
5742
  try {
5306
- const raw = await readFile15(LAST_SEEN_PATH, "utf8");
5743
+ const raw = await readFile17(LAST_SEEN_PATH, "utf8");
5307
5744
  const parsed = JSON.parse(raw);
5308
5745
  return parsed.version ?? null;
5309
5746
  } catch {
@@ -5312,9 +5749,9 @@ async function readLastSeen() {
5312
5749
  }
5313
5750
  async function writeLastSeen(version) {
5314
5751
  try {
5315
- await mkdir11(SYNTHRA_DIR, { recursive: true });
5752
+ await mkdir13(SYNTHRA_DIR, { recursive: true });
5316
5753
  const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
5317
- await writeFile9(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
5754
+ await writeFile11(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
5318
5755
  } catch {
5319
5756
  }
5320
5757
  }
@@ -5346,7 +5783,7 @@ async function readInstalledChangelog() {
5346
5783
  const root = await npmGlobalRoot();
5347
5784
  if (!root) return null;
5348
5785
  try {
5349
- return await readFile15(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
5786
+ return await readFile17(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
5350
5787
  } catch {
5351
5788
  return null;
5352
5789
  }
@@ -5478,7 +5915,9 @@ function runClaude(bin, args, cwd, stdio = "pipe") {
5478
5915
  }
5479
5916
  async function registerMcp(bin, mcpPort, cwd) {
5480
5917
  const url = `http://127.0.0.1:${mcpPort}/mcp`;
5481
- await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "project"], cwd).catch(() => void 0);
5918
+ await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "project"], cwd).catch(
5919
+ () => void 0
5920
+ );
5482
5921
  const reg = await runClaude(
5483
5922
  bin,
5484
5923
  ["mcp", "add", MCP_NAME, "--transport", "http", "--scope", "project", url],
@@ -5509,7 +5948,9 @@ async function spawnClaude(bin, opts) {
5509
5948
  var VERSION2 = package_default.version;
5510
5949
  function printReadyBanner(info) {
5511
5950
  log.info("");
5512
- log.info(` \u2705 scanned ${info.scan.parsed} files \xB7 ${info.scan.symbolCount} symbols \xB7 ${info.scan.edgeCount} edges`);
5951
+ log.info(
5952
+ ` \u2705 scanned ${info.scan.parsed} files \xB7 ${info.scan.symbolCount} symbols \xB7 ${info.scan.edgeCount} edges`
5953
+ );
5513
5954
  if (info.mcpRegistered) {
5514
5955
  log.info(` \u{1F9E0} MCP ${info.mcpUrl} \u2192 registered as 'synthra'`);
5515
5956
  } else {
@@ -5522,7 +5963,9 @@ function printReadyBanner(info) {
5522
5963
  }
5523
5964
  log.info(` \u{1FA9D} Hooks installed in .claude/settings.local.json`);
5524
5965
  log.info("");
5525
- log.info(` \u{1F916} Ready \u2014 open the Claude Code IDE extension (or run \`claude\` in another terminal).`);
5966
+ log.info(
5967
+ ` \u{1F916} Ready \u2014 open the Claude Code IDE extension (or run \`claude\` in another terminal).`
5968
+ );
5526
5969
  log.info(` Synthra's tools and gate will be active for that session.`);
5527
5970
  log.info("");
5528
5971
  log.info(` Press Ctrl+C here when you're done.`);
@@ -5579,24 +6022,22 @@ async function defaultFlow(rawPath, opts) {
5579
6022
  } finally {
5580
6023
  await unregisterMcp(cfg.claudeBin, projectRoot).catch(() => void 0);
5581
6024
  if (dashboardHandle) {
5582
- await dashboardHandle.stop().catch(
5583
- (err2) => log.warn(`dashboard stop error: ${err2.message}`)
5584
- );
6025
+ await dashboardHandle.stop().catch((err2) => log.warn(`dashboard stop error: ${err2.message}`));
5585
6026
  }
5586
- await mcpHandle.stop().catch(
5587
- (err2) => log.warn(`MCP server stop error: ${err2.message}`)
5588
- );
5589
- await cleanup(paths).catch(
5590
- (err2) => log.warn(`cleanup error: ${err2.message}`)
5591
- );
6027
+ await mcpHandle.stop().catch((err2) => log.warn(`MCP server stop error: ${err2.message}`));
6028
+ await cleanup(paths).catch((err2) => log.warn(`cleanup error: ${err2.message}`));
5592
6029
  }
5593
6030
  }
5594
6031
  function buildProgram() {
5595
6032
  const prog = sade("syn");
5596
6033
  prog.version(VERSION2).describe("Local context engine for AI coding assistants.");
5597
- prog.command(". [path]", "Scan + MCP + dashboard + hooks. Default flow \u2014 use with the Claude Code IDE extension.", {
5598
- default: true
5599
- }).option("--resume <id>", "Resume an existing Claude session (only with --launch-cli)").option("--launch-cli", "Also spawn `claude` CLI in this terminal (legacy M3 behavior)", false).action(async (path, opts) => {
6034
+ prog.command(
6035
+ ". [path]",
6036
+ "Scan + MCP + dashboard + hooks. Default flow \u2014 use with the Claude Code IDE extension.",
6037
+ {
6038
+ default: true
6039
+ }
6040
+ ).option("--resume <id>", "Resume an existing Claude session (only with --launch-cli)").option("--launch-cli", "Also spawn `claude` CLI in this terminal (legacy M3 behavior)", false).action(async (path, opts) => {
5600
6041
  await defaultFlow(path ?? ".", opts);
5601
6042
  });
5602
6043
  prog.command("scan [path]", "Scan only \u2014 walk + parse + write graph.").action(async (path) => {