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

- Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list.

- `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off.
- `codegraph_explore` can avoid re-sending source it already returned earlier on the same MCP connection. Set `CODEGRAPH_EXPLORE_DEDUP=1` only when that connection is guaranteed to belong to one durable agent context; the optimization is off by default because some hosts reuse a connection for subagents or keep it alive across context compaction.

- When an agent connects over MCP, CodeGraph now states up front that it indexes 30+ languages — TypeScript/JavaScript, Python, Go, Rust, Java, C#, C/C++, PHP, Ruby, Swift, Kotlin, and more — so agents no longer assume a language isn't supported and skip the graph. (#671)

Expand All @@ -27,6 +27,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- `codegraph_explore` no longer tells a fresh subagent, or an agent after context compaction, to reuse source that only an earlier context received. Cross-call source suppression now requires explicit `CODEGRAPH_EXPLORE_DEDUP=1`; without a reliable host-provided context lifecycle, the default safely re-serves source on every call. (#1620)

- Erlang functions that share a name but differ in arity are now separate symbols with the language's own `module:fun/arity` identity, so the everyday `f/1` delegating to `f/2` shows as a real call edge instead of a self-loop, each arity keeps its own `-spec` and source span, `-export([f/1])` marks exactly that arity as public, and asking `codegraph_explore` for a symbol the way Erlang spells it — `cowboy_req:header/3` — returns that definition. Re-index Erlang projects after upgrading. Thanks @Dshuishui. (#1610) (Erlang)

- Erlang behaviour dispatch no longer miscounts a call site's arity when an argument is a binary literal like `<<1,2,3>>` — the commas inside were counted as argument separators, which silently dropped (or could mislink) the dispatch edge to the behaviour callback. (#1358) (Erlang)
Expand Down
44 changes: 44 additions & 0 deletions __tests__/explore-cross-call-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explor
import {
EXPLORE_DEDUP,
dedupeRange,
exploreDedupEnabled,
fileFingerprint,
formatBackReference,
intersectRange,
Expand All @@ -41,6 +42,27 @@ const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
const QUERY = 'how does payroll cycle create and calculate payslips?';
const POINTER = 'Already sent earlier in this conversation';

describe('dedup configuration', () => {
it('defaults off and requires an explicit truthy opt-in', () => {
const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
try {
delete process.env.CODEGRAPH_EXPLORE_DEDUP;
expect(exploreDedupEnabled()).toBe(false);
for (const enabled of ['1', 'true', 'on', 'yes', ' YES ']) {
process.env.CODEGRAPH_EXPLORE_DEDUP = enabled;
expect(exploreDedupEnabled()).toBe(true);
}
for (const disabled of ['0', 'false', 'off', 'no', 'unexpected']) {
process.env.CODEGRAPH_EXPLORE_DEDUP = disabled;
expect(exploreDedupEnabled()).toBe(false);
}
} finally {
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
}
});
});

/** A prior-state shaped like the session tracker's, for the algebra tests. */
function prior(files: Array<{ path: string; ranges: Array<[number, number]>; fingerprint?: string }>): ExploreProjectState {
return {
Expand Down Expand Up @@ -183,8 +205,11 @@ describe('a second call against a real index', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
let previousDedup: string | undefined;

beforeAll(async () => {
previousDedup = process.env.CODEGRAPH_EXPLORE_DEDUP;
process.env.CODEGRAPH_EXPLORE_DEDUP = '1';
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg18-'));
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
Expand All @@ -194,6 +219,8 @@ describe('a second call against a real index', () => {
}, 120_000);

afterAll(() => {
if (previousDedup === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
else process.env.CODEGRAPH_EXPLORE_DEDUP = previousDedup;
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
Expand Down Expand Up @@ -328,6 +355,23 @@ describe('a second call against a real index', () => {
}
}, 120_000);

it('re-serves source by default when a connection may outlive the current context', async () => {
const session = new ExploreSessionState();
const previous = process.env.CODEGRAPH_EXPLORE_DEDUP;
delete process.env.CODEGRAPH_EXPLORE_DEDUP;
try {
const first = await explore(QUERY, session);
const second = await explore(QUERY, session);
expect(second).toBe(first);
expect(second).not.toContain(POINTER);
expect([...fencedLines(second).values()].reduce((sum, lines) => sum + lines.size, 0))
.toBeGreaterThan(20);
} finally {
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP;
else process.env.CODEGRAPH_EXPLORE_DEDUP = previous;
}
}, 120_000);

it('reports the reclaimed bytes through the CG-4 diagnostic', async () => {
const sidecar = path.join(testDir, 'cg18-diagnostic.jsonl');
const session = new ExploreSessionState();
Expand Down
13 changes: 8 additions & 5 deletions src/mcp/explore-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,19 @@ export const EXPLORE_DEDUP = {
MAX_SYMBOLS_IN_POINTER: 5,
} as const;

const OFF = new Set(['0', 'false', 'off', 'no']);
const ON = new Set(['1', 'true', 'on', 'yes']);

/**
* Kill switch: `CODEGRAPH_EXPLORE_DEDUP=0` renders every call as if the session
* had no history. Read per call (not memoized) so a test can toggle it.
* Cross-call source suppression is opt-in. An MCP connection is not a reliable
* conversation boundary: some hosts reuse it for subagents, and compaction can
* discard source while keeping the connection alive (#1620). Without a host-
* supplied context lifecycle, re-serving source is the only always-correct
* default. Read per call (not memoized) so tests and launchers can toggle it.
*/
export function exploreDedupEnabled(): boolean {
const raw = process.env.CODEGRAPH_EXPLORE_DEDUP;
if (raw === undefined) return true;
return !OFF.has(raw.trim().toLowerCase());
if (raw === undefined) return false;
return ON.has(raw.trim().toLowerCase());
}

/**
Expand Down