Skip to content

Commit 452975c

Browse files
fix(transcript): include final second of day in CLI day-view range (#1508)
Day-view CLI transcript reads (`--date` and default "today") used an exclusive end of `${date}T23:59:59Z`, dropping entries in the final second of the day. Add a shared `utcDayRange(date)` helper (next-day 00:00:00Z exclusive end) and wire it into both branches, preserving readRange's half-open [start, end) semantics (rule #35). Includes a test asserting a 23:59:59.500Z entry is returned and a next-day boundary entry is excluded.
1 parent 15d27b3 commit 452975c

3 files changed

Lines changed: 137 additions & 12 deletions

File tree

packages/remnic-core/src/cli.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
55
import type { Readable, Writable } from "node:stream";
66
import type { Orchestrator } from "./orchestrator.js";
77
import { ThreadingManager } from "./threading.js";
8+
import { utcDayRange } from "./transcript.js";
89
import { runWearablesCliCommand } from "./wearables/cli.js";
910
import type {
1011
BehaviorSignalEvent,
@@ -8630,26 +8631,23 @@ export function registerCli(
86308631
}
86318632

86328633
if (date) {
8633-
// Read specific date
8634-
const entries = await orchestrator.transcript.readRange(
8635-
`${date}T00:00:00Z`,
8636-
`${date}T23:59:59Z`,
8637-
channel,
8638-
);
8634+
// Read specific date. Use a half-open [start, next-day-00:00:00Z)
8635+
// window so `readRange`'s exclusive upper bound still covers the
8636+
// final second of the day (rule #35).
8637+
const { start, end } = utcDayRange(date);
8638+
const entries = await orchestrator.transcript.readRange(start, end, channel);
86398639
console.log(formatTranscript(entries));
86408640
} else if (recent) {
86418641
// Parse duration (e.g., "12h", "30m")
86428642
const hours = parseDuration(recent);
86438643
const entries = await orchestrator.transcript.readRecent(hours, channel);
86448644
console.log(formatTranscript(entries));
86458645
} else {
8646-
// Default: show today's transcript
8646+
// Default: show today's transcript. Same half-open day window as
8647+
// the --date branch so the final second of the day is included.
86478648
const today = new Date().toISOString().slice(0, 10);
8648-
const entries = await orchestrator.transcript.readRange(
8649-
`${today}T00:00:00Z`,
8650-
`${today}T23:59:59Z`,
8651-
channel,
8652-
);
8649+
const { start, end } = utcDayRange(today);
8650+
const entries = await orchestrator.transcript.readRange(start, end, channel);
86538651
console.log(formatTranscript(entries));
86548652
}
86558653
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Tests for the CLI transcript day-view window helper `utcDayRange`.
3+
*
4+
* The `transcript --date <day>` and default "today" CLI views read a single
5+
* calendar day via {@link TranscriptManager.readRange}, which uses a half-open
6+
* `[start, end)` interval (`entryTime < end`, CLAUDE.md rule #35). The day end
7+
* must therefore be the NEXT day's `00:00:00Z`, not `${date}T23:59:59Z`:
8+
* a literal `23:59:59Z` end (== `.000Z`) silently drops any entry stamped in
9+
* the final second of the day (`23:59:59.000Z`–`23:59:59.999Z`).
10+
*
11+
* (Pre-existing boundary gap flagged as a P2 on PR #1507, fixed out of band.)
12+
*/
13+
14+
import assert from "node:assert/strict";
15+
import { mkdtemp, realpath, rm } from "node:fs/promises";
16+
import os from "node:os";
17+
import path from "node:path";
18+
import test from "node:test";
19+
20+
import { TranscriptManager, utcDayRange } from "./transcript.js";
21+
import type { PluginConfig, TranscriptEntry } from "./types.js";
22+
23+
function makeConfig(memoryDir: string): PluginConfig {
24+
// TranscriptManager only reads memoryDir + transcriptSkipChannelTypes.
25+
return {
26+
memoryDir,
27+
transcriptSkipChannelTypes: [],
28+
} as unknown as PluginConfig;
29+
}
30+
31+
// On macOS `os.tmpdir()` is a `/var/folders/...` symlink to `/private/var/...`.
32+
// Canonicalize the test root upfront (issue #691 symlink convention).
33+
async function makeMemoryDir(): Promise<string> {
34+
return realpath(await mkdtemp(path.join(os.tmpdir(), "remnic-tx-dayrange-")));
35+
}
36+
37+
function entryAt(timestamp: string, turnId: string): TranscriptEntry {
38+
return {
39+
sessionKey: "agent:generalist:main",
40+
turnId,
41+
role: "user",
42+
content: `content-${turnId}`,
43+
timestamp,
44+
} as TranscriptEntry;
45+
}
46+
47+
// ── utcDayRange: half-open [start, next-day-00:00:00Z) ───────────────────────
48+
49+
test("utcDayRange end is the next day's 00:00:00Z (half-open day window)", () => {
50+
assert.deepEqual(utcDayRange("2025-03-15"), {
51+
start: "2025-03-15T00:00:00Z",
52+
end: "2025-03-16T00:00:00Z",
53+
});
54+
});
55+
56+
test("utcDayRange rolls over month boundaries", () => {
57+
assert.deepEqual(utcDayRange("2025-01-31"), {
58+
start: "2025-01-31T00:00:00Z",
59+
end: "2025-02-01T00:00:00Z",
60+
});
61+
});
62+
63+
test("utcDayRange rolls over leap-day and year boundaries", () => {
64+
assert.deepEqual(utcDayRange("2024-02-28"), {
65+
start: "2024-02-28T00:00:00Z",
66+
end: "2024-02-29T00:00:00Z",
67+
});
68+
assert.deepEqual(utcDayRange("2025-12-31"), {
69+
start: "2025-12-31T00:00:00Z",
70+
end: "2026-01-01T00:00:00Z",
71+
});
72+
});
73+
74+
// ── Day-view range includes the final second of the day (the bug fix) ────────
75+
76+
test("day-view range returns an entry stamped at 23:59:59.500Z of the queried day", async () => {
77+
const memoryDir = await makeMemoryDir();
78+
try {
79+
const tm = new TranscriptManager(makeConfig(memoryDir));
80+
const date = "2025-03-15";
81+
const channel = "agent:generalist:main";
82+
83+
// Entry in the final second of the day — dropped by the old
84+
// `${date}T23:59:59Z` (== `.000Z`) exclusive upper bound.
85+
await tm.append(entryAt(`${date}T23:59:59.500Z`, "final-second"));
86+
// Midday control entry, comfortably inside the window.
87+
await tm.append(entryAt(`${date}T12:00:00.000Z`, "midday"));
88+
// Start-of-next-day entry must stay OUT — the upper bound is exclusive
89+
// (`[start, end)`, rule #35), so `nextDate T00:00:00.000Z` is not the
90+
// queried day.
91+
await tm.append(entryAt("2025-03-16T00:00:00.000Z", "next-day"));
92+
93+
const { start, end } = utcDayRange(date);
94+
const entries = await tm.readRange(start, end, channel);
95+
const ids = entries.map((e) => e.turnId).sort();
96+
97+
assert.deepEqual(ids, ["final-second", "midday"]);
98+
} finally {
99+
await rm(memoryDir, { recursive: true, force: true });
100+
}
101+
});

packages/remnic-core/src/transcript.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ function makeRawLineDeduper(): (rawLine: string) => boolean {
3333
};
3434
}
3535

36+
/**
37+
* Compute the half-open UTC instant range `[start, end)` that covers an entire
38+
* calendar day, suitable for {@link TranscriptManager.readRange}.
39+
*
40+
* `readRange` filters with an EXCLUSIVE upper bound (`entryTime < end`, CLAUDE.md
41+
* rule #35 / AGENTS.md rule 23). The end is therefore the NEXT day's
42+
* `00:00:00Z`, not `${date}T23:59:59Z`: a literal `23:59:59Z` end (== `.000Z`)
43+
* would drop any entry stamped in the final second of the day
44+
* (`23:59:59.000Z`–`23:59:59.999Z`). Using the next day's midnight keeps the
45+
* `[start, end)` semantics intact while including the whole day.
46+
*
47+
* @param date - A `YYYY-MM-DD` calendar day (UTC).
48+
*/
49+
export function utcDayRange(date: string): { start: string; end: string } {
50+
const start = `${date}T00:00:00Z`;
51+
const startMs = Date.parse(start);
52+
if (Number.isNaN(startMs)) {
53+
// Malformed date: preserve the pre-existing "empty range" behavior (an
54+
// unparseable bound makes `readRange` match nothing) rather than throwing.
55+
return { start, end: start };
56+
}
57+
const next = new Date(startMs);
58+
next.setUTCDate(next.getUTCDate() + 1);
59+
return { start, end: `${next.toISOString().slice(0, 10)}T00:00:00Z` };
60+
}
61+
3662
/**
3763
* Manages conversation transcript storage, checkpointing, and recall formatting.
3864
*

0 commit comments

Comments
 (0)