diff --git a/CHANGELOG.md b/CHANGELOG.md index e0482ce82..9852cac39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_sessions` and `codegraph sessions` take `full` / `--full` to return each hit's whole stored passage instead of a 24-token snippet, up to 16,000 bytes in all; hits past the budget keep their snippet and the answer says how many. Snippets stay the default. - `codegraph_sessions` and `codegraph sessions` take `context` / `--context ` (0 to 5, implies `full`) to return the passages either side of each hit from the same transcript. Neighbours spend the same byte budget after every hit's own passage, nearest first, and the answer says how many it left out. Passages are returned as stored, including any credentials a transcript holds. +- **Experimental:** `codegraph_sessions` takes `projects` (CLI: `--project `), up to five project paths, to search several projects' session histories in one call. Each project keeps its own session index and answers as it would alone; the hits are interleaved by each project's own ranking (scores are not comparable across indexes), each names its project, and one limit and one 16,000-byte passage budget cover the whole list. A project with no transcripts, with `"sessions": false`, or without an index is listed as skipped and never fails the call. Searching refreshes each listed project's own session index, as a search inside it would. There is no allowlist and no config key, so the experiment is one file (`src/sessions/fanout.ts`) plus its callers. - Installers can verify daemon readiness and safely hand writer ownership across build promotion and rollback through a supported runtime-control API. diff --git a/README.md b/README.md index c88a2e772..5ca47f9dd 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Compared with upstream `main` at `1d3619d6` (after v1.6.2). Each item here and i | Feature | Upstream | Fork | What it does | |---|:-:|:-:|---| -| Session search (`codegraph sessions`, `codegraph_sessions`) | — | ✓ | Searches this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts and its git commit messages, so an agent can find what a past session decided. `codegraph_explore` also names the sessions that mentioned the symbols it returns. `"sessions": false` in `codegraph.json` turns it off. | +| Session search (`codegraph sessions`, `codegraph_sessions`) | — | ✓ | Searches this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts and its git commit messages, so an agent can find what a past session decided. `codegraph_explore` also names the sessions that mentioned the symbols it returns. `"sessions": false` in `codegraph.json` turns it off. Experimental: `projects` / `--project` searches up to five projects' histories in one call. | | Markdown indexing | — | ✓ | Headings, sections, tables and links become graph nodes; a documentation question gets the matching section. Explicit code-file pins remain eligible in mixed documentation/code requests. | | Missing names reported | — | ✓ | After a complete scan of indexed source without skipped files, `codegraph_explore` lists requested names it cannot find, helping an agent catch a guessed name. | | Requested-source limits reported | — | ✓ | Explore reports requested files that did not receive pin priority within the file limit, unexamined path-like references beyond its bounded scan, and uncovered indexed continuation ranges after a capped file gather. Existing output limits still apply. | @@ -766,7 +766,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) codegraph context # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code) -codegraph sessions # Search Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and commit messages for this project (--role, --since , --session, --any, --full, --context , --json; same output as codegraph_sessions) +codegraph sessions # Search Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and commit messages for this project (--role, --since , --session, --any, --full, --context , --project (experimental), --json; same output as codegraph_sessions) codegraph node # 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 # Find what calls a function/method (--limit, --json) @@ -817,7 +817,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-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; `full` returns each hit's whole stored passage instead of the snippet, up to 16,000 bytes in all, and later hits keep their snippet; `context` (0-5, implies `full`) adds that many neighbouring passages either side of each hit from the same transcript, spending the same budget after the hits themselves. Passages come back as stored, credentials included. 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; `full` returns each hit's whole stored passage instead of the snippet, up to 16,000 bytes in all, and later hits keep their snippet; `context` (0-5, implies `full`) adds that many neighbouring passages either side of each hit from the same transcript, spending the same budget after the hits themselves; `projects` (experimental, up to five paths) searches those projects' session histories in one call, interleaving hits by each project's own ranking and naming each hit's project. Passages come back as stored, credentials included. 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`). diff --git a/__tests__/mcp-tool-annotations.test.ts b/__tests__/mcp-tool-annotations.test.ts index 2b5920bcb..89c51a1d5 100644 --- a/__tests__/mcp-tool-annotations.test.ts +++ b/__tests__/mcp-tool-annotations.test.ts @@ -74,7 +74,9 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { expect(got.length).toBe(tools.length); for (const tool of got) { expectReadOnly(tool); - // Sanity: this IS the clone path (projectPath got marked required). + // Sanity: this IS the clone path (projectPath got marked required) — except + // codegraph_sessions, which can name its own `projects` instead. + if (tool.name === 'codegraph_sessions') continue; expect(tool.inputSchema.required ?? []).toContain('projectPath'); } expectExploreAlwaysLoad(got); diff --git a/__tests__/sessions-fanout.test.ts b/__tests__/sessions-fanout.test.ts new file mode 100644 index 000000000..90846c062 --- /dev/null +++ b/__tests__/sessions-fanout.test.ts @@ -0,0 +1,234 @@ +/** + * Experimental cross-project session search (src/sessions/fanout.ts): each + * project keeps its own session index, the search runs per project and the + * answers are merged. Synthetic projects only; transcripts sit where Claude + * Code keeps them (CLAUDE_CONFIG_DIR/projects/). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { CodeGraph } from '../src'; +import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools'; +import { clearProjectConfigCache } from '../src/project-config'; +import { claudeProjectSlug } from '../src/sessions/claude-code'; +import { querySessions } from '../src/sessions'; +import { formatCrossProjectHits, MAX_PROJECTS, querySessionsAcross, type ProjectTarget } from '../src/sessions/fanout'; + +const at = '2026-09-04T20:00:00.000Z'; +const dirs: string[] = []; +const savedEnv = { ...process.env }; + +const fixtureDir = (): string => { + const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fanout-'))); + dirs.push(d); + return d; +}; + +/** A project root with a `.codegraph/` and one transcript per entry of `prompts`. */ +function project(config: string, name: string, prompts: string[]): string { + const root = path.join(fixtureDir(), name); + fs.mkdirSync(path.join(root, '.codegraph'), { recursive: true }); + if (prompts.length > 0) { + const dir = path.join(config, 'projects', claudeProjectSlug(root)); + fs.mkdirSync(dir, { recursive: true }); + prompts.forEach((text, i) => { + const file = path.join(dir, `${name}-${i}.jsonl`); + fs.writeFileSync(file, JSON.stringify({ type: 'user', timestamp: at, message: { content: text } }) + '\n'); + fs.utimesSync(file, 1_700_000_000 + i, 1_700_000_000 + i); + }); + } + return root; +} + +const roots = (...r: string[]): ProjectTarget[] => r.map((root) => ({ root })); + +afterEach(() => { + (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 ['CODEGRAPH_SESSIONS_DIR', 'CLAUDE_CONFIG_DIR']) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + clearProjectConfigCache(); + __setLoadCodeGraphForTests(null); +}); + +const words = (n: number) => `We settled the sprocket handling for case ${n} by keeping the flange; sprocket flange decision recorded.`; + +describe('querySessionsAcross', () => { + it('interleaves by each project\'s own rank and labels every hit with its project', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1), words(2), words(3)]); + const b = project(config, 'beta', [words(4), words(5)]); + const result = querySessionsAcross(roots(a, b), 'sprocket flange', { limit: 10 }); + expect(result.hits.map((h) => path.basename(h.project))).toEqual(['alpha', 'beta', 'alpha', 'beta', 'alpha']); + expect(result.projects.map((p) => [p.name, p.hits, p.files])).toEqual([['alpha', 3, 3], ['beta', 2, 2]]); + const text = formatCrossProjectHits('sprocket flange', result); + expect(text).toContain('across 2 projects (experimental) — 5 hits'); + expect(text).toContain('## alpha · claude:alpha-'); + expect(text).toContain('## beta · claude:beta-'); + }); + + it('applies one limit across the projects', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1), words(2), words(3)]); + const b = project(config, 'beta', [words(4), words(5), words(6)]); + const result = querySessionsAcross(roots(a, b), 'sprocket flange', { limit: 3 }); + expect(result.hits.map((h) => path.basename(h.project))).toEqual(['alpha', 'beta', 'alpha']); + expect(result.projects.map((p) => p.hits)).toEqual([2, 1]); + }); + + it('lists a project that cannot answer as skipped and still answers from the rest', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1)]); + const empty = project(config, 'empty', []); + const off = project(config, 'off', [words(2)]); + fs.writeFileSync(path.join(off, 'codegraph.json'), JSON.stringify({ sessions: false })); + const missing: ProjectTarget = { path: '/nonexistent/project', skipped: 'CodeGraph is not initialized there' }; + const result = querySessionsAcross([...roots(a, empty, off), missing], 'sprocket flange'); + expect(result.hits).toHaveLength(1); + expect(result.projects.filter((p) => p.skipped).map((p) => p.name)).toEqual(['empty', 'off', 'project']); + const text = formatCrossProjectHits('sprocket flange', result); + expect(text).toContain('across 1 project (experimental)'); + expect(text).toContain('empty'); + expect(text).toMatch(/off .*: skipped, no session transcripts, or sessions are turned off/); + }); + + it('searches at most five projects and ignores a repeated one', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const all = Array.from({ length: 7 }, (_, i) => project(config, `p${i}`, [words(i)])); + const result = querySessionsAcross(roots(all[0]!, all[0]!, ...all.slice(1)), 'sprocket flange', { limit: 20 }); + expect(result.projects).toHaveLength(MAX_PROJECTS); + expect(result.dropped).toBe(2); + expect(result.hits).toHaveLength(MAX_PROJECTS); + expect(formatCrossProjectHits('sprocket flange', result)).toContain('2 more were left out'); + }); + + it('keeps the per-project indexes apart: each holds only its own transcripts', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1)]); + const b = project(config, 'beta', [words(2), words(3)]); + querySessionsAcross(roots(a, b), 'sprocket flange'); + const alone = querySessions(a, 'sprocket flange'); + expect(alone.index).toEqual({ files: 1, refreshed: 0, docs: 0 }); + expect(alone.hits.map((h) => h.session)).toEqual(['claude:alpha-0']); + expect(querySessions(b, 'sprocket flange').index.refreshed).toBe(0); + }); + + it('spends one byte budget across projects', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const long = (n: number) => `sprocket flange ${n} ${'x'.repeat(3500)}`; + const a = project(config, 'alpha', [long(1), long(2), long(3)]); + const b = project(config, 'beta', [long(4), long(5)]); + const separately = [a, b].map((p) => querySessions(p, 'sprocket flange', { full: true }).fullCut); + expect(separately).toEqual([0, 0]); + const result = querySessionsAcross(roots(a, b), 'sprocket flange', { full: true }); + expect(result.hits.filter((h) => h.text !== undefined)).toHaveLength(4); + expect(result.fullCut).toBe(1); + expect(formatCrossProjectHits('sprocket flange', result)).toContain('across projects: the last 1 hit'); + }); +}); + +describe('querySessionsAcross review cases', () => { + it('counts passages cut by the shared budget, not by a per-project one', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const long = (n: number) => `sprocket flange ${n} ${'x'.repeat(3500)}`; + const a = project(config, 'alpha', [1, 2, 3, 4, 5].map(long)); + const result = querySessionsAcross(roots(a), 'sprocket flange', { full: true, limit: 5 }); + expect(result.hits).toHaveLength(5); + expect(result.hits.filter((h) => h.text !== undefined)).toHaveLength(4); + expect(result.fullCut).toBe(1); + }); + + it('names a project by its root when two listed projects share a directory name', () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'app', [words(1)]); + const b = project(config, 'app', [words(2)]); + expect(path.basename(a)).toBe(path.basename(b)); + const text = formatCrossProjectHits('sprocket flange', querySessionsAcross(roots(a, b), 'sprocket flange')); + expect(text).toContain(`## ${a} · claude:app-0`); + expect(text).toContain(`## ${b} · claude:app-0`); + }); +}); + +describe('codegraph_sessions projects argument', () => { + it('searches the listed projects, reports an unindexed one as skipped, and leaves the single-project path alone', async () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1)]); + const b = project(config, 'beta', [words(2)]); + for (const root of [a, b]) { + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n'); + fs.rmSync(path.join(root, '.codegraph'), { recursive: true, force: true }); + const cg = CodeGraph.initSync(root); + await cg.indexAll(); + cg.close(); + } + const unindexed = fixtureDir(); + __setLoadCodeGraphForTests(CodeGraph); + const handler = new ToolHandler(null); + const res = await handler.execute('codegraph_sessions', { query: 'sprocket flange', projects: [a, b, unindexed] }); + const text = res.content[0]!.text; + expect(res.isError).toBeFalsy(); + expect(text).toContain('across 2 projects (experimental) — 2 hits'); + expect(text).toContain('## alpha · claude:alpha-0'); + expect(text).toContain('## beta · claude:beta-0'); + expect(text).toMatch(/skipped/); + const single = await handler.execute('codegraph_sessions', { query: 'sprocket flange', projectPath: a }); + expect(single.content[0]!.text).toMatch(/^Sessions matching "sprocket flange" — 1 hit across 1 transcript/); + expect(single.content[0]!.text).not.toContain('experimental'); + }); + it('answers a projects-only call with no default project, even when none of them is indexed, and bounds the list', async () => { + __setLoadCodeGraphForTests(CodeGraph); + const handler = new ToolHandler(null); + const schema = handler.getTools().find((t) => t.name === 'codegraph_sessions')!; + expect(schema.inputSchema.required).toEqual(['query']); + const none = Array.from({ length: 7 }, (_, i) => path.join(fixtureDir(), `none-${i}`)); + const res = await handler.execute('codegraph_sessions', { query: 'sprocket flange', projects: none }); + const text = res.content[0]!.text; + expect(res.isError).toBeFalsy(); + expect(text).toContain('across 0 projects (experimental) — 0 hits'); + expect(text.match(/: skipped, /g)).toHaveLength(MAX_PROJECTS); + expect(text).toContain('2 more were left out'); + }); +}); + +describe('codegraph sessions --project', () => { + it('searches the listed projects from the shell and marks an unindexed one as skipped', async () => { + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + const a = project(config, 'alpha', [words(1)]); + const b = project(config, 'beta', [words(2)]); + for (const root of [a, b]) { + fs.rmSync(path.join(root, '.codegraph'), { recursive: true, force: true }); + const cg = CodeGraph.initSync(root); + await cg.indexAll(); + cg.close(); + } + const unindexed = fixtureDir(); + const run = (...args: string[]) => + execFileSync(process.execPath, [path.resolve(__dirname, '../dist/bin/codegraph.js'), 'sessions', ...args], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const out = run('sprocket', 'flange', '--project', a, b, unindexed); + expect(out).toContain('across 2 projects (experimental) — 2 hits'); + expect(out).toContain('## alpha · claude:alpha-0'); + expect(out).toContain('## beta · claude:beta-0'); + expect(out).toContain('skipped, CodeGraph is not initialized there'); + const json = JSON.parse(run('sprocket', 'flange', '--project', a, b, '--json')); + expect(json.projects.map((p: { name: string }) => p.name)).toEqual(['alpha', 'beta']); + }); +}); diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index ebc4b5460..08cb111b0 100644 --- a/site/src/content/docs/reference/cli.md +++ b/site/src/content/docs/reference/cli.md @@ -17,7 +17,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) codegraph context # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code) -codegraph sessions # Search this project's earlier agent sessions (--role, --since , --session, --any, --full, --context , --json; same output as codegraph_sessions) +codegraph sessions # Search this project's earlier agent sessions (--role, --since , --session, --any, --full, --context , --project (experimental), --json; same output as codegraph_sessions) codegraph node # 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, --pattern, --max-depth, --json) codegraph callers # Find what calls a function/method (--limit, --json) diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index d9c5daf99..ddc5be01c 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -17,7 +17,7 @@ By default the server exposes `codegraph_explore` for code and `codegraph_sessio `codegraph_explore` It's Read-equivalent: give it a natural-language question or a bag of symbol and file names, and it returns the **verbatim, line-numbered source** of the relevant symbols grouped by file — the same shape the `Read` tool gives you — plus the call paths between them (including dynamic-dispatch hops like callbacks, React re-render, and JSX children that grep can't follow) and a blast-radius summary of what depends on them. One call usually answers the whole question. When a complete scan of indexed source finishes without skipped files, the summary lists requested names it could not find, helping catch a guessed name. -`codegraph_sessions` searches this project's earlier agent sessions (Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts, plus git commit messages) for what a previous session asked, decided or tried. It answers "why is X like this" questions, which the code graph cannot. A hit shows a short snippet; pass `full` (CLI: `--full`) to get each hit's whole stored passage instead, up to 16,000 bytes in all, with later hits keeping their snippet. Add `context` (CLI: `--context `, 0 to 5, implies `full`) for the passages either side of each hit in the same transcript; they spend the same budget after the hits themselves. Passages are returned as stored, so a credential a transcript holds is returned too. Set `"sessions": false` in `codegraph.json` to turn it off. +`codegraph_sessions` searches this project's earlier agent sessions (Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts, plus git commit messages) for what a previous session asked, decided or tried. It answers "why is X like this" questions, which the code graph cannot. A hit shows a short snippet; pass `full` (CLI: `--full`) to get each hit's whole stored passage instead, up to 16,000 bytes in all, with later hits keeping their snippet. Add `context` (CLI: `--context `, 0 to 5, implies `full`) for the passages either side of each hit in the same transcript; they spend the same budget after the hits themselves. Experimental: `projects` (CLI: `--project `) takes up to five project paths and searches their session histories in one call. Each project answers from its own index, the hits are interleaved by each project's own ranking because scores cannot be compared across indexes, and each hit names its project. One limit and one passage budget cover the list; a project with no transcripts, with sessions turned off, or without an index is listed as skipped. Searching refreshes each listed project's own session index. Passages are returned as stored, so a credential a transcript holds is returned too. Set `"sessions": false` in `codegraph.json` to turn it off. 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. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e2d2b4a87..a46d50965 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1504,10 +1504,32 @@ program .option('--any', 'OR the words instead of requiring all of them') .option('--full', 'Show each hit\'s whole passage (16,000 bytes in all) instead of a snippet') .option('--context ', 'Also show n passages (0-5) before and after each hit; implies --full', '0') + .option('--project ', 'Experimental: search these projects instead (at most 5); hits name their project') .option('-j, --json', 'Output as JSON') - .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; session?: string; any?: boolean; full?: boolean; context?: string; json?: boolean }) => { + .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; session?: string; any?: boolean; full?: boolean; context?: string; project?: string[]; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { + if (options.project && options.project.length > 0) { + const { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS } = await import('../sessions/fanout'); + const sinceDays = Number(options.since); + const query = words.join(' '); + const requested = [...new Set(options.project)]; + const targets = requested.slice(0, MAX_PROJECTS).map((p) => { + const root = resolveProjectPath(p); + return isInitialized(root) ? { root } : { path: root, skipped: 'CodeGraph is not initialized there' }; + }); + const result = querySessionsAcross(targets, query, { + limit: parseInt(options.limit || '10', 10), + role: options.role, + sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + session: options.session, + any: options.any, + full: options.full, + context: parseInt(options.context || '0', 10) || 0, + }, requested.length - targets.length); + console.log(options.json ? JSON.stringify(result, null, 2) : formatCrossProjectHits(query, result)); + return; + } if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 06223acc0..063f78d30 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -54,7 +54,7 @@ against dozens of greps and reads. - **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, riding dynamic-dispatch hops, and returns their source. - **Reading or editing a file/symbol you can name** → put its name or file path in the \`codegraph_explore\` query — it returns that current line-numbered source (safe to \`Edit\` from) with the call path and blast radius attached, so you don't Read it separately. For an overloaded name it returns every matching definition's body in one call. - Name what you need as precisely as you can — a receiver type with its method, a constant or variable, a callable, or a file path. Named items are funded first within the output cap; an oversized body comes back as a bounded excerpt whose gap markers name what was left out. Treat returned ranges as already Read; for trimmed parts, query the names in the gap marker. -- **"Why is this like this? What did the last session decide / try / get told about X?"** → \`codegraph_sessions\` with a few words. It searches the prose of this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, and Devin sessions (prompts, replies, compaction summaries — stemmed, ranked) and names the session each hit came from (\`claude:\`, \`codex:\`, \`cursor:\`, \`opencode:\`, \`agy:\`, \`devin:\`). History and rationale live there, not in the code. A hit is a short snippet; pass \`full: true\` to read each hit's whole passage in the same call (up to 16,000 bytes in all), and \`context: N\` (0-5, implies \`full\`) to also get the N passages either side of each hit from the same transcript. Passages are returned as stored, including any credentials a transcript holds. +- **"Why is this like this? What did the last session decide / try / get told about X?"** → \`codegraph_sessions\` with a few words. It searches the prose of this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, and Devin sessions (prompts, replies, compaction summaries — stemmed, ranked) and names the session each hit came from (\`claude:\`, \`codex:\`, \`cursor:\`, \`opencode:\`, \`agy:\`, \`devin:\`). History and rationale live there, not in the code. A hit is a short snippet; pass \`full: true\` to read each hit's whole passage in the same call (up to 16,000 bytes in all), and \`context: N\` (0-5, implies \`full\`) to also get the N passages either side of each hit from the same transcript. Experimental: \`projects: [paths]\` (up to 5) searches those projects' session histories in one call instead of this one, each hit naming its project. Passages are returned as stored, including any credentials a transcript holds. - **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. Suggested call counts are advisory only, NOT a quota; extra calls are never rejected or rate-limited. - Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`). - Named-symbol call paths require exact matches; partial or mistyped names are never silently substituted as flow endpoints. If a graph query reports a missing symbol with did-you-mean suggestions, query the suggested name explicitly. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index a8875c08f..4fa29b0e3 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -45,6 +45,7 @@ import { type QuerySetAsideMatch, } from '../search/query-paths'; import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, MAX_CONTEXT } from '../sessions'; +import { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS, type ProjectTarget } from '../sessions/fanout'; import { existsSync, readFileSync, @@ -1799,6 +1800,7 @@ interface PropertySchema { type: string; description: string; enum?: string[]; + items?: { type: string }; default?: unknown; } @@ -2069,6 +2071,11 @@ export const tools: ToolDefinition[] = [ description: 'With full: also return this many neighbouring passages (0-5) on each side of every hit, from the same transcript; they spend the same 16,000-byte budget after the hits themselves (default: 0).', default: 0, }, + projects: { + type: 'array', + items: { type: 'string' }, + description: 'Experimental: search these projects\' session histories in one call instead of one project (at most 5 absolute paths, each inside a project that has a .codegraph/). Hits name their project and are interleaved by each project\'s own ranking. Leave projectPath out when you use it. A project that has no transcripts, or has turned sessions off, is listed as skipped.', + }, projectPath: projectPathProperty, }, required: ['query'], @@ -2143,7 +2150,8 @@ export const tools: ToolDefinition[] = [ */ function withRequiredProjectPath(defs: ToolDefinition[]): ToolDefinition[] { return defs.map((tool) => { - if (!tool.inputSchema.properties.projectPath) return tool; + // A tool that names its own projects (codegraph_sessions `projects`) works without projectPath. + if (!tool.inputSchema.properties.projectPath || tool.inputSchema.properties.projects) return tool; const required = tool.inputSchema.required ?? []; if (required.includes('projectPath')) return tool; return { @@ -3099,6 +3107,16 @@ export class ToolHandler { args: Record, sessionState?: ExploreSessionState, ): Promise { + // A cross-project session search (experimental) names its projects itself and + // needs no default one: anchor the call on the first listed project that has an + // index, so the shared checks and the worker route behave as for any projectPath. + let allProjectsSkipped = false; + if (toolName === 'codegraph_sessions' && args.projectPath === undefined && !this.cg && Array.isArray(args.projects)) { + const listed = [...new Set(args.projects.filter((p): p is string => typeof p === 'string' && p !== ''))].slice(0, MAX_PROJECTS); + const anchor = listed.find((p) => findNearestCodeGraphRoot(p)); + if (anchor) args = { ...args, projectPath: anchor }; + else allProjectsSkipped = listed.length > 0; + } if (this.closing) return this.textResult('This MCP session is closing; retry with a connected session.'); this.activeCalls++; try { @@ -3119,6 +3137,8 @@ export class ToolHandler { if (!this.isToolAllowed(toolName)) { return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`); } + // Nothing to open: every listed project is unindexed, so the answer is the skipped list. + if (allProjectsSkipped) return this.handleSessions(args); // Cross-cutting input validation. All tools accept an optional // `projectPath` and most accept either `query`, `task`, or // `symbol` — bound their lengths centrally so individual handlers @@ -4457,12 +4477,15 @@ export class ToolHandler { private handleSessions(args: Record): ToolResult { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; - const projectRoot = this.getCodeGraph(args.projectPath as string | undefined).getProjectRoot(); + const requested = Array.isArray(args.projects) ? [...new Set(args.projects.filter((p): p is string => typeof p === 'string' && p !== ''))] : []; + // Bound the list before resolving any path; the cap is applied again, with the omitted count, by the search. + const listed = requested.slice(0, MAX_PROJECTS); + const projectRoot = listed.length > 0 ? '' : this.getCodeGraph(args.projectPath as string | undefined).getProjectRoot(); const sinceDays = Number(args.sinceDays); const role = typeof args.role === 'string' ? args.role : undefined; const session = typeof args.session === 'string' ? args.session : undefined; try { - const result = querySessions(projectRoot, query, { + const opts = { limit: clamp(Number(args.limit) || 10, 1, 100), role, sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, @@ -4470,7 +4493,18 @@ export class ToolHandler { any: args.any === true, full: args.full === true, context: clamp(Math.trunc(Number(args.context)) || 0, 0, MAX_CONTEXT), - }); + }; + if (listed.length > 0) { + const targets = listed.map((p): ProjectTarget => { + try { + return { root: this.getCodeGraph(p).getProjectRoot() }; + } catch (err) { + return { path: p, skipped: err instanceof Error ? err.message.split('\n')[0]! : 'cannot be opened' }; + } + }); + return this.textResult(formatCrossProjectHits(query, querySessionsAcross(targets, query, opts, requested.length - listed.length))); + } + const result = querySessions(projectRoot, query, opts); return this.textResult(formatSessionHits(query, result)); } catch (err) { if (err instanceof NoSessionsError) return this.textResult(err.message); diff --git a/src/sessions/fanout.ts b/src/sessions/fanout.ts new file mode 100644 index 000000000..be0ce23be --- /dev/null +++ b/src/sessions/fanout.ts @@ -0,0 +1,164 @@ +/** + * Experimental: one session search over several projects. + * + * Each project keeps its own `.codegraph/sessions-v2.db`; this runs the ordinary + * per-project query on every listed project and merges the answers. Nothing is + * stored across projects, and a query here refreshes the other projects' own + * session indexes exactly as a query inside them would. BM25 ranks are only + * comparable within one index, so the merge interleaves by per-project rank. + * + * Self-contained so the experiment can be removed by deleting this file and its + * callers (the `projects` argument of `codegraph_sessions`, the `--project` + * option of `codegraph sessions`). + */ +import * as path from 'path'; +import { + FULL_PASSAGE_BUDGET, + NoSessionsError, + querySessions, + withinPassageBudget, + type SessionHit, + type SessionSearchOptions, +} from './index'; + +/** At most this many projects are searched in one call. */ +export const MAX_PROJECTS = 5; + +export interface ProjectHit extends SessionHit { + /** Root of the project whose index holds this hit. */ + project: string; +} + +export interface ProjectSummary { + root: string; + name: string; + /** Hits this project contributed to the merged list. */ + hits: number; + /** Transcripts its index covers. */ + files: number; + /** Why this project was not searched. */ + skipped?: string; +} + +export interface CrossProjectResult { + projects: ProjectSummary[]; + hits: ProjectHit[]; + /** True when some project had no passage holding every word. */ + fallback?: boolean; + fullCut?: number; + contextCut?: number; + /** Listed projects beyond {@link MAX_PROJECTS} that were not searched. */ + dropped?: number; +} + +/** A listed project, or the reason it cannot be searched. */ +export type ProjectTarget = { root: string } | { path: string; skipped: string }; + +/** + * Searches each target and merges: the best hit of every project, then the next + * best of every project, until `limit`. A project that cannot answer is listed + * with the reason and never fails the call. + */ +export function querySessionsAcross( + targets: readonly ProjectTarget[], + query: string, + opts: SessionSearchOptions = {}, + /** Listed projects the caller left out before resolving them, to keep the count honest. */ + omitted = 0, +): CrossProjectResult { + const limit = opts.limit ?? 10; + const seen = new Set(); + const listed: ProjectTarget[] = []; + for (const t of targets) { + const key = 'root' in t ? t.root : t.path; + if (seen.has(key)) continue; + seen.add(key); + listed.push(t); + } + const projects: ProjectSummary[] = []; + const perProject: ProjectHit[][] = []; + let fallback = false; + for (const target of listed.slice(0, MAX_PROJECTS)) { + if (!('root' in target)) { + projects.push({ root: target.path, name: path.basename(target.path), hits: 0, files: 0, skipped: target.skipped }); + continue; + } + try { + const r = querySessions(target.root, query, { ...opts, unbudgeted: true }); + if (r.fallback) fallback = true; + projects.push({ root: target.root, name: path.basename(target.root), hits: 0, files: r.index.files }); + perProject.push(r.hits.map((h) => ({ ...h, project: target.root }))); + } catch (err) { + const skipped = err instanceof NoSessionsError ? 'no session transcripts, or sessions are turned off' : `search failed: ${err instanceof Error ? err.message : String(err)}`; + projects.push({ root: target.root, name: path.basename(target.root), hits: 0, files: 0, skipped }); + } + } + const hits: ProjectHit[] = []; + for (let rank = 0; hits.length < limit; rank++) { + let any = false; + for (const list of perProject) { + const hit = list[rank]; + if (!hit) continue; + any = true; + if (hits.length < limit) hits.push(hit); + } + if (!any) break; + } + for (const h of hits) { + const summary = projects.find((p) => p.root === h.project); + if (summary) summary.hits++; + } + const out: CrossProjectResult = { projects, hits }; + if (fallback) out.fallback = true; + const dropped = Math.max(0, listed.length - MAX_PROJECTS) + omitted; + if (dropped > 0) out.dropped = dropped; + if (opts.full || opts.context) { + // Projects return unbudgeted passages; one budget covers the merged selection. + const { fullCut, contextCut } = withinPassageBudget(hits); + out.fullCut = fullCut; + if (contextCut > 0) out.contextCut = contextCut; + } + return out; +} + +/** The text the CLI and the MCP tool print for a cross-project search. */ +export function formatCrossProjectHits(query: string, result: CrossProjectResult): string { + const { hits, projects } = result; + const searched = projects.filter((p) => !p.skipped); + const lines = [ + `Sessions matching "${query}" across ${searched.length} project${searched.length === 1 ? '' : 's'} (experimental) — ${hits.length} hit${hits.length === 1 ? '' : 's'}:`, + ...projects.map((p) => (p.skipped ? `- ${p.name} (${p.root}): skipped, ${p.skipped}` : `- ${p.name} (${p.root}): ${p.hits} hit${p.hits === 1 ? '' : 's'} shown, ${p.files} transcript${p.files === 1 ? '' : 's'} indexed`)), + ]; + if (result.dropped) lines.push(`Only the first ${MAX_PROJECTS} projects are searched; ${result.dropped} more were left out.`); + lines.push('Hits are interleaved by each project\'s own ranking, best of every project first, because scores are not comparable across projects.', ''); + if (hits.length === 0) { + lines.push('No transcript prose in these projects holds any of these words. A stem ("merge" also finds "merged") or a different term may find it.'); + return lines.join('\n'); + } + if (result.fallback) lines.push('Some projects had no passage holding every word; their hits match some of the words.', ''); + // The directory name, or the whole root when two listed projects share one. + const names = projects.map((p) => p.name); + const label = (root: string) => (names.filter((n) => n === path.basename(root)).length > 1 ? root : path.basename(root)); + for (const h of hits) { + const title = h.title ? ` · ${h.title}` : ''; + lines.push(`## ${label(h.project)} · ${h.session}${title}`); + lines.push(`${h.role} · ${h.ts} · ${h.file}${h.partial ? ' · some words' : ''}`); + if (h.text === undefined) lines.push(h.snippet.replace(/\s+/g, ' ').trim()); + else if (h.before || h.after) { + const quote = (p: { text: string }) => p.text.split('\n').map((l) => `> ${l}`); + const label = (what: string, p: { role: string; ts: string }) => `${what} · ${p.role} · ${p.ts}`; + for (const p of h.before ?? []) lines.push(label('before', p), ...quote(p), ''); + lines.push('match', ...quote(h as { text: string })); + for (const p of h.after ?? []) lines.push('', label('after', p), ...quote(p)); + } else lines.push(...h.text.split('\n').map((l) => `> ${l}`)); + lines.push(''); + } + if (result.fullCut) { + lines.push(`Whole passages are limited to ${FULL_PASSAGE_BUDGET.toLocaleString('en-US')} bytes in all, across projects: the last ${result.fullCut} hit${result.fullCut === 1 ? '' : 's'} show only the matching snippet.`, ''); + } + if (result.contextCut) { + lines.push(`The byte budget left out ${result.contextCut} neighbouring passage${result.contextCut === 1 ? '' : 's'}; the nearest ones are shown.`, ''); + } + lines.push('A hit names its project and session id; the path after the timestamp is the transcript to read when the snippet is not enough.'); + return lines.join('\n'); +} diff --git a/src/sessions/index.ts b/src/sessions/index.ts index 8ba16e7cf..be18db77e 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -143,6 +143,8 @@ export interface SessionSearchOptions { * Implies `full`; neighbours spend the byte budget after every hit's own passage. */ context?: number; + /** Internal: leave `full` and `context` passages unbudgeted so a caller merging several searches can apply one budget. */ + unbudgeted?: boolean; } /** @@ -717,7 +719,7 @@ export function querySessions( * back to their snippet. Neighbours then spend what is left, rank by rank and * nearest first, so context never costs a hit its own passage. */ -function withinPassageBudget(hits: SessionHit[]): { fullCut: number; contextCut: number } { +export function withinPassageBudget(hits: SessionHit[]): { fullCut: number; contextCut: number } { let spent = 0; let fullCut = 0; for (const hit of hits) { @@ -765,7 +767,7 @@ function result( ): SessionsQueryResult { const hits = [...found]; const out: SessionsQueryResult = found.fallback ? { index, hits, fallback: true } : { index, hits }; - if (opts.full) { + if (opts.full && !opts.unbudgeted) { const { fullCut, contextCut } = withinPassageBudget(hits); out.fullCut = fullCut; if (contextCut > 0) out.contextCut = contextCut;