Skip to content
Merged
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 @@ -95,6 +95,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Session search replaces changed transcripts through an indexed source lookup. A separate store protects that lookup from older writers. Remembered roots carry forward; available transcript content rebuilds on the first session search.

- C++ anonymous pointer aggregates and partially qualified owners inside macro-opened namespaces retain their method calls. Pointer-annotation normalization leaves multiplication operands intact. Named-only React components do not invent default exports, and spaced type arguments do not create rendering edges.

- Cross-file route-table reconciliation commits nodes and references together, allowing retry after a failed reference write.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Then run `codegraph init` in each project, as in [Get Started](#get-started). In

T3-hosted provider transcripts can appear in session search; CodeGraph does not read the T3 database. A [pure offline Codex metadata prototype](docs/design/t3-session-metadata.md) associates caller-supplied app titles and thread links with existing hits. It has no CLI or MCP caller and adds no searchable prose.

Session search uses `.codegraph/sessions-v2.db`. The first session search imports remembered roots from `sessions.db` and reindexes available transcripts. It leaves the older file untouched for older executables. Session mentions in code answers resume after the new store is populated. Cached passages from unavailable transcripts remain only in the older store. Retaining both files uses extra disk space. The graph index and project-local scope stay unchanged.

### What the fork adds

Compared with upstream `main` at `b635dd46` (after v1.6.2). Each item here and in the dispatch and framework lists below was checked against upstream's tree at that commit.
Expand Down Expand Up @@ -814,7 +816,7 @@ When running as an MCP server, CodeGraph exposes **one tool for code** — `code
| 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` | Search this project's Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and its last 2000 git commit messages, including active sessions: prompts, replies, and compaction summaries, excluding tool traffic. Uses stemmed, BM25-ranked full-text search, stored locally in `.codegraph/sessions.db` and refreshed for changed files on each call. Each hit includes its session (`claude:`, `codex:`, `cursor:`, `opencode:`, `agy:`, `devin:`, `grok:`, or `git:`), role, time, transcript path, and matching passage. Common words are dropped, and when too few passages hold every remaining word, passages holding some of them follow, marked. Harness-injected text (skill bodies, system reminders) is not indexed. Set `"sessions": false` in `codegraph.json` to opt out; `CODEGRAPH_SESSIONS_DIR` selects another Claude-format transcript directory exclusively. |
| `codegraph_sessions` | Search this project's Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and its last 2000 git commit messages, including active sessions: prompts, replies, and compaction summaries, excluding tool traffic. Uses stemmed, BM25-ranked full-text search, stored locally in `.codegraph/sessions-v2.db` and refreshed for changed files on each call. Each hit includes its session (`claude:`, `codex:`, `cursor:`, `opencode:`, `agy:`, `devin:`, `grok:`, or `git:`), role, time, transcript path, and matching passage. Common words are dropped, and when too few passages hold every remaining word, passages holding some of them follow, marked. Harness-injected text (skill bodies, system reminders) is not indexed. Set `"sessions": false` in `codegraph.json` to opt out; `CODEGRAPH_SESSIONS_DIR` selects another Claude-format transcript directory exclusively. |

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
16 changes: 9 additions & 7 deletions __tests__/sessions-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ beforeEach(() => {
afterEach(() => {
// Under Bun (a contributor running vitest on it), node:sqlite keeps the file
// handle of a prepared statement until GC even after `close()`, so the temp
// dir holding sessions.db is EBUSY without this. A no-op on Node.
// dir holding the session database is EBUSY without this. A no-op on Node.
(globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc?.(true);
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
for (const k of [
Expand Down Expand Up @@ -362,7 +362,7 @@ describe('SessionsIndex', () => {
const dir = fixtureDir();
const file = path.join(dir, 'aaaa-1111.jsonl');
writeJsonl(file, [user('the pool sees one transcript from two threads')], 1_700_000_000);
const dbPath = path.join(fixtureDir(), 'sessions.db');
const dbPath = path.join(fixtureDir(), 'sessions-v2.db');
const index = SessionsIndex.open(dbPath);
const st = fs.statSync(file);
const other = new Worker(
Expand All @@ -372,8 +372,10 @@ describe('SessionsIndex', () => {
db.exec('BEGIN IMMEDIATE');
parentPort.postMessage('locked');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
db.prepare('INSERT INTO docs (text, file, role, ts) VALUES (?, ?, ?, ?)')
const inserted = db.prepare('INSERT INTO docs (text, file, role, ts) VALUES (?, ?, ?, ?)')
.run('the pool sees one transcript from two threads', workerData.file, 'user', workerData.ts);
db.prepare('INSERT INTO doc_sources (doc_rowid, file) VALUES (?, ?)')
.run(inserted.lastInsertRowid, workerData.file);
db.prepare('INSERT OR REPLACE INTO files (path, session, title, mtime, size) VALUES (?, ?, ?, ?, ?)')
.run(workerData.file, 'aaaa-1111', null, workerData.mtime, workerData.size);
db.exec('COMMIT');
Expand All @@ -390,7 +392,7 @@ describe('SessionsIndex', () => {
it('opens a fresh database while another connection holds it, converting to WAL once free', async () => {
// An ordinary lock wait on the conversion is covered by busy_timeout: the
// holder commits and this open then converts, rather than throwing.
const dbPath = path.join(fixtureDir(), 'sessions.db');
const dbPath = path.join(fixtureDir(), 'sessions-v2.db');
const other = new Worker(
`const { workerData, parentPort } = require('worker_threads');
const { DatabaseSync } = require('node:sqlite');
Expand Down Expand Up @@ -442,7 +444,7 @@ describe('SessionsIndex', () => {
});

describe('querySessions (project entry point)', () => {
it('indexes into .codegraph/sessions.db from CODEGRAPH_SESSIONS_DIR and honors "sessions": false', () => {
it('indexes into .codegraph/sessions-v2.db from CODEGRAPH_SESSIONS_DIR and honors "sessions": false', () => {
const project = fixtureDir();
fs.mkdirSync(path.join(project, '.codegraph'));
const transcripts = fixtureDir();
Expand All @@ -455,11 +457,11 @@ describe('querySessions (project entry point)', () => {
// Same reader version: the file is not re-read. An index written by an
// older reader (user_version behind) is re-read once in full.
expect(querySessions(project, 'deciding dedupe').index.refreshed).toBe(0);
const { db } = createDatabase(path.join(project, '.codegraph', 'sessions.db'));
const { db } = createDatabase(path.join(project, '.codegraph', 'sessions-v2.db'));
db.exec('PRAGMA user_version = 1');
db.close();
expect(querySessions(project, 'deciding dedupe').index).toEqual({ files: 1, refreshed: 1, docs: 1 });
expect(fs.existsSync(path.join(project, '.codegraph', 'sessions.db'))).toBe(true);
expect(fs.existsSync(path.join(project, '.codegraph', 'sessions-v2.db'))).toBe(true);
expect(formatSessionHits('deciding dedupe', result)).toMatch(/^Sessions matching "deciding dedupe" — 1 hit across 1 transcript:/);
expect(formatSessionHits('nothing', { index: result.index, hits: [] })).toMatch(/holds any of these words/);

Expand Down
182 changes: 182 additions & 0 deletions __tests__/sessions-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Worker } from 'worker_threads';
import { createDatabase } from '../src/db/sqlite-adapter';
import { SessionsIndex, sessionsDbPath } from '../src/sessions';

const dirs: string[] = [];
const indexes: SessionsIndex[] = [];
const fixture = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sessions-storage-'));
dirs.push(dir);
const current = sessionsDbPath(dir);
return { legacy: path.join(path.dirname(current), 'sessions.db'), current };
};
const open = (file: string) => {
const index = SessionsIndex.open(file);
indexes.push(index);
return index;
};
const record = (file: string, mtime: number, text = 'Durable ownership decisions remain searchable across project worktrees.') => ({
path: file, mtime, size: mtime,
load: () => ({ session: file, title: null, docs: [{ text, role: 'user', ts: '2026-10-07T00:00:00Z' }] }),
});
const legacyStore = (file: string) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const { db } = createDatabase(file);
db.exec(`CREATE TABLE roots(path TEXT PRIMARY KEY);
INSERT INTO roots VALUES ('removed-worktree');
CREATE VIRTUAL TABLE docs USING fts5(text, file UNINDEXED, role UNINDEXED, ts UNINDEXED);
INSERT INTO docs VALUES ('Older writers retain their own transcript passages.', 'old', 'user', '2026-10-07T00:00:00Z');
PRAGMA user_version = 5;`);
return db;
};

afterEach(() => {
for (const index of indexes.splice(0)) index.close();
(globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc?.(true);
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5 });
});

describe('session storage generations', () => {
it('upgrades a version-4 index that predates remembered roots without altering it', () => {
const { legacy, current } = fixture();
fs.mkdirSync(path.dirname(legacy), { recursive: true });
const { db } = createDatabase(legacy);
db.exec(`CREATE TABLE files (
path TEXT PRIMARY KEY, session TEXT NOT NULL, title TEXT, mtime REAL NOT NULL, size INTEGER NOT NULL
);
CREATE VIRTUAL TABLE docs USING fts5(
text, file UNINDEXED, role UNINDEXED, ts UNINDEXED, tokenize = 'porter unicode61'
);
PRAGMA user_version = 4;`);
db.close();
const before = fs.readFileSync(legacy);
const index = open(current);
expect(index.rememberRoots(['current-worktree'])).toEqual(['current-worktree']);
expect(index.refreshRecords([record('current', 1)])).toEqual({ files: 1, refreshed: 1, docs: 1 });
expect(index.search('durable ownership')).toHaveLength(1);
expect(fs.readFileSync(legacy)).toEqual(before);
});

it('imports remembered roots without copying prose or changing the older writer store', () => {
const { legacy, current } = fixture();
legacyStore(legacy).close();
const before = fs.readFileSync(legacy);
const index = open(current);
expect(index.rememberRoots([])).toEqual(['removed-worktree']);
expect(index.search('older transcript')).toEqual([]);
expect(fs.readFileSync(legacy)).toEqual(before);
expect(() => SessionsIndex.open(legacy)).toThrow(/legacy sessions.db.*sessions-v2.db/);
expect(fs.readFileSync(legacy)).toEqual(before);
});

it('keeps legacy writes, replacement, deletion and reopen independent in every three-event ordering', () => {
const events = ['legacy', 'replace', 'delete', 'reopen'] as const;
const sequences: Array<Array<typeof events[number]>> = [];
const enumerate = (prefix: Array<typeof events[number]>) => {
if (prefix.length === 3) { sequences.push(prefix); return; }
for (const event of events) enumerate([...prefix, event]);
};
enumerate([]);
for (const sequence of sequences) {
const { legacy, current } = fixture();
const old = legacyStore(legacy);
let index = SessionsIndex.open(current);
let stamp = 1;
let present = true;
index.refreshRecords([record('current', stamp)]);
try {
for (const event of sequence) {
if (event === 'legacy') {
old.exec("DELETE FROM docs; INSERT INTO docs(text, file) VALUES ('Legacy row identifiers may be reused independently.', 'other');");
} else if (event === 'replace') {
present = true;
index.refreshRecords([record('current', ++stamp)]);
} else if (event === 'delete') {
present = false;
index.refreshRecords([]);
} else {
index.close();
index = SessionsIndex.open(current);
}
expect(index.search('durable ownership'), sequence.join(' -> ')).toHaveLength(present ? 1 : 0);
expect(index.rememberRoots([]), sequence.join(' -> ')).toEqual(['removed-worktree']);
const check = createDatabase(current, { readOnly: true }).db;
try {
expect(check.prepare('SELECT count(*) AS n FROM docs').get(), sequence.join(' -> '))
.toEqual({ n: present ? 1 : 0 });
expect(check.prepare('SELECT doc_rowid AS rowid, file FROM doc_sources ORDER BY doc_rowid').all())
.toEqual(check.prepare('SELECT rowid, file FROM docs ORDER BY rowid').all());
} finally { check.close(); }
}
} finally { index.close(); old.close(); }
}
});

it('rolls back a failed mapping insert with its transcript replacement', () => {
const { current } = fixture();
const index = open(current);
index.refreshRecords([record('current', 1)]);
const before = index.search('durable ownership');
const { db } = createDatabase(current);
try {
db.exec("CREATE TRIGGER reject_mapping BEFORE INSERT ON doc_sources BEGIN SELECT RAISE(ABORT, 'mapping rejected'); END;");
expect(() => index.refreshRecords([record('current', 2, 'Changed content must not survive a failed replacement.')])).toThrow('mapping rejected');
expect(index.search('durable ownership')).toEqual(before);
expect(index.search('changed content')).toEqual([]);
db.exec('DROP TRIGGER reject_mapping');
expect(index.refreshRecords([record('current', 2)])).toEqual({ files: 1, refreshed: 1, docs: 1 });
} finally { db.close(); }
});

it('rolls back failed root import and succeeds after the legacy store is repaired', () => {
const { legacy, current } = fixture();
const old = legacyStore(legacy);
old.exec('DROP TABLE roots');
old.close();
expect(() => SessionsIndex.open(current)).toThrow('no such table: roots');
const { db } = createDatabase(current);
try {
expect(db.prepare("SELECT name FROM sqlite_master WHERE name IN ('files', 'roots', 'docs', 'doc_sources')").all()).toEqual([]);
} finally { db.close(); }
const repair = createDatabase(legacy).db;
repair.exec("CREATE TABLE roots(path TEXT PRIMARY KEY); INSERT INTO roots VALUES ('recovered-worktree');");
repair.close();
expect(open(current).rememberRoots([])).toEqual(['recovered-worktree']);
});

it('opens and searches an initialized store before the other writer releases its lock', async () => {
const { current } = fixture();
const index = SessionsIndex.open(current);
index.refreshRecords([record('current', 1)]);
index.close();
const barrier = new Int32Array(new SharedArrayBuffer(4));
const holder = new Worker(`
const { parentPort, workerData } = require('worker_threads');
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.file);
db.exec('BEGIN IMMEDIATE');
parentPort.postMessage('locked');
Atomics.wait(new Int32Array(workerData.barrier), 0, 0);
db.exec('ROLLBACK');
db.close();
require(workerData.teardown).collectBeforeExit();
`, { eval: true, workerData: {
file: current, barrier: barrier.buffer,
teardown: path.resolve(__dirname, '../dist/worker-teardown.js'),
} });
const ended = new Promise<number>((resolve, reject) => { holder.once('exit', resolve); holder.once('error', reject); });
try {
await new Promise((resolve, reject) => { holder.once('message', resolve); holder.once('error', reject); });
expect(open(current).search('durable ownership')).toHaveLength(1);
expect(Atomics.load(barrier, 0)).toBe(0);
} finally {
Atomics.store(barrier, 0, 1);
Atomics.notify(barrier, 0);
expect(await ended).toBe(0);
}
}, 10_000);
});
2 changes: 2 additions & 0 deletions site/src/content/docs/reference/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ By default the server exposes `codegraph_explore` for code and `codegraph_sessio

T3-hosted provider transcripts can appear in session search; CodeGraph does not read the T3 database. The source includes a disabled-by-default offline Codex metadata prototype that associates caller-supplied T3 titles and thread links with existing hits. It has no CLI or MCP caller, adds no searchable prose and leaves provider titles, keys and ranking unchanged.

Session history lives in `.codegraph/sessions-v2.db`, separate from the code graph. The first session search imports remembered roots from `sessions.db` and reindexes available transcripts. The older file remains untouched for older executables. Session mentions in code answers resume after the new store is populated. Cached passages from unavailable transcripts remain only in the older store. Retaining both files uses extra disk space. Indexes that predate remembered roots start with none. A damaged newer roots table stops initialization.

Exposing one strong code tool is deliberate. Measured agent behavior showed that one well-aimed tool steers agents to a direct answer better than a menu of narrower ones — fewer mis-picks — and agents reach for it both when answering questions and while editing code.

Explore reports requested files that did not receive pin priority within the file limit. Those files may still appear through other matches. References beyond the bounded path scan are marked unexamined. When a capped file gather leaves indexed source uncovered, the summary offers continuation ranges for another explore call. Existing file and output limits still apply.
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4561,7 +4561,7 @@ export class ToolHandler {
*/
/**
* Handle codegraph_sessions: refresh the project's session index (its own
* `.codegraph/sessions.db`, see src/sessions) and search it. A project with
* `.codegraph/sessions-v2.db`, see src/sessions) and search it. A project with
* no transcripts, or one that opted out, answers as guidance rather than an
* error, like an unindexed projectPath does.
*/
Expand Down
Loading
Loading