Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>` 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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
codegraph context <task...> # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code)
codegraph sessions <words...> # Search Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and commit messages for this project (--role, --since <days>, --session, --any, --full, --context <n>, --project <path...> (experimental), --json; same output as codegraph_sessions)
codegraph sessions <words...> # Search Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and commit messages for this project (--role, --since <days>, --until <date>, --session, --any, --full, --context <n>, --project <path...> (experimental), --json; same output as codegraph_sessions)
codegraph node <symbol|file> # 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 <symbol> # Find what calls a function/method (--limit, --json)
Expand Down
8 changes: 8 additions & 0 deletions __tests__/cli-sessions-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
48 changes: 48 additions & 0 deletions __tests__/sessions-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
2 changes: 1 addition & 1 deletion site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind
codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
codegraph context <task...> # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code)
codegraph sessions <words...> # Search this project's earlier agent sessions (--role, --since <days>, --session, --any, --full, --context <n>, --project <path...> (experimental), --json; same output as codegraph_sessions)
codegraph sessions <words...> # Search this project's earlier agent sessions (--role, --since <days>, --until <date>, --session, --any, --full, --context <n>, --project <path...> (experimental), --json; same output as codegraph_sessions)
codegraph node <symbol|file> # 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 <symbol> # Find what calls a function/method (--limit, --json)
Expand Down
11 changes: 10 additions & 1 deletion src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,14 +1505,21 @@ program
.option('-l, --limit <number>', 'Maximum hits', '10')
.option('-r, --role <role>', 'Only user, assistant, summary or commit docs')
.option('--since <days>', 'Only docs from the last N days')
.option('--until <date>', 'Only docs before this date (2026-10-20, UTC) or timestamp with its zone (2026-10-20T05:00:00-06:00)')
.option('--session <id-prefix>', '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 <n>', 'Also show n passages (0-5) before and after each hit; implies --full', '0')
.option('--project <path...>', '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');
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -4689,13 +4693,16 @@ 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 {
const opts = {
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,
Expand Down
25 changes: 25 additions & 0 deletions src/sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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],
Comment thread
bompus marked this conversation as resolved.
Resolved
['files.session GLOB ?', opts.session ? `${opts.session}*` : undefined],
];
for (const [clause, value] of filters) {
Expand Down Expand Up @@ -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);
Expand Down
Loading