Skip to content

Commit 774f40b

Browse files
committed
Implement session tokens --since / --until filters
Both options were parsed but never applied in analyzeTokens, so they were silent no-ops. Apply them session-level using file mtime (last activity), skipping out-of-window files before the expensive per-file parse. Extract isFileWithinTimeRange as a pure, exported helper so the bound checks are unit-testable without fixture files. Inclusive on both ends. Folded into v0.2.1 CHANGELOG alongside the --project fix.
1 parent 19bfc53 commit 774f40b

3 files changed

Lines changed: 68 additions & 1 deletion

File tree

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- **`session tokens --project` now works with absolute paths** — the filter was comparing an on-disk slug (e.g. `-workspaces-projects-CodeForge`) to the raw `--project` path (e.g. `/workspaces/projects/CodeForge`), which never matched. Absolute paths and paths starting with `./` / `../` are now encoded to Claude's slug form (replacing `/` and `.` with `-`) before matching. Plain substrings without separators still pass through unchanged, so `--project CodeForge` keeps working.
8+
- **`session tokens --since` / `--until` now actually filter results** — both options were parsed but never applied. Filtering is session-level and uses file mtime (last-activity time) so sessions outside the window are skipped without being parsed.
89

910
## v0.2.0 — 2026-04-16
1011

cli/src/commands/session/tokens.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import chalk from "chalk";
22
import type { Command } from "commander";
3+
import { stat } from "fs/promises";
34
import { basename, isAbsolute, resolve } from "path";
45
import { readLines } from "../../search/engine.js";
56
import { discoverSessionFiles } from "../../utils/glob.js";
@@ -104,6 +105,25 @@ export function pathToProjectSlug(input: string): string {
104105
return input;
105106
}
106107

108+
/**
109+
* Pure check for whether a file's last-modified time falls within an optional
110+
* [since, until] window. Exported for direct unit testing without fixtures.
111+
*
112+
* Semantics:
113+
* - `since`: mtime must be >= since (inclusive)
114+
* - `until`: mtime must be <= until (inclusive)
115+
* - If both bounds are omitted, always returns true.
116+
*/
117+
export function isFileWithinTimeRange(
118+
mtime: Date,
119+
since?: Date,
120+
until?: Date,
121+
): boolean {
122+
if (since && mtime < since) return false;
123+
if (until && mtime > until) return false;
124+
return true;
125+
}
126+
107127
function isSubagentPath(filePath: string): boolean {
108128
return filePath.includes("/subagents/");
109129
}
@@ -332,13 +352,28 @@ async function analyzeTokens(options: {
332352
? pathToProjectSlug(options.project)
333353
: undefined;
334354

355+
const hasTimeFilter = !!(options.since || options.until);
356+
335357
for (const filePath of files) {
336358
// Filter by project (slug-to-slug match; absolute paths are encoded).
337359
if (projectNeedle) {
338360
const project = extractProjectFromPath(filePath);
339361
if (!project?.includes(projectNeedle)) continue;
340362
}
341363

364+
// Filter by file mtime (session activity time) before the expensive
365+
// per-file parse. Sessions outside the window are skipped without reads.
366+
if (hasTimeFilter) {
367+
try {
368+
const st = await stat(filePath);
369+
if (!isFileWithinTimeRange(st.mtime, options.since, options.until)) {
370+
continue;
371+
}
372+
} catch {
373+
continue;
374+
}
375+
}
376+
342377
const stats = await analyzeSessionTokens(filePath);
343378
if (!stats) continue;
344379

cli/tests/tokens.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, test } from "bun:test";
2-
import { pathToProjectSlug } from "../src/commands/session/tokens.js";
2+
import {
3+
isFileWithinTimeRange,
4+
pathToProjectSlug,
5+
} from "../src/commands/session/tokens.js";
36

47
describe("pathToProjectSlug", () => {
58
test("encodes absolute paths to claude's slug form", () => {
@@ -41,3 +44,31 @@ describe("pathToProjectSlug", () => {
4144
expect(abs2.endsWith("-bar")).toBe(true);
4245
});
4346
});
47+
48+
describe("isFileWithinTimeRange", () => {
49+
const jan1 = new Date("2026-01-01T00:00:00Z");
50+
const feb1 = new Date("2026-02-01T00:00:00Z");
51+
const mar1 = new Date("2026-03-01T00:00:00Z");
52+
53+
test("returns true when no bounds are given", () => {
54+
expect(isFileWithinTimeRange(feb1)).toBe(true);
55+
});
56+
57+
test("since: includes mtimes at or after the bound", () => {
58+
expect(isFileWithinTimeRange(feb1, jan1)).toBe(true);
59+
expect(isFileWithinTimeRange(feb1, feb1)).toBe(true); // inclusive
60+
expect(isFileWithinTimeRange(feb1, mar1)).toBe(false);
61+
});
62+
63+
test("until: includes mtimes at or before the bound", () => {
64+
expect(isFileWithinTimeRange(feb1, undefined, mar1)).toBe(true);
65+
expect(isFileWithinTimeRange(feb1, undefined, feb1)).toBe(true); // inclusive
66+
expect(isFileWithinTimeRange(feb1, undefined, jan1)).toBe(false);
67+
});
68+
69+
test("since + until: inclusive range", () => {
70+
expect(isFileWithinTimeRange(feb1, jan1, mar1)).toBe(true);
71+
expect(isFileWithinTimeRange(jan1, feb1, mar1)).toBe(false);
72+
expect(isFileWithinTimeRange(mar1, jan1, feb1)).toBe(false);
73+
});
74+
});

0 commit comments

Comments
 (0)