@sdsrs/code-graph 0.168.0 → 0.170.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/README.md CHANGED
@@ -201,7 +201,7 @@ What you get:
201
201
  - **Skills** — `explore` (structure-first navigation before reading files) and `index` (health-check / re-index / full rebuild); see [Plugin Skills](#plugin-skills)
202
202
  - **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
203
203
  - **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
204
- - **StatusLine** — Real-time health display (nodes, files, watch status) — compatible with other plugins' StatusLine via composite multiplexer
204
+ - **StatusLine** — Real-time health display (nodes, files, watch status) and what code-graph did in this session (`5 answers · 2 calls`: answers its grep, edit, read, subagent and Stop hooks delivered to Claude, code-graph queries Claude ran) — compatible with other plugins' StatusLine via composite multiplexer
205
205
  - **Auto-update** — Checks for a new version at session start (throttled to at most one check every 2 minutes). Between forced checks the re-check interval is 30 minutes after an "up to date" answer and 6 hours while an update is already pending. Updates install silently.
206
206
 
207
207
  #### Manual Update
@@ -552,6 +552,7 @@ defaults are what you get by doing nothing.
552
552
  | `CODE_GRAPH_VERBOSE_HOOKS=1` | The opposite: opt into the noisy form. |
553
553
  | `CODE_GRAPH_NO_BLOCK_GREP=1` | Never turn a `grep` hint into a block — prefix a single command with it to get past one. |
554
554
  | `CODE_GRAPH_NO_INJECT=1` | No post-tool AST context injection. |
555
+ | `CODE_GRAPH_STATUSLINE_COUNTS=0` | Drop the session counts (`5 answers · 2 calls`) from the statusline. |
555
556
  | `CODE_GRAPH_HOOK_INDEX=on\|off` | Force the incremental-index hook on or off instead of letting it decide. |
556
557
  | `CODE_GRAPH_MODEL_DIR=<dir>` | Load the embedding model from here (air-gapped installs). |
557
558
  | `CODE_GRAPH_DISABLE_MODEL_DOWNLOAD=1` | Never fetch the model; fail instead. |
@@ -609,6 +610,7 @@ plugins' entries. An installer that reads exit codes must not treat it as
609
610
  | Variable | Set by | Purpose |
610
611
  |---|---|---|
611
612
  | `CODE_GRAPH_STATUSLINE_CWD` | statusline-composite | Forwards Claude Code's authoritative cwd to each statusline provider. |
613
+ | `CODE_GRAPH_STATUSLINE_SESSION` | statusline-composite | Forwards the stdin `session_id` (UUID-shaped only), so the code-graph segment counts this session's answers and calls. |
612
614
  | `CODE_GRAPH_STATUSLINE_DEBUG` | test suite | Makes statusline-composite print why it dropped a provider instead of swallowing the error. Off by default so a broken third-party provider stays silent in a real statusline. |
613
615
  | `CODE_GRAPH_INSTALL_LOCK_HELD` | launcher | Tells a child that the parent already holds the install lock, so it does not deadlock. |
614
616
  | `CODE_GRAPH_AUTO_UPDATE_SILENT` | session-init | Runs the update check without console output. |
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.168.0",
7
+ "version": "0.170.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -559,8 +559,69 @@ function runCallgraphAnswer(opts = {}) {
559
559
  }
560
560
  }
561
561
 
562
+ // A definition label longer than this is cut (a C++ or Go qualified name can
563
+ // keep text from its declaration).
564
+ const DEFINITION_LABEL_MAX = 160;
565
+
566
+ /**
567
+ * Where exactly `symbol` is defined in `file`: `show <symbol> --file <file>
568
+ * --json`, kept only when ONE row in that file carries the name (`X`, a
569
+ * qualified `Cls.X` or `mod::X`). The outside-path grep inject names this in
570
+ * place of a command to run (tasks/specs/outside-path-definition-answer.md):
571
+ * the model's next step after that pointer was a read of the definition, not
572
+ * a code-graph call (0 of 45 such pointers was followed by a code-graph call
573
+ * within 10 tool calls; 23 by a Read of the defining file or a Bash/Grep call
574
+ * naming it).
575
+ *
576
+ * `label` is `<type> <name>` on one line, whitespace collapsed, at most
577
+ * DEFINITION_LABEL_MAX characters (`…` when cut). No signature: each
578
+ * extractor writes its own shape, and the lines are what the next read needs.
579
+ * @returns {{status: 'hits', start: number, end: number, label: string}
580
+ * | {status: 'several'} | {status: 'no-hits'}
581
+ * | {status: 'no-binary'} | {status: 'unavailable', reason?: 'budget'}}
582
+ */
583
+ function runDefinitionAnswer(opts = {}) {
584
+ const { cwd, symbol, file, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
585
+ try {
586
+ if (typeof symbol !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(symbol)
587
+ || typeof file !== 'string' || !file) {
588
+ return { status: 'unavailable' };
589
+ }
590
+ const binary = resolveAnswerBinary(opts);
591
+ if (!binary) return { status: 'no-binary' };
592
+ // --compact: the rows without `code_content` (a whole class body otherwise).
593
+ const res = runCg(binary, ['show', symbol, '--file', file, '--json', '--compact'], { cwd, timeoutMs });
594
+ const verdict = classifyRun(res, { exitOneIsNoHits: true });
595
+ if (verdict !== 'ok') {
596
+ return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
597
+ }
598
+ let rows;
599
+ try {
600
+ rows = JSON.parse(res.stdout || '');
601
+ } catch {
602
+ return { status: 'unavailable' };
603
+ }
604
+ if (!Array.isArray(rows)) return { status: 'unavailable' };
605
+ const named = rows.filter((r) => r && r.file_path === file && typeof r.name === 'string'
606
+ && (r.name === symbol || r.name.endsWith(`.${symbol}`) || r.name.endsWith(`::${symbol}`)));
607
+ if (named.length === 0) return { status: 'no-hits' };
608
+ if (named.length > 1) return { status: 'several' };
609
+ const r = named[0];
610
+ if (!Number.isInteger(r.start_line) || !Number.isInteger(r.end_line)) return { status: 'unavailable' };
611
+ let label = `${r.type || 'symbol'} ${r.name}`
612
+ // U+0085 and U+001C-1E too: JS `\s` lacks them, and a reader splitting
613
+ // lines (Python's splitlines) breaks on them.
614
+ .replace(/[\s\u0085\u001c-\u001e]+/g, ' ').trim();
615
+ const chars = [...label];
616
+ if (chars.length > DEFINITION_LABEL_MAX) label = chars.slice(0, DEFINITION_LABEL_MAX - 1).join('') + '…';
617
+ return { status: 'hits', start: r.start_line, end: r.end_line, label };
618
+ } catch {
619
+ return { status: 'unavailable' };
620
+ }
621
+ }
622
+
562
623
  module.exports = {
563
- runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
624
+ runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer, runDefinitionAnswer,
564
625
  truncateAtLine, sanitizeSearchPath,
565
626
  // The one argv builder + its renderer, so the deny copy and the child process
566
627
  // cannot describe different commands.
@@ -36,7 +36,9 @@ const { recordRecommendation } = require('./recommendation-log');
36
36
  // `sanitizeSearchPath` is deliberately NOT imported any more: this hook passes
37
37
  // the RAW path so buildGrepArgs can split a glob into scope + `-g` instead of
38
38
  // widening it away. Leaving the require behind would read as "still used".
39
- const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, shellQuoteArg, formatCgCommand } = require('./cg-answer');
39
+ const {
40
+ runGrepAnswer, runShowAnswer, runCallgraphAnswer, runDefinitionAnswer, shellQuoteArg,
41
+ } = require('./cg-answer');
40
42
  const { emitPostToolContext } = require('./hook-emit');
41
43
  const {
42
44
  splitTopLevelSegments,
@@ -214,7 +216,10 @@ const CALLGRAPH_HEADER =
214
216
  // is looking; a same-named symbol's 4 KB call tree from elsewhere is not about
215
217
  // that place (a grep in tests/cli_e2e.rs got `indexed_project`'s tree from
216
218
  // src/indexer/resync.rs). Its location was the useful part, so that is what it
217
- // gets: one pointer line.
219
+ // gets: one line. It used to end in the `callgraph` command for the tree; no
220
+ // session ran it (0 of 45, 0.164.0 to 2026-10-16) and the next step was a read
221
+ // of the definition, so the line carries the definition's lines, type and name
222
+ // instead when `show` names exactly one (tasks/specs/outside-path-definition-answer.md).
218
223
 
219
224
  /** The file on a `callgraph` answer's root line (`name (path)`), or null. */
220
225
  function definitionFile(text) {
@@ -236,9 +241,14 @@ function searchCovers(searchPath, file) {
236
241
  return file === p || file.startsWith(p + '/');
237
242
  }
238
243
 
239
- function buildPointerText(symbol, file, searchPath) {
240
- return `[code-graph] ${symbol} is defined in ${shellQuoteArg(file)}, outside the path your grep ` +
241
- `searched (${shellQuoteArg(searchPath)}). Its callers and callees: ${formatCgCommand(['callgraph', symbol])}`;
244
+ /** @param {object} [def] a runDefinitionAnswer result; only `hits` adds lines, type and name. */
245
+ function buildPointerText(symbol, file, searchPath, def) {
246
+ const where = `${symbol} is defined in ${shellQuoteArg(file)}`;
247
+ const outside = `outside the path your grep searched (${shellQuoteArg(searchPath)})`;
248
+ if (def && def.status === 'hits') {
249
+ return `[code-graph] ${where}:${def.start}-${def.end}, ${outside}: ${def.label}`;
250
+ }
251
+ return `[code-graph] ${where}, ${outside}.`;
242
252
  }
243
253
 
244
254
  function buildInjectText(answer, mode) {
@@ -520,7 +530,8 @@ function runMain() {
520
530
  ...(outside ? { scope: 'outside' } : {}),
521
531
  });
522
532
  const text = outside
523
- ? buildPointerText(answeredSymbol, rootFile, searchPath)
533
+ ? buildPointerText(answeredSymbol, rootFile, searchPath,
534
+ runDefinitionAnswer({ cwd: root, symbol: answeredSymbol, file: rootFile }))
524
535
  : buildInjectText(answer, answeredMode);
525
536
  process.stdout.write(emitPostToolContext(text) + '\n');
526
537
  }
@@ -158,6 +158,16 @@ function rotateIfNeeded(file) {
158
158
  }
159
159
  }
160
160
 
161
+ // The first 8 hex digits, lowercased, of a UUID-shaped Claude Code session id
162
+ // (CLAUDE_CODE_SESSION_ID, which Claude Code sets for every hook and tool
163
+ // child), else null. Rust `cli::session_tag` is the twin for the CLI's `use`
164
+ // rows, so one session's rows from both writers share a tag; the statusline
165
+ // counts this session's answers and calls by it.
166
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
167
+ function sessionTag(raw) {
168
+ return typeof raw === 'string' && SESSION_ID.test(raw) ? raw.slice(0, 8).toLowerCase() : null;
169
+ }
170
+
161
171
  /**
162
172
  * Append one recommendation event to <cwd>/.code-graph/recommendations.jsonl.
163
173
  * @param {string} cwd project root (the hook's process.cwd())
@@ -187,7 +197,9 @@ function recordRecommendation(cwd, event = {}) {
187
197
  const file = path.join(dir, REC_FILE);
188
198
  if (!isOwnedPath(file, 'file')) return false;
189
199
  rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
190
- const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
200
+ const { session: _ignored, ...fields } = event;
201
+ const session = sessionTag(process.env.CLAUDE_CODE_SESSION_ID);
202
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...fields, ...(session ? { session } : {}) }) + '\n';
191
203
  const fd = openOwned(
192
204
  file,
193
205
  fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
@@ -204,4 +216,4 @@ function recordRecommendation(cwd, event = {}) {
204
216
  }
205
217
  }
206
218
 
207
- module.exports = { recordRecommendation, REC_FILE, isOwnedPath, openOwned };
219
+ module.exports = { recordRecommendation, REC_FILE, isOwnedPath, openOwned, sessionTag };
@@ -127,7 +127,16 @@ function runProvider(command, needsStdin, stdin, id) {
127
127
  // to `_previous`/third-party providers, which ignore the unknown var. The
128
128
  // CODE_GRAPH_ prefix (not CLAUDE_) keeps it out of Claude Code's own namespace.
129
129
  const cwd = cwdFromStdin(stdin);
130
- const env = cwd ? { ...process.env, CODE_GRAPH_STATUSLINE_CWD: cwd } : process.env;
130
+ // Likewise the session id: the code-graph segment counts this session's
131
+ // answers and calls by it. Only a UUID-shaped id is forwarded.
132
+ const session = sessionFromStdin(stdin);
133
+ const env = (cwd || session)
134
+ ? {
135
+ ...process.env,
136
+ ...(cwd ? { CODE_GRAPH_STATUSLINE_CWD: cwd } : {}),
137
+ ...(session ? { CODE_GRAPH_STATUSLINE_SESSION: session } : {}),
138
+ }
139
+ : process.env;
131
140
 
132
141
  const out = execFileSync(argv[0], argv.slice(1), hidden({
133
142
  timeout: 3000,
@@ -217,6 +226,18 @@ function cwdFromStdin(stdin) {
217
226
  } catch { return null; }
218
227
  }
219
228
 
229
+ // Claude Code's session id from the stdin JSON context, when UUID-shaped
230
+ // (the only shape both session-tag rules accept); null otherwise.
231
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
232
+ function sessionFromStdin(stdin) {
233
+ if (!stdin) return null;
234
+ try {
235
+ const v = JSON.parse(stdin);
236
+ const id = v && v.session_id;
237
+ return typeof id === 'string' && SESSION_ID.test(id) ? id : null;
238
+ } catch { return null; }
239
+ }
240
+
220
241
  /// Shell constructs a direct `execFileSync` cannot honour at all: pipelines,
221
242
  /// redirection, sequencing, command substitution, backgrounding.
222
243
  ///
@@ -214,4 +214,70 @@ try {
214
214
  errText = [(e && e.stderr) || '', (e && e.stdout) || ''].map(String).join('\n');
215
215
  }
216
216
 
217
- process.stdout.write(report ? renderHealth(report) : statusUnavailable(errText));
217
+ // This session's code-graph activity (tasks/specs/statusline-session-counts.md):
218
+ // answers a hook delivered to the model, and code-graph commands the model ran
219
+ // itself. Without it the user cannot see code-graph working at all — every
220
+ // answer reaches the model silently. Rows are matched by the `session` tag
221
+ // both recorders stamp; the id comes from the composite's stdin payload.
222
+ // Kinds that delivered code-graph content (an unanswered hint or deny carried
223
+ // only advice; observe/bypass/follow-up rows delivered nothing).
224
+ const ANSWER_KINDS = [
225
+ ['grep', 'deny', true], ['grep', 'inject', true], ['edit', 'hint', true], ['read', 'hint', true],
226
+ ['subagent', 'subagent_context', null], ['stop', 'stop_check', null],
227
+ ];
228
+ // Read no more than the rotation's keep size: the log is ~1 MB at most, and a
229
+ // render runs on every frame.
230
+ const COUNTS_TAIL_BYTES = 524288;
231
+
232
+ function sessionCounts(dir, tag) {
233
+ const { isOwnedPath, openOwned, REC_FILE } = require('./recommendation-log');
234
+ const what = 'statusline';
235
+ let dirStat;
236
+ try { dirStat = fs.lstatSync(dir); } catch { return null; }
237
+ if (!isOwnedPath(dir, 'dir', dirStat, what)) return null;
238
+ const file = path.join(dir, REC_FILE);
239
+ if (!isOwnedPath(file, 'file', undefined, what)) return null;
240
+ let fd;
241
+ try {
242
+ // O_NONBLOCK: a FIFO planted in the repo must not hold the render.
243
+ fd = openOwned(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0), what);
244
+ } catch { return null; }
245
+ if (fd === null) return null;
246
+ let text;
247
+ try {
248
+ const size = fs.fstatSync(fd).size;
249
+ const len = Math.min(size, COUNTS_TAIL_BYTES);
250
+ const buf = Buffer.alloc(len);
251
+ fs.readSync(fd, buf, 0, len, size - len);
252
+ text = buf.toString('utf8');
253
+ } catch { return null; } finally { fs.closeSync(fd); }
254
+ let answers = 0;
255
+ let calls = 0;
256
+ for (const line of text.split('\n')) {
257
+ // Cheap prefilter on the tag alone; the parsed field decides.
258
+ if (!line.includes(tag)) continue;
259
+ let r;
260
+ try { r = JSON.parse(line); } catch { continue; }
261
+ if (!r || r.session !== tag) continue;
262
+ if (r.hook === 'cli' && r.action === 'use') calls++;
263
+ else if (ANSWER_KINDS.some(([h, a, ans]) => r.hook === h && r.action === a && (ans === null || r.answered === ans))) answers++;
264
+ }
265
+ return { answers, calls };
266
+ }
267
+
268
+ function renderSessionCounts() {
269
+ if (process.env.CODE_GRAPH_STATUSLINE_COUNTS === '0') return '';
270
+ try {
271
+ const { sessionTag } = require('./recommendation-log');
272
+ const tag = sessionTag(process.env.CODE_GRAPH_STATUSLINE_SESSION);
273
+ if (!tag) return '';
274
+ const c = sessionCounts(codeGraphDir, tag);
275
+ if (!c) return '';
276
+ const parts = [];
277
+ if (c.answers) parts.push(`${c.answers} answer${c.answers === 1 ? '' : 's'}`);
278
+ if (c.calls) parts.push(`${c.calls} call${c.calls === 1 ? '' : 's'}`);
279
+ return parts.length ? ` | ${parts.join(' · ')}` : '';
280
+ } catch { return ''; }
281
+ }
282
+
283
+ process.stdout.write(report ? renderHealth(report) + renderSessionCounts() : statusUnavailable(errText));
@@ -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.168.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.170.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.168.0",
3
+ "version": "0.170.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.168.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.168.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.168.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.168.0",
42
- "@sdsrs/code-graph-win32-x64": "0.168.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.170.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.170.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.170.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.170.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.170.0"
43
43
  }
44
44
  }