@jefuriiij/synthra 0.9.0 → 0.11.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 +36 -0
- package/dist/cli/index.js +362 -34
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +31 -7
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +326 -24
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -1785,6 +1785,7 @@ function resolvePaths(projectRoot) {
|
|
|
1785
1785
|
activityLog: join5(graphDir, "activity.jsonl"),
|
|
1786
1786
|
tokenLog: join5(graphDir, "token_log.jsonl"),
|
|
1787
1787
|
gateLog: join5(graphDir, "gate_log.jsonl"),
|
|
1788
|
+
bashLog: join5(graphDir, "bash_log.jsonl"),
|
|
1788
1789
|
toolLog: join5(graphDir, "tool_log.jsonl"),
|
|
1789
1790
|
accessLog: join5(graphDir, "access_log.jsonl"),
|
|
1790
1791
|
learnStore: join5(graphDir, "learn_store.json"),
|
|
@@ -2341,6 +2342,10 @@ function loadConfig() {
|
|
|
2341
2342
|
// mid-session. Set SYN_NO_AUTOREINDEX to disable entirely.
|
|
2342
2343
|
reindexDebounceMs: num("SYN_REINDEX_DEBOUNCE_MS", 1e3),
|
|
2343
2344
|
autoReindex: !process.env.SYN_NO_AUTOREINDEX,
|
|
2345
|
+
// Observe-only: log codebase-exploration Bash commands (grep/cat/find …) so
|
|
2346
|
+
// the terminal bypass of the Moat can be measured. Never blocks. Opt out
|
|
2347
|
+
// with SYN_NO_BASH_OBSERVE.
|
|
2348
|
+
bashObserve: !process.env.SYN_NO_BASH_OBSERVE,
|
|
2344
2349
|
mcpPort: process.env.SYN_MCP_PORT ? num("SYN_MCP_PORT", 0) : null,
|
|
2345
2350
|
dashboardPort: num("SYN_DASHBOARD_PORT", 8901),
|
|
2346
2351
|
logLevel: str("SYN_LOG_LEVEL", "info"),
|
|
@@ -3101,11 +3106,14 @@ var TOOLS = [
|
|
|
3101
3106
|
},
|
|
3102
3107
|
{
|
|
3103
3108
|
name: "blast_radius",
|
|
3104
|
-
description: "
|
|
3109
|
+
description: "See what could break before an edit. A bare file target returns all files that depend on it transitively via imports, tests, and call edges. A 'file::symbol' target returns the exact caller SYMBOLS that transitively call it (name \u2192 file:line) plus the test files guarding the impact \u2014 the precise rename-safety view. Call edges are name-resolved (precise within a file, unique-name across files).",
|
|
3105
3110
|
inputSchema: {
|
|
3106
3111
|
type: "object",
|
|
3107
3112
|
properties: {
|
|
3108
|
-
target: {
|
|
3113
|
+
target: {
|
|
3114
|
+
type: "string",
|
|
3115
|
+
description: "File path (file-level dependents) or 'file::symbol' (caller symbols)."
|
|
3116
|
+
},
|
|
3109
3117
|
depth: { type: "number", description: "Max hops to traverse. Default 3." }
|
|
3110
3118
|
},
|
|
3111
3119
|
required: ["target"]
|
|
@@ -3156,7 +3164,8 @@ function blastRadius(args, ctx) {
|
|
|
3156
3164
|
const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
|
|
3157
3165
|
const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
|
|
3158
3166
|
if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
|
|
3159
|
-
|
|
3167
|
+
if (targetRaw.includes("::")) return blastRadiusSymbol(targetRaw, maxDepth, ctx);
|
|
3168
|
+
const filePath = targetRaw;
|
|
3160
3169
|
const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
|
|
3161
3170
|
if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
|
|
3162
3171
|
const fileIdBySymbol = /* @__PURE__ */ new Map();
|
|
@@ -3211,6 +3220,92 @@ _(no dependents \u2014 file is isolated)_`);
|
|
|
3211
3220
|
}
|
|
3212
3221
|
return textContent(lines.join("\n"));
|
|
3213
3222
|
}
|
|
3223
|
+
function blastRadiusSymbol(targetRaw, maxDepth, ctx) {
|
|
3224
|
+
const [rawFile, rawSym] = targetRaw.split("::", 2);
|
|
3225
|
+
const filePath = (rawFile ?? "").trim();
|
|
3226
|
+
const symName = (rawSym ?? "").trim();
|
|
3227
|
+
if (!symName) return errorContent("blast_radius: 'file::symbol' target needs a symbol name");
|
|
3228
|
+
const resolved = resolveFileTarget(ctx.graph, filePath);
|
|
3229
|
+
if ("ambiguous" in resolved) {
|
|
3230
|
+
const shown = resolved.ambiguous.slice(0, 5).join(", ");
|
|
3231
|
+
return errorContent(
|
|
3232
|
+
`blast_radius: '${filePath}' matches multiple files (${shown}). Pass a longer path.`
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
if ("none" in resolved) return errorContent(`blast_radius: file not in graph: ${filePath}`);
|
|
3236
|
+
const fileNode = resolved.node;
|
|
3237
|
+
const symbol = ctx.graph.nodes.find(
|
|
3238
|
+
(n) => n.kind === "symbol" && n.file === fileNode.path && n.name === symName
|
|
3239
|
+
);
|
|
3240
|
+
if (!symbol)
|
|
3241
|
+
return errorContent(`blast_radius: symbol '${symName}' not found in ${fileNode.path}`);
|
|
3242
|
+
const callersBySym = /* @__PURE__ */ new Map();
|
|
3243
|
+
for (const e of ctx.graph.edges) {
|
|
3244
|
+
if (e.kind !== "calls" || e.from === e.to) continue;
|
|
3245
|
+
const list = callersBySym.get(e.to) ?? [];
|
|
3246
|
+
list.push(e.from);
|
|
3247
|
+
callersBySym.set(e.to, list);
|
|
3248
|
+
}
|
|
3249
|
+
const symById = /* @__PURE__ */ new Map();
|
|
3250
|
+
for (const n of ctx.graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
|
|
3251
|
+
const visited = /* @__PURE__ */ new Set([symbol.id]);
|
|
3252
|
+
const hits = [];
|
|
3253
|
+
let frontier = [symbol.id];
|
|
3254
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
3255
|
+
const next = [];
|
|
3256
|
+
for (const cur of frontier) {
|
|
3257
|
+
for (const fromId of callersBySym.get(cur) ?? []) {
|
|
3258
|
+
if (visited.has(fromId)) continue;
|
|
3259
|
+
visited.add(fromId);
|
|
3260
|
+
next.push(fromId);
|
|
3261
|
+
const s = symById.get(fromId);
|
|
3262
|
+
if (s) hits.push({ name: s.name, file: s.file, line: s.start_line, depth: d });
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
frontier = next;
|
|
3266
|
+
if (next.length === 0) break;
|
|
3267
|
+
}
|
|
3268
|
+
const header = `# Blast radius for ${fileNode.path}::${symbol.name} (callers, depth \u2264 ${maxDepth})`;
|
|
3269
|
+
if (hits.length === 0) {
|
|
3270
|
+
const tline2 = testsCoveringLine(ctx.graph, [fileNode.path]);
|
|
3271
|
+
return textContent(
|
|
3272
|
+
`${header}
|
|
3273
|
+
|
|
3274
|
+
_(no callers \u2014 safe to rename)_${tline2 ? `
|
|
3275
|
+
|
|
3276
|
+
${tline2}` : ""}`
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
hits.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file) || a.line - b.line);
|
|
3280
|
+
const lines = [header, "", `${hits.length} caller symbol(s):`];
|
|
3281
|
+
for (const h of hits) lines.push(`- **depth ${h.depth}** \`${h.name}\` \u2192 ${h.file}:${h.line}`);
|
|
3282
|
+
const tline = testsCoveringLine(ctx.graph, [fileNode.path, ...hits.map((h) => h.file)]);
|
|
3283
|
+
if (tline) {
|
|
3284
|
+
lines.push("");
|
|
3285
|
+
lines.push(tline);
|
|
3286
|
+
}
|
|
3287
|
+
return textContent(lines.join("\n"));
|
|
3288
|
+
}
|
|
3289
|
+
function testsCoveringLine(graph, filePaths) {
|
|
3290
|
+
const fileByPath = /* @__PURE__ */ new Map();
|
|
3291
|
+
for (const n of graph.nodes) if (n.kind === "file") fileByPath.set(n.path, n);
|
|
3292
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3293
|
+
const tests = [];
|
|
3294
|
+
for (const p of new Set(filePaths)) {
|
|
3295
|
+
const fn = fileByPath.get(p);
|
|
3296
|
+
if (!fn) continue;
|
|
3297
|
+
for (const t of findTestsForFile(graph, fn)) {
|
|
3298
|
+
if (!seen.has(t.path)) {
|
|
3299
|
+
seen.add(t.path);
|
|
3300
|
+
tests.push(t.path);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
if (tests.length === 0) return "";
|
|
3305
|
+
const shown = tests.slice(0, TESTS_MAX_FILES);
|
|
3306
|
+
const omitted = tests.length - shown.length;
|
|
3307
|
+
return `Tests covering the impact: ${shown.join(" \xB7 ")}${omitted > 0 ? ` \u2026+${omitted} more` : ""}`;
|
|
3308
|
+
}
|
|
3214
3309
|
var LIKELY_ENTRY_PATTERNS = [
|
|
3215
3310
|
/(?:^|\/)main\.[a-z0-9_]+$/i,
|
|
3216
3311
|
/(?:^|\/)index\.[a-z0-9_]+$/i,
|
|
@@ -3288,6 +3383,7 @@ function resolveFileTarget(graph, filePath) {
|
|
|
3288
3383
|
var DEPS_SIG_MAX = 140;
|
|
3289
3384
|
var DEPS_MAX_CALLEES = 10;
|
|
3290
3385
|
var DEPS_MAX_CALLERS = 12;
|
|
3386
|
+
var TESTS_MAX_FILES = 6;
|
|
3291
3387
|
function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars) {
|
|
3292
3388
|
const symById = /* @__PURE__ */ new Map();
|
|
3293
3389
|
for (const n of graph.nodes) if (n.kind === "symbol") symById.set(n.id, n);
|
|
@@ -3351,6 +3447,21 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
|
|
|
3351
3447
|
}
|
|
3352
3448
|
return lines.join("\n");
|
|
3353
3449
|
}
|
|
3450
|
+
function buildTestsFooter(symbol, graph) {
|
|
3451
|
+
const fileNode = graph.nodes.find(
|
|
3452
|
+
(n) => n.kind === "file" && n.path === symbol.file
|
|
3453
|
+
);
|
|
3454
|
+
if (!fileNode) return "";
|
|
3455
|
+
const tests = findTestsForFile(graph, fileNode);
|
|
3456
|
+
if (tests.length > 0) {
|
|
3457
|
+
const shown = tests.slice(0, TESTS_MAX_FILES).map((t) => t.path);
|
|
3458
|
+
const omitted = tests.length - shown.length;
|
|
3459
|
+
const more = omitted > 0 ? ` \u2026+${omitted} more` : "";
|
|
3460
|
+
return `Tests (file-level): ${shown.join(" \xB7 ")}${more} \u2014 run after editing`;
|
|
3461
|
+
}
|
|
3462
|
+
if (isLikelyEntry(symbol.file)) return "";
|
|
3463
|
+
return "Tests: none linked to this file.";
|
|
3464
|
+
}
|
|
3354
3465
|
async function graphRead(args, ctx) {
|
|
3355
3466
|
const target = typeof args?.target === "string" ? args.target : "";
|
|
3356
3467
|
if (!target) return errorContent("graph_read: 'target' (string) is required");
|
|
@@ -3394,10 +3505,15 @@ ${fileNode.content}`);
|
|
|
3394
3505
|
|
|
3395
3506
|
---
|
|
3396
3507
|
${deps}` : "";
|
|
3508
|
+
const tests = buildTestsFooter(symbol, ctx.graph);
|
|
3509
|
+
const testsBlock = tests ? `
|
|
3510
|
+
|
|
3511
|
+
---
|
|
3512
|
+
${tests}` : "";
|
|
3397
3513
|
return textContent(
|
|
3398
3514
|
`# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
|
|
3399
3515
|
|
|
3400
|
-
${body}${depsBlock}${editHint}`
|
|
3516
|
+
${body}${depsBlock}${testsBlock}${editHint}`
|
|
3401
3517
|
);
|
|
3402
3518
|
}
|
|
3403
3519
|
var editedFiles = /* @__PURE__ */ new Set();
|
|
@@ -3716,22 +3832,14 @@ async function handleContextUpdate(req, ctx) {
|
|
|
3716
3832
|
}
|
|
3717
3833
|
|
|
3718
3834
|
// src/server/routes/gate.ts
|
|
3835
|
+
import { appendFile as appendFile5, mkdir as mkdir11 } from "fs/promises";
|
|
3836
|
+
import { dirname as dirname12 } from "path";
|
|
3837
|
+
|
|
3838
|
+
// src/server/routes/bash-observe.ts
|
|
3719
3839
|
import { appendFile as appendFile4, mkdir as mkdir10 } from "fs/promises";
|
|
3720
3840
|
import { dirname as dirname11 } from "path";
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
function extractQuery(toolName, input) {
|
|
3724
|
-
if (toolName === "Grep") {
|
|
3725
|
-
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
3726
|
-
const query = typeof input.query === "string" ? input.query : "";
|
|
3727
|
-
return (pattern || query).trim() || null;
|
|
3728
|
-
}
|
|
3729
|
-
if (toolName === "Glob") {
|
|
3730
|
-
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
3731
|
-
return pattern.replace(/[*?/\\.]+/g, " ").trim() || null;
|
|
3732
|
-
}
|
|
3733
|
-
return null;
|
|
3734
|
-
}
|
|
3841
|
+
|
|
3842
|
+
// src/server/routes/query-heuristics.ts
|
|
3735
3843
|
function looksLikeNonSymbolQuery(pattern) {
|
|
3736
3844
|
if (/<\/?[a-zA-Z]/.test(pattern)) return true;
|
|
3737
3845
|
if (/[a-zA-Z][\w-]*-[\w-]*\s*=/.test(pattern)) return true;
|
|
@@ -3747,6 +3855,195 @@ function looksLikeNonSymbolQuery(pattern) {
|
|
|
3747
3855
|
if (branches.length > 0 && branches.every(isKebab)) return true;
|
|
3748
3856
|
return false;
|
|
3749
3857
|
}
|
|
3858
|
+
|
|
3859
|
+
// src/server/routes/bash-observe.ts
|
|
3860
|
+
var SEARCH_TOOLS = /* @__PURE__ */ new Set(["grep", "egrep", "fgrep", "rg", "ripgrep", "ag", "ack"]);
|
|
3861
|
+
var READ_TOOLS = /* @__PURE__ */ new Set(["cat", "head", "tail", "less", "more", "bat", "tac"]);
|
|
3862
|
+
var LIST_TOOLS = /* @__PURE__ */ new Set(["find", "tree"]);
|
|
3863
|
+
var SOURCE_EXT = /\.(ts|tsx|js|jsx|cts|mts|cjs|mjs|py|pyi|svelte|vue|go|rs|java|kt|kts|php|rb|c|h|cpp|cc|cxx|hpp|cs|dart|json|md|css|html|hubl|sh|yml|yaml|toml)$/i;
|
|
3864
|
+
function tokenizeCommand(cmd) {
|
|
3865
|
+
const tokens = [];
|
|
3866
|
+
let cur = "";
|
|
3867
|
+
let quote = null;
|
|
3868
|
+
let hasContent2 = false;
|
|
3869
|
+
const flush = () => {
|
|
3870
|
+
if (hasContent2) tokens.push(cur);
|
|
3871
|
+
cur = "";
|
|
3872
|
+
hasContent2 = false;
|
|
3873
|
+
};
|
|
3874
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
3875
|
+
const ch = cmd[i];
|
|
3876
|
+
if (quote) {
|
|
3877
|
+
if (ch === quote) quote = null;
|
|
3878
|
+
else cur += ch;
|
|
3879
|
+
hasContent2 = true;
|
|
3880
|
+
continue;
|
|
3881
|
+
}
|
|
3882
|
+
if (ch === '"' || ch === "'") {
|
|
3883
|
+
quote = ch;
|
|
3884
|
+
hasContent2 = true;
|
|
3885
|
+
continue;
|
|
3886
|
+
}
|
|
3887
|
+
if (ch === "|" || ch === "&" || ch === ";") {
|
|
3888
|
+
flush();
|
|
3889
|
+
let op = ch;
|
|
3890
|
+
if ((ch === "|" || ch === "&") && cmd[i + 1] === ch) {
|
|
3891
|
+
op += ch;
|
|
3892
|
+
i++;
|
|
3893
|
+
}
|
|
3894
|
+
tokens.push(op);
|
|
3895
|
+
continue;
|
|
3896
|
+
}
|
|
3897
|
+
if (/\s/.test(ch)) {
|
|
3898
|
+
flush();
|
|
3899
|
+
continue;
|
|
3900
|
+
}
|
|
3901
|
+
cur += ch;
|
|
3902
|
+
hasContent2 = true;
|
|
3903
|
+
}
|
|
3904
|
+
flush();
|
|
3905
|
+
return tokens;
|
|
3906
|
+
}
|
|
3907
|
+
function isOperator(t) {
|
|
3908
|
+
return t === "|" || t === "||" || t === "&&" || t === ";";
|
|
3909
|
+
}
|
|
3910
|
+
function splitSegments(tokens) {
|
|
3911
|
+
const segs = [];
|
|
3912
|
+
let cur = [];
|
|
3913
|
+
for (const t of tokens) {
|
|
3914
|
+
if (isOperator(t)) {
|
|
3915
|
+
if (cur.length) segs.push(cur);
|
|
3916
|
+
cur = [];
|
|
3917
|
+
} else {
|
|
3918
|
+
cur.push(t);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
if (cur.length) segs.push(cur);
|
|
3922
|
+
return segs;
|
|
3923
|
+
}
|
|
3924
|
+
function commandTokens(seg) {
|
|
3925
|
+
let i = 0;
|
|
3926
|
+
while (i < seg.length) {
|
|
3927
|
+
const t = seg[i];
|
|
3928
|
+
if (t === "env") {
|
|
3929
|
+
i++;
|
|
3930
|
+
continue;
|
|
3931
|
+
}
|
|
3932
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
3933
|
+
i++;
|
|
3934
|
+
continue;
|
|
3935
|
+
}
|
|
3936
|
+
break;
|
|
3937
|
+
}
|
|
3938
|
+
return seg.slice(i);
|
|
3939
|
+
}
|
|
3940
|
+
function baseCmd(tok) {
|
|
3941
|
+
return (tok.split(/[\\/]/).pop() ?? tok).toLowerCase();
|
|
3942
|
+
}
|
|
3943
|
+
function classifySegment(seg) {
|
|
3944
|
+
const toks = commandTokens(seg);
|
|
3945
|
+
if (toks.length === 0) return null;
|
|
3946
|
+
const cmd = baseCmd(toks[0]);
|
|
3947
|
+
const rest = toks.slice(1);
|
|
3948
|
+
const flags = rest.filter((a) => a.startsWith("-"));
|
|
3949
|
+
const nonFlags = rest.filter((a) => !a.startsWith("-"));
|
|
3950
|
+
if (SEARCH_TOOLS.has(cmd)) {
|
|
3951
|
+
const pattern = nonFlags[0];
|
|
3952
|
+
if (!pattern) return null;
|
|
3953
|
+
const recursive = flags.some(
|
|
3954
|
+
(f) => f === "-r" || f === "-R" || f === "--recursive" || /^-[a-zA-Z]*[rR]$/.test(f)
|
|
3955
|
+
);
|
|
3956
|
+
const rgLike = cmd === "rg" || cmd === "ripgrep" || cmd === "ag" || cmd === "ack";
|
|
3957
|
+
const hasPathArg = nonFlags.length > 1;
|
|
3958
|
+
if (!rgLike && !recursive && !hasPathArg) return null;
|
|
3959
|
+
return { kind: "search", tool: cmd, query: pattern };
|
|
3960
|
+
}
|
|
3961
|
+
if (READ_TOOLS.has(cmd)) {
|
|
3962
|
+
const file = nonFlags.find((a) => SOURCE_EXT.test(a));
|
|
3963
|
+
if (!file) return null;
|
|
3964
|
+
return { kind: "read", tool: cmd, query: file };
|
|
3965
|
+
}
|
|
3966
|
+
if (LIST_TOOLS.has(cmd)) {
|
|
3967
|
+
const nameIdx = rest.findIndex((a) => a === "-name" || a === "-iname" || a === "-path");
|
|
3968
|
+
const target = nameIdx >= 0 ? rest[nameIdx + 1] ?? null : nonFlags[0] ?? ".";
|
|
3969
|
+
return { kind: "list", tool: cmd, query: target };
|
|
3970
|
+
}
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
function classifyBashCommand(command) {
|
|
3974
|
+
if (!command || typeof command !== "string") return null;
|
|
3975
|
+
const tokens = tokenizeCommand(command);
|
|
3976
|
+
if (tokens.length === 0) return null;
|
|
3977
|
+
if (tokens.some((t) => !isOperator(t) && t.includes(">"))) return null;
|
|
3978
|
+
const found = splitSegments(tokens).map(classifySegment).filter((x) => x !== null);
|
|
3979
|
+
if (found.length === 0) return null;
|
|
3980
|
+
const prio = { search: 0, read: 1, list: 2 };
|
|
3981
|
+
found.sort((a, b) => prio[a.kind] - prio[b.kind]);
|
|
3982
|
+
return found[0];
|
|
3983
|
+
}
|
|
3984
|
+
function graphHasFile(ctx, target) {
|
|
3985
|
+
const base = target.split(/[\\/]/).pop() ?? target;
|
|
3986
|
+
for (const n of ctx.graph.nodes) {
|
|
3987
|
+
if (n.kind !== "file") continue;
|
|
3988
|
+
if (n.path === target || n.path.endsWith(`/${target}`) || n.path.split("/").pop() === base) {
|
|
3989
|
+
return true;
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
return false;
|
|
3993
|
+
}
|
|
3994
|
+
var Q_MAX = 200;
|
|
3995
|
+
var CMD_MAX = 300;
|
|
3996
|
+
var trunc = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
|
|
3997
|
+
async function logObservation(ctx, exp, confidence, avoidable, command) {
|
|
3998
|
+
try {
|
|
3999
|
+
await mkdir10(dirname11(ctx.paths.bashLog), { recursive: true });
|
|
4000
|
+
const entry = {
|
|
4001
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4002
|
+
kind: exp.kind,
|
|
4003
|
+
tool: exp.tool,
|
|
4004
|
+
query: exp.query ? trunc(exp.query, Q_MAX) : null,
|
|
4005
|
+
confidence,
|
|
4006
|
+
avoidable,
|
|
4007
|
+
command: trunc(command, CMD_MAX)
|
|
4008
|
+
};
|
|
4009
|
+
await appendFile4(ctx.paths.bashLog, JSON.stringify(entry) + "\n", "utf8");
|
|
4010
|
+
} catch {
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
async function observeBash(input, ctx) {
|
|
4014
|
+
if (!loadConfig().bashObserve) return;
|
|
4015
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
4016
|
+
const exp = classifyBashCommand(command);
|
|
4017
|
+
if (!exp) return;
|
|
4018
|
+
let confidence = null;
|
|
4019
|
+
let avoidable = false;
|
|
4020
|
+
if (exp.kind === "search" && exp.query) {
|
|
4021
|
+
if (!looksLikeNonSymbolQuery(exp.query)) {
|
|
4022
|
+
const r = await retrieve(ctx.graph, exp.query);
|
|
4023
|
+
confidence = r.confidence;
|
|
4024
|
+
avoidable = r.confidence !== "low" && r.symbolMatched;
|
|
4025
|
+
}
|
|
4026
|
+
} else if (exp.kind === "read" && exp.query) {
|
|
4027
|
+
avoidable = graphHasFile(ctx, exp.query);
|
|
4028
|
+
}
|
|
4029
|
+
await logObservation(ctx, exp, confidence, avoidable, command);
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
// src/server/routes/gate.ts
|
|
4033
|
+
var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
|
|
4034
|
+
var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
|
|
4035
|
+
function extractQuery(toolName, input) {
|
|
4036
|
+
if (toolName === "Grep") {
|
|
4037
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
4038
|
+
const query = typeof input.query === "string" ? input.query : "";
|
|
4039
|
+
return (pattern || query).trim() || null;
|
|
4040
|
+
}
|
|
4041
|
+
if (toolName === "Glob") {
|
|
4042
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
4043
|
+
return pattern.replace(/[*?/\\.]+/g, " ").trim() || null;
|
|
4044
|
+
}
|
|
4045
|
+
return null;
|
|
4046
|
+
}
|
|
3750
4047
|
function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
|
|
3751
4048
|
if (recentPaths.length === 0) return [];
|
|
3752
4049
|
const recent = new Set(recentPaths);
|
|
@@ -3779,7 +4076,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
|
|
|
3779
4076
|
var LOG_REASON_MAX_CHARS = 240;
|
|
3780
4077
|
async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
|
|
3781
4078
|
try {
|
|
3782
|
-
await
|
|
4079
|
+
await mkdir11(dirname12(ctx.paths.gateLog), { recursive: true });
|
|
3783
4080
|
const entry = {
|
|
3784
4081
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3785
4082
|
tool: toolName,
|
|
@@ -3788,7 +4085,7 @@ async function logDecision(ctx, toolName, query, decision, reason, hintChars) {
|
|
|
3788
4085
|
reason: reason && reason.length > LOG_REASON_MAX_CHARS ? `${reason.slice(0, LOG_REASON_MAX_CHARS)}\u2026` : reason,
|
|
3789
4086
|
...hintChars === void 0 ? {} : { hint_chars: hintChars }
|
|
3790
4087
|
};
|
|
3791
|
-
await
|
|
4088
|
+
await appendFile5(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
|
|
3792
4089
|
} catch {
|
|
3793
4090
|
}
|
|
3794
4091
|
}
|
|
@@ -3853,6 +4150,11 @@ async function handleGate(req, ctx) {
|
|
|
3853
4150
|
if (!req?.tool_name || typeof req.tool_name !== "string") {
|
|
3854
4151
|
return { decision: "allow", reason: "no tool_name" };
|
|
3855
4152
|
}
|
|
4153
|
+
if (req.tool_name === "Bash") {
|
|
4154
|
+
const input2 = req.tool_input && typeof req.tool_input === "object" ? req.tool_input : {};
|
|
4155
|
+
await observeBash(input2, ctx);
|
|
4156
|
+
return { decision: "allow" };
|
|
4157
|
+
}
|
|
3856
4158
|
if (!BLOCKABLE_TOOLS.has(req.tool_name)) {
|
|
3857
4159
|
return { decision: "allow" };
|
|
3858
4160
|
}
|
|
@@ -3906,16 +4208,16 @@ async function handleGate(req, ctx) {
|
|
|
3906
4208
|
}
|
|
3907
4209
|
|
|
3908
4210
|
// src/server/routes/log.ts
|
|
3909
|
-
import { appendFile as
|
|
3910
|
-
import { dirname as
|
|
4211
|
+
import { appendFile as appendFile6, mkdir as mkdir12 } from "fs/promises";
|
|
4212
|
+
import { dirname as dirname13 } from "path";
|
|
3911
4213
|
async function handleLog(entry, ctx) {
|
|
3912
4214
|
if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
|
|
3913
4215
|
throw new Error("log: input_tokens and output_tokens (number) are required");
|
|
3914
4216
|
}
|
|
3915
4217
|
const written_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3916
4218
|
const record = { ...entry, written_at };
|
|
3917
|
-
await
|
|
3918
|
-
await
|
|
4219
|
+
await mkdir12(dirname13(ctx.paths.tokenLog), { recursive: true });
|
|
4220
|
+
await appendFile6(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
|
|
3919
4221
|
return { ok: true, written_at };
|
|
3920
4222
|
}
|
|
3921
4223
|
|