Skip to content

Commit 60594f9

Browse files
authored
feat(sessions): search history before a date with --until (#595)
* feat(sessions): add an upper time bound (--until / until) to session search * fix(sessions): refuse nonexistent and zone-less until values * fix(sessions): compare the until bound as an instant, not as text * fix(sessions): refuse until values finer than a millisecond
1 parent 4891e3e commit 60594f9

9 files changed

Lines changed: 104 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
100100

101101
### Fixes
102102

103+
- **`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.
104+
103105
- 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.
104106
- On Windows, Git sync hooks from older installs are recognized across drive-letter casing and short directory names.
105107

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind
793793
codegraph query <search> # Search symbols (--kind, --limit, --json)
794794
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
795795
codegraph context <task...> # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code)
796-
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)
796+
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)
797797
codegraph node <symbol|file> # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node)
798798
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
799799
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)

‎__tests__/cli-sessions-command.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ describe('codegraph sessions — CLI command', () => {
6868
expect(out).not.toContain('user · ');
6969
});
7070

71+
it('--until keeps only docs before a date and rejects text that is not one', () => {
72+
const before = JSON.parse(sessions(tempDir, transcripts, ['trim', '--until', '2026-09-04', '--json']));
73+
expect(before.hits).toEqual([]);
74+
const after = JSON.parse(sessions(tempDir, transcripts, ['trim', '--until', '2026-09-05', '--since', '3650', '--json']));
75+
expect(after.hits).toHaveLength(2);
76+
expect(() => sessions(tempDir, transcripts, ['trim', '--until', 'last tuesday'])).toThrow();
77+
});
78+
7179
it('--json carries the index stats and the raw hits; --role and --any filter and widen', () => {
7280
const parsed = JSON.parse(sessions(tempDir, transcripts, ['trim', '--json']));
7381
expect(parsed.index).toEqual({ files: 1, refreshed: 1, docs: 2 });

‎__tests__/sessions-index.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
formatSessionHits,
3737
FULL_PASSAGE_BUDGET,
3838
MAX_CONTEXT,
39+
parseUntil,
3940
} from '../src/sessions';
4041
import { clearProjectConfigCache } from '../src/project-config';
4142
import { isInjectedDoc, slashCommandText, splitPassages, indexableDocs } from '../src/sessions/noise';
@@ -335,6 +336,10 @@ describe('SessionsIndex', () => {
335336
expect(hits.every((h) => h.snippet.includes('['))).toBe(true);
336337
expect(index.search('merging', { role: 'assistant' }).map((h) => h.role)).toEqual(['assistant']);
337338
expect(index.search('merging', { sinceIso: '2027-01-01T00:00:00.000Z' })).toEqual([]);
339+
// The fixture's docs are stamped 2026-09-04: an upper bound before that drops them, one after keeps them.
340+
expect(index.search('merging', { untilIso: '2026-09-04T00:00:00.000Z' })).toEqual([]);
341+
expect(index.search('merging', { untilIso: '2026-09-05T00:00:00.000Z' })).toHaveLength(2);
342+
expect(index.search('merging', { sinceIso: '2023-01-01T00:00:00.000Z', untilIso: '2026-09-05T00:00:00.000Z' })).toHaveLength(2);
338343
// No passage holds both words: the any-word query answers, flagged as a fallback.
339344
const partial = index.search('unrelatedword kept');
340345
expect(partial).toHaveLength(1);
@@ -1498,3 +1503,46 @@ describe('large Codex rollouts are scanned, not parsed whole', () => {
14981503
expect(parsed.session).toBe('codex:none');
14991504
});
15001505
});
1506+
1507+
describe('SessionsIndex until bound', () => {
1508+
it('compares stored timestamps as instants, whatever their fractional-second or offset form', () => {
1509+
const dir = fixtureDir();
1510+
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'];
1511+
writeJsonl(
1512+
path.join(dir, 'aaaa-1111.jsonl'),
1513+
stamps.map((timestamp, i) => user(`cutoffprobe passage number ${i} for the cutoff test`, { timestamp })),
1514+
1_700_000_000,
1515+
);
1516+
const index = SessionsIndex.open(':memory:');
1517+
index.refresh(dir);
1518+
expect(index.search('cutoffprobe', { limit: 10 }).map((h) => h.ts).sort()).toEqual([...stamps].sort());
1519+
const before = (until: string) => index.search('cutoffprobe', { limit: 10, untilIso: until }).map((h) => h.ts).sort();
1520+
// 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'.
1521+
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']);
1522+
// An instant equal to the bound is not before it, in any spelling.
1523+
expect(before('2026-10-20T12:00:00.000Z')).toEqual([]);
1524+
expect(before('2026-10-20T12:00:01.000Z')).toHaveLength(4);
1525+
index.close();
1526+
});
1527+
});
1528+
1529+
describe('parseUntil', () => {
1530+
it('reads a date as midnight UTC, keeps a timestamp, and flags text that is not a date', () => {
1531+
expect(parseUntil('2026-10-20')).toBe('2026-10-20T00:00:00.000Z');
1532+
expect(parseUntil('2026-10-20T12:30:00Z')).toBe('2026-10-20T12:30:00.000Z');
1533+
expect(parseUntil(undefined)).toBeUndefined();
1534+
expect(parseUntil('')).toBeUndefined();
1535+
expect(parseUntil('last tuesday')).toBeNull();
1536+
// A date that does not exist is refused, not moved to the next month.
1537+
expect(parseUntil('2026-02-30')).toBeNull();
1538+
expect(parseUntil('2026-02-29')).toBeNull();
1539+
expect(parseUntil('2028-02-29')).toBe('2028-02-29T00:00:00.000Z');
1540+
// A timestamp must name its zone; guessing the machine's would move the cutoff with TZ.
1541+
expect(parseUntil('2026-10-20T00:00:00')).toBeNull();
1542+
// The bound is exact to the millisecond: more digits would be cut off silently.
1543+
expect(parseUntil('2026-10-20T12:00:00.500Z')).toBe('2026-10-20T12:00:00.500Z');
1544+
expect(parseUntil('2026-10-20T12:00:00.5009Z')).toBeNull();
1545+
expect(parseUntil('2026-10-20T05:00:00-06:00')).toBe('2026-10-20T11:00:00.000Z');
1546+
expect(parseUntil(5)).toBeNull();
1547+
});
1548+
});

‎site/src/content/docs/reference/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ codegraph unlock [path] # Remove a stale lock file that's blocking ind
1717
codegraph query <search> # Search symbols (--kind, --limit, --json)
1818
codegraph explore <query> # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool)
1919
codegraph context <task...> # Context for a task: relevant symbols, relationships and code (--format markdown|json, --max-nodes, --no-code)
20-
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)
20+
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)
2121
codegraph node <symbol|file> # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node)
2222
codegraph files [path] # Show file structure (--format, --filter, --pattern, --max-depth, --json)
2323
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)

‎src/bin/codegraph.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1505,14 +1505,21 @@ program
15051505
.option('-l, --limit <number>', 'Maximum hits', '10')
15061506
.option('-r, --role <role>', 'Only user, assistant, summary or commit docs')
15071507
.option('--since <days>', 'Only docs from the last N days')
1508+
.option('--until <date>', 'Only docs before this date (2026-10-20, UTC) or timestamp with its zone (2026-10-20T05:00:00-06:00)')
15081509
.option('--session <id-prefix>', 'Only one session (id prefix)')
15091510
.option('--any', 'OR the words instead of requiring all of them')
15101511
.option('--full', 'Show each hit\'s whole passage (16,000 bytes in all) instead of a snippet')
15111512
.option('--context <n>', 'Also show n passages (0-5) before and after each hit; implies --full', '0')
15121513
.option('--project <path...>', 'Experimental: search these projects instead (at most 5); hits name their project')
15131514
.option('-j, --json', 'Output as JSON')
1514-
.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 }) => {
1515+
.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 }) => {
15151516
const projectPath = resolveProjectPath(options.path);
1517+
const { parseUntil } = await import('../sessions');
1518+
const untilIso = parseUntil(options.until);
1519+
if (untilIso === null) {
1520+
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}"`);
1521+
process.exit(1);
1522+
}
15161523
try {
15171524
if (options.project && options.project.length > 0) {
15181525
const { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS } = await import('../sessions/fanout');
@@ -1527,6 +1534,7 @@ program
15271534
limit: parseInt(options.limit || '10', 10),
15281535
role: options.role,
15291536
sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined,
1537+
untilIso,
15301538
session: options.session,
15311539
any: options.any,
15321540
full: options.full,
@@ -1548,6 +1556,7 @@ program
15481556
limit: parseInt(options.limit || '10', 10),
15491557
role: options.role,
15501558
sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined,
1559+
untilIso,
15511560
session: options.session,
15521561
any: options.any,
15531562
full: options.full,

‎src/mcp/server-instructions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ against dozens of greps and reads.
5454
- **"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.
5555
- **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.
5656
- 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.
57-
- **"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.
57+
- **"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.
5858
- **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.
5959
- Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`).
6060
- 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.

‎src/mcp/tools.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
type QueryLineAnchor,
4848
type QuerySetAsideMatch,
4949
} from '../search/query-paths';
50-
import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, MAX_CONTEXT } from '../sessions';
50+
import { querySessions, formatSessionHits, NoSessionsError, sessionsMentioning, parseUntil, MAX_CONTEXT } from '../sessions';
5151
import { querySessionsAcross, formatCrossProjectHits, MAX_PROJECTS, type ProjectTarget } from '../sessions/fanout';
5252
import {
5353
existsSync,
@@ -2153,6 +2153,10 @@ export const tools: ToolDefinition[] = [
21532153
type: 'number',
21542154
description: 'Only docs from the last N days.',
21552155
},
2156+
until: {
2157+
type: 'string',
2158+
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.',
2159+
},
21562160
session: {
21572161
type: 'string',
21582162
description: 'Only one session: its id or a prefix of it.',
@@ -4689,13 +4693,16 @@ export class ToolHandler {
46894693
const listed = requested.slice(0, MAX_PROJECTS);
46904694
const projectRoot = listed.length > 0 ? '' : this.getCodeGraph(args.projectPath as string | undefined).getProjectRoot();
46914695
const sinceDays = Number(args.sinceDays);
4696+
const untilIso = parseUntil(args.until);
4697+
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');
46924698
const role = typeof args.role === 'string' ? args.role : undefined;
46934699
const session = typeof args.session === 'string' ? args.session : undefined;
46944700
try {
46954701
const opts = {
46964702
limit: clamp(Number(args.limit) || 10, 1, 100),
46974703
role,
46984704
sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined,
4705+
untilIso,
46994706
session,
47004707
any: args.any === true,
47014708
full: args.full === true,

‎src/sessions/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export interface SessionSearchOptions {
132132
role?: string;
133133
/** ISO timestamp; hits before it are dropped. */
134134
sinceIso?: string;
135+
/** ISO timestamp; hits at or after it are dropped. */
136+
untilIso?: string;
135137
/** Session id prefix. */
136138
session?: string;
137139
/** OR the words instead of ANDing them. */
@@ -488,6 +490,8 @@ export class SessionsIndex {
488490
const filters: Array<[string, string | undefined]> = [
489491
['docs.role = ?', opts.role],
490492
['docs.ts >= ?', opts.sinceIso],
493+
// 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.
494+
['julianday(docs.ts) < julianday(?)', opts.untilIso],
491495
['files.session GLOB ?', opts.session ? `${opts.session}*` : undefined],
492496
];
493497
for (const [clause, value] of filters) {
@@ -552,6 +556,27 @@ export class SessionsIndex {
552556
}
553557
}
554558

559+
/** `2026-10-20`, or a timestamp that names its zone: `2026-10-20T05:00:00-06:00`, `...Z`. */
560+
const UNTIL_FORM = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2}))?$/;
561+
562+
/**
563+
* The ISO instant a user-typed `until` bound means: a date (`2026-10-20`, read
564+
* as midnight UTC) or a timestamp with its zone. `undefined` when none was
565+
* given, `null` when the text is neither, names a day that does not exist,
566+
* leaves the zone to the machine's, or gives more than millisecond precision.
567+
* Stored stamps are compared to the bound at millisecond precision too.
568+
*/
569+
export function parseUntil(raw: unknown): string | undefined | null {
570+
if (raw === undefined || raw === null || raw === '') return undefined;
571+
const form = typeof raw === 'string' ? UNTIL_FORM.exec(raw) : null;
572+
if (!form) return null;
573+
const [, year, month, day] = form.map(Number);
574+
const real = new Date(Date.UTC(year!, month! - 1, day!));
575+
if (real.getUTCMonth() !== month! - 1 || real.getUTCDate() !== day) return null;
576+
const ms = Date.parse(raw as string);
577+
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
578+
}
579+
555580
/** Where a project's session index lives. */
556581
export function sessionsDbPath(projectRoot: string): string {
557582
return path.join(getCodeGraphDir(projectRoot), SESSIONS_DB_FILENAME);

0 commit comments

Comments
 (0)