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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- **Your earlier agent sessions are searchable: `codegraph sessions` and the `codegraph_sessions` tool.** A code graph answers "how does X work"; it cannot answer "why is X like this" or "what did the last session decide about Y" — that history lives in the transcripts the agent already wrote, hundreds of megabytes of JSONL nobody greps. CodeGraph now indexes the prose of a project's Claude Code sessions (`~/.claude/projects/<slug>/`: prompts, replies and compaction summaries; tool calls, results and thinking stay out) into an FTS5 table with porter stemming and BM25 rank, in its own `.codegraph/sessions.db` beside the graph. The index refreshes on each call for files whose size or mtime moved, so a query after one live session costs tens of milliseconds and the first index of a few hundred transcripts about a second. A hit names its session id, title, role, time and the matching passage; `role`, `sinceDays`, `session` (an id prefix) and `any` (OR the words) narrow or widen it. The tool joins `codegraph_explore` in the default MCP surface — a different question over a different corpus, so it cannot steer a mis-pick against explore — and the CLI command prints the same text for subagents without MCP. `"sessions": false` in `codegraph.json` opts a project out; `CODEGRAPH_SESSIONS_DIR` points at another transcript directory. Readers are one module per agent host, Claude Code first.

- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.

- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,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 @@ -638,11 +639,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