@jefuriiij/synthra 0.1.21 → 0.1.23
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 +81 -0
- package/dist/cli/index.js +105 -47
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +10 -5
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +68 -15
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,87 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.1.23] — 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Dashboard token-log dedupe can now be disabled via `SYN_DASHBOARD_DEDUPE=0`.**
|
|
15
|
+
By default, `delta.ts` deduplicates `token_log.jsonl` entries that share the
|
|
16
|
+
same project, usage totals, and second-rounded timestamp — collapsing the
|
|
17
|
+
duplicate writes that a co-installed AI tool's Stop hook may produce. Set
|
|
18
|
+
`SYN_DASHBOARD_DEDUPE=0` (also accepts `off` or `false`) to see every raw
|
|
19
|
+
entry. Useful when debugging multi-tool coexistence or auditing raw log data.
|
|
20
|
+
|
|
21
|
+
- **Graph schema-migration check on load.** A new `SCHEMA_VERSION` constant is
|
|
22
|
+
exported from `src/graph/types.ts` and stamped into `info_graph.json` by
|
|
23
|
+
`buildGraph`. On server start, `http.ts` compares the stored graph's
|
|
24
|
+
`schema_version` to the current constant; if they differ it triggers an
|
|
25
|
+
automatic one-time rescan instead of serving an incompatible graph. No
|
|
26
|
+
behavior change today — all graphs are v1 and schema_version matches — but
|
|
27
|
+
this is the forward-safety mechanism for future schema bumps so existing
|
|
28
|
+
graphs are never silently misread.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **JS/TS parser now captures member-assigned functions** (`exports.handler = fn`,
|
|
33
|
+
`module.exports.route = () => {}`, `this.x = () => {}`). Previously these
|
|
34
|
+
CommonJS/member-export patterns were invisible to the query, so modules that
|
|
35
|
+
exclusively use this style extracted zero symbols and degraded to whole-file
|
|
36
|
+
reads via `graph_read`. A member-assignment capture has been added to both
|
|
37
|
+
`JS_QUERY` and `TS_QUERY` in `src/scanner/parsers/typescript.ts`. Note: a
|
|
38
|
+
pure-wiring `server.js` whose only structure is anonymous inline-callback
|
|
39
|
+
arguments (e.g. `io.use(...)` / `socket.on(event, fn)`) is genuinely
|
|
40
|
+
symbol-less — that is correct, and the gate's symbol-hit guard already
|
|
41
|
+
prevents blocking such files.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Policy block v4 → v5.** Adds a "large file — pull the symbol, don't
|
|
46
|
+
chunk" nudge to address recurring dogfood friction: on large files Claude
|
|
47
|
+
was reading successive line-range chunks instead of fetching the specific
|
|
48
|
+
symbol via `graph_read("file::symbol")`. The v5 block now explicitly
|
|
49
|
+
instructs: when a file is large, use `graph_read("file/path.ts::SymbolName")`
|
|
50
|
+
to pull the symbol directly rather than reading successive line-range chunks.
|
|
51
|
+
`POLICY_VERSION` bumped `4 → 5`; existing v4 blocks auto-upgrade on the
|
|
52
|
+
next `syn .` run.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## [0.1.22] — 2026-06-06
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **`graph_read` now resolves shortened file paths (path-suffix fallback).** Previously
|
|
61
|
+
`graph_read` performed an exact `path === target` match only. Passing a shortened path
|
|
62
|
+
like `appsettings.json` returned "file not found" even when
|
|
63
|
+
`connectwarev2/.../appsettings.json` was indexed. A new `resolveFileTarget` helper (now
|
|
64
|
+
exported) tries an exact match first; on a miss it looks for a unique path-suffix match
|
|
65
|
+
and serves that file; if multiple files share the suffix it reports them as ambiguous with
|
|
66
|
+
candidate paths rather than guessing. Symbol lookups use the resolved path. No API or
|
|
67
|
+
protocol change. Roadmap item #11.
|
|
68
|
+
|
|
69
|
+
- **Gate content-keyword relaxation now intersects file contents, not just file paths.**
|
|
70
|
+
The Moat's recent-activity relaxation previously matched query tokens against the paths of
|
|
71
|
+
recently-touched files only. A query like `Grep "login"` would not relax on a recent save
|
|
72
|
+
of `auth.ts` unless the word "login" appeared in the path. Now the relaxation also checks
|
|
73
|
+
the recently-touched file's graph-node keywords (its indexed content), so a recent save
|
|
74
|
+
relaxes a Grep whenever the file *contains* the queried term — not just when the path
|
|
75
|
+
matches it. Completes roadmap item #3.
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- **Dashboard Projects card shows a first-run hint in the empty state.** When no projects
|
|
80
|
+
have run `syn .` yet, the Projects card now displays "No projects yet — run `syn .` in a
|
|
81
|
+
project to start" instead of a blank card. The Recent-turns card already carried this
|
|
82
|
+
hint; Projects now matches it. Roadmap item #10.
|
|
83
|
+
|
|
84
|
+
- **`bin` path normalization (chore).** Ran `npm pkg fix` to normalize `bin` entries from
|
|
85
|
+
`./bin/syn` to `bin/syn`. Silences the cosmetic publish warnings
|
|
86
|
+
(`"bin[syn]" script name was cleaned`). `syn` and `synthra` still resolve to the same
|
|
87
|
+
entry point. Roadmap item #4.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
10
91
|
## [0.1.21] — 2026-06-06
|
|
11
92
|
|
|
12
93
|
### Added
|
package/dist/cli/index.js
CHANGED
|
@@ -18,15 +18,15 @@ var init_package = __esm({
|
|
|
18
18
|
"package.json"() {
|
|
19
19
|
package_default = {
|
|
20
20
|
name: "@jefuriiij/synthra",
|
|
21
|
-
version: "0.1.
|
|
21
|
+
version: "0.1.23",
|
|
22
22
|
publishConfig: {
|
|
23
23
|
access: "public"
|
|
24
24
|
},
|
|
25
25
|
description: "Local context engine for AI coding assistants \u2014 graph-based context, branch-aware memory, real-time human-activity awareness, deterministic Grep/Glob gating, and a live token dashboard.",
|
|
26
26
|
type: "module",
|
|
27
27
|
bin: {
|
|
28
|
-
syn: "
|
|
29
|
-
synthra: "
|
|
28
|
+
syn: "bin/syn",
|
|
29
|
+
synthra: "bin/syn"
|
|
30
30
|
},
|
|
31
31
|
scripts: {
|
|
32
32
|
build: "tsup",
|
|
@@ -297,13 +297,18 @@ function summarize(p) {
|
|
|
297
297
|
models
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
|
+
function dedupeEnabled() {
|
|
301
|
+
const v = process.env.SYN_DASHBOARD_DEDUPE;
|
|
302
|
+
return v !== "0" && v !== "off" && v !== "false";
|
|
303
|
+
}
|
|
300
304
|
async function loadProjectFiles(path, name, lastSeen) {
|
|
301
305
|
const paths = resolvePaths(path);
|
|
302
306
|
const [rawTokens, gates] = await Promise.all([
|
|
303
307
|
readJsonl(paths.tokenLog),
|
|
304
308
|
readJsonl(paths.gateLog)
|
|
305
309
|
]);
|
|
306
|
-
|
|
310
|
+
const tokens = dedupeEnabled() ? dedupeTokens(rawTokens) : rawTokens;
|
|
311
|
+
return { path, name, last_seen: lastSeen, tokens, gates };
|
|
307
312
|
}
|
|
308
313
|
function dedupeTokens(entries) {
|
|
309
314
|
const score2 = (model) => {
|
|
@@ -1075,7 +1080,7 @@ var public_default = `<!doctype html>
|
|
|
1075
1080
|
const el = $('#proj-chart');
|
|
1076
1081
|
el.innerHTML = '';
|
|
1077
1082
|
if (!projects.length) {
|
|
1078
|
-
el.innerHTML = '<div class="empty">No projects yet.</div>';
|
|
1083
|
+
el.innerHTML = '<div class="empty">No projects yet \u2014 run <code>syn .</code> in a project to start.</div>';
|
|
1079
1084
|
return;
|
|
1080
1085
|
}
|
|
1081
1086
|
const ranked = [...projects].sort((a, b) => (b.total_turns || 0) - (a.total_turns || 0));
|
|
@@ -1401,33 +1406,33 @@ exit 0
|
|
|
1401
1406
|
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';
|
|
1402
1407
|
|
|
1403
1408
|
// src/hooks/scripts/pre-tool-use.sh
|
|
1404
|
-
var pre_tool_use_default2 = `#!/usr/bin/env bash
|
|
1405
|
-
# PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns
|
|
1406
|
-
# "block", emits the deny-decision JSON to stdout for Claude Code to enforce
|
|
1407
|
-
# Always exits 0; server failures leave Claude untouched
|
|
1408
|
-
|
|
1409
|
-
set +e
|
|
1410
|
-
|
|
1411
|
-
PORT_FILE="$PWD/.synthra-graph/mcp_port"
|
|
1412
|
-
if [ ! -f "$PORT_FILE" ]; then exit 0; fi
|
|
1413
|
-
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
1414
|
-
if [ -z "$PORT" ]; then exit 0; fi
|
|
1415
|
-
|
|
1416
|
-
INPUT=$(cat 2>/dev/null)
|
|
1417
|
-
if [ -z "$INPUT" ]; then exit 0; fi
|
|
1418
|
-
|
|
1419
|
-
RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json"
|
|
1420
|
-
--data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)
|
|
1421
|
-
|
|
1422
|
-
case "$RESP" in
|
|
1423
|
-
*'"decision":"block"'*)
|
|
1424
|
-
REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')
|
|
1425
|
-
cat <<EOF
|
|
1426
|
-
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}
|
|
1427
|
-
EOF
|
|
1428
|
-
|
|
1429
|
-
esac
|
|
1430
|
-
exit 0
|
|
1409
|
+
var pre_tool_use_default2 = `#!/usr/bin/env bash\r
|
|
1410
|
+
# PreToolUse hook \u2014 bash. POSTs the tool call to /gate; if server returns\r
|
|
1411
|
+
# "block", emits the deny-decision JSON to stdout for Claude Code to enforce.\r
|
|
1412
|
+
# Always exits 0; server failures leave Claude untouched.\r
|
|
1413
|
+
\r
|
|
1414
|
+
set +e\r
|
|
1415
|
+
\r
|
|
1416
|
+
PORT_FILE="$PWD/.synthra-graph/mcp_port"\r
|
|
1417
|
+
if [ ! -f "$PORT_FILE" ]; then exit 0; fi\r
|
|
1418
|
+
PORT=$(cat "$PORT_FILE" 2>/dev/null | tr -d '[:space:]')\r
|
|
1419
|
+
if [ -z "$PORT" ]; then exit 0; fi\r
|
|
1420
|
+
\r
|
|
1421
|
+
INPUT=$(cat 2>/dev/null)\r
|
|
1422
|
+
if [ -z "$INPUT" ]; then exit 0; fi\r
|
|
1423
|
+
\r
|
|
1424
|
+
RESP=$(curl -sS --max-time 3 -X POST -H "Content-Type: application/json" \\\r
|
|
1425
|
+
--data "$INPUT" "http://127.0.0.1:$PORT/gate" 2>/dev/null)\r
|
|
1426
|
+
\r
|
|
1427
|
+
case "$RESP" in\r
|
|
1428
|
+
*'"decision":"block"'*)\r
|
|
1429
|
+
REASON=$(printf '%s' "$RESP" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/p')\r
|
|
1430
|
+
cat <<EOF\r
|
|
1431
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"\${REASON}"}}\r
|
|
1432
|
+
EOF\r
|
|
1433
|
+
;;\r
|
|
1434
|
+
esac\r
|
|
1435
|
+
exit 0\r
|
|
1431
1436
|
`;
|
|
1432
1437
|
|
|
1433
1438
|
// src/hooks/scripts/prime.ps1
|
|
@@ -1958,6 +1963,9 @@ import { resolve } from "path";
|
|
|
1958
1963
|
// src/scanner/extract.ts
|
|
1959
1964
|
import { dirname as dirname4, join as join6, posix } from "path";
|
|
1960
1965
|
|
|
1966
|
+
// src/graph/types.ts
|
|
1967
|
+
var SCHEMA_VERSION2 = 1;
|
|
1968
|
+
|
|
1961
1969
|
// src/scanner/hash.ts
|
|
1962
1970
|
import { createHash } from "crypto";
|
|
1963
1971
|
function fileHash(content) {
|
|
@@ -2294,7 +2302,7 @@ async function buildGraph(root, parsed) {
|
|
|
2294
2302
|
nodes,
|
|
2295
2303
|
edges,
|
|
2296
2304
|
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2297
|
-
schema_version:
|
|
2305
|
+
schema_version: SCHEMA_VERSION2
|
|
2298
2306
|
};
|
|
2299
2307
|
}
|
|
2300
2308
|
function buildSymbolIndex(graph) {
|
|
@@ -2828,6 +2836,7 @@ var TS_QUERY = `
|
|
|
2828
2836
|
(enum_declaration name: (identifier) @enum.name) @enum
|
|
2829
2837
|
(method_definition name: (property_identifier) @method.name) @method
|
|
2830
2838
|
(lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
|
|
2839
|
+
(assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
|
|
2831
2840
|
(import_statement source: (string) @import)
|
|
2832
2841
|
`;
|
|
2833
2842
|
var JS_QUERY = `
|
|
@@ -2835,6 +2844,7 @@ var JS_QUERY = `
|
|
|
2835
2844
|
(class_declaration name: (identifier) @class.name) @class
|
|
2836
2845
|
(method_definition name: (property_identifier) @method.name) @method
|
|
2837
2846
|
(lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
|
|
2847
|
+
(assignment_expression left: (member_expression property: (property_identifier) @member-fn.name) right: [(arrow_function) (function_expression)]) @member-fn
|
|
2838
2848
|
(import_statement source: (string) @import)
|
|
2839
2849
|
(call_expression function: (identifier) @_require_fn arguments: (arguments . (string) @require_source))
|
|
2840
2850
|
`;
|
|
@@ -2859,7 +2869,7 @@ function shapeFromCaptures(captures) {
|
|
|
2859
2869
|
const name = captures.get(`${k}.name`);
|
|
2860
2870
|
return decl && name ? { decl, name, kind: sk } : null;
|
|
2861
2871
|
};
|
|
2862
|
-
return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function");
|
|
2872
|
+
return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function") ?? findDecl("member-fn", "function");
|
|
2863
2873
|
}
|
|
2864
2874
|
async function parseTypeScript(f, source) {
|
|
2865
2875
|
const grammar = grammarFor(f.ext);
|
|
@@ -3267,7 +3277,7 @@ import { basename as basename4 } from "path";
|
|
|
3267
3277
|
// src/hooks/claude-md.ts
|
|
3268
3278
|
import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
|
|
3269
3279
|
import { basename as basename3, dirname as dirname6 } from "path";
|
|
3270
|
-
var POLICY_VERSION =
|
|
3280
|
+
var POLICY_VERSION = 5;
|
|
3271
3281
|
var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
|
|
3272
3282
|
var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
|
|
3273
3283
|
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;
|
|
@@ -3335,6 +3345,10 @@ function policyBlock() {
|
|
|
3335
3345
|
" reads should be rare \u2014 only when you genuinely need the full file.",
|
|
3336
3346
|
"- If `graph_continue`'s `Files` list contains a `::` entry, pass it",
|
|
3337
3347
|
" verbatim to `graph_read`.",
|
|
3348
|
+
"- **Large file?** Don't read it in successive line-range chunks \u2014 call",
|
|
3349
|
+
' `graph_continue` or `graph_read("file::symbol")` to pull the one symbol',
|
|
3350
|
+
" you need. Chunked whole-file Reads are exactly the cost `graph_read`",
|
|
3351
|
+
" exists to avoid.",
|
|
3338
3352
|
"",
|
|
3339
3353
|
"### Editing a file",
|
|
3340
3354
|
"",
|
|
@@ -3892,7 +3906,7 @@ async function writeContextMd(path, ctx) {
|
|
|
3892
3906
|
// src/memory/context-store.ts
|
|
3893
3907
|
import { mkdir as mkdir7, readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
|
|
3894
3908
|
import { dirname as dirname8 } from "path";
|
|
3895
|
-
var
|
|
3909
|
+
var SCHEMA_VERSION3 = 1;
|
|
3896
3910
|
async function readEntries(path) {
|
|
3897
3911
|
try {
|
|
3898
3912
|
const raw = await readFile13(path, "utf8");
|
|
@@ -3904,7 +3918,7 @@ async function readEntries(path) {
|
|
|
3904
3918
|
}
|
|
3905
3919
|
async function writeEntries(path, entries) {
|
|
3906
3920
|
await mkdir7(dirname8(path), { recursive: true });
|
|
3907
|
-
const store = { schema_version:
|
|
3921
|
+
const store = { schema_version: SCHEMA_VERSION3, entries };
|
|
3908
3922
|
await writeFile7(path, JSON.stringify(store, null, 2) + "\n", "utf8");
|
|
3909
3923
|
}
|
|
3910
3924
|
async function appendEntry(path, entry) {
|
|
@@ -4438,15 +4452,33 @@ Reason: ${retrieval.reason}
|
|
|
4438
4452
|
return textContent(`${header}
|
|
4439
4453
|
${packed.text}`);
|
|
4440
4454
|
}
|
|
4455
|
+
function resolveFileTarget(graph, filePath) {
|
|
4456
|
+
const files = graph.nodes.filter((n) => n.kind === "file");
|
|
4457
|
+
const exact = files.find((n) => n.path === filePath);
|
|
4458
|
+
if (exact) return { node: exact };
|
|
4459
|
+
const suffix = "/" + filePath;
|
|
4460
|
+
const matches = files.filter((n) => n.path.endsWith(suffix));
|
|
4461
|
+
if (matches.length === 1) return { node: matches[0] };
|
|
4462
|
+
if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
|
|
4463
|
+
return { none: true };
|
|
4464
|
+
}
|
|
4441
4465
|
function graphRead(args, ctx) {
|
|
4442
4466
|
const target = typeof args?.target === "string" ? args.target : "";
|
|
4443
4467
|
if (!target) return errorContent("graph_read: 'target' (string) is required");
|
|
4444
4468
|
const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
|
|
4445
4469
|
const filePath = (rawFile ?? "").trim();
|
|
4446
|
-
const
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4470
|
+
const resolved = resolveFileTarget(ctx.graph, filePath);
|
|
4471
|
+
if ("ambiguous" in resolved) {
|
|
4472
|
+
const shown = resolved.ambiguous.slice(0, 5).join(", ");
|
|
4473
|
+
const more = resolved.ambiguous.length > 5 ? ", \u2026" : "";
|
|
4474
|
+
return errorContent(
|
|
4475
|
+
`graph_read: '${filePath}' matches multiple files (${shown}${more}). Pass a longer path.`
|
|
4476
|
+
);
|
|
4477
|
+
}
|
|
4478
|
+
if ("none" in resolved) {
|
|
4479
|
+
return errorContent(`graph_read: file not found in graph: ${filePath}`);
|
|
4480
|
+
}
|
|
4481
|
+
const fileNode = resolved.node;
|
|
4450
4482
|
if (!symbolName) {
|
|
4451
4483
|
return textContent(`# ${fileNode.path}
|
|
4452
4484
|
|
|
@@ -4454,10 +4486,10 @@ ${fileNode.content}`);
|
|
|
4454
4486
|
}
|
|
4455
4487
|
const cleanSym = symbolName.trim();
|
|
4456
4488
|
const symbol = ctx.graph.nodes.find(
|
|
4457
|
-
(n) => n.kind === "symbol" && n.file ===
|
|
4489
|
+
(n) => n.kind === "symbol" && n.file === fileNode.path && n.name === cleanSym
|
|
4458
4490
|
);
|
|
4459
4491
|
if (!symbol) {
|
|
4460
|
-
return errorContent(`graph_read: symbol '${cleanSym}' not found in ${
|
|
4492
|
+
return errorContent(`graph_read: symbol '${cleanSym}' not found in ${fileNode.path}`);
|
|
4461
4493
|
}
|
|
4462
4494
|
const lines = fileNode.content.split(/\r?\n/);
|
|
4463
4495
|
const body = lines.slice(symbol.start_line - 1, symbol.end_line).join("\n");
|
|
@@ -4623,16 +4655,32 @@ function looksLikeNonSymbolQuery(pattern) {
|
|
|
4623
4655
|
}
|
|
4624
4656
|
return false;
|
|
4625
4657
|
}
|
|
4626
|
-
function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
|
|
4658
|
+
function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
|
|
4659
|
+
if (recentPaths.length === 0) return [];
|
|
4660
|
+
const recent = new Set(recentPaths);
|
|
4661
|
+
const keywordsByPath = /* @__PURE__ */ new Map();
|
|
4662
|
+
for (const n of graph.nodes) {
|
|
4663
|
+
if (n.kind === "file" && recent.has(n.path)) keywordsByPath.set(n.path, n.keywords);
|
|
4664
|
+
}
|
|
4627
4665
|
const matches = [];
|
|
4628
4666
|
for (const path of recentPaths) {
|
|
4629
4667
|
const lower = path.toLowerCase();
|
|
4668
|
+
let matched = false;
|
|
4630
4669
|
for (const t of queryTokens) {
|
|
4631
4670
|
if (lower.includes(t)) {
|
|
4632
|
-
|
|
4671
|
+
matched = true;
|
|
4633
4672
|
break;
|
|
4634
4673
|
}
|
|
4635
4674
|
}
|
|
4675
|
+
if (!matched) {
|
|
4676
|
+
for (const kw of keywordsByPath.get(path) ?? []) {
|
|
4677
|
+
if (queryTokens.has(kw)) {
|
|
4678
|
+
matched = true;
|
|
4679
|
+
break;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
if (matched) matches.push(path);
|
|
4636
4684
|
}
|
|
4637
4685
|
return matches;
|
|
4638
4686
|
}
|
|
@@ -4683,7 +4731,7 @@ async function handleGate(req, ctx) {
|
|
|
4683
4731
|
}
|
|
4684
4732
|
const qTokens = new Set(tokenizeQuery(query));
|
|
4685
4733
|
const recentPaths = ctx.activity.recentFilePaths(RECENT_ACTIVITY_WINDOW_MS);
|
|
4686
|
-
const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens);
|
|
4734
|
+
const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens, ctx.graph);
|
|
4687
4735
|
if (overlap.length > 0) {
|
|
4688
4736
|
const res2 = {
|
|
4689
4737
|
decision: "allow",
|
|
@@ -4770,10 +4818,20 @@ ${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools
|
|
|
4770
4818
|
// src/server/http.ts
|
|
4771
4819
|
async function loadContext(paths) {
|
|
4772
4820
|
try {
|
|
4773
|
-
|
|
4821
|
+
let [graph, symbolIndex] = await Promise.all([
|
|
4774
4822
|
readGraph(paths.infoGraph),
|
|
4775
4823
|
readSymbolIndex(paths.symbolIndex)
|
|
4776
4824
|
]);
|
|
4825
|
+
if (graph.schema_version !== SCHEMA_VERSION2) {
|
|
4826
|
+
log.info(
|
|
4827
|
+
`graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION2} \u2014 rescanning\u2026`
|
|
4828
|
+
);
|
|
4829
|
+
await scanProject(paths.projectRoot, { silent: true });
|
|
4830
|
+
[graph, symbolIndex] = await Promise.all([
|
|
4831
|
+
readGraph(paths.infoGraph),
|
|
4832
|
+
readSymbolIndex(paths.symbolIndex)
|
|
4833
|
+
]);
|
|
4834
|
+
}
|
|
4777
4835
|
const activity = new ActivityStore(paths.activityLog);
|
|
4778
4836
|
return { paths, graph, symbolIndex, activity };
|
|
4779
4837
|
} catch (err2) {
|