diff --git a/CHANGELOG.md b/CHANGELOG.md index 393c145fb..9c6a20611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features - `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. - 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 1bc56e074..2047193d0 100644 --- a/README.md +++ b/README.md @@ -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, --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 , --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. 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. 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__/cli-sessions-command.test.ts b/__tests__/cli-sessions-command.test.ts index e8587c429..56100ac75 100644 --- a/__tests__/cli-sessions-command.test.ts +++ b/__tests__/cli-sessions-command.test.ts @@ -94,6 +94,12 @@ describe('codegraph sessions — CLI command', () => { expect(parsed.fullCut).toBe(0); }); + it('--context returns neighbouring passages and implies --full', () => { + const parsed = JSON.parse(sessions(tempDir, transcripts, ['trailing', 'newlines', '--context', '1', '--json'])); + expect(parsed.hits[0].text).toBe('Trimming first keeps a trailing newline from failing the signature check.'); + expect([...(parsed.hits[0].before ?? []), ...(parsed.hits[0].after ?? [])].length).toBeGreaterThan(0); + }); + 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/); diff --git a/__tests__/sessions-index.test.ts b/__tests__/sessions-index.test.ts index 5eca0aae3..4638efdd5 100644 --- a/__tests__/sessions-index.test.ts +++ b/__tests__/sessions-index.test.ts @@ -35,6 +35,7 @@ import { NoSessionsError, formatSessionHits, FULL_PASSAGE_BUDGET, + MAX_CONTEXT, } from '../src/sessions'; import { clearProjectConfigCache } from '../src/project-config'; import { isInjectedDoc, slashCommandText, splitPassages, indexableDocs } from '../src/sessions/noise'; @@ -1278,6 +1279,66 @@ describe('whole passages (full)', () => { expect(hit.hits).toHaveLength(1); expect(querySessions(project, 'write-time dedupe', { full: true, role: 'assistant' }).hits).toEqual([]); }); + + describe('context', () => { + /** Passages under 20 characters are not indexed, so every turn carries a suffix. */ + const t = (s: string) => `${s}, long enough to be indexed`; + const turns = ['first turn', 'second turn', 'third turn', 'the pivotword decision', 'fifth turn', 'sixth turn', 'seventh turn'].map(t); + + it('returns the passages either side of a hit in transcript order, and implies full', () => { + const project = store(turns); + const { hits } = querySessions(project, 'pivotword', { context: 2 }); + expect(hits[0]!.text).toBe(t('the pivotword decision')); + expect(hits[0]!.before!.map((p) => p.text)).toEqual([t('second turn'), t('third turn')]); + expect(hits[0]!.after!.map((p) => p.text)).toEqual([t('fifth turn'), t('sixth turn')]); + expect(hits[0]!.before![0]).toMatchObject({ role: 'user' }); + const printed = formatSessionHits('pivotword', querySessions(project, 'pivotword', { context: 1 })); + expect(printed.indexOf(`> ${t('third turn')}`)).toBeLessThan(printed.indexOf(`> ${t('the pivotword decision')}`)); + expect(printed.indexOf(`> ${t('the pivotword decision')}`)).toBeLessThan(printed.indexOf(`> ${t('fifth turn')}`)); + expect(printed).not.toContain(`> ${t('second turn')}`); + }); + + it('stops at the ends of the transcript and never crosses into another transcript', () => { + const project = store([t('pivotword opens'), t('second turn')]); + writeJsonl(path.join(process.env.CODEGRAPH_SESSIONS_DIR!, 's2.jsonl'), [user(t('other transcript turn'))], 1_700_000_100); + const hit = querySessions(project, 'pivotword', { context: 5 }).hits[0]!; + expect(hit.before).toBeUndefined(); + expect(hit.after!.map((p) => p.text)).toEqual([t('second turn')]); + }); + + it('clamps the request and leaves a plain search alone', () => { + const project = store(Array.from({ length: 20 }, (_, i) => (i === 10 ? t('pivotword') : t(`turn ${i}`)))); + const hit = querySessions(project, 'pivotword', { full: true, context: 99 }).hits[0]!; + expect(hit.before).toHaveLength(MAX_CONTEXT); + expect(hit.after).toHaveLength(MAX_CONTEXT); + expect(querySessions(project, 'pivotword', { full: true }).hits[0]!.before).toBeUndefined(); + expect(querySessions(project, 'pivotword').hits[0]!.text).toBeUndefined(); + }); + + it('keeps passages in order after the transcript is re-indexed', () => { + const project = store(turns); + expect(querySessions(project, 'pivotword', { context: 1 }).hits[0]!.after![0]!.text).toBe(t('fifth turn')); + const file = path.join(process.env.CODEGRAPH_SESSIONS_DIR!, 's1.jsonl'); + writeJsonl(file, [...turns.slice(0, 4), t('inserted turn'), ...turns.slice(4), t('a longer tail so the size moves')].map((t) => user(t)), 1_700_000_500); + const hit = querySessions(project, 'pivotword', { context: 1 }).hits[0]!; + expect(hit.before![0]!.text).toBe(t('third turn')); + expect(hit.after![0]!.text).toBe(t('inserted turn')); + }); + + it('spends the budget on every hit first, then on neighbours nearest first, and says what was left out', () => { + const big = (label: string) => `${label} ${'padding '.repeat(500)}`.slice(0, 3500); + const project = store([big('before-far'), big('before-near'), t('pivotword decision'), big('after-near'), big('after-far')]); + const result = querySessions(project, 'pivotword', { context: 2 }); + const used = [result.hits[0]!.text!, ...result.hits[0]!.before!.map((p) => p.text), ...result.hits[0]!.after!.map((p) => p.text)]; + expect(used.reduce((n, t) => n + Buffer.byteLength(t), 0)).toBeLessThanOrEqual(FULL_PASSAGE_BUDGET); + const tight = store([t('pivotword decision'), ...Array.from({ length: 12 }, (_, i) => big(`after-${i}`))]); + const cut = querySessions(tight, 'pivotword', { context: 5 }); + expect(cut.contextCut).toBeGreaterThan(0); + expect(cut.hits[0]!.text).toBe(t('pivotword decision')); + expect(cut.hits[0]!.after![0]!.text).toMatch(/^after-0/); + expect(formatSessionHits('pivotword', cut)).toContain('neighbouring passage'); + }); + }); }); describe('large Claude transcripts are scanned, not parsed whole', () => { diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index d1811c2ed..ebc4b5460 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, --json; same output as codegraph_sessions) +codegraph sessions # Search this project's earlier agent sessions (--role, --since , --session, --any, --full, --context , --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 4e7bc38bc..d9c5daf99 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. 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. 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 8eada08b8..e2d2b4a87 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1503,8 +1503,9 @@ program .option('--session ', 'Only one session (id prefix)') .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('-j, --json', 'Output as JSON') - .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; session?: string; any?: boolean; full?: boolean; json?: boolean }) => { + .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; session?: string; any?: boolean; full?: boolean; context?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { @@ -1523,6 +1524,7 @@ program session: options.session, any: options.any, full: options.full, + context: parseInt(options.context || '0', 10) || 0, }); } catch (err) { if (err instanceof NoSessionsError) { diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 9519b2951..06223acc0 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). +- **"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. - **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 ede3f66d5..a8875c08f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -44,7 +44,7 @@ import { type QueryLineAnchor, type QuerySetAsideMatch, } from '../search/query-paths'; -import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning } from '../sessions'; +import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, MAX_CONTEXT } from '../sessions'; import { existsSync, readFileSync, @@ -2064,6 +2064,11 @@ export const tools: ToolDefinition[] = [ description: 'Return each hit\'s whole passage instead of a short snippet, up to 16,000 bytes in all; later hits keep their snippet (default: false).', default: false, }, + context: { + type: 'number', + 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, + }, projectPath: projectPathProperty, }, required: ['query'], @@ -4464,6 +4469,7 @@ export class ToolHandler { session, any: args.any === true, full: args.full === true, + context: clamp(Math.trunc(Number(args.context)) || 0, 0, MAX_CONTEXT), }); return this.textResult(formatSessionHits(query, result)); } catch (err) { diff --git a/src/sessions/index.ts b/src/sessions/index.ts index 1e0a9b080..8ba16e7cf 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -106,8 +106,22 @@ export interface SessionHit { partial?: boolean; /** The whole stored passage; set only by a `full` search, within {@link FULL_PASSAGE_BUDGET}. */ text?: string; + /** With `context`: the passages just before the hit in its transcript, oldest first. */ + before?: SessionPassage[]; + /** With `context`: the passages just after the hit in its transcript, in order. */ + after?: SessionPassage[]; } +/** A passage next to a hit in the same transcript. */ +export interface SessionPassage { + role: string; + ts: string; + text: string; +} + +/** The most passages `context` returns on each side of a hit. */ +export const MAX_CONTEXT = 5; + /** Bytes of passage text one `full` search returns in all; hits past it keep their snippet. */ export const FULL_PASSAGE_BUDGET = 16_000; @@ -124,6 +138,11 @@ export interface SessionSearchOptions { any?: boolean; /** Return each hit's whole passage (see {@link FULL_PASSAGE_BUDGET}) beside its snippet. */ full?: boolean; + /** + * Passages to return on each side of a hit, 0 to {@link MAX_CONTEXT} (default 0). + * Implies `full`; neighbours spend the byte budget after every hit's own passage. + */ + context?: number; } /** @@ -460,6 +479,7 @@ export class SessionsIndex { */ search(raw: string, opts: SessionSearchOptions = {}): SessionHit[] & { fallback?: boolean } { const limit = Math.max(1, Math.min(opts.limit ?? 10, 100)); + const context = opts.full ? Math.max(0, Math.min(Math.trunc(opts.context ?? 0), MAX_CONTEXT)) : 0; const stems = [...new Set(queryWords(raw).map(stem))]; const where: string[] = []; const params: string[] = []; @@ -497,7 +517,7 @@ export class SessionsIndex { if (seen.has(key) || seen.has(`#${id}`)) continue; seen.add(key); seen.add(`#${id}`); - const found = opts.full ? { ...hit, text } : hit; + const found = opts.full ? { ...hit, text, ...(context > 0 ? this.neighbours(hit.file, id, context) : {}) } : hit; hits.push(any && !opts.any ? { ...found, partial: true } : found); if (hits.length === limit) break; } @@ -506,6 +526,25 @@ export class SessionsIndex { return hits; } + /** + * The passages up to `n` rows either side of row `id` in the same transcript. + * `replaceRecord` writes one transcript's passages in one transaction, so its + * rows are consecutive and in transcript order. + */ + private neighbours(file: string, id: number, n: number): Pick { + const rows = this.db + .prepare( + `SELECT rowid AS id, role, ts, text FROM docs + WHERE rowid BETWEEN ? AND ? AND file = ? AND rowid != ? ORDER BY rowid`, + ) + .all(id - n, id + n, file, id) as Array; + const pick = (wanted: Array): SessionPassage[] | undefined => + wanted.length > 0 ? wanted.map(({ role, ts, text }) => ({ role, ts, text })) : undefined; + const before = pick(rows.filter((r) => r.id < id)); + const after = pick(rows.filter((r) => r.id > id)); + return { ...(before ? { before } : {}), ...(after ? { after } : {}) }; + } + close(): void { this.db.close(); } @@ -633,6 +672,8 @@ export interface SessionsQueryResult { fallback?: boolean; /** A `full` search: how many hits lost their whole passage to the byte budget and keep only the snippet. */ fullCut?: number; + /** A `context` search: how many neighbouring passages the byte budget left out. */ + contextCut?: number; } /** @@ -646,6 +687,7 @@ export function querySessions( opts: SessionSearchOptions = {}, ): SessionsQueryResult { if (!loadSessionsEnabled(projectRoot)) throw new NoSessionsError(projectRoot); + if (opts.context) opts = { ...opts, full: true }; const override = process.env.CODEGRAPH_SESSIONS_DIR; const index = SessionsIndex.open(sessionsDbPath(projectRoot)); try { @@ -670,19 +712,50 @@ export function querySessions( } } -/** Keeps whole passages in rank order until the budget is spent; later hits fall back to their snippet. */ -function withinPassageBudget(hits: SessionHit[]): number { +/** + * Keeps whole passages in rank order until the budget is spent; later hits fall + * 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 } { let spent = 0; - let cut = 0; + let fullCut = 0; for (const hit of hits) { if (hit.text === undefined) continue; spent += Buffer.byteLength(hit.text); - if (cut > 0 || spent > FULL_PASSAGE_BUDGET) { + if (fullCut > 0 || spent > FULL_PASSAGE_BUDGET) { delete hit.text; - cut++; + delete hit.before; + delete hit.after; + fullCut++; + } + } + let contextCut = 0; + let full = fullCut > 0; + for (const hit of hits) { + const before = hit.before ?? []; + const after = hit.after ?? []; + const keptBefore: SessionPassage[] = []; + const keptAfter: SessionPassage[] = []; + for (let i = 0; i < Math.max(before.length, after.length); i++) { + for (const [from, kept] of [[before[before.length - 1 - i], keptBefore], [after[i], keptAfter]] as const) { + if (!from) continue; + const size = Buffer.byteLength(from.text); + if (full || spent + size > FULL_PASSAGE_BUDGET) { + full = true; + contextCut++; + } else { + spent += size; + kept.push(from); + } + } } + delete hit.before; + delete hit.after; + if (keptBefore.length > 0) hit.before = keptBefore.reverse(); + if (keptAfter.length > 0) hit.after = keptAfter; } - return cut; + return { fullCut, contextCut }; } function result( @@ -692,7 +765,11 @@ function result( ): SessionsQueryResult { const hits = [...found]; const out: SessionsQueryResult = found.fallback ? { index, hits, fallback: true } : { index, hits }; - if (opts.full) out.fullCut = withinPassageBudget(hits); + if (opts.full) { + const { fullCut, contextCut } = withinPassageBudget(hits); + out.fullCut = fullCut; + if (contextCut > 0) out.contextCut = contextCut; + } return out; } @@ -784,12 +861,21 @@ export function formatSessionHits(query: string, result: SessionsQueryResult): s lines.push(`## ${h.session}${title}`); lines.push(`${h.role} · ${h.ts} · ${h.file}${h.partial && !result.fallback ? ' · some words' : ''}`); if (h.text === undefined) lines.push(h.snippet.replace(/\s+/g, ' ').trim()); - else lines.push(...h.text.split('\n').map((l) => `> ${l}`)); + 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: the last ${result.fullCut} hit${result.fullCut === 1 ? '' : 's'} show only the matching snippet. Narrow the search (--role, --session, a smaller limit) to read them whole.`, ''); } + if (result.contextCut) { + lines.push(`The byte budget left out ${result.contextCut} neighbouring passage${result.contextCut === 1 ? '' : 's'}; the nearest ones are shown. Lower \`context\` or narrow the search to see more.`, ''); + } lines.push('A hit names its session id (`claude:`, `codex:`, `cursor:`, `opencode:`, `agy:`, `devin:`, `grok:`, or `git:` for commit messages); the path after the timestamp is the transcript to read when the snippet is not enough.'); return lines.join('\n'); }