@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/CHANGELOG.md +465 -426
- package/LICENSE +21 -21
- package/README.md +222 -222
- package/dist/cli/index.js +726 -285
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +14 -3
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +446 -66
- package/dist/server/index.js.map +1 -1
- package/package.json +9 -2
package/dist/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ var init_package = __esm({
|
|
|
18
18
|
"package.json"() {
|
|
19
19
|
package_default = {
|
|
20
20
|
name: "@jefuriiij/synthra",
|
|
21
|
-
version: "0.
|
|
21
|
+
version: "0.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
|
-
|
|
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(
|
|
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
|
-
|
|
|
1462
|
+
| jq -r '.primer // empty' 2>/dev/null \\
|
|
1445
1463
|
| head -c 8000)
|
|
1446
1464
|
|
|
1447
1465
|
if [ -n "$PRIMER" ]; then
|
|
1448
|
-
printf '%
|
|
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
|
-
{
|
|
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
|
|
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 = [
|
|
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 =
|
|
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(
|
|
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
|
|
3655
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
3919
|
-
import { dirname as
|
|
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
|
|
3963
|
-
await
|
|
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
|
|
3968
|
-
import { dirname as
|
|
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
|
|
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
|
|
4224
|
+
await mkdir8(dirname9(path), { recursive: true });
|
|
3981
4225
|
const store = { schema_version: SCHEMA_VERSION3, entries };
|
|
3982
|
-
await
|
|
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: {
|
|
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[
|
|
4462
|
-
/(?:^|\/)bin[
|
|
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,
|
|
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)
|
|
4569
|
-
|
|
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
|
|
4640
|
-
await
|
|
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
|
|
4711
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
4840
|
-
import { dirname as
|
|
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
|
|
4848
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
(
|
|
4893
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}` } : {
|
|
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({
|
|
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
|
|
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))
|
|
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({
|
|
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")) ? {
|
|
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({
|
|
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
|
|
5632
|
+
const md = await readFile16(paths.claudeMd, "utf8");
|
|
5210
5633
|
if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
|
|
5211
|
-
checks.push({
|
|
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({
|
|
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
|
|
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" } : {
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
5752
|
+
await mkdir13(SYNTHRA_DIR, { recursive: true });
|
|
5316
5753
|
const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5317
|
-
await
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
5598
|
-
|
|
5599
|
-
|
|
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) => {
|