diff --git a/CHANGELOG.md b/CHANGELOG.md index cf61889250..37b6c711ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **`codegraph sessions` and `codegraph_sessions` can search history before a date.** A search could only look back a number of days (`--since`, `sinceDays`), so its best 100 hits always came from the newest matching sessions and older context could not be reached. `--until ` and `until` keep only docs before a date (read as UTC) or a timestamp that names its zone; together with `--since` they bound a window. + - A background sync after editing a widely imported file no longer leaves the MCP daemon holding about 19 GB (n8n, one new export in a barrel imported from 335 files). Re-checking the importers built a native resolver per binding and the process kept about 2.5 MB of each; they now share one. That check went from 38.7 s to 0.5 s, and idle memory after the edit from 19.4 GB to 1.4 GB. - On Windows, Git sync hooks from older installs are recognized across drive-letter casing and short directory names. diff --git a/README.md b/README.md index 7e58c18ee5..02482491ec 100644 --- a/README.md +++ b/README.md @@ -793,7 +793,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 , --project (experimental), --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 , --until , --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) diff --git a/__tests__/cli-sessions-command.test.ts b/__tests__/cli-sessions-command.test.ts index 56100ac757..a9ae8be8fd 100644 --- a/__tests__/cli-sessions-command.test.ts +++ b/__tests__/cli-sessions-command.test.ts @@ -68,6 +68,14 @@ describe('codegraph sessions — CLI command', () => { expect(out).not.toContain('user · '); }); + it('--until keeps only docs before a date and rejects text that is not one', () => { + const before = JSON.parse(sessions(tempDir, transcripts, ['trim', '--until', '2026-09-04', '--json'])); + expect(before.hits).toEqual([]); + const after = JSON.parse(sessions(tempDir, transcripts, ['trim', '--until', '2026-09-05', '--since', '3650', '--json'])); + expect(after.hits).toHaveLength(2); + expect(() => sessions(tempDir, transcripts, ['trim', '--until', 'last tuesday'])).toThrow(); + }); + it('--json carries the index stats and the raw hits; --role and --any filter and widen', () => { const parsed = JSON.parse(sessions(tempDir, transcripts, ['trim', '--json'])); expect(parsed.index).toEqual({ files: 1, refreshed: 1, docs: 2 }); diff --git a/__tests__/sessions-index.test.ts b/__tests__/sessions-index.test.ts index 4638efdd5e..5af831f86b 100644 --- a/__tests__/sessions-index.test.ts +++ b/__tests__/sessions-index.test.ts @@ -36,6 +36,7 @@ import { formatSessionHits, FULL_PASSAGE_BUDGET, MAX_CONTEXT, + parseUntil, } from '../src/sessions'; import { clearProjectConfigCache } from '../src/project-config'; import { isInjectedDoc, slashCommandText, splitPassages, indexableDocs } from '../src/sessions/noise'; @@ -335,6 +336,10 @@ describe('SessionsIndex', () => { expect(hits.every((h) => h.snippet.includes('['))).toBe(true); expect(index.search('merging', { role: 'assistant' }).map((h) => h.role)).toEqual(['assistant']); expect(index.search('merging', { sinceIso: '2027-01-01T00:00:00.000Z' })).toEqual([]); + // The fixture's docs are stamped 2026-09-04: an upper bound before that drops them, one after keeps them. + expect(index.search('merging', { untilIso: '2026-09-04T00:00:00.000Z' })).toEqual([]); + expect(index.search('merging', { untilIso: '2026-09-05T00:00:00.000Z' })).toHaveLength(2); + expect(index.search('merging', { sinceIso: '2023-01-01T00:00:00.000Z', untilIso: '2026-09-05T00:00:00.000Z' })).toHaveLength(2); // No passage holds both words: the any-word query answers, flagged as a fallback. const partial = index.search('unrelatedword kept'); expect(partial).toHaveLength(1); @@ -1498,3 +1503,46 @@ describe('large Codex rollouts are scanned, not parsed whole', () => { expect(parsed.session).toBe('codex:none'); }); }); + +describe('SessionsIndex until bound', () => { + it('compares stored timestamps as instants, whatever their fractional-second or offset form', () => { + const dir = fixtureDir(); + const stamps = ['2026-10-20T12:00:00Z', '2026-10-20T12:00:00.250Z', '2026-10-20T12:00:00.750Z', '2026-10-20T06:00:00-06:00', '2026-10-20T07:00:00-06:00']; + writeJsonl( + path.join(dir, 'aaaa-1111.jsonl'), + stamps.map((timestamp, i) => user(`cutoffprobe passage number ${i} for the cutoff test`, { timestamp })), + 1_700_000_000, + ); + const index = SessionsIndex.open(':memory:'); + index.refresh(dir); + expect(index.search('cutoffprobe', { limit: 10 }).map((h) => h.ts).sort()).toEqual([...stamps].sort()); + const before = (until: string) => index.search('cutoffprobe', { limit: 10, untilIso: until }).map((h) => h.ts).sort(); + // 12:00:00Z, 12:00:00.250Z and 06:00:00-06:00 (which is 12:00:00Z) all precede 12:00:00.500Z; text order puts the plain-Z form after '.500Z'. + expect(before('2026-10-20T12:00:00.500Z')).toEqual(['2026-10-20T06:00:00-06:00', '2026-10-20T12:00:00.250Z', '2026-10-20T12:00:00Z']); + // An instant equal to the bound is not before it, in any spelling. + expect(before('2026-10-20T12:00:00.000Z')).toEqual([]); + expect(before('2026-10-20T12:00:01.000Z')).toHaveLength(4); + index.close(); + }); +}); + +describe('parseUntil', () => { + it('reads a date as midnight UTC, keeps a timestamp, and flags text that is not a date', () => { + expect(parseUntil('2026-10-20')).toBe('2026-10-20T00:00:00.000Z'); + expect(parseUntil('2026-10-20T12:30:00Z')).toBe('2026-10-20T12:30:00.000Z'); + expect(parseUntil(undefined)).toBeUndefined(); + expect(parseUntil('')).toBeUndefined(); + expect(parseUntil('last tuesday')).toBeNull(); + // A date that does not exist is refused, not moved to the next month. + expect(parseUntil('2026-02-30')).toBeNull(); + expect(parseUntil('2026-02-29')).toBeNull(); + expect(parseUntil('2028-02-29')).toBe('2028-02-29T00:00:00.000Z'); + // A timestamp must name its zone; guessing the machine's would move the cutoff with TZ. + expect(parseUntil('2026-10-20T00:00:00')).toBeNull(); + // The bound is exact to the millisecond: more digits would be cut off silently. + expect(parseUntil('2026-10-20T12:00:00.500Z')).toBe('2026-10-20T12:00:00.500Z'); + expect(parseUntil('2026-10-20T12:00:00.5009Z')).toBeNull(); + expect(parseUntil('2026-10-20T05:00:00-06:00')).toBe('2026-10-20T11:00:00.000Z'); + expect(parseUntil(5)).toBeNull(); + }); +}); diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index 08cb111b07..ca51187159 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 , --project (experimental), --json; same output as codegraph_sessions) +codegraph sessions # Search this project's earlier agent sessions (--role, --since , --until , --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/src/bin/codegraph.ts b/src/bin/codegraph.ts index 5238b9168a..f44e8f4adb 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1505,14 +1505,21 @@ program .option('-l, --limit ', 'Maximum hits', '10') .option('-r, --role ', 'Only user, assistant, summary or commit docs') .option('--since ', 'Only docs from the last N days') + .option('--until ', 'Only docs before this date (2026-10-20, UTC) or timestamp with its zone (2026-10-20T05:00:00-06:00)') .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('--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; project?: string[]; json?: boolean }) => { + .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; until?: string; session?: string; any?: boolean; full?: boolean; context?: string; project?: string[]; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); + const { parseUntil } = await import('../sessions'); + const untilIso = parseUntil(options.until); + if (untilIso === null) { + error(`--until must be a real date such as 2026-10-20, or a timestamp with its zone such as 2026-10-20T05:00:00Z, got "${options.until}"`); + process.exit(1); + } try { if (options.project && options.project.length > 0) { const { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS } = await import('../sessions/fanout'); @@ -1527,6 +1534,7 @@ program limit: parseInt(options.limit || '10', 10), role: options.role, sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + untilIso, session: options.session, any: options.any, full: options.full, @@ -1548,6 +1556,7 @@ program limit: parseInt(options.limit || '10', 10), role: options.role, sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + untilIso, session: options.session, any: options.any, full: options.full, diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 063f78d308..30d1732ef9 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. 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. +- **"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. \`sinceDays: N\` keeps only the last N days and \`until: "2026-10-20"\` only docs before that date, so a search can reach history older than the newest 100 hits. 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 f7b2fc8a12..30a3dedcef 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -47,7 +47,7 @@ import { type QueryLineAnchor, type QuerySetAsideMatch, } from '../search/query-paths'; -import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, MAX_CONTEXT } from '../sessions'; +import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, parseUntil, MAX_CONTEXT } from '../sessions'; import { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS, type ProjectTarget } from '../sessions/fanout'; import { existsSync, @@ -2153,6 +2153,10 @@ export const tools: ToolDefinition[] = [ type: 'number', description: 'Only docs from the last N days.', }, + until: { + type: 'string', + description: 'Only docs before this date ("2026-10-20", read as UTC) or timestamp with its zone ("2026-10-20T05:00:00-06:00"). With sinceDays it bounds a window.', + }, session: { type: 'string', description: 'Only one session: its id or a prefix of it.', @@ -4689,6 +4693,8 @@ export class ToolHandler { 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 untilIso = parseUntil(args.until); + if (untilIso === null) return this.errorResult('until must be a real date such as 2026-10-20, or a timestamp with its zone such as 2026-10-20T05:00:00Z'); const role = typeof args.role === 'string' ? args.role : undefined; const session = typeof args.session === 'string' ? args.session : undefined; try { @@ -4696,6 +4702,7 @@ export class ToolHandler { limit: clamp(Number(args.limit) || 10, 1, 100), role, sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + untilIso, session, any: args.any === true, full: args.full === true, diff --git a/src/sessions/index.ts b/src/sessions/index.ts index be18db77ef..d53a7def79 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -132,6 +132,8 @@ export interface SessionSearchOptions { role?: string; /** ISO timestamp; hits before it are dropped. */ sinceIso?: string; + /** ISO timestamp; hits at or after it are dropped. */ + untilIso?: string; /** Session id prefix. */ session?: string; /** OR the words instead of ANDing them. */ @@ -488,6 +490,8 @@ export class SessionsIndex { const filters: Array<[string, string | undefined]> = [ ['docs.role = ?', opts.role], ['docs.ts >= ?', opts.sinceIso], + // As instants: stored stamps keep their source's spelling (`12:00:00Z`, `12:00:00.250Z`, `+00:00`), which text order does not sort by time. + ['julianday(docs.ts) < julianday(?)', opts.untilIso], ['files.session GLOB ?', opts.session ? `${opts.session}*` : undefined], ]; for (const [clause, value] of filters) { @@ -552,6 +556,27 @@ export class SessionsIndex { } } +/** `2026-10-20`, or a timestamp that names its zone: `2026-10-20T05:00:00-06:00`, `...Z`. */ +const UNTIL_FORM = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2}))?$/; + +/** + * The ISO instant a user-typed `until` bound means: a date (`2026-10-20`, read + * as midnight UTC) or a timestamp with its zone. `undefined` when none was + * given, `null` when the text is neither, names a day that does not exist, + * leaves the zone to the machine's, or gives more than millisecond precision. + * Stored stamps are compared to the bound at millisecond precision too. + */ +export function parseUntil(raw: unknown): string | undefined | null { + if (raw === undefined || raw === null || raw === '') return undefined; + const form = typeof raw === 'string' ? UNTIL_FORM.exec(raw) : null; + if (!form) return null; + const [, year, month, day] = form.map(Number); + const real = new Date(Date.UTC(year!, month! - 1, day!)); + if (real.getUTCMonth() !== month! - 1 || real.getUTCDate() !== day) return null; + const ms = Date.parse(raw as string); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); +} + /** Where a project's session index lives. */ export function sessionsDbPath(projectRoot: string): string { return path.join(getCodeGraphDir(projectRoot), SESSIONS_DB_FILENAME);