Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ codegraph ui [path] # Open the browser viewer for an indexed proje
codegraph unlock [path] # Remove a stale lock file that's blocking indexing
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
codegraph sessions <words> # Search the project's earlier agent sessions (--role, --since, --session, --any, --json; same output as codegraph_sessions)
codegraph node <symbol|file> # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
Expand Down Expand Up @@ -641,11 +642,12 @@ fi

## MCP Tools

When running as an MCP server, CodeGraph exposes a **single tool** — `codegraph_explore`. Measured agent behavior showed that one strong tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session:
When running as an MCP server, CodeGraph exposes **one tool for code** — `codegraph_explore` — and one for the project's own history — `codegraph_sessions`. Measured agent behavior showed that one strong code tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session:

| Tool | Purpose |
|------|---------|
| `codegraph_explore` | Answer almost any question in one call — "how does X work", a flow ("how does X reach Y"), or surveying an area — returning the relevant symbols' verbatim source grouped by file, plus the call paths between them and a blast-radius summary. Surfaces dynamic-dispatch hops (callbacks, React re-render, interface→impl) grep can't follow. Name a file or symbol in the query to read its current line-numbered source, the same shape the Read tool gives you. |
| `codegraph_sessions` | Answer "why is this like this?", "what did the last session decide about X?", "did we already try Y?" — full-text search (stemmed, BM25-ranked) over the prose of the project's earlier agent sessions: prompts, replies and compaction summaries, never tool traffic. Reads Claude Code's transcripts for the project (`~/.claude/projects/<slug>/`) into `.codegraph/sessions.db`, refreshed on each call for files that changed. Each hit names its session, role, time and the matching passage. Set `"sessions": false` in `codegraph.json` to opt a project out; `CODEGRAPH_SESSIONS_DIR` points it at another transcript directory. |

The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`).

Expand Down
87 changes: 87 additions & 0 deletions __tests__/cli-sessions-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* `codegraph sessions` CLI command — the shell face of codegraph_sessions.
*
* Exercised end-to-end against the built binary, mirroring
* cli-query-command.test.ts: an initialized project, a transcript directory
* handed in through CODEGRAPH_SESSIONS_DIR, human and --json output, the
* filters, and the guidance (not an error) when a project has no transcripts.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

function sessions(cwd: string, transcripts: string | undefined, args: string[]): string {
const env: NodeJS.ProcessEnv = { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' };
if (transcripts) env.CODEGRAPH_SESSIONS_DIR = transcripts;
else env.CLAUDE_CONFIG_DIR = path.join(cwd, 'no-claude-here');
return execFileSync(process.execPath, [BIN, 'sessions', ...args, '-p', cwd], {
encoding: 'utf-8',
env,
stdio: ['ignore', 'pipe', 'ignore'], // drop stderr (SQLite experimental warning)
});
}

const at = '2026-09-04T20:00:00.000Z';
const entry = (type: string, text: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({ type, timestamp: at, message: { content: text }, ...extra });

describe('codegraph sessions — CLI command', () => {
let tempDir: string;
let transcripts: string;

beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sessions-cmd-'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(path.join(tempDir, 'src/auth.ts'), 'export function parseToken(t: string){ return t.trim(); }\n');
const cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.close();
transcripts = path.join(tempDir, 'transcripts');
fs.mkdirSync(transcripts);
fs.writeFileSync(
path.join(transcripts, 'abcd-0001.jsonl'),
[
JSON.stringify({ type: 'custom-title', customTitle: 'token parsing' }),
entry('user', 'why does parseToken trim before validating the signature?'),
entry('assistant', 'Trimming first keeps a trailing newline from failing the signature check.'),
].join('\n') + '\n',
);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('prints ranked hits with session id, title, role and a marked snippet', () => {
// "trailing" and "newline" appear only in the reply; porter would also let
// "trim" reach both docs, so the words are chosen to keep the prompt out.
const out = sessions(tempDir, transcripts, ['trailing', 'newlines']);
expect(out).toContain('## abcd-0001 · token parsing');
expect(out).toContain('assistant · ' + at);
expect(out).toMatch(/\[trailing\] \[newline\]/);
expect(out).not.toContain('user · ');
});

it('--json carries the index stats and the raw hits; --role and --any filter and widen', () => {
const parsed = JSON.parse(sessions(tempDir, transcripts, ['trim', '--json']));
expect(parsed.index).toEqual({ files: 1, refreshed: 1, docs: 2 });
expect(parsed.hits.map((h: { role: string }) => h.role).sort()).toEqual(['assistant', 'user']);
const users = JSON.parse(sessions(tempDir, transcripts, ['trim', '--role', 'user', '--json']));
expect(users.hits.map((h: { role: string }) => h.role)).toEqual(['user']);
// Second run: nothing re-read.
expect(users.index.refreshed).toBe(0);
expect(JSON.parse(sessions(tempDir, transcripts, ['newline', 'nonexistentword', '--json'])).hits).toEqual([]);
expect(JSON.parse(sessions(tempDir, transcripts, ['newline', 'nonexistentword', '--any', '--json'])).hits).toHaveLength(1);
});

it('a project without transcripts gets guidance, not an error', () => {
const out = sessions(tempDir, undefined, ['anything']);
expect(out).toMatch(/No agent-session transcripts to index/);
});
});
11 changes: 6 additions & 5 deletions __tests__/mcp-tool-allowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {

const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort();

it('exposes ONLY codegraph_explore by default when unset', () => {
it('exposes codegraph_explore and codegraph_sessions by default when unset', () => {
delete process.env[ENV];
// The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one
// tool that earns its place (verbatim source grouped by file).
// The default set (see DEFAULT_MCP_TOOLS) is explore — the one code tool that
// earns its place (verbatim source grouped by file) — plus sessions, which
// searches a different corpus (the project's agent transcripts).
// node/search/callers/callees/impact/files/status stay defined and executable
// but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
expect(listed()).toEqual(['codegraph_explore']);
expect(listed()).toEqual(['codegraph_explore', 'codegraph_sessions']);
});

it('re-enables an unlisted tool via the allowlist (impact)', () => {
Expand All @@ -43,7 +44,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {

it('treats an empty/whitespace value as unset (default surface)', () => {
process.env[ENV] = ' ';
expect(listed()).toEqual(['codegraph_explore']);
expect(listed()).toEqual(['codegraph_explore', 'codegraph_sessions']);
});

it('rejects a disabled tool on execute (defense in depth)', async () => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/mcp-unindexed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('No-root-index session policy', () => {

const list = await request(child, { id: 1, method: 'tools/list' });
const tools = (list.result as { tools: Array<{ name: string }> }).tools;
// The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the
// The default surface is explore plus sessions (see DEFAULT_MCP_TOOLS) — the
// contract under test is "indexed → tools are PRESENT", in contrast to the
// unindexed empty list above.
expect(tools.length).toBeGreaterThanOrEqual(1);
Expand Down
Loading