@sdsrs/code-graph 0.158.0 → 0.159.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.
|
@@ -56,7 +56,7 @@ const {
|
|
|
56
56
|
runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand, shellQuoteArg,
|
|
57
57
|
resolveAnswerBinary,
|
|
58
58
|
} = require('./cg-answer');
|
|
59
|
-
const { emitPreToolRewrite } = require('./hook-emit');
|
|
59
|
+
const { emitPreToolRewrite, emitPreToolContext, MAX_INJECTED_BYTES } = require('./hook-emit');
|
|
60
60
|
|
|
61
61
|
// --- Pure logic (testable) ---
|
|
62
62
|
|
|
@@ -1177,6 +1177,43 @@ function isAnswerDisabled(env = process.env) {
|
|
|
1177
1177
|
return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
// Smallest per-dir share of the context cap worth spending on an overview answer.
|
|
1181
|
+
const MIN_FANOUT_ANSWER_BYTES = 400;
|
|
1182
|
+
|
|
1183
|
+
/// Byte budget for each overview answer when several dirs fire in one command
|
|
1184
|
+
/// (undefined for one dir: the answer keeps its own default). Every hint first
|
|
1185
|
+
/// pays for its own lines — an answer's header, footer and separator are about
|
|
1186
|
+
/// 175 bytes plus the dir name twice, the one-line advice about 165 plus the
|
|
1187
|
+
/// name three times — reserved as 200 plus the name three times, and the
|
|
1188
|
+
/// answers share what is left of the cap. A share under MIN_FANOUT_ANSWER_BYTES
|
|
1189
|
+
/// returns 0: every dir then gets the one-line advice instead.
|
|
1190
|
+
function fanoutAnswerBudget(dirs) {
|
|
1191
|
+
if (dirs.length < 2) return undefined;
|
|
1192
|
+
const reserved = dirs.reduce((sum, d) => sum + 200 + 3 * Buffer.byteLength(d, 'utf8'), 0);
|
|
1193
|
+
const share = Math.floor((MAX_INJECTED_BYTES - reserved) / dirs.length);
|
|
1194
|
+
return share >= MIN_FANOUT_ANSWER_BYTES ? share : 0;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/// The fired dirs' hints as one context text. Every dir was marked delivered,
|
|
1198
|
+
/// so each must be named in it: once the next hint would leave no room for a
|
|
1199
|
+
/// line naming the dirs still to come, that line replaces the remaining hints.
|
|
1200
|
+
function joinFanoutHints(dirs, hints) {
|
|
1201
|
+
const rest = (from) => `[code-graph] 5+ Reads also into ${dirs.slice(from).map((d) => `${d}/`).join(', ')} — \`code-graph-mcp overview <dir>/\` gives each dir's symbols+callers in one call.`;
|
|
1202
|
+
const out = [];
|
|
1203
|
+
let used = 0;
|
|
1204
|
+
for (let i = 0; i < hints.length; i++) {
|
|
1205
|
+
const need = Buffer.byteLength(hints[i], 'utf8') + (out.length ? 2 : 0);
|
|
1206
|
+
const tail = i + 1 < hints.length ? Buffer.byteLength(rest(i + 1), 'utf8') + 2 : 0;
|
|
1207
|
+
if (used + need + tail > MAX_INJECTED_BYTES) {
|
|
1208
|
+
out.push(rest(i));
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
out.push(hints[i]);
|
|
1212
|
+
used += need;
|
|
1213
|
+
}
|
|
1214
|
+
return out.join('\n\n');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1180
1217
|
function runMain() {
|
|
1181
1218
|
if (isSilenced()) return;
|
|
1182
1219
|
// v0.48 — process.cwd() follows the persistent shell; resolve the project
|
|
@@ -1198,18 +1235,34 @@ function runMain() {
|
|
|
1198
1235
|
// v0.49 — sed-range reads count toward the read-fanout state (the Read hook
|
|
1199
1236
|
// never sees Bash-side file reads). A fired fanout hint already delivered an
|
|
1200
1237
|
// overview — skip grep hinting for this command to avoid double output.
|
|
1238
|
+
//
|
|
1239
|
+
// Every dir that fired goes into ONE envelope. Claude Code parses a hook's
|
|
1240
|
+
// stdout as a single JSON value: two envelopes (two dirs crossing in one
|
|
1241
|
+
// compound command) were rejected whole, so the model saw neither hint while
|
|
1242
|
+
// the state recorded both as delivered. The shared context cap is split
|
|
1243
|
+
// between the answers, so a large first overview cannot truncate the second
|
|
1244
|
+
// dir out of the envelope.
|
|
1245
|
+
//
|
|
1246
|
+
// No permission decision: this is a Bash call, and `allow` would skip the
|
|
1247
|
+
// user's prompt for the WHOLE command (`sed -n 1,5p a.js; <anything>`). The
|
|
1248
|
+
// read hook's allow envelope used to leak here because pre-read-guide wrote
|
|
1249
|
+
// it from inside the shared tracker, out of hook-emit.test.js's allowlist view.
|
|
1201
1250
|
const sedTargets = extractSedReadTargets(rawCmd);
|
|
1202
1251
|
if (sedTargets.length > 0) {
|
|
1203
1252
|
const readGuide = require('./pre-read-guide');
|
|
1204
|
-
|
|
1253
|
+
const firedDirs = [];
|
|
1205
1254
|
for (const t of sedTargets) {
|
|
1206
1255
|
if (!readGuide.isSourceFile(t)) continue;
|
|
1207
1256
|
const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1257
|
+
const dir = readGuide.trackRead(root, path.relative(root, abs));
|
|
1258
|
+
if (dir !== null) firedDirs.push(dir); // a dir fires once: markHint starts its cooldown
|
|
1259
|
+
}
|
|
1260
|
+
if (firedDirs.length > 0) {
|
|
1261
|
+
const maxBytes = fanoutAnswerBudget(firedDirs);
|
|
1262
|
+
const hints = firedDirs.map((dir) => readGuide.buildFanoutHint(root, dir, { maxBytes }));
|
|
1263
|
+
process.stdout.write(emitPreToolContext(joinFanoutHints(firedDirs, hints)) + '\n');
|
|
1264
|
+
return;
|
|
1211
1265
|
}
|
|
1212
|
-
if (fanoutFired) return;
|
|
1213
1266
|
}
|
|
1214
1267
|
|
|
1215
1268
|
// v0.47.1 — match against the root-stripped form so absolute paths under the
|
|
@@ -157,13 +157,14 @@ function isAnswerDisabled(env = process.env) {
|
|
|
157
157
|
|
|
158
158
|
// --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
|
|
159
159
|
|
|
160
|
-
/// Record one read of `rel` (project-root-relative source path)
|
|
161
|
-
///
|
|
162
|
-
///
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
/// Record one read of `rel` (project-root-relative source path). Returns the
|
|
161
|
+
/// dir when this read crossed the fanout threshold (the hint is marked as
|
|
162
|
+
/// delivered), else null. Writes nothing to stdout: a hook's stdout is parsed as
|
|
163
|
+
/// ONE JSON value, so only the entry point may write, and only once.
|
|
164
|
+
function trackRead(root, rel, now = Date.now()) {
|
|
165
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
165
166
|
const dir = path.dirname(rel);
|
|
166
|
-
if (!dir || dir === '.' || dir === '') return
|
|
167
|
+
if (!dir || dir === '.' || dir === '') return null; // top-level file: not fanout
|
|
167
168
|
|
|
168
169
|
const state = loadState(root, now);
|
|
169
170
|
recordRead(state, dir, now);
|
|
@@ -178,12 +179,21 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
178
179
|
// Record it (best-effort) so `stats` can measure the model's read fan-out —
|
|
179
180
|
// e.g. a read right after cg answered a grep in-place (search-decay).
|
|
180
181
|
recordRecommendation(root, { hook: 'read', action: 'observe' });
|
|
181
|
-
return
|
|
182
|
+
return null;
|
|
182
183
|
}
|
|
184
|
+
return dir;
|
|
185
|
+
}
|
|
183
186
|
|
|
187
|
+
/// The fanout hint text for `dir`, with the overview answer embedded when the
|
|
188
|
+
/// CLI delivers one. `maxBytes` bounds that answer — the pre-grep sed path
|
|
189
|
+
/// splits one envelope's budget between several dirs.
|
|
190
|
+
function buildFanoutHint(root, dir, { maxBytes } = {}) {
|
|
184
191
|
let answer = { status: 'unavailable' };
|
|
185
|
-
if (
|
|
186
|
-
|
|
192
|
+
if (maxBytes === 0) {
|
|
193
|
+
// No room left in a shared envelope: the advice line, and no CLI run.
|
|
194
|
+
answer = { status: 'unavailable', reason: 'budget' };
|
|
195
|
+
} else if (!isAnswerDisabled()) {
|
|
196
|
+
answer = runOverviewAnswer({ cwd: root, dir, ...(maxBytes ? { maxBytes } : {}) });
|
|
187
197
|
}
|
|
188
198
|
const answered = answer.status === 'hits';
|
|
189
199
|
recordRecommendation(root, {
|
|
@@ -202,14 +212,14 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
202
212
|
...(answered ? {} : { reason: answer.status }),
|
|
203
213
|
...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
|
|
204
214
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return
|
|
215
|
+
return answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// trackRead + buildFanoutHint for one read. Returns the hint text when the
|
|
219
|
+
/// hint fired, else null. The caller emits it.
|
|
220
|
+
function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
221
|
+
const dir = trackRead(root, rel, now);
|
|
222
|
+
return dir === null ? null : buildFanoutHint(root, dir);
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
// --- Main execution ---
|
|
@@ -237,7 +247,13 @@ function runMain() {
|
|
|
237
247
|
rel = path.relative(root, filePath);
|
|
238
248
|
} catch { return; }
|
|
239
249
|
|
|
240
|
-
trackReadAndMaybeHint(root, rel);
|
|
250
|
+
const hint = trackReadAndMaybeHint(root, rel);
|
|
251
|
+
// Emit via the PreToolUse allow+additionalContext envelope (shared
|
|
252
|
+
// hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the debug log
|
|
253
|
+
// only and never reaches the model (CC docs v2026-06); the additionalContext
|
|
254
|
+
// channel is what actually surfaces the fanout hint. Read is a safe tool, so
|
|
255
|
+
// the allow elevation is negligible.
|
|
256
|
+
if (hint) process.stdout.write(emitPreToolAllowContext(hint) + '\n');
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
if (require.main === module) {
|
|
@@ -249,5 +265,6 @@ module.exports = {
|
|
|
249
265
|
loadState, saveState, recordRead, shouldHint, markHint,
|
|
250
266
|
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
251
267
|
trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
|
|
268
|
+
trackRead, buildFanoutHint, // the sed path's two halves: one envelope for every dir that fired
|
|
252
269
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
|
|
253
270
|
};
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
node-version: '20'
|
|
36
36
|
- name: Build snapshot
|
|
37
37
|
run: |
|
|
38
|
-
npx -y -p @sdsrs/code-graph@0.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.159.0 code-graph-mcp snapshot create --out snapshot.db
|
|
39
39
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
40
40
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
41
41
|
- name: Upload to release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.159.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.159.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.159.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.159.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.159.0"
|
|
43
43
|
}
|
|
44
44
|
}
|