@jefuriiij/synthra 0.14.1 → 0.16.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 +43 -0
- package/dist/cli/index.js +339 -34
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +2 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +525 -41
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.16.0] — 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The Dispatcher — Synthra now routes your tasks.** On every prompt, Synthra
|
|
15
|
+
scores the request against every installed subagent and skill (plus the
|
|
16
|
+
project's language fingerprint) and — when it finds a clear fit — injects a
|
|
17
|
+
one-line hint: *"[Synthra route] This task fits agent 'X' (model: sonnet) +
|
|
18
|
+
skill 'Y'. Plan here first, then delegate execution."* Works for any domain
|
|
19
|
+
your arsenal covers: UI work routes to your frontend agents, security audits
|
|
20
|
+
to your security agents, and so on. Silent when unsure. Disable with
|
|
21
|
+
`SYN_NO_ROUTE`; tune the confidence bar with `SYN_ROUTE_MIN_SCORE`.
|
|
22
|
+
- **`route_task` tool** — the on-demand version: ask which installed
|
|
23
|
+
agent/skill fits a task and which model to run it on; returns ranked
|
|
24
|
+
candidates with match reasons.
|
|
25
|
+
- **Model policy baked in:** plan on the primary model, delegate execution to a
|
|
26
|
+
subagent on a cheaper one (Sonnet is ~5× cheaper than Opus on every rate).
|
|
27
|
+
Agents that pin their own `model:` are respected; everything else defaults to
|
|
28
|
+
a `sonnet` recommendation.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## [0.15.0] — 2026-07-02
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **Your remembered context now talks back.** Synthra's second brain was
|
|
37
|
+
write-only — decisions and gotchas went in via `context_remember` and never
|
|
38
|
+
resurfaced. Now they appear exactly where they're relevant:
|
|
39
|
+
- `graph_read` of a file (or a symbol in it) shows a `📌 Remembered for this
|
|
40
|
+
file` block with the entries linked to that file.
|
|
41
|
+
- `graph_continue` packs include `Remembered:` lines for entries matching the
|
|
42
|
+
query.
|
|
43
|
+
- **Memories know when they might be wrong.** Entries linked to files are now
|
|
44
|
+
*anchored* to those files' content hashes at capture time. When the code
|
|
45
|
+
changes afterwards (tracked live by auto-reindex), every surfacing of that
|
|
46
|
+
entry — graph_read, graph_continue, context_recall — carries
|
|
47
|
+
`⚠ possibly stale — <file> changed since stored`. Old entries without anchors
|
|
48
|
+
keep working and are never flagged; the shared context-store format is
|
|
49
|
+
unchanged (additive optional field).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
10
53
|
## [0.14.1] — 2026-07-02
|
|
11
54
|
|
|
12
55
|
### Added
|
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.16.0",
|
|
22
22
|
publishConfig: {
|
|
23
23
|
access: "public"
|
|
24
24
|
},
|
|
@@ -517,6 +517,7 @@ function resolvePaths(projectRoot) {
|
|
|
517
517
|
tokenLog: join2(graphDir, "token_log.jsonl"),
|
|
518
518
|
gateLog: join2(graphDir, "gate_log.jsonl"),
|
|
519
519
|
bashLog: join2(graphDir, "bash_log.jsonl"),
|
|
520
|
+
routeLog: join2(graphDir, "route_log.jsonl"),
|
|
520
521
|
toolLog: join2(graphDir, "tool_log.jsonl"),
|
|
521
522
|
accessLog: join2(graphDir, "access_log.jsonl"),
|
|
522
523
|
learnStore: join2(graphDir, "learn_store.json"),
|
|
@@ -1062,6 +1063,44 @@ fi
|
|
|
1062
1063
|
exit 0
|
|
1063
1064
|
`;
|
|
1064
1065
|
|
|
1066
|
+
// src/hooks/scripts/route.ps1
|
|
1067
|
+
var route_default = '# UserPromptSubmit hook \u2014 Windows PowerShell.\n# THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,\n# and prints the routing hint (if any) to stdout \u2014 Claude Code injects\n# UserPromptSubmit stdout into the conversation as added context.\n# Always exits 0; any failure leaves the prompt 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}\nif (-not $hookInput.prompt) { exit 0 }\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 = @{ prompt = $hookInput.prompt } | ConvertTo-Json -Compress\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/route" -Method POST `\n -Body $payload -ContentType "application/json" -TimeoutSec 3\n if ($resp.hint) { Write-Output $resp.hint }\n} catch {\n # silent on failure \u2014 the prompt proceeds without a routing hint\n}\nexit 0\n';
|
|
1068
|
+
|
|
1069
|
+
// src/hooks/scripts/route.sh
|
|
1070
|
+
var route_default2 = `#!/usr/bin/env bash
|
|
1071
|
+
# UserPromptSubmit hook \u2014 bash.
|
|
1072
|
+
# THE DISPATCHER. Reads the prompt event from stdin (JSON), POSTs it to /route,
|
|
1073
|
+
# and prints the routing hint (if any) to stdout \u2014 Claude Code injects
|
|
1074
|
+
# UserPromptSubmit stdout into the conversation as added context.
|
|
1075
|
+
# Always exits 0; requires \`jq\` (silently no-ops without it, like the other hooks).
|
|
1076
|
+
|
|
1077
|
+
set +e
|
|
1078
|
+
|
|
1079
|
+
PORT_FILE="$PWD/.synthra-graph/mcp_port"
|
|
1080
|
+
if [ ! -f "$PORT_FILE" ]; then exit 0; fi
|
|
1081
|
+
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1082
|
+
if [ -z "$PORT" ]; then exit 0; fi
|
|
1083
|
+
|
|
1084
|
+
if ! command -v jq >/dev/null 2>&1; then exit 0; fi
|
|
1085
|
+
|
|
1086
|
+
INPUT=$(cat 2>/dev/null)
|
|
1087
|
+
if [ -z "$INPUT" ]; then exit 0; fi
|
|
1088
|
+
|
|
1089
|
+
# Re-emit only the prompt field as the request body (jq handles escaping).
|
|
1090
|
+
BODY=$(printf '%s' "$INPUT" | jq -c '{prompt: (.prompt // empty)}' 2>/dev/null)
|
|
1091
|
+
if [ -z "$BODY" ] || [ "$BODY" = "{}" ]; then exit 0; fi
|
|
1092
|
+
|
|
1093
|
+
HINT=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\
|
|
1094
|
+
--data "$BODY" "http://127.0.0.1:$PORT/route" 2>/dev/null \\
|
|
1095
|
+
| jq -r '.hint // empty' 2>/dev/null \\
|
|
1096
|
+
| head -c 2000)
|
|
1097
|
+
|
|
1098
|
+
if [ -n "$HINT" ]; then
|
|
1099
|
+
printf '%s\\n' "$HINT"
|
|
1100
|
+
fi
|
|
1101
|
+
exit 0
|
|
1102
|
+
`;
|
|
1103
|
+
|
|
1065
1104
|
// src/hooks/scripts/stop.ps1
|
|
1066
1105
|
var stop_default = `# Stop hook \u2014 Windows PowerShell.\r
|
|
1067
1106
|
# Reads Claude's transcript JSONL from $hookInput.transcript_path, sums\r
|
|
@@ -1226,7 +1265,9 @@ var SCRIPTS = [
|
|
|
1226
1265
|
sh: pre_tool_use_default2
|
|
1227
1266
|
},
|
|
1228
1267
|
{ event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
|
|
1229
|
-
{ event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
|
|
1268
|
+
{ event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 },
|
|
1269
|
+
// The Dispatcher — per-prompt routing hint (best-fit agent/skill/model).
|
|
1270
|
+
{ event: "UserPromptSubmit", baseName: "synthra-route", ps1: route_default, sh: route_default2 }
|
|
1230
1271
|
];
|
|
1231
1272
|
function commandFor(scriptPath) {
|
|
1232
1273
|
if (process.platform === "win32") {
|
|
@@ -3046,7 +3087,7 @@ import { basename as basename5 } from "path";
|
|
|
3046
3087
|
// src/hooks/claude-md.ts
|
|
3047
3088
|
import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
|
|
3048
3089
|
import { basename as basename4, dirname as dirname9 } from "path";
|
|
3049
|
-
var POLICY_VERSION =
|
|
3090
|
+
var POLICY_VERSION = 9;
|
|
3050
3091
|
var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
|
|
3051
3092
|
var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
|
|
3052
3093
|
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;
|
|
@@ -3068,7 +3109,7 @@ function policyBlock() {
|
|
|
3068
3109
|
"> `mcp__synthra__graph_register_edit`. **Short names will NOT resolve**",
|
|
3069
3110
|
"> in ToolSearch or invocation \u2014 always use the full namespaced form.",
|
|
3070
3111
|
"> If the tools are deferred, load their schemas with ToolSearch:",
|
|
3071
|
-
"> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol`.",
|
|
3112
|
+
"> `select:mcp__synthra__graph_continue,mcp__synthra__graph_read,mcp__synthra__graph_register_edit,mcp__synthra__find_symbol,mcp__synthra__route_task`.",
|
|
3072
3113
|
"> Below, short names (`graph_continue` etc.) appear in prose for",
|
|
3073
3114
|
"> readability only.",
|
|
3074
3115
|
"",
|
|
@@ -3086,6 +3127,11 @@ function policyBlock() {
|
|
|
3086
3127
|
" util, or function, call this to check whether one already exists. If it",
|
|
3087
3128
|
" returns matches, reuse or extend them instead of re-implementing; only",
|
|
3088
3129
|
' "no match \u2014 safe to create" means it is genuinely new.',
|
|
3130
|
+
"- **`route_task(task)`** \u2014 **delegate-first**: before starting a multi-step",
|
|
3131
|
+
" implementation task, ask which installed subagent/skill fits it. Plan on",
|
|
3132
|
+
" the primary model, then hand execution to the recommended subagent on a",
|
|
3133
|
+
" cheaper model (sonnet \u2248 5\xD7 cheaper than opus). Synthra may also inject a",
|
|
3134
|
+
" `[Synthra route]` hint on your prompt \u2014 treat it the same way.",
|
|
3089
3135
|
"",
|
|
3090
3136
|
"### When to call `graph_continue` \u2014 and when to skip",
|
|
3091
3137
|
"",
|
|
@@ -3473,6 +3519,10 @@ function loadConfig() {
|
|
|
3473
3519
|
// the terminal bypass of the Moat can be measured. Never blocks. Opt out
|
|
3474
3520
|
// with SYN_NO_BASH_OBSERVE.
|
|
3475
3521
|
bashObserve: !process.env.SYN_NO_BASH_OBSERVE,
|
|
3522
|
+
// The Dispatcher: per-prompt routing hints (best-fit agent/skill/model).
|
|
3523
|
+
// Silent unless the top agent clears routeMinScore. SYN_NO_ROUTE disables.
|
|
3524
|
+
route: !process.env.SYN_NO_ROUTE,
|
|
3525
|
+
routeMinScore: num("SYN_ROUTE_MIN_SCORE", 3),
|
|
3476
3526
|
mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
|
|
3477
3527
|
dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
|
|
3478
3528
|
logLevel: str("SYN_LOG_LEVEL", "info"),
|
|
@@ -3481,8 +3531,8 @@ function loadConfig() {
|
|
|
3481
3531
|
}
|
|
3482
3532
|
|
|
3483
3533
|
// src/server/mcp.ts
|
|
3484
|
-
import { appendFile as
|
|
3485
|
-
import { dirname as
|
|
3534
|
+
import { appendFile as appendFile4, mkdir as mkdir11 } from "fs/promises";
|
|
3535
|
+
import { dirname as dirname13 } from "path";
|
|
3486
3536
|
|
|
3487
3537
|
// src/graph/rank.ts
|
|
3488
3538
|
var KW_BASE_WEIGHT = 2;
|
|
@@ -3882,7 +3932,8 @@ async function rememberEntry(paths, input) {
|
|
|
3882
3932
|
content: input.text,
|
|
3883
3933
|
tags: input.tags ?? [],
|
|
3884
3934
|
files: input.files ?? [],
|
|
3885
|
-
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
3935
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3936
|
+
...input.anchors && input.anchors.length > 0 ? { anchors: input.anchors } : {}
|
|
3886
3937
|
};
|
|
3887
3938
|
await appendEntry(active.paths.contextStore, entry);
|
|
3888
3939
|
const entries = await readEntries(active.paths.contextStore);
|
|
@@ -4101,6 +4152,158 @@ async function pack(files, opts) {
|
|
|
4101
4152
|
};
|
|
4102
4153
|
}
|
|
4103
4154
|
|
|
4155
|
+
// src/server/routes/route-match.ts
|
|
4156
|
+
var ROUTE_MIN_PROMPT_TOKENS = 3;
|
|
4157
|
+
var ROUTE_MAX_AGENTS = 3;
|
|
4158
|
+
var ROUTE_MAX_SKILLS = 2;
|
|
4159
|
+
var FINGERPRINT_BOOST = 2;
|
|
4160
|
+
var NAME_HIT_WEIGHT = 3;
|
|
4161
|
+
var EXT_KEYWORDS = {
|
|
4162
|
+
".svelte": ["svelte"],
|
|
4163
|
+
".vue": ["vue"],
|
|
4164
|
+
".tsx": ["react", "typescript"],
|
|
4165
|
+
".jsx": ["react"],
|
|
4166
|
+
".ts": ["typescript"],
|
|
4167
|
+
".py": ["python"],
|
|
4168
|
+
".cs": ["csharp", "dotnet"],
|
|
4169
|
+
".dart": ["flutter", "dart"],
|
|
4170
|
+
".rs": ["rust"],
|
|
4171
|
+
".go": ["golang", "go"],
|
|
4172
|
+
".java": ["java"],
|
|
4173
|
+
".kt": ["kotlin"],
|
|
4174
|
+
".php": ["php", "laravel"],
|
|
4175
|
+
".rb": ["ruby", "rails"],
|
|
4176
|
+
".hubl": ["hubspot", "hubl"],
|
|
4177
|
+
".html": ["html", "css"]
|
|
4178
|
+
};
|
|
4179
|
+
function fingerprintKeywords(extCounts) {
|
|
4180
|
+
const total = [...extCounts.values()].reduce((a, b) => a + b, 0);
|
|
4181
|
+
const out = /* @__PURE__ */ new Set();
|
|
4182
|
+
if (total === 0) return out;
|
|
4183
|
+
for (const [ext, count] of extCounts) {
|
|
4184
|
+
if (count / total < 0.2) continue;
|
|
4185
|
+
for (const kw of EXT_KEYWORDS[ext] ?? []) out.add(kw);
|
|
4186
|
+
}
|
|
4187
|
+
return out;
|
|
4188
|
+
}
|
|
4189
|
+
function scoreItem(item, qTokens, fingerprint) {
|
|
4190
|
+
const nameTokens = new Set(tokenizeQuery(item.name));
|
|
4191
|
+
const descTokens = new Set(tokenizeQuery(item.description));
|
|
4192
|
+
let score2 = 0;
|
|
4193
|
+
const hits = [];
|
|
4194
|
+
for (const t of qTokens) {
|
|
4195
|
+
if (nameTokens.has(t)) {
|
|
4196
|
+
score2 += NAME_HIT_WEIGHT;
|
|
4197
|
+
hits.push(t);
|
|
4198
|
+
} else if (descTokens.has(t)) {
|
|
4199
|
+
score2 += 1;
|
|
4200
|
+
hits.push(t);
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
for (const kw of fingerprint) {
|
|
4204
|
+
if (nameTokens.has(kw) || descTokens.has(kw)) {
|
|
4205
|
+
score2 += FINGERPRINT_BOOST;
|
|
4206
|
+
break;
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
return { item, score: score2, hits };
|
|
4210
|
+
}
|
|
4211
|
+
function scoreArsenal(prompt, arsenal, extCounts, minScore) {
|
|
4212
|
+
const qTokens = new Set(tokenizeQuery(prompt));
|
|
4213
|
+
if (qTokens.size < ROUTE_MIN_PROMPT_TOKENS) {
|
|
4214
|
+
return { confident: false, agents: [], skills: [] };
|
|
4215
|
+
}
|
|
4216
|
+
const fingerprint = fingerprintKeywords(extCounts);
|
|
4217
|
+
const rank = (items) => items.filter((i) => i.enabled !== false).map((i) => scoreItem(i, qTokens, fingerprint)).filter((s) => s.score > 0).sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
|
|
4218
|
+
const agents = rank(arsenal.agents).slice(0, ROUTE_MAX_AGENTS).map((s) => ({
|
|
4219
|
+
name: s.item.name,
|
|
4220
|
+
score: s.score,
|
|
4221
|
+
reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit",
|
|
4222
|
+
model: s.item.meta?.model?.trim() || "sonnet"
|
|
4223
|
+
}));
|
|
4224
|
+
const skills = rank(arsenal.skills).slice(0, ROUTE_MAX_SKILLS).map((s) => ({
|
|
4225
|
+
name: s.item.name,
|
|
4226
|
+
score: s.score,
|
|
4227
|
+
reason: s.hits.length ? `matches: ${s.hits.slice(0, 4).join(", ")}` : "language fit"
|
|
4228
|
+
}));
|
|
4229
|
+
const confident = agents.length > 0 && (agents[0]?.score ?? 0) >= minScore;
|
|
4230
|
+
return { confident, agents, skills };
|
|
4231
|
+
}
|
|
4232
|
+
function renderHint(match) {
|
|
4233
|
+
if (!match.confident || match.agents.length === 0) return "";
|
|
4234
|
+
const a = match.agents[0];
|
|
4235
|
+
const skill = match.skills[0] ? ` + skill '${match.skills[0].name}'` : "";
|
|
4236
|
+
return `[Synthra route] This task fits agent '${a.name}' (model: ${a.model})${skill}. Plan here first, then delegate execution to it - execution on cheaper models cuts cost ~5x.`;
|
|
4237
|
+
}
|
|
4238
|
+
function renderRouteReport(task, match) {
|
|
4239
|
+
const lines = [`# route_task: "${task}"`, ""];
|
|
4240
|
+
if (match.agents.length === 0 && match.skills.length === 0) {
|
|
4241
|
+
lines.push(
|
|
4242
|
+
"No strong match in the installed Arsenal \u2014 proceed yourself (browse the dashboard's Arsenal tab to see what's available)."
|
|
4243
|
+
);
|
|
4244
|
+
return lines.join("\n");
|
|
4245
|
+
}
|
|
4246
|
+
if (match.agents.length > 0) {
|
|
4247
|
+
lines.push(match.confident ? "Recommended agents:" : "Possible agents (low confidence):");
|
|
4248
|
+
for (const a of match.agents) {
|
|
4249
|
+
lines.push(`- \`${a.name}\` (model: ${a.model}) \u2014 score ${a.score}, ${a.reason}`);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
if (match.skills.length > 0) {
|
|
4253
|
+
lines.push("");
|
|
4254
|
+
lines.push("Relevant skills:");
|
|
4255
|
+
for (const s of match.skills) lines.push(`- \`${s.name}\` \u2014 score ${s.score}, ${s.reason}`);
|
|
4256
|
+
}
|
|
4257
|
+
lines.push("");
|
|
4258
|
+
lines.push(
|
|
4259
|
+
"_Model policy: plan on the primary model; delegate execution to a subagent on a cheaper model (sonnet \u2248 5\xD7 cheaper than opus)._"
|
|
4260
|
+
);
|
|
4261
|
+
return lines.join("\n");
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
// src/server/routes/route.ts
|
|
4265
|
+
import { appendFile as appendFile3, mkdir as mkdir10 } from "fs/promises";
|
|
4266
|
+
import { dirname as dirname12 } from "path";
|
|
4267
|
+
var defaultDeps = { arsenal: (root) => computeArsenal(root) };
|
|
4268
|
+
function graphExtCounts(ctx) {
|
|
4269
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4270
|
+
for (const n of ctx.graph.nodes) {
|
|
4271
|
+
if (n.kind !== "file") continue;
|
|
4272
|
+
const ext = n.ext;
|
|
4273
|
+
counts.set(ext, (counts.get(ext) ?? 0) + 1);
|
|
4274
|
+
}
|
|
4275
|
+
return counts;
|
|
4276
|
+
}
|
|
4277
|
+
var PROMPT_LOG_MAX = 200;
|
|
4278
|
+
async function logRoute(ctx, prompt, hint) {
|
|
4279
|
+
try {
|
|
4280
|
+
await mkdir10(dirname12(ctx.paths.routeLog), { recursive: true });
|
|
4281
|
+
const entry = {
|
|
4282
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4283
|
+
prompt: prompt.length > PROMPT_LOG_MAX ? `${prompt.slice(0, PROMPT_LOG_MAX)}\u2026` : prompt,
|
|
4284
|
+
routed: hint.length > 0,
|
|
4285
|
+
hint_chars: hint.length
|
|
4286
|
+
};
|
|
4287
|
+
await appendFile3(ctx.paths.routeLog, JSON.stringify(entry) + "\n", "utf8");
|
|
4288
|
+
} catch {
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
async function handleRoute(req, ctx, deps = defaultDeps) {
|
|
4292
|
+
const cfg = loadConfig();
|
|
4293
|
+
if (!cfg.route) return { hint: "" };
|
|
4294
|
+
const prompt = typeof req?.prompt === "string" ? req.prompt.trim() : "";
|
|
4295
|
+
if (!prompt) return { hint: "" };
|
|
4296
|
+
try {
|
|
4297
|
+
const arsenal = await deps.arsenal(ctx.paths.projectRoot);
|
|
4298
|
+
const match = scoreArsenal(prompt, arsenal, graphExtCounts(ctx), cfg.routeMinScore);
|
|
4299
|
+
const hint = renderHint(match);
|
|
4300
|
+
await logRoute(ctx, prompt, hint);
|
|
4301
|
+
return { hint };
|
|
4302
|
+
} catch {
|
|
4303
|
+
return { hint: "" };
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4104
4307
|
// src/server/mcp.ts
|
|
4105
4308
|
var PROTOCOL_VERSION = "2024-11-05";
|
|
4106
4309
|
var SERVER_INFO = { name: "synthra", version: "0.0.1" };
|
|
@@ -4184,7 +4387,7 @@ var TOOLS = [
|
|
|
4184
4387
|
files: {
|
|
4185
4388
|
type: "array",
|
|
4186
4389
|
items: { type: "string" },
|
|
4187
|
-
description: "Optional project-relative file paths this entry relates to."
|
|
4390
|
+
description: "Optional project-relative file paths this entry relates to. Linked files also anchor the entry: recall flags it 'possibly stale' if they change, and graph_read of those files surfaces it automatically."
|
|
4188
4391
|
}
|
|
4189
4392
|
},
|
|
4190
4393
|
required: ["text", "kind"]
|
|
@@ -4289,6 +4492,17 @@ var TOOLS = [
|
|
|
4289
4492
|
},
|
|
4290
4493
|
required: ["from", "to"]
|
|
4291
4494
|
}
|
|
4495
|
+
},
|
|
4496
|
+
{
|
|
4497
|
+
name: "route_task",
|
|
4498
|
+
description: "Ask Synthra which installed subagent/skill best fits a task, and which model to run it on. Scores the task against every installed agent and skill (plus the project's language fingerprint). Use BEFORE starting a multi-step implementation task: plan on the primary model, then delegate execution to the recommended agent on a cheaper model (sonnet \u2248 5\xD7 cheaper than opus).",
|
|
4499
|
+
inputSchema: {
|
|
4500
|
+
type: "object",
|
|
4501
|
+
properties: {
|
|
4502
|
+
task: { type: "string", description: "The task to route, in a sentence." }
|
|
4503
|
+
},
|
|
4504
|
+
required: ["task"]
|
|
4505
|
+
}
|
|
4292
4506
|
}
|
|
4293
4507
|
];
|
|
4294
4508
|
async function callTool(name, args, ctx) {
|
|
@@ -4317,6 +4531,8 @@ async function callTool(name, args, ctx) {
|
|
|
4317
4531
|
return duplicateSymbols(args, ctx);
|
|
4318
4532
|
case "call_path":
|
|
4319
4533
|
return callPath(args, ctx);
|
|
4534
|
+
case "route_task":
|
|
4535
|
+
return routeTask(args, ctx);
|
|
4320
4536
|
default:
|
|
4321
4537
|
return errorContent(`Unknown tool: ${name}`);
|
|
4322
4538
|
}
|
|
@@ -4473,6 +4689,17 @@ function testsCoveringLine(graph, filePaths) {
|
|
|
4473
4689
|
const omitted = tests.length - shown.length;
|
|
4474
4690
|
return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
|
|
4475
4691
|
}
|
|
4692
|
+
async function routeTask(args, ctx) {
|
|
4693
|
+
const task = typeof args?.task === "string" ? args.task.trim() : "";
|
|
4694
|
+
if (!task) return errorContent("route_task: 'task' (string) is required");
|
|
4695
|
+
try {
|
|
4696
|
+
const arsenal = await computeArsenal(ctx.paths.projectRoot);
|
|
4697
|
+
const match = scoreArsenal(task, arsenal, graphExtCounts(ctx), loadConfig().routeMinScore);
|
|
4698
|
+
return textContent(renderRouteReport(task, match));
|
|
4699
|
+
} catch (err2) {
|
|
4700
|
+
return errorContent(`route_task: arsenal scan failed \u2014 ${err2.message}`);
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4476
4703
|
function resolveSymbolArg(ctx, arg) {
|
|
4477
4704
|
const a = arg.trim();
|
|
4478
4705
|
if (a.includes("::")) {
|
|
@@ -4726,9 +4953,28 @@ async function graphContinue(args, ctx) {
|
|
|
4726
4953
|
Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
|
|
4727
4954
|
Reason: ${retrieval.reason}
|
|
4728
4955
|
`;
|
|
4729
|
-
|
|
4956
|
+
const remembered = matchRememberedFacts(query, retrieval.files, await safeRecallAll(ctx), ctx);
|
|
4957
|
+
return textContent(`${header}${remembered}
|
|
4730
4958
|
${packed.text}`);
|
|
4731
4959
|
}
|
|
4960
|
+
function matchRememberedFacts(query, retrievedFiles, entries, ctx) {
|
|
4961
|
+
if (entries.length === 0) return "";
|
|
4962
|
+
const qTokens = new Set(tokenizeQuery(query));
|
|
4963
|
+
const retrievedPaths = new Set(retrievedFiles.map((f) => f.path));
|
|
4964
|
+
const scored = entries.map((e) => {
|
|
4965
|
+
let score2 = 0;
|
|
4966
|
+
for (const t of tokenizeQuery(`${e.content} ${e.tags.join(" ")}`)) {
|
|
4967
|
+
if (qTokens.has(t)) score2 += 1;
|
|
4968
|
+
}
|
|
4969
|
+
if (e.files.some((f) => retrievedPaths.has(f)) || e.anchors?.some((a) => retrievedPaths.has(a.path))) {
|
|
4970
|
+
score2 += 2;
|
|
4971
|
+
}
|
|
4972
|
+
return { e, score: score2 };
|
|
4973
|
+
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, PACK_FACTS_MAX);
|
|
4974
|
+
if (scored.length === 0) return "";
|
|
4975
|
+
return `${scored.map((x) => `Remembered: ${factLine(x.e, ctx.graph).slice(2)}`).join("\n")}
|
|
4976
|
+
`;
|
|
4977
|
+
}
|
|
4732
4978
|
function resolveFileTarget(graph, filePath) {
|
|
4733
4979
|
const files = graph.nodes.filter((n) => n.kind === "file");
|
|
4734
4980
|
const exact = files.find((n) => n.path === filePath);
|
|
@@ -4821,6 +5067,43 @@ function buildTestsFooter(symbol, graph) {
|
|
|
4821
5067
|
if (isLikelyEntry(symbol.file)) return "";
|
|
4822
5068
|
return "Tests: none linked to this file.";
|
|
4823
5069
|
}
|
|
5070
|
+
var FACTS_MAX = 3;
|
|
5071
|
+
var FACTS_CONTENT_MAX = 160;
|
|
5072
|
+
var PACK_FACTS_MAX = 2;
|
|
5073
|
+
function staleAnchorPaths(entry, graph) {
|
|
5074
|
+
if (!entry.anchors || entry.anchors.length === 0) return [];
|
|
5075
|
+
const hashByPath = /* @__PURE__ */ new Map();
|
|
5076
|
+
for (const n of graph.nodes) if (n.kind === "file") hashByPath.set(n.path, n.file_hash);
|
|
5077
|
+
return entry.anchors.filter((a) => hashByPath.get(a.path) !== a.hash).map((a) => a.path);
|
|
5078
|
+
}
|
|
5079
|
+
function factLine(entry, graph) {
|
|
5080
|
+
const content = entry.content.length > FACTS_CONTENT_MAX ? `${entry.content.slice(0, FACTS_CONTENT_MAX - 1)}\u2026` : entry.content;
|
|
5081
|
+
const date = entry.date ? ` (${entry.date.slice(0, 10)})` : "";
|
|
5082
|
+
const stale = staleAnchorPaths(entry, graph);
|
|
5083
|
+
const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale[0]} changed since stored` : "";
|
|
5084
|
+
return `- [${entry.type}] ${content}${date}${staleNote}`;
|
|
5085
|
+
}
|
|
5086
|
+
function entryLinksFile(entry, filePath) {
|
|
5087
|
+
if (entry.anchors?.some((a) => a.path === filePath)) return true;
|
|
5088
|
+
return entry.files.some((f) => f === filePath || filePath.endsWith(`/${f}`));
|
|
5089
|
+
}
|
|
5090
|
+
function buildFactsFooter(filePath, entries, graph) {
|
|
5091
|
+
const linked = entries.filter((e) => entryLinksFile(e, filePath));
|
|
5092
|
+
if (linked.length === 0) return "";
|
|
5093
|
+
const newestFirst = linked.slice().reverse();
|
|
5094
|
+
const shown = newestFirst.slice(0, FACTS_MAX);
|
|
5095
|
+
const omitted = newestFirst.length - shown.length;
|
|
5096
|
+
const lines = ["\u{1F4CC} Remembered for this file:", ...shown.map((e) => factLine(e, graph))];
|
|
5097
|
+
if (omitted > 0) lines.push(`\u2026+${omitted} more \u2014 mcp__synthra__context_recall()`);
|
|
5098
|
+
return lines.join("\n");
|
|
5099
|
+
}
|
|
5100
|
+
async function safeRecallAll(ctx) {
|
|
5101
|
+
try {
|
|
5102
|
+
return (await recallEntries(ctx.paths, {})).entries;
|
|
5103
|
+
} catch {
|
|
5104
|
+
return [];
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
4824
5107
|
async function graphRead(args, ctx) {
|
|
4825
5108
|
const target = typeof args?.target === "string" ? args.target : "";
|
|
4826
5109
|
if (!target) return errorContent("graph_read: 'target' (string) is required");
|
|
@@ -4839,10 +5122,15 @@ async function graphRead(args, ctx) {
|
|
|
4839
5122
|
}
|
|
4840
5123
|
const fileNode = resolved.node;
|
|
4841
5124
|
await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
|
|
5125
|
+
const facts = buildFactsFooter(fileNode.path, await safeRecallAll(ctx), ctx.graph);
|
|
5126
|
+
const factsBlock = facts ? `
|
|
5127
|
+
|
|
5128
|
+
---
|
|
5129
|
+
${facts}` : "";
|
|
4842
5130
|
if (!symbolName) {
|
|
4843
5131
|
return textContent(`# ${fileNode.path}
|
|
4844
5132
|
|
|
4845
|
-
${fileNode.content}`);
|
|
5133
|
+
${fileNode.content}${factsBlock}`);
|
|
4846
5134
|
}
|
|
4847
5135
|
const cleanSym = symbolName.trim();
|
|
4848
5136
|
const symbol = ctx.graph.nodes.find(
|
|
@@ -4872,7 +5160,7 @@ ${tests}` : "";
|
|
|
4872
5160
|
return textContent(
|
|
4873
5161
|
`# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
|
|
4874
5162
|
|
|
4875
|
-
${body}${depsBlock}${testsBlock}${editHint}`
|
|
5163
|
+
${body}${depsBlock}${testsBlock}${factsBlock}${editHint}`
|
|
4876
5164
|
);
|
|
4877
5165
|
}
|
|
4878
5166
|
var editedFiles = /* @__PURE__ */ new Set();
|
|
@@ -4907,16 +5195,26 @@ async function contextRemember(args, ctx) {
|
|
|
4907
5195
|
}
|
|
4908
5196
|
const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
|
|
4909
5197
|
const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
5198
|
+
const anchors = [];
|
|
5199
|
+
for (const f of files) {
|
|
5200
|
+
const resolved = resolveFileTarget(ctx.graph, f);
|
|
5201
|
+
if ("node" in resolved) {
|
|
5202
|
+
anchors.push({ path: resolved.node.path, hash: resolved.node.file_hash });
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
4910
5205
|
const result = await rememberEntry(ctx.paths, {
|
|
4911
5206
|
text,
|
|
4912
5207
|
kind: kindRaw,
|
|
4913
5208
|
tags,
|
|
4914
|
-
files
|
|
5209
|
+
files,
|
|
5210
|
+
anchors
|
|
4915
5211
|
});
|
|
5212
|
+
const anchorNote = anchors.length ? `
|
|
5213
|
+
Anchored to ${anchors.length} file(s) \u2014 recall will flag this entry if they change.` : "";
|
|
4916
5214
|
return textContent(
|
|
4917
5215
|
`Remembered ${result.entry.type} on branch '${result.branch}'.
|
|
4918
5216
|
Stored: ${result.storePath}
|
|
4919
|
-
CONTEXT.md refreshed: ${result.contextMdPath}`
|
|
5217
|
+
CONTEXT.md refreshed: ${result.contextMdPath}${anchorNote}`
|
|
4920
5218
|
);
|
|
4921
5219
|
}
|
|
4922
5220
|
var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
|
|
@@ -4951,15 +5249,17 @@ async function contextRecall(args, ctx) {
|
|
|
4951
5249
|
const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
|
|
4952
5250
|
for (const e of result.entries) {
|
|
4953
5251
|
const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
|
|
4954
|
-
|
|
5252
|
+
const stale = staleAnchorPaths(e, ctx.graph);
|
|
5253
|
+
const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale.join(", ")} changed since stored` : "";
|
|
5254
|
+
lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}${staleNote}`);
|
|
4955
5255
|
if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
|
|
4956
5256
|
}
|
|
4957
5257
|
return textContent(lines.join("\n"));
|
|
4958
5258
|
}
|
|
4959
5259
|
async function logToolCall(ctx, tool) {
|
|
4960
5260
|
try {
|
|
4961
|
-
await
|
|
4962
|
-
await
|
|
5261
|
+
await mkdir11(dirname13(ctx.paths.toolLog), { recursive: true });
|
|
5262
|
+
await appendFile4(
|
|
4963
5263
|
ctx.paths.toolLog,
|
|
4964
5264
|
JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
|
|
4965
5265
|
"utf8"
|
|
@@ -5152,8 +5452,8 @@ async function getChangedLineRanges(projectRoot, sinceRef) {
|
|
|
5152
5452
|
}
|
|
5153
5453
|
|
|
5154
5454
|
// src/memory/session.ts
|
|
5155
|
-
import { mkdir as
|
|
5156
|
-
import { dirname as
|
|
5455
|
+
import { mkdir as mkdir12, readFile as readFile17, writeFile as writeFile10 } from "fs/promises";
|
|
5456
|
+
import { dirname as dirname14 } from "path";
|
|
5157
5457
|
var SESSION_SCHEMA_VERSION = 2;
|
|
5158
5458
|
async function readSession(path) {
|
|
5159
5459
|
try {
|
|
@@ -5166,7 +5466,7 @@ async function readSession(path) {
|
|
|
5166
5466
|
}
|
|
5167
5467
|
}
|
|
5168
5468
|
async function writeSession(path, state) {
|
|
5169
|
-
await
|
|
5469
|
+
await mkdir12(dirname14(path), { recursive: true });
|
|
5170
5470
|
await writeFile10(path, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
5171
5471
|
}
|
|
5172
5472
|
|
|
@@ -5214,12 +5514,12 @@ async function handleContextUpdate(req, ctx) {
|
|
|
5214
5514
|
}
|
|
5215
5515
|
|
|
5216
5516
|
// src/server/routes/gate.ts
|
|
5217
|
-
import { appendFile as
|
|
5218
|
-
import { dirname as
|
|
5517
|
+
import { appendFile as appendFile6, mkdir as mkdir14 } from "fs/promises";
|
|
5518
|
+
import { dirname as dirname16 } from "path";
|
|
5219
5519
|
|
|
5220
5520
|
// src/server/routes/bash-observe.ts
|
|
5221
|
-
import { appendFile as
|
|
5222
|
-
import { dirname as
|
|
5521
|
+
import { appendFile as appendFile5, mkdir as mkdir13 } from "fs/promises";
|
|
5522
|
+
import { dirname as dirname15 } from "path";
|
|
5223
5523
|
|
|
5224
5524
|
// src/server/routes/query-heuristics.ts
|
|
5225
5525
|
function looksLikeNonSymbolQuery(pattern) {
|
|
@@ -5378,7 +5678,7 @@ var CMD_MAX = 300;
|
|
|
5378
5678
|
var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
|
|
5379
5679
|
async function logObservation(ctx, exp, confidence, avoidable, command) {
|
|
5380
5680
|
try {
|
|
5381
|
-
await
|
|
5681
|
+
await mkdir13(dirname15(ctx.paths.bashLog), { recursive: true });
|
|
5382
5682
|
const entry = {
|
|
5383
5683
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5384
5684
|
kind: exp.kind,
|
|
@@ -5388,7 +5688,7 @@ async function logObservation(ctx, exp, confidence, avoidable, command) {
|
|
|
5388
5688
|
avoidable,
|
|
5389
5689
|
command: trunc(command, CMD_MAX)
|
|
5390
5690
|
};
|
|
5391
|
-
await
|
|
5691
|
+
await appendFile5(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
|
|
5392
5692
|
} catch {
|
|
5393
5693
|
}
|
|
5394
5694
|
}
|
|
@@ -5458,7 +5758,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
|
|
|
5458
5758
|
var LOG_REASON_MAX_CHARS = 240;
|
|
5459
5759
|
async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
|
|
5460
5760
|
try {
|
|
5461
|
-
await
|
|
5761
|
+
await mkdir14(dirname16(ctx.paths.gateLog), { recursive: true });
|
|
5462
5762
|
const entry = {
|
|
5463
5763
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5464
5764
|
tool: toolName,
|
|
@@ -5467,7 +5767,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
|
|
|
5467
5767
|
reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
|
|
5468
5768
|
...hintChars === void 0 ? {} : { hint_chars: hintChars }
|
|
5469
5769
|
};
|
|
5470
|
-
await
|
|
5770
|
+
await appendFile6(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
|
|
5471
5771
|
} catch {
|
|
5472
5772
|
}
|
|
5473
5773
|
}
|
|
@@ -5590,16 +5890,16 @@ async function handleGate(req, ctx) {
|
|
|
5590
5890
|
}
|
|
5591
5891
|
|
|
5592
5892
|
// src/server/routes/log.ts
|
|
5593
|
-
import { appendFile as
|
|
5594
|
-
import { dirname as
|
|
5893
|
+
import { appendFile as appendFile7, mkdir as mkdir15 } from "fs/promises";
|
|
5894
|
+
import { dirname as dirname17 } from "path";
|
|
5595
5895
|
async function handleLog(entry, ctx) {
|
|
5596
5896
|
if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
|
|
5597
5897
|
throw new Error("log: input_tokens and output_tokens (number) are required");
|
|
5598
5898
|
}
|
|
5599
5899
|
const written_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
5600
5900
|
const record = { ...entry, written_at };
|
|
5601
|
-
await
|
|
5602
|
-
await
|
|
5901
|
+
await mkdir15(dirname17(ctx.paths.tokenLog), { recursive: true });
|
|
5902
|
+
await appendFile7(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
|
|
5603
5903
|
return { ok: true, written_at };
|
|
5604
5904
|
}
|
|
5605
5905
|
|
|
@@ -5793,6 +6093,10 @@ function buildApp(ctx, port) {
|
|
|
5793
6093
|
const body = await c.req.json().catch(() => ({}));
|
|
5794
6094
|
return c.json(await handleGate(body, ctx));
|
|
5795
6095
|
});
|
|
6096
|
+
app.post("/route", async (c) => {
|
|
6097
|
+
const body = await c.req.json().catch(() => ({}));
|
|
6098
|
+
return c.json(await handleRoute(body, ctx));
|
|
6099
|
+
});
|
|
5796
6100
|
app.get("/activity", async (c) => {
|
|
5797
6101
|
const sinceParam = c.req.query("since");
|
|
5798
6102
|
const sinceMs = sinceParam ? Number(sinceParam) : void 0;
|
|
@@ -6099,7 +6403,7 @@ import { readFile as readFile20, readdir as readdir4, rm, rmdir, stat as stat5,
|
|
|
6099
6403
|
import { basename as basename6, join as join13, resolve as resolve4 } from "path";
|
|
6100
6404
|
|
|
6101
6405
|
// src/cli/self-update.ts
|
|
6102
|
-
import { mkdir as
|
|
6406
|
+
import { mkdir as mkdir16, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
|
|
6103
6407
|
import { homedir as homedir4 } from "os";
|
|
6104
6408
|
import { join as join12 } from "path";
|
|
6105
6409
|
import { createInterface } from "readline/promises";
|
|
@@ -6166,7 +6470,7 @@ async function readLastSeen() {
|
|
|
6166
6470
|
}
|
|
6167
6471
|
async function writeLastSeen(version) {
|
|
6168
6472
|
try {
|
|
6169
|
-
await
|
|
6473
|
+
await mkdir16(SYNTHRA_DIR, { recursive: true });
|
|
6170
6474
|
const data = { version, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
6171
6475
|
await writeFile12(LAST_SEEN_PATH, JSON.stringify(data, null, 2), "utf8");
|
|
6172
6476
|
} catch {
|
|
@@ -6336,7 +6640,8 @@ var HOOK_BASENAMES = [
|
|
|
6336
6640
|
"synthra-prime",
|
|
6337
6641
|
"synthra-pre-tool-use",
|
|
6338
6642
|
"synthra-pre-compact",
|
|
6339
|
-
"synthra-stop"
|
|
6643
|
+
"synthra-stop",
|
|
6644
|
+
"synthra-route"
|
|
6340
6645
|
];
|
|
6341
6646
|
async function exists3(path) {
|
|
6342
6647
|
try {
|