@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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.158.0",
7
+ "version": "0.159.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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
- let fanoutFired = false;
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
- if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
1209
- fanoutFired = true;
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) and fire the
161
- /// fanout hint when the threshold crosses. Emits to stdout + records the
162
- /// recommendation. Returns true when a hint fired.
163
- function trackReadAndMaybeHint(root, rel, now = Date.now()) {
164
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
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 false; // top-level file: not fanout
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 false;
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 (!isAnswerDisabled()) {
186
- answer = runOverviewAnswer({ cwd: root, dir });
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
- // Compound-grep sibling sweep: emit via the PreToolUse allow+additionalContext
206
- // envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in
207
- // the debug log only and never reaches the model (CC docs v2026-06); the
208
- // additionalContext channel is what actually surfaces the fanout hint. Read is
209
- // a safe tool, so the allow elevation is negligible.
210
- const hintText = answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
211
- process.stdout.write(emitPreToolAllowContext(hintText) + '\n');
212
- return true;
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.158.0 code-graph-mcp snapshot create --out snapshot.db
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.158.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.158.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.158.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.158.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.158.0",
42
- "@sdsrs/code-graph-win32-x64": "0.158.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
  }