@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/CHANGELOG.md +481 -426
- package/LICENSE +21 -21
- package/README.md +222 -222
- package/dist/cli/index.js +756 -287
- 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 +476 -68
- 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.1
|
|
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
|
-
|
|
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,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
|
|
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 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
|
-
|
|
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 +=
|
|
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
|
|
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
|
|
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
|
|
3919
|
-
import { dirname as
|
|
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
|
|
3963
|
-
await
|
|
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
|
|
3968
|
-
import { dirname as
|
|
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
|
|
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
|
|
4252
|
+
await mkdir8(dirname9(path), { recursive: true });
|
|
3981
4253
|
const store = { schema_version: SCHEMA_VERSION3, entries };
|
|
3982
|
-
await
|
|
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: {
|
|
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[
|
|
4462
|
-
/(?:^|\/)bin[
|
|
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,
|
|
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)
|
|
4569
|
-
|
|
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
|
|
4640
|
-
await
|
|
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
|
|
4711
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
4840
|
-
import { dirname as
|
|
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
|
|
4848
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
(
|
|
4893
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}` } : {
|
|
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({
|
|
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
|
|
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))
|
|
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({
|
|
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")) ? {
|
|
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({
|
|
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
|
|
5660
|
+
const md = await readFile16(paths.claudeMd, "utf8");
|
|
5210
5661
|
if (md.includes(`synthra-policy v${POLICY_VERSION} BEGIN`)) {
|
|
5211
|
-
checks.push({
|
|
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({
|
|
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
|
|
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" } : {
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
5780
|
+
await mkdir13(SYNTHRA_DIR, { recursive: true });
|
|
5316
5781
|
const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5317
|
-
await
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
5598
|
-
|
|
5599
|
-
|
|
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) => {
|