@jefuriiij/synthra 0.1.25 → 0.2.1

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.1",
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,192 @@ 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 KW_BASE_WEIGHT = 2;
3884
+ var USAGE_BOOST_CAP_DEFAULT = 4;
3885
+ function usageBoostCap() {
3886
+ const env = Number(process.env.SYN_LEARN_BOOST_CAP);
3887
+ return Number.isFinite(env) && env >= 0 ? env : USAGE_BOOST_CAP_DEFAULT;
3888
+ }
3658
3889
  var STOPWORDS2 = /* @__PURE__ */ new Set([
3659
3890
  "a",
3660
3891
  "an",
@@ -3739,14 +3970,41 @@ function scoreFiles(inputs) {
3739
3970
  const importsFrom = indexImportEdges(inputs.graph);
3740
3971
  const seeds = new Set(inputs.sessionKnownPaths ?? []);
3741
3972
  for (const p of inputs.recentlyEditedPaths ?? []) seeds.add(p);
3973
+ const corpusSize = inputs.candidates.length;
3974
+ const queryDf = /* @__PURE__ */ new Map();
3975
+ for (const f of inputs.candidates) {
3976
+ for (const kw of f.keywords) {
3977
+ if (qTokens.has(kw)) queryDf.set(kw, (queryDf.get(kw) ?? 0) + 1);
3978
+ }
3979
+ }
3980
+ const idf = (token) => {
3981
+ const n = queryDf.get(token) ?? 0;
3982
+ if (n <= 0) return 0;
3983
+ return Math.log(1 + (corpusSize - n + 0.5) / (n + 0.5));
3984
+ };
3985
+ let idfSum = 0;
3986
+ let idfCount = 0;
3987
+ for (const t of qTokens) {
3988
+ const v = idf(t);
3989
+ if (v > 0) {
3990
+ idfSum += v;
3991
+ idfCount += 1;
3992
+ }
3993
+ }
3994
+ const refIdf = idfCount > 0 ? idfSum / idfCount : 1;
3742
3995
  const scored = [];
3743
3996
  for (const file of inputs.candidates) {
3744
3997
  const reasons = [];
3745
3998
  let score2 = 0;
3746
3999
  let kwHits = 0;
3747
- for (const kw of file.keywords) if (qTokens.has(kw)) kwHits += 1;
4000
+ let kwScore = 0;
4001
+ for (const kw of file.keywords) {
4002
+ if (!qTokens.has(kw)) continue;
4003
+ kwHits += 1;
4004
+ kwScore += KW_BASE_WEIGHT * (idf(kw) / refIdf);
4005
+ }
3748
4006
  if (kwHits) {
3749
- score2 += kwHits * 2;
4007
+ score2 += kwScore;
3750
4008
  reasons.push(`kw=${kwHits}`);
3751
4009
  }
3752
4010
  const symbols = symbolsByFile.get(file.path) ?? [];
@@ -3801,6 +4059,21 @@ function scoreFiles(inputs) {
3801
4059
  }
3802
4060
  }
3803
4061
  }
4062
+ const usage = inputs.usageScores;
4063
+ if (usage && usage.size > 0) {
4064
+ let maxU = 0;
4065
+ for (const v of usage.values()) if (v > maxU) maxU = v;
4066
+ if (maxU > 0) {
4067
+ const cap = usageBoostCap();
4068
+ for (const s of scored) {
4069
+ if (s.score <= 0) continue;
4070
+ const u = usage.get(s.file.path) ?? 0;
4071
+ if (u <= 0) continue;
4072
+ s.score += cap * (u / maxU);
4073
+ s.reasons.push(`used\xD7${Math.round(u)}`);
4074
+ }
4075
+ }
4076
+ }
3804
4077
  scored.sort((a, b) => b.score - a.score);
3805
4078
  return scored;
3806
4079
  }
@@ -3809,9 +4082,7 @@ function scoreFiles(inputs) {
3809
4082
  async function retrieve(graph, query, options = {}) {
3810
4083
  const topK = options.topK ?? 12;
3811
4084
  const qTokens = tokenizeQuery(query);
3812
- const allFiles = graph.nodes.filter(
3813
- (n) => n.kind === "file"
3814
- );
4085
+ const allFiles = graph.nodes.filter((n) => n.kind === "file");
3815
4086
  if (allFiles.length === 0 || qTokens.length === 0) {
3816
4087
  return {
3817
4088
  files: [],
@@ -3825,7 +4096,8 @@ async function retrieve(graph, query, options = {}) {
3825
4096
  query,
3826
4097
  graph,
3827
4098
  recentlyEditedPaths: options.recentlyEditedPaths,
3828
- sessionKnownPaths: options.sessionKnownPaths
4099
+ sessionKnownPaths: options.sessionKnownPaths,
4100
+ usageScores: options.usageScores
3829
4101
  };
3830
4102
  const scored = scoreFiles(rankInputs);
3831
4103
  const positive = scored.filter((s) => s.score > 0);
@@ -3858,14 +4130,14 @@ async function retrieve(graph, query, options = {}) {
3858
4130
 
3859
4131
  // src/memory/branches.ts
3860
4132
  import { execFile as execFile2 } from "child_process";
3861
- import { readFile as readFile11 } from "fs/promises";
4133
+ import { readFile as readFile12 } from "fs/promises";
3862
4134
  import { join as join8 } from "path";
3863
4135
  import { promisify as promisify2 } from "util";
3864
4136
  var execFileAsync2 = promisify2(execFile2);
3865
4137
  async function currentBranch(projectRoot) {
3866
4138
  try {
3867
4139
  const headPath = join8(projectRoot, ".git", "HEAD");
3868
- const head = await readFile11(headPath, "utf8");
4140
+ const head = await readFile12(headPath, "utf8");
3869
4141
  const trimmed = head.trim();
3870
4142
  const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
3871
4143
  if (match?.[1]) return match[1];
@@ -3915,8 +4187,8 @@ function resolveBranchPaths(contextDir, branch, isDefault) {
3915
4187
  }
3916
4188
 
3917
4189
  // 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";
4190
+ import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
4191
+ import { dirname as dirname8 } from "path";
3920
4192
  var MAX_BULLETS = 3;
3921
4193
  function deriveContextMd(entries, branch) {
3922
4194
  const tasks = entries.filter((e) => e.type === "task").reverse();
@@ -3959,17 +4231,17 @@ function formatContextMd(ctx) {
3959
4231
  return lines.join("\n");
3960
4232
  }
3961
4233
  async function writeContextMd(path, ctx) {
3962
- await mkdir6(dirname7(path), { recursive: true });
3963
- await writeFile6(path, formatContextMd(ctx), "utf8");
4234
+ await mkdir7(dirname8(path), { recursive: true });
4235
+ await writeFile7(path, formatContextMd(ctx), "utf8");
3964
4236
  }
3965
4237
 
3966
4238
  // 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";
4239
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "fs/promises";
4240
+ import { dirname as dirname9 } from "path";
3969
4241
  var SCHEMA_VERSION3 = 1;
3970
4242
  async function readEntries(path) {
3971
4243
  try {
3972
- const raw = await readFile13(path, "utf8");
4244
+ const raw = await readFile14(path, "utf8");
3973
4245
  const parsed = JSON.parse(raw);
3974
4246
  return Array.isArray(parsed.entries) ? parsed.entries : [];
3975
4247
  } catch {
@@ -3977,9 +4249,9 @@ async function readEntries(path) {
3977
4249
  }
3978
4250
  }
3979
4251
  async function writeEntries(path, entries) {
3980
- await mkdir7(dirname8(path), { recursive: true });
4252
+ await mkdir8(dirname9(path), { recursive: true });
3981
4253
  const store = { schema_version: SCHEMA_VERSION3, entries };
3982
- await writeFile7(path, JSON.stringify(store, null, 2) + "\n", "utf8");
4254
+ await writeFile8(path, JSON.stringify(store, null, 2) + "\n", "utf8");
3983
4255
  }
3984
4256
  async function appendEntry(path, entry) {
3985
4257
  const entries = await readEntries(path);
@@ -4253,7 +4525,10 @@ var TOOLS = [
4253
4525
  inputSchema: {
4254
4526
  type: "object",
4255
4527
  properties: {
4256
- query: { type: "string", description: "Natural-language description of what you're looking for." }
4528
+ query: {
4529
+ type: "string",
4530
+ description: "Natural-language description of what you're looking for."
4531
+ }
4257
4532
  },
4258
4533
  required: ["query"]
4259
4534
  }
@@ -4409,9 +4684,7 @@ function blastRadius(args, ctx) {
4409
4684
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
4410
4685
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
4411
4686
  const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
4412
- const root = ctx.graph.nodes.find(
4413
- (n) => n.kind === "file" && n.path === filePath
4414
- );
4687
+ const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
4415
4688
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
4416
4689
  const incoming = /* @__PURE__ */ new Map();
4417
4690
  for (const e of ctx.graph.edges) {
@@ -4458,8 +4731,8 @@ var LIKELY_ENTRY_PATTERNS = [
4458
4731
  /(?:^|\/)index\.[a-z0-9_]+$/i,
4459
4732
  /(?:^|\/)app\.[a-z0-9_]+$/i,
4460
4733
  /(?:^|\/)entry\.[a-z0-9_]+$/i,
4461
- /(?:^|\/)cli[\/.]/i,
4462
- /(?:^|\/)bin[\/.]/i,
4734
+ /(?:^|\/)cli[/.]/i,
4735
+ /(?:^|\/)bin[/.]/i,
4463
4736
  /(?:^|\/)server\.[a-z0-9_]+$/i,
4464
4737
  /\.test\.[a-z0-9_]+$/i,
4465
4738
  /\.spec\.[a-z0-9_]+$/i,
@@ -4505,9 +4778,11 @@ async function graphContinue(args, ctx) {
4505
4778
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
4506
4779
  const retrieval = await retrieve(ctx.graph, query, {
4507
4780
  recentlyEditedPaths: ctx.activity.recentFilePaths(15 * 60 * 1e3),
4508
- sessionKnownPaths: getRegisteredEdits()
4781
+ sessionKnownPaths: getRegisteredEdits(),
4782
+ usageScores: ctx.learn?.effectiveScores()
4509
4783
  });
4510
4784
  const packed = await pack(retrieval.files, { query, graph: ctx.graph });
4785
+ await logAccess(ctx, { ts: nowIso(), path: "", source: "continue", query });
4511
4786
  const header = `Confidence: ${retrieval.confidence}
4512
4787
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
4513
4788
  Reason: ${retrieval.reason}
@@ -4525,7 +4800,7 @@ function resolveFileTarget(graph, filePath) {
4525
4800
  if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
4526
4801
  return { none: true };
4527
4802
  }
4528
- function graphRead(args, ctx) {
4803
+ async function graphRead(args, ctx) {
4529
4804
  const target = typeof args?.target === "string" ? args.target : "";
4530
4805
  if (!target) return errorContent("graph_read: 'target' (string) is required");
4531
4806
  const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
@@ -4542,6 +4817,7 @@ function graphRead(args, ctx) {
4542
4817
  return errorContent(`graph_read: file not found in graph: ${filePath}`);
4543
4818
  }
4544
4819
  const fileNode = resolved.node;
4820
+ await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
4545
4821
  if (!symbolName) {
4546
4822
  return textContent(`# ${fileNode.path}
4547
4823
 
@@ -4563,10 +4839,21 @@ ${body}`
4563
4839
  );
4564
4840
  }
4565
4841
  var editedFiles = /* @__PURE__ */ new Set();
4566
- function graphRegisterEdit(args, _ctx) {
4842
+ async function graphRegisterEdit(args, ctx) {
4567
4843
  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}.`);
4844
+ for (const f of files) {
4845
+ const file = f;
4846
+ editedFiles.add(file);
4847
+ const resolved = resolveFileTarget(ctx.graph, file);
4848
+ await logAccess(ctx, {
4849
+ ts: nowIso(),
4850
+ path: "node" in resolved ? resolved.node.path : file,
4851
+ source: "register_edit"
4852
+ });
4853
+ }
4854
+ return textContent(
4855
+ `Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`
4856
+ );
4570
4857
  }
4571
4858
  function getRegisteredEdits() {
4572
4859
  return Array.from(editedFiles);
@@ -4602,9 +4889,7 @@ function recentActivity(args, ctx) {
4602
4889
  let events = ctx.activity.getEvents(sinceMs);
4603
4890
  if (limit) events = events.slice(-limit);
4604
4891
  if (events.length === 0) {
4605
- return textContent(
4606
- `No human-activity events since ${new Date(sinceMs).toISOString()}.`
4607
- );
4892
+ return textContent(`No human-activity events since ${new Date(sinceMs).toISOString()}.`);
4608
4893
  }
4609
4894
  const lines = [`# Recent human activity (${events.length} events)`, ""];
4610
4895
  for (const e of events) {
@@ -4636,8 +4921,8 @@ async function contextRecall(args, ctx) {
4636
4921
  }
4637
4922
  async function logToolCall(ctx, tool) {
4638
4923
  try {
4639
- await mkdir8(dirname9(ctx.paths.toolLog), { recursive: true });
4640
- await appendFile2(
4924
+ await mkdir9(dirname10(ctx.paths.toolLog), { recursive: true });
4925
+ await appendFile3(
4641
4926
  ctx.paths.toolLog,
4642
4927
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
4643
4928
  "utf8"
@@ -4645,6 +4930,16 @@ async function logToolCall(ctx, tool) {
4645
4930
  } catch {
4646
4931
  }
4647
4932
  }
4933
+ async function logAccess(ctx, ev) {
4934
+ try {
4935
+ if (ctx.learn) await ctx.learn.record(ev);
4936
+ else await appendAccess(ctx.paths.accessLog, ev);
4937
+ } catch {
4938
+ }
4939
+ }
4940
+ function nowIso() {
4941
+ return (/* @__PURE__ */ new Date()).toISOString();
4942
+ }
4648
4943
  async function handleMcpRequest(body, ctx) {
4649
4944
  if (!body || typeof body !== "object") {
4650
4945
  return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
@@ -4695,9 +4990,87 @@ async function handleActivity(sinceMs, ctx) {
4695
4990
  };
4696
4991
  }
4697
4992
 
4993
+ // src/memory/git-snapshot.ts
4994
+ import { execFile as execFile3 } from "child_process";
4995
+ import { promisify as promisify3 } from "util";
4996
+ var execFileAsync3 = promisify3(execFile3);
4997
+ var MAX_COMMITS = 5;
4998
+ var FIELD = "";
4999
+ async function getCommitsSince(projectRoot, sinceIso) {
5000
+ const args = [
5001
+ "log",
5002
+ `--max-count=${MAX_COMMITS}`,
5003
+ "--no-merges",
5004
+ `--pretty=format:%h${FIELD}%s${FIELD}%aI`
5005
+ ];
5006
+ if (Number.isFinite(Date.parse(sinceIso))) args.push(`--since=${sinceIso}`);
5007
+ try {
5008
+ const { stdout } = await execFileAsync3("git", args, { cwd: projectRoot });
5009
+ const out = [];
5010
+ for (const line of stdout.split("\n")) {
5011
+ const t = line.trim();
5012
+ if (!t) continue;
5013
+ const [hash, message, date] = t.split(FIELD);
5014
+ if (hash && message) out.push({ hash, message, date: date ?? "" });
5015
+ }
5016
+ return out;
5017
+ } catch {
5018
+ return [];
5019
+ }
5020
+ }
5021
+
5022
+ // src/memory/session.ts
5023
+ import { mkdir as mkdir10, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5024
+ import { dirname as dirname11 } from "path";
5025
+ var SESSION_SCHEMA_VERSION = 1;
5026
+ async function readSession(path) {
5027
+ try {
5028
+ const raw = await readFile15(path, "utf8");
5029
+ const parsed = JSON.parse(raw);
5030
+ if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
5031
+ return parsed;
5032
+ } catch {
5033
+ return null;
5034
+ }
5035
+ }
5036
+ async function writeSession(path, state) {
5037
+ await mkdir10(dirname11(path), { recursive: true });
5038
+ await writeFile9(path, JSON.stringify(state, null, 2) + "\n", "utf8");
5039
+ }
5040
+
4698
5041
  // src/server/routes/context-update.ts
5042
+ var TOUCHED_WINDOW_MS = 24 * 60 * 60 * 1e3;
5043
+ async function captureSnapshot(ctx, branchOverride) {
5044
+ const active = await resolveActiveBranch(ctx.paths, branchOverride);
5045
+ const [tasks, decisions, next] = await Promise.all([
5046
+ recallEntries(ctx.paths, { kind: "task", branch: active.branch, limit: 1 }),
5047
+ recallEntries(ctx.paths, { kind: "decision", branch: active.branch, limit: 3 }),
5048
+ recallEntries(ctx.paths, { kind: "next", branch: active.branch, limit: 3 })
5049
+ ]);
5050
+ const touched = new Set(getRegisteredEdits());
5051
+ for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
5052
+ const prev = await readSession(ctx.paths.sessionState);
5053
+ const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
5054
+ const snapshot = {
5055
+ schema_version: SESSION_SCHEMA_VERSION,
5056
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
5057
+ branch: active.branch,
5058
+ filesTouched: Array.from(touched),
5059
+ recentCommits,
5060
+ summary: {
5061
+ tasks: tasks.entries.map((e) => e.content),
5062
+ decisions: decisions.entries.map((e) => e.content),
5063
+ next: next.entries.map((e) => e.content)
5064
+ }
5065
+ };
5066
+ await writeSession(ctx.paths.sessionState, snapshot);
5067
+ }
4699
5068
  async function handleContextUpdate(req, ctx) {
4700
5069
  const r = await refreshContextMd(ctx.paths, req?.branch);
5070
+ try {
5071
+ await captureSnapshot(ctx, req?.branch);
5072
+ } catch {
5073
+ }
4701
5074
  return {
4702
5075
  updated: true,
4703
5076
  branch: r.branch,
@@ -4707,8 +5080,8 @@ async function handleContextUpdate(req, ctx) {
4707
5080
  }
4708
5081
 
4709
5082
  // src/server/routes/gate.ts
4710
- import { appendFile as appendFile3, mkdir as mkdir9 } from "fs/promises";
4711
- import { dirname as dirname10 } from "path";
5083
+ import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
5084
+ import { dirname as dirname12 } from "path";
4712
5085
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
4713
5086
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
4714
5087
  function extractQuery(toolName, input) {
@@ -4764,7 +5137,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
4764
5137
  }
4765
5138
  async function logDecision(ctx, toolName, query, decision, reason) {
4766
5139
  try {
4767
- await mkdir9(dirname10(ctx.paths.gateLog), { recursive: true });
5140
+ await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
4768
5141
  const entry = {
4769
5142
  ts: (/* @__PURE__ */ new Date()).toISOString(),
4770
5143
  tool: toolName,
@@ -4772,7 +5145,7 @@ async function logDecision(ctx, toolName, query, decision, reason) {
4772
5145
  query,
4773
5146
  reason
4774
5147
  };
4775
- await appendFile3(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
5148
+ await appendFile4(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
4776
5149
  } catch {
4777
5150
  }
4778
5151
  }
@@ -4836,16 +5209,16 @@ async function handleGate(req, ctx) {
4836
5209
  }
4837
5210
 
4838
5211
  // src/server/routes/log.ts
4839
- import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
4840
- import { dirname as dirname11 } from "path";
5212
+ import { appendFile as appendFile5, mkdir as mkdir12 } from "fs/promises";
5213
+ import { dirname as dirname13 } from "path";
4841
5214
  async function handleLog(entry, ctx) {
4842
5215
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
4843
5216
  throw new Error("log: input_tokens and output_tokens (number) are required");
4844
5217
  }
4845
5218
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
4846
5219
  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");
5220
+ await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
5221
+ await appendFile5(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
4849
5222
  return { ok: true, written_at };
4850
5223
  }
4851
5224
 
@@ -4855,13 +5228,15 @@ async function handlePack(req, ctx) {
4855
5228
  throw new Error("pack: 'query' (string) is required");
4856
5229
  }
4857
5230
  const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
4858
- const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
5231
+ const usageScores = ctx.learn?.effectiveScores();
5232
+ const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths, usageScores });
4859
5233
  const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
4860
5234
  const scored = scoreFiles({
4861
5235
  candidates: allFiles,
4862
5236
  query: req.query,
4863
5237
  graph: ctx.graph,
4864
- recentlyEditedPaths
5238
+ recentlyEditedPaths,
5239
+ usageScores
4865
5240
  });
4866
5241
  const reasons = /* @__PURE__ */ new Map();
4867
5242
  for (const s of scored) {
@@ -4883,14 +5258,74 @@ async function handlePack(req, ctx) {
4883
5258
  }
4884
5259
 
4885
5260
  // src/server/routes/prime.ts
4886
- async function handlePrime(ctx, port) {
5261
+ var RESUME_PRIMER_MAX_CHARS = 2720;
5262
+ var MAX_FILES = 15;
5263
+ var MAX_COMMITS2 = 5;
5264
+ var MAX_BULLETS2 = 3;
5265
+ function legacyPrimer(ctx) {
4887
5266
  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 };
5267
+ return `Synthra context loaded for ${g.root}.
5268
+ ${g.file_count} files indexed, ${g.symbol_count} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.`;
5269
+ }
5270
+ function hasContent(snap) {
5271
+ return Boolean(
5272
+ snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
5273
+ );
5274
+ }
5275
+ function buildResumeDigest(snap, branchNow) {
5276
+ const plural = (n) => n === 1 ? "" : "s";
5277
+ 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)`;
5278
+ const essential = [head];
5279
+ if (snap.branch !== branchNow) {
5280
+ essential.push("");
5281
+ essential.push(
5282
+ `_(snapshot was for branch '${snap.branch}'; you're now on '${branchNow}' \u2014 may be stale)_`
5283
+ );
5284
+ }
5285
+ if (snap.summary.tasks[0]) {
5286
+ essential.push("", "### In progress", `- ${snap.summary.tasks[0]}`);
5287
+ }
5288
+ if (snap.summary.next.length) {
5289
+ essential.push("", "### Open next steps");
5290
+ for (const n of snap.summary.next.slice(0, MAX_BULLETS2)) essential.push(`- ${n}`);
5291
+ }
5292
+ if (snap.summary.decisions.length) {
5293
+ essential.push("", "### Recent decisions");
5294
+ for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
5295
+ }
5296
+ const extra = [];
5297
+ if (snap.recentCommits.length) {
5298
+ extra.push("", "### Recent commits");
5299
+ for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
5300
+ const date = c.date ? ` (${c.date.slice(0, 10)})` : "";
5301
+ extra.push(`- \`${c.hash}\` ${c.message}${date}`);
5302
+ }
5303
+ }
5304
+ if (snap.filesTouched.length) {
5305
+ const shown = snap.filesTouched.slice(0, MAX_FILES);
5306
+ const more = snap.filesTouched.length - shown.length;
5307
+ extra.push("", "### Files touched", shown.join(", ") + (more > 0 ? `, +${more} more` : ""));
5308
+ }
5309
+ let out = essential.join("\n");
5310
+ for (const line of extra) {
5311
+ if ((out + "\n" + line).length > RESUME_PRIMER_MAX_CHARS) break;
5312
+ out += "\n" + line;
5313
+ }
5314
+ return (out.length > RESUME_PRIMER_MAX_CHARS ? out.slice(0, RESUME_PRIMER_MAX_CHARS) : out).trimEnd();
5315
+ }
5316
+ async function handlePrime(ctx, port) {
5317
+ const legacy = legacyPrimer(ctx);
5318
+ const snap = await readSession(ctx.paths.sessionState);
5319
+ if (!snap || !hasContent(snap)) {
5320
+ return { primer: legacy, port };
5321
+ }
5322
+ const branchNow = await currentBranch(ctx.paths.projectRoot);
5323
+ const digest = buildResumeDigest(snap, branchNow);
5324
+ return { primer: `${digest}
5325
+
5326
+ ---
5327
+
5328
+ ${legacy}`, port };
4894
5329
  }
4895
5330
 
4896
5331
  // src/server/http.ts
@@ -4901,9 +5336,7 @@ async function loadContext(paths) {
4901
5336
  readSymbolIndex(paths.symbolIndex)
4902
5337
  ]);
4903
5338
  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
- );
5339
+ log.info(`graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION2} \u2014 rescanning\u2026`);
4907
5340
  await scanProject(paths.projectRoot, { silent: true });
4908
5341
  [graph, symbolIndex] = await Promise.all([
4909
5342
  readGraph(paths.infoGraph),
@@ -4911,7 +5344,8 @@ async function loadContext(paths) {
4911
5344
  ]);
4912
5345
  }
4913
5346
  const activity = new ActivityStore(paths.activityLog);
4914
- return { paths, graph, symbolIndex, activity };
5347
+ const learn = await LearnRuntime.load(paths.accessLog, paths.learnStore);
5348
+ return { paths, graph, symbolIndex, activity, learn };
4915
5349
  } catch (err2) {
4916
5350
  throw new Error(
4917
5351
  `failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
@@ -4948,9 +5382,7 @@ function buildApp(ctx, port) {
4948
5382
  app.get("/activity", async (c) => {
4949
5383
  const sinceParam = c.req.query("since");
4950
5384
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
4951
- return c.json(
4952
- await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
4953
- );
5385
+ return c.json(await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx));
4954
5386
  });
4955
5387
  app.post("/context-update", async (c) => {
4956
5388
  const body = await c.req.json().catch(() => ({}));
@@ -4971,11 +5403,8 @@ async function startServer(paths, options = {}) {
4971
5403
  const port = options.port ?? await findFreePort();
4972
5404
  const app = buildApp(ctx, port);
4973
5405
  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
- );
5406
+ await writeFile10(paths.mcpPort, String(port), "utf8");
5407
+ const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
4979
5408
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
4980
5409
  await ctx.activity.add(e);
4981
5410
  if (e.kind === "branch-switch") {
@@ -5012,6 +5441,7 @@ async function startServer(paths, options = {}) {
5012
5441
  async stop() {
5013
5442
  await fileWatcher.stop().catch(() => void 0);
5014
5443
  await gitWatcher.stop().catch(() => void 0);
5444
+ await ctx.learn?.flush().catch(() => void 0);
5015
5445
  await new Promise((resolve6, reject) => {
5016
5446
  nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
5017
5447
  });
@@ -5117,7 +5547,7 @@ async function dashboardCommand(rawPath) {
5117
5547
  }
5118
5548
 
5119
5549
  // src/cli/doctor-command.ts
5120
- import { readFile as readFile14, stat as stat4 } from "fs/promises";
5550
+ import { readFile as readFile16, stat as stat4 } from "fs/promises";
5121
5551
  import { join as join10, resolve as resolve3 } from "path";
5122
5552
  import spawn from "cross-spawn";
5123
5553
  var ICON = { ok: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
@@ -5148,7 +5578,11 @@ async function runDoctorChecks(projectRoot) {
5148
5578
  const checks = [];
5149
5579
  const nodeMajor = Number(process.versions.node.split(".")[0]);
5150
5580
  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` }
5581
+ nodeMajor >= 18 ? { status: "ok", label: "Node", detail: `v${process.versions.node}` } : {
5582
+ status: "fail",
5583
+ label: "Node",
5584
+ detail: `v${process.versions.node} \u2014 Synthra needs Node >= 18`
5585
+ }
5152
5586
  );
5153
5587
  const hasJq = await binWorks("jq", ["--version"]);
5154
5588
  if (process.platform === "win32") {
@@ -5175,14 +5609,19 @@ async function runDoctorChecks(projectRoot) {
5175
5609
  }
5176
5610
  );
5177
5611
  if (!await exists2(paths.infoGraph)) {
5178
- checks.push({ status: "warn", label: "Graph", detail: "no info_graph.json \u2014 run `syn .` (or `syn scan`) here." });
5612
+ checks.push({
5613
+ status: "warn",
5614
+ label: "Graph",
5615
+ detail: "no info_graph.json \u2014 run `syn .` (or `syn scan`) here."
5616
+ });
5179
5617
  } else {
5180
5618
  try {
5181
- const graph = JSON.parse(await readFile14(paths.infoGraph, "utf8"));
5619
+ const graph = JSON.parse(await readFile16(paths.infoGraph, "utf8"));
5182
5620
  const parts = [`${graph.symbol_count} symbols`, `${graph.file_count} files`];
5183
5621
  let status = "ok";
5184
5622
  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`);
5623
+ if (Number.isFinite(ageMs))
5624
+ parts.push(`scanned ${Math.max(0, Math.round(ageMs / 6e4))}m ago`);
5186
5625
  if (graph.schema_version !== SCHEMA_VERSION2) {
5187
5626
  status = "warn";
5188
5627
  parts.push(`schema v${graph.schema_version} \u2260 v${SCHEMA_VERSION2} (auto-rescans on serve)`);
@@ -5193,22 +5632,38 @@ async function runDoctorChecks(projectRoot) {
5193
5632
  }
5194
5633
  checks.push({ status, label: "Graph", detail: parts.join(" \xB7 ") });
5195
5634
  } catch {
5196
- checks.push({ status: "warn", label: "Graph", detail: "info_graph.json unreadable \u2014 re-run `syn scan`." });
5635
+ checks.push({
5636
+ status: "warn",
5637
+ label: "Graph",
5638
+ detail: "info_graph.json unreadable \u2014 re-run `syn scan`."
5639
+ });
5197
5640
  }
5198
5641
  }
5199
5642
  checks.push(
5200
- await exists2(join10(projectRoot, ".mcp.json")) ? { status: "ok", label: "MCP registration", detail: ".mcp.json present (IDE can see graph_* tools)" } : {
5643
+ await exists2(join10(projectRoot, ".mcp.json")) ? {
5644
+ status: "ok",
5645
+ label: "MCP registration",
5646
+ detail: ".mcp.json present (IDE can see graph_* tools)"
5647
+ } : {
5201
5648
  status: "warn",
5202
5649
  label: "MCP registration",
5203
5650
  detail: "no .mcp.json \u2014 the IDE extension won't see Synthra's tools; run `syn .`."
5204
5651
  }
5205
5652
  );
5206
5653
  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." });
5654
+ checks.push({
5655
+ status: "warn",
5656
+ label: "CLAUDE.md policy",
5657
+ detail: "no CLAUDE.md \u2014 run `syn .` to scaffold + inject the policy block."
5658
+ });
5208
5659
  } else {
5209
- const md = await readFile14(paths.claudeMd, "utf8");
5660
+ const md = await readFile16(paths.claudeMd, "utf8");
5210
5661
  if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
5211
- checks.push({ status: "ok", label: "CLAUDE.md policy", detail: `policy block v${POLICY_VERSION}` });
5662
+ checks.push({
5663
+ status: "ok",
5664
+ label: "CLAUDE.md policy",
5665
+ detail: `policy block v${POLICY_VERSION}`
5666
+ });
5212
5667
  } else {
5213
5668
  const m = md.match(/synthra-policy v(\d+) BEGIN/);
5214
5669
  checks.push({
@@ -5219,11 +5674,19 @@ async function runDoctorChecks(projectRoot) {
5219
5674
  }
5220
5675
  }
5221
5676
  if (!await exists2(paths.claudeSettings)) {
5222
- checks.push({ status: "warn", label: "Hooks", detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks." });
5677
+ checks.push({
5678
+ status: "warn",
5679
+ label: "Hooks",
5680
+ detail: "no .claude/settings.local.json \u2014 run `syn .` to install hooks."
5681
+ });
5223
5682
  } else {
5224
- const s = await readFile14(paths.claudeSettings, "utf8");
5683
+ const s = await readFile16(paths.claudeSettings, "utf8");
5225
5684
  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 .`." }
5685
+ s.includes("synthra-hook=true") ? { status: "ok", label: "Hooks", detail: "registered in .claude/settings.local.json" } : {
5686
+ status: "warn",
5687
+ label: "Hooks",
5688
+ detail: "settings.local.json present but no Synthra hooks \u2014 run `syn .`."
5689
+ }
5227
5690
  );
5228
5691
  }
5229
5692
  return checks;
@@ -5240,12 +5703,14 @@ async function doctorCommand(rawPath) {
5240
5703
  const warn = checks.filter((c) => c.status === "warn").length;
5241
5704
  const fail = checks.filter((c) => c.status === "fail").length;
5242
5705
  log.info("");
5243
- log.info(fail === 0 && warn === 0 ? " All checks passed." : ` ${fail} failed \xB7 ${warn} warning(s).`);
5706
+ log.info(
5707
+ fail === 0 && warn === 0 ? " All checks passed." : ` ${fail} failed \xB7 ${warn} warning(s).`
5708
+ );
5244
5709
  log.info("");
5245
5710
  }
5246
5711
 
5247
5712
  // src/cli/self-update.ts
5248
- import { mkdir as mkdir11, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
5713
+ import { mkdir as mkdir13, readFile as readFile17, writeFile as writeFile11 } from "fs/promises";
5249
5714
  import { homedir as homedir3 } from "os";
5250
5715
  import { join as join11 } from "path";
5251
5716
  import { createInterface } from "readline/promises";
@@ -5303,7 +5768,7 @@ async function checkForUpdate() {
5303
5768
  }
5304
5769
  async function readLastSeen() {
5305
5770
  try {
5306
- const raw = await readFile15(LAST_SEEN_PATH, "utf8");
5771
+ const raw = await readFile17(LAST_SEEN_PATH, "utf8");
5307
5772
  const parsed = JSON.parse(raw);
5308
5773
  return parsed.version ?? null;
5309
5774
  } catch {
@@ -5312,9 +5777,9 @@ async function readLastSeen() {
5312
5777
  }
5313
5778
  async function writeLastSeen(version) {
5314
5779
  try {
5315
- await mkdir11(SYNTHRA_DIR, { recursive: true });
5780
+ await mkdir13(SYNTHRA_DIR, { recursive: true });
5316
5781
  const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
5317
- await writeFile9(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
5782
+ await writeFile11(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
5318
5783
  } catch {
5319
5784
  }
5320
5785
  }
@@ -5346,7 +5811,7 @@ async function readInstalledChangelog() {
5346
5811
  const root = await npmGlobalRoot();
5347
5812
  if (!root) return null;
5348
5813
  try {
5349
- return await readFile15(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
5814
+ return await readFile17(join11(root, "@jefuriiij", "synthra", "CHANGELOG.md"), "utf8");
5350
5815
  } catch {
5351
5816
  return null;
5352
5817
  }
@@ -5478,7 +5943,9 @@ function runClaude(bin, args, cwd, stdio = "pipe") {
5478
5943
  }
5479
5944
  async function registerMcp(bin, mcpPort, cwd) {
5480
5945
  const url = `http://127.0.0.1:${mcpPort}/mcp`;
5481
- await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "project"], cwd).catch(() => void 0);
5946
+ await runClaude(bin, ["mcp", "remove", MCP_NAME, "--scope", "project"], cwd).catch(
5947
+ () => void 0
5948
+ );
5482
5949
  const reg = await runClaude(
5483
5950
  bin,
5484
5951
  ["mcp", "add", MCP_NAME, "--transport", "http", "--scope", "project", url],
@@ -5509,7 +5976,9 @@ async function spawnClaude(bin, opts) {
5509
5976
  var VERSION2 = package_default.version;
5510
5977
  function printReadyBanner(info) {
5511
5978
  log.info("");
5512
- log.info(` \u2705 scanned ${info.scan.parsed} files \xB7 ${info.scan.symbolCount} symbols \xB7 ${info.scan.edgeCount} edges`);
5979
+ log.info(
5980
+ ` \u2705 scanned ${info.scan.parsed} files \xB7 ${info.scan.symbolCount} symbols \xB7 ${info.scan.edgeCount} edges`
5981
+ );
5513
5982
  if (info.mcpRegistered) {
5514
5983
  log.info(` \u{1F9E0} MCP ${info.mcpUrl} \u2192 registered as 'synthra'`);
5515
5984
  } else {
@@ -5522,7 +5991,9 @@ function printReadyBanner(info) {
5522
5991
  }
5523
5992
  log.info(` \u{1FA9D} Hooks installed in .claude/settings.local.json`);
5524
5993
  log.info("");
5525
- log.info(` \u{1F916} Ready \u2014 open the Claude Code IDE extension (or run \`claude\` in another terminal).`);
5994
+ log.info(
5995
+ ` \u{1F916} Ready \u2014 open the Claude Code IDE extension (or run \`claude\` in another terminal).`
5996
+ );
5526
5997
  log.info(` Synthra's tools and gate will be active for that session.`);
5527
5998
  log.info("");
5528
5999
  log.info(` Press Ctrl+C here when you're done.`);
@@ -5579,24 +6050,22 @@ async function defaultFlow(rawPath, opts) {
5579
6050
  } finally {
5580
6051
  await unregisterMcp(cfg.claudeBin, projectRoot).catch(() => void 0);
5581
6052
  if (dashboardHandle) {
5582
- await dashboardHandle.stop().catch(
5583
- (err2) => log.warn(`dashboard stop error: ${err2.message}`)
5584
- );
6053
+ await dashboardHandle.stop().catch((err2) => log.warn(`dashboard stop error: ${err2.message}`));
5585
6054
  }
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
- );
6055
+ await mcpHandle.stop().catch((err2) => log.warn(`MCP server stop error: ${err2.message}`));
6056
+ await cleanup(paths).catch((err2) => log.warn(`cleanup error: ${err2.message}`));
5592
6057
  }
5593
6058
  }
5594
6059
  function buildProgram() {
5595
6060
  const prog = sade("syn");
5596
6061
  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) => {
6062
+ prog.command(
6063
+ ". [path]",
6064
+ "Scan + MCP + dashboard + hooks. Default flow \u2014 use with the Claude Code IDE extension.",
6065
+ {
6066
+ default: true
6067
+ }
6068
+ ).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
6069
  await defaultFlow(path ?? ".", opts);
5601
6070
  });
5602
6071
  prog.command("scan [path]", "Scan only \u2014 walk + parse + write graph.").action(async (path) => {