feat(timezone): make all user/LLM-facing timestamps timezone-configurable (#129)

Add top-level `timezone` config option (default: "system") supporting
IANA names and UTC offset strings (ECMA-402 2024).

- Introduce unified `src/utils/time.ts` module with formatForLLM,
  formatLocalDate, formatLocalDateTime, startOfLocalDay, nowInstantISO
- Converge 4 scattered time helpers into the single module
- L1 extraction / auto-recall / scene-extraction / persona-generation
  prompts now emit ISO 8601 with explicit UTC offset
- Add timezone declaration to LLM system prompts
- Local-day shard boundaries and cleaner cutoff follow configured tz
- Storage instants (SQLite/TCVDB) remain UTC — no data migration needed
- 38 unit tests covering IANA, offset, DST, half-hour zones, fallback
This commit is contained in:
Ruida Xu
2026-06-04 11:44:01 +08:00
committed by GitHub
parent c6ed755835
commit 32ba4d303d
15 changed files with 702 additions and 48 deletions
+23
View File
@@ -4,6 +4,29 @@
---
## [Unreleased]
### ✨ 新功能
- **时区可配置** ([#75](https://github.com/Tencent/TencentDB-Agent-Memory/issues/75) / [#87](https://github.com/Tencent/TencentDB-Agent-Memory/issues/87)):新增顶层 `timezone` 配置项,支持 IANA 时区名(`Asia/Shanghai``Europe/Berlin`)和 UTC 偏移串(`+08:00``-05:30`)。默认 `"system"`(跟随进程系统时区),升级零感。
- **暴露给 LLM 的时间戳**统一为带显式 offset 的 ISO 8601(如 `2026-04-07T11:04:45+08:00`),修复 #87 报告的 UTC/本地时区混用导致 LLM 误算时间差的问题。
- **L1 / L2 prompt 顶部**自动插入时区声明,指引 LLM 按正确时区推算"昨天"、"上周"等相对时间。
- **L0 JSONL 分片日**和 **cleaner 清理边界**跟随配置时区(默认仍为系统时区)。
- 存储层(SQLite / TCVDB)时间戳始终为 UTC instant**无需数据迁移**。
- 统一收敛原有 4 处分散的时间格式化 helper 到 `src/utils/time.ts`,减少代码重复。
### ⚠️ 升级注意(仅在显式配置 `timezone` 时生效)
如果你**显式**设置了 IANA 时区(如 `"Asia/Shanghai"`):
1. **L0 JSONL 分片日**:将以配置时区的午夜为界。如果你的服务器系统时区与配置时区不同,升级当天的分片文件名可能与之前一天有重叠——不会丢数据(cursor 按 instant 比较)。如有外部工具按文件名做去重/归档,请确认其能正确处理同一日期出现两次的情况。
2. **cleaner `cleanTime` 触发时机**:从"系统时区的指定时刻"改为"配置时区的指定时刻"。
3. **scene/persona META 头部时间戳格式**:新写入将使用 `YYYY-MM-DDTHH:mm:ss±HH:MM` 完整 ISO 8601。老数据保持原样,召回展示时由系统统一换算。
不动配置 = 行为完全不变。
---
## [0.3.6] - 2026-05-27
### ✨ 新功能
+1
View File
@@ -295,6 +295,7 @@ If `MEMORY_TENCENTDB_GATEWAY_API_KEY` is unset, the plugin also looks at `TDAI_G
| Field | Default | Description |
| :--- | :--- | :--- |
| `timezone` | `"system"` | Timezone for user/LLM-facing timestamps: `"system"` (follow process tz) / IANA name (`Asia/Shanghai`) / offset string (`+08:00`) |
| `storeBackend` | `"sqlite"` | Storage backend: `sqlite` |
| `recall.strategy` | `"hybrid"` | Recall strategy: `keyword` / `embedding` / `hybrid` (RRF fusion, recommended) |
| `recall.maxResults` | `5` | Number of items returned per recall |
+1
View File
@@ -299,6 +299,7 @@ export MEMORY_TENCENTDB_GATEWAY_API_KEY="<与 Gateway 同一份密钥>"
| 字段 | 默认 | 说明 |
| :--- | :--- | :--- |
| `timezone` | `"system"` | 时区:`"system"`(跟随系统)/ IANA 名(`Asia/Shanghai`/ offset 串(`+08:00` |
| `storeBackend` | `"sqlite"` | 存储后端:`sqlite` |
| `recall.strategy` | `"hybrid"` | 召回策略:`keyword` / `embedding` / `hybrid`RRF 融合,推荐) |
| `recall.maxResults` | `5` | 每次召回条数 |
+4
View File
@@ -23,6 +23,7 @@ import { createRequire } from "node:module";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { parseConfig } from "./src/config.js";
import type { MemoryTdaiConfig } from "./src/config.js";
import { initTimeModule, getActiveTimeZone } from "./src/utils/time.js";
import { registerOffload } from "./src/offload/index.js";
import {
setPreferredEmbeddedAgentRuntime,
@@ -190,6 +191,9 @@ export default function register(api: OpenClawPluginApi) {
throw err;
}
// Initialize unified time module (must happen before any timestamp formatting)
initTimeModule({ timezone: cfg.timezone }, api.logger);
// ============================
// Hook policy auto-patch (v2026.4.24+ compat)
// ============================
+5
View File
@@ -13,6 +13,11 @@
"type": "object",
"additionalProperties": true,
"properties": {
"timezone": {
"type": "string",
"default": "system",
"description": "时区配置:影响所有暴露给用户/LLM 的时间戳和本地日切片。可选值:\"system\"(跟随进程系统时区)、IANA 时区名(如 \"Asia/Shanghai\"、\"Europe/Berlin\")、UTC 偏移串(如 \"+08:00\"、\"-05:30\")。存储时间戳始终为 UTC,此配置仅影响展示层。"
},
"storeBackend": {
"type": "string",
"enum": ["sqlite", "tcvdb"],
+10
View File
@@ -269,6 +269,15 @@ export interface OffloadConfig {
/** Fully resolved plugin configuration (v3). */
export interface MemoryTdaiConfig {
/**
* Timezone for user/LLM-facing timestamps and local-day boundaries.
* - "system" (default): follow process system timezone
* - IANA name: "Asia/Shanghai", "Europe/Berlin", "UTC"
* - UTC offset string: "+08:00", "-05:30" (ECMA-402 2024)
*
* Storage instants (SQLite/TCVDB) are always UTC regardless of this setting.
*/
timezone: string;
capture: CaptureConfig;
extraction: ExtractionConfig;
persona: PersonaConfig;
@@ -468,6 +477,7 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
};
return {
timezone: str(c, "timezone") ?? "system",
capture: {
enabled: bool(captureGroup, "enabled") ?? true,
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
+2 -9
View File
@@ -19,6 +19,7 @@ import path from "node:path";
import crypto from "node:crypto";
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
import type { Logger } from "../types.js";
import { formatLocalDate } from "../../utils/time.js";
// ============================
// Types
@@ -565,12 +566,4 @@ function extractUserAssistantMessages(messages: unknown[]): ConversationMessage[
return result;
}
/**
* Format local date as YYYY-MM-DD.
*/
function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
+13 -7
View File
@@ -12,6 +12,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatForLLM } from "../../utils/time.js";
import type { MemoryTdaiConfig } from "../../config.js";
import { readSceneIndex } from "../scene/scene-index.js";
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js";
@@ -788,22 +789,27 @@ function truncateRecallLine(line: string, maxChars: number): string {
}
/**
* Format an ISO 8601 timestamp to a concise date or datetime string.
* Format an ISO 8601 timestamp to a concise, timezone-aware string for display.
* Uses the configured timezone (via time module).
* - If the time part is 00:00:00 show date only (e.g. "2025-03-01")
* - Otherwise show date + time (e.g. "2025-03-01 14:30")
* - Otherwise show full ISO 8601 with offset (e.g. "2025-03-01T14:30:00+08:00")
* - Returns undefined for empty/invalid inputs.
*/
function formatTimestamp(ts: string | undefined): string | undefined {
if (!ts) return undefined;
// Try to parse ISO format: "2025-03-01T14:30:00.000Z" or "2025-03-01"
const d = new Date(ts);
if (isNaN(d.getTime())) return undefined;
// Check if time part is midnight UTC (date-only semantics)
const match = ts.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2})(?::\d{2})?)?/);
if (!match) return undefined;
const datePart = match[1];
if (match) {
const timePart = match[2];
if (!timePart || timePart === "00:00") {
return datePart;
return match[1]; // date-only, no timezone conversion needed
}
return `${datePart} ${timePart}`;
}
return formatForLLM(ts);
}
/**
+2 -1
View File
@@ -5,6 +5,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatForLLM } from "../../utils/time.js";
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
import { CheckpointManager } from "../../utils/checkpoint.js";
import { readSceneIndex } from "../scene/scene-index.js";
@@ -127,7 +128,7 @@ export class PersonaGenerator {
// 6. Build prompt
const { systemPrompt, userPrompt } = buildPersonaPrompt({
mode,
currentTime: new Date().toISOString(),
currentTime: formatForLLM(new Date()),
totalProcessed: cp.total_processed,
sceneCount: index.length,
changedSceneCount: changedScenes.length,
+6 -3
View File
@@ -7,6 +7,7 @@
*/
import type { ConversationMessage } from "../conversation/l0-recorder.js";
import { formatForLLM, describeTimeZoneForPrompt } from "../../utils/time.js";
// ============================
// System Prompt
@@ -120,15 +121,17 @@ export function formatExtractionPrompt(params: {
const bgText = backgroundMessages.length > 0
? backgroundMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
.join("\n\n")
: "无";
const newText = newMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.map((m) => `[${m.id}] [${m.role}] [${formatForLLM(m.timestamp)}]: ${m.content}`)
.join("\n\n");
return `**输出语言**:根据下方"待提取的新消息"中 user 发言的主导语言书写 \`scene_name\` 和 memory \`content\`
return `**${describeTimeZoneForPrompt()}**
****"待提取的新消息" user \`scene_name\` 和 memory \`content\`
${previousSceneName}
+1 -7
View File
@@ -22,6 +22,7 @@ import crypto from "node:crypto";
import type { IMemoryStore } from "../store/types.js";
import type { EmbeddingService } from "../store/embedding.js";
import type { Logger } from "../types.js";
import { formatLocalDate } from "../../utils/time.js";
// ============================
// Types
@@ -265,10 +266,3 @@ export async function writeMemory(params: {
// ============================
// Helpers
// ============================
function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
+3 -2
View File
@@ -18,6 +18,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatForLLM } from "../../utils/time.js";
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
import { CheckpointManager } from "../../utils/checkpoint.js";
import { BackupManager } from "../../utils/backup.js";
@@ -179,7 +180,7 @@ export class SceneExtractor {
const memoriesJson = JSON.stringify(
memories.map((m) => ({
content: m.content,
created_at: m.created_at,
created_at: m.created_at ? formatForLLM(m.created_at) : m.created_at,
id: m.id ?? "",
})),
null,
@@ -475,5 +476,5 @@ export class SceneExtractor {
}
function formatTimestamp(d: Date): string {
return d.toISOString();
return formatForLLM(d);
}
+8 -16
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import type { IMemoryStore } from "../core/store/types.js";
import { ManagedTimer } from "./managed-timer.js";
import type { Logger } from "../core/types.js";
import { formatLocalDateTime, startOfLocalDay } from "./time.js";
export interface MemoryCleanerOptions {
baseDir: string;
@@ -314,18 +315,10 @@ function extractShardDateFromFileName(
}
function localDayEndMs(year: number, month: number, day: number): number {
const end = new Date(year, month - 1, day, 23, 59, 59, 999);
return end.getTime();
}
function formatLocalDateTime(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
// End of day = start of next day minus 1ms (in configured timezone)
const nextDay = new Date(Date.UTC(year, month - 1, day + 1));
const nextDayStartMs = startOfLocalDay(nextDay);
return nextDayStartMs - 1;
}
function formatUtcOffset(offsetMinutes: number): string {
@@ -338,11 +331,10 @@ function formatUtcOffset(offsetMinutes: number): string {
function computeCutoffMsByLocalDay(nowMs: number, retentionDays: number): number {
// 自然日策略,保留"今天 + 往前 retentionDays-1 天"
// 删除阈值为 keepStart 当天 00:00:00.000本地时区)
// 删除阈值为 keepStart 当天 00:00:00.000配置时区)
const now = new Date(nowMs);
const keepStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
keepStart.setDate(keepStart.getDate() - (retentionDays - 1));
const cutoffMs = keepStart.getTime();
const todayStartMs = startOfLocalDay(now);
const cutoffMs = todayStartMs - (retentionDays - 1) * 24 * 60 * 60 * 1000;
// Sanity check: cutoff must be strictly in the past
if (cutoffMs >= nowMs) {
+333
View File
@@ -0,0 +1,333 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
initTimeModule,
getActiveTimeZone,
nowInstantISO,
formatLocalDate,
formatLocalDateTime,
formatForLLM,
describeTimeZoneForPrompt,
startOfLocalDay,
_resetTimeModuleForTest,
} from "./time.js";
describe("time module", () => {
afterEach(() => {
_resetTimeModuleForTest();
});
// ============================
// resolveTimeZone / initTimeModule
// ============================
describe("initTimeModule / resolveTimeZone", () => {
it("defaults to system timezone when no config", () => {
initTimeModule({});
const tz = getActiveTimeZone();
// Should be a valid IANA timezone from the system
expect(tz).toBeTruthy();
expect(() => new Intl.DateTimeFormat("en-US", { timeZone: tz })).not.toThrow();
});
it('resolves "system" to process timezone', () => {
initTimeModule({ timezone: "system" });
const tz = getActiveTimeZone();
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(tz).toBe(systemTz);
});
it("accepts valid IANA timezone names", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
expect(getActiveTimeZone()).toBe("Asia/Shanghai");
});
it("accepts Europe/London (DST-aware timezone)", () => {
initTimeModule({ timezone: "Europe/London" });
expect(getActiveTimeZone()).toBe("Europe/London");
});
it("accepts America/New_York", () => {
initTimeModule({ timezone: "America/New_York" });
expect(getActiveTimeZone()).toBe("America/New_York");
});
it("accepts UTC", () => {
initTimeModule({ timezone: "UTC" });
expect(getActiveTimeZone()).toBe("UTC");
});
it("accepts UTC offset string +08:00", () => {
initTimeModule({ timezone: "+08:00" });
expect(getActiveTimeZone()).toBe("+08:00");
});
it("accepts UTC offset string -05:00", () => {
initTimeModule({ timezone: "-05:00" });
expect(getActiveTimeZone()).toBe("-05:00");
});
it("accepts half-hour offset +05:30 (India)", () => {
initTimeModule({ timezone: "+05:30" });
expect(getActiveTimeZone()).toBe("+05:30");
});
it("accepts +09:30 (Australia/Darwin equivalent)", () => {
initTimeModule({ timezone: "+09:30" });
expect(getActiveTimeZone()).toBe("+09:30");
});
it("accepts +00:00", () => {
initTimeModule({ timezone: "+00:00" });
expect(getActiveTimeZone()).toBe("+00:00");
});
it("falls back to system timezone for invalid string", () => {
const warnings: string[] = [];
initTimeModule({ timezone: "Invalid/FakeZone" }, { warn: (msg) => warnings.push(msg) });
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(getActiveTimeZone()).toBe(systemTz);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain("Invalid timezone");
});
it("falls back to system timezone for empty string", () => {
initTimeModule({ timezone: "" });
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(getActiveTimeZone()).toBe(systemTz);
});
it("falls back for garbage input", () => {
const warnings: string[] = [];
initTimeModule({ timezone: "!!garbage!!" }, { warn: (msg) => warnings.push(msg) });
expect(warnings.length).toBe(1);
});
});
// ============================
// _resetTimeModuleForTest
// ============================
describe("_resetTimeModuleForTest", () => {
it("resets timezone back to UTC", () => {
initTimeModule({ timezone: "Asia/Tokyo" });
expect(getActiveTimeZone()).toBe("Asia/Tokyo");
_resetTimeModuleForTest();
expect(getActiveTimeZone()).toBe("UTC");
});
});
// ============================
// A-type: nowInstantISO
// ============================
describe("nowInstantISO", () => {
it("returns ISO 8601 string with Z suffix", () => {
const result = nowInstantISO();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it("is not affected by timezone configuration", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
const t1 = nowInstantISO();
_resetTimeModuleForTest();
initTimeModule({ timezone: "America/New_York" });
const t2 = nowInstantISO();
// Both should be valid UTC and close in time
const d1 = new Date(t1).getTime();
const d2 = new Date(t2).getTime();
expect(Math.abs(d1 - d2)).toBeLessThan(1000);
});
});
// ============================
// B-type: formatLocalDate
// ============================
describe("formatLocalDate", () => {
it("formats as YYYY-MM-DD in configured timezone", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
// 2026-06-01T20:00:00Z = 2026-06-02 04:00 in CST
const d = new Date("2026-06-01T20:00:00Z");
expect(formatLocalDate(d)).toBe("2026-06-02");
});
it("respects timezone — same instant, different local date", () => {
// 2026-01-01T03:00:00Z
// In UTC: Jan 1
// In Asia/Shanghai (+8): Jan 1 11:00 → Jan 1
// In America/New_York (-5): Dec 31 22:00 → Dec 31
const d = new Date("2026-01-01T03:00:00Z");
initTimeModule({ timezone: "UTC" });
expect(formatLocalDate(d)).toBe("2026-01-01");
_resetTimeModuleForTest();
initTimeModule({ timezone: "Asia/Shanghai" });
expect(formatLocalDate(d)).toBe("2026-01-01");
_resetTimeModuleForTest();
initTimeModule({ timezone: "America/New_York" });
expect(formatLocalDate(d)).toBe("2025-12-31");
});
it("handles DST transition (Europe/London BST)", () => {
initTimeModule({ timezone: "Europe/London" });
// 2026-03-29 is the BST switch day (clocks go forward at 01:00)
// 2026-03-29T00:30:00Z = 00:30 GMT (before switch) → Mar 29
const beforeSwitch = new Date("2026-03-29T00:30:00Z");
expect(formatLocalDate(beforeSwitch)).toBe("2026-03-29");
// 2026-03-29T01:30:00Z = 02:30 BST (after switch) → still Mar 29
const afterSwitch = new Date("2026-03-29T01:30:00Z");
expect(formatLocalDate(afterSwitch)).toBe("2026-03-29");
});
it("uses offset string +05:30 correctly", () => {
initTimeModule({ timezone: "+05:30" });
// 2026-01-01T18:30:00Z = 2026-01-02 00:00 in +05:30
const d = new Date("2026-01-01T18:30:00Z");
expect(formatLocalDate(d)).toBe("2026-01-02");
});
});
// ============================
// B-type: formatLocalDateTime
// ============================
describe("formatLocalDateTime", () => {
it("formats as YYYY-MM-DD HH:mm:ss", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
const d = new Date("2026-03-15T06:30:45Z"); // 14:30:45 in CST
expect(formatLocalDateTime(d)).toBe("2026-03-15 14:30:45");
});
it("handles midnight correctly", () => {
initTimeModule({ timezone: "UTC" });
const d = new Date("2026-06-01T00:00:00Z");
expect(formatLocalDateTime(d)).toBe("2026-06-01 00:00:00");
});
it("handles end of day", () => {
initTimeModule({ timezone: "UTC" });
const d = new Date("2026-06-01T23:59:59Z");
expect(formatLocalDateTime(d)).toBe("2026-06-01 23:59:59");
});
});
// ============================
// B-type: startOfLocalDay
// ============================
describe("startOfLocalDay", () => {
it("returns midnight UTC ms for configured timezone", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
// For 2026-06-02 in CST, midnight = 2026-06-01T16:00:00Z
const d = new Date("2026-06-02T04:00:00Z"); // 12:00 CST on Jun 2
const start = startOfLocalDay(d);
// Midnight CST Jun 2 = UTC Jun 1 16:00
expect(start).toBe(new Date("2026-06-01T16:00:00Z").getTime());
});
it("works for UTC", () => {
initTimeModule({ timezone: "UTC" });
const d = new Date("2026-03-15T10:30:00Z");
const start = startOfLocalDay(d);
expect(start).toBe(new Date("2026-03-15T00:00:00Z").getTime());
});
it("works for negative offset timezone", () => {
initTimeModule({ timezone: "America/New_York" });
// In winter (EST = UTC-5), midnight EST = 05:00 UTC
const d = new Date("2026-01-15T10:00:00Z"); // 05:00 EST
const start = startOfLocalDay(d);
expect(start).toBe(new Date("2026-01-15T05:00:00Z").getTime());
});
});
// ============================
// C-type: formatForLLM
// ============================
describe("formatForLLM", () => {
it("formats Date with timezone offset", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
const d = new Date("2026-04-07T03:04:45Z"); // 11:04:45+08:00
const result = formatForLLM(d);
expect(result).toBe("2026-04-07T11:04:45+08:00");
});
it("formats ISO string input (Z suffix)", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
const result = formatForLLM("2026-04-07T03:04:45.000Z");
expect(result).toBe("2026-04-07T11:04:45+08:00");
});
it("formats Unix millisecond timestamp", () => {
initTimeModule({ timezone: "UTC" });
const ms = new Date("2026-06-01T12:00:00Z").getTime();
const result = formatForLLM(ms);
expect(result).toBe("2026-06-01T12:00:00+00:00");
});
it("handles negative offset (America/New_York winter)", () => {
initTimeModule({ timezone: "America/New_York" });
// 2026-01-15T17:30:00Z = 12:30:00 EST (-05:00)
const result = formatForLLM("2026-01-15T17:30:00Z");
expect(result).toBe("2026-01-15T12:30:00-05:00");
});
it("handles DST (America/New_York summer)", () => {
initTimeModule({ timezone: "America/New_York" });
// 2026-07-15T17:30:00Z = 13:30:00 EDT (-04:00)
const result = formatForLLM("2026-07-15T17:30:00Z");
expect(result).toBe("2026-07-15T13:30:00-04:00");
});
it("handles half-hour offset +05:30", () => {
initTimeModule({ timezone: "+05:30" });
// 2026-01-01T00:00:00Z = 05:30 in +05:30
const result = formatForLLM("2026-01-01T00:00:00Z");
expect(result).toBe("2026-01-01T05:30:00+05:30");
});
it("passes through invalid/unparseable input", () => {
initTimeModule({ timezone: "UTC" });
expect(formatForLLM("not-a-date")).toBe("not-a-date");
});
it("handles old UTC data correctly (backward compat)", () => {
initTimeModule({ timezone: "Europe/Berlin" });
// 2025-12-15T22:00:00.000Z — old data stored as UTC
// Berlin in winter = CET = UTC+1, so 23:00:00+01:00
const result = formatForLLM("2025-12-15T22:00:00.000Z");
expect(result).toBe("2025-12-15T23:00:00+01:00");
});
});
// ============================
// C-type: describeTimeZoneForPrompt
// ============================
describe("describeTimeZoneForPrompt", () => {
it("includes timezone name and offset", () => {
initTimeModule({ timezone: "Asia/Shanghai" });
const desc = describeTimeZoneForPrompt();
expect(desc).toContain("Asia/Shanghai");
expect(desc).toContain("+08:00");
expect(desc).toContain("timestamps");
});
it("includes timezone for UTC", () => {
initTimeModule({ timezone: "UTC" });
const desc = describeTimeZoneForPrompt();
expect(desc).toContain("UTC");
expect(desc).toContain("+00:00");
});
it("works with offset string config", () => {
initTimeModule({ timezone: "+05:30" });
const desc = describeTimeZoneForPrompt();
expect(desc).toContain("+05:30");
});
});
});
+287
View File
@@ -0,0 +1,287 @@
/**
* Unified time module the single source of truth for all timezone-aware
* timestamp formatting in the plugin.
*
* Other modules MUST NOT directly call `toISOString()`, `getHours()`, or
* `Intl.DateTimeFormat` for user/LLM-facing timestamps. Import from here instead.
*
* Design: module-level singleton. `initTimeModule()` is called once during
* plugin registration; all subsequent calls read the resolved timezone.
*/
// ============================
// Internal state
// ============================
let _resolvedTz = "UTC"; // default, overwritten by initTimeModule()
interface Logger {
warn?: (msg: string) => void;
debug?: (msg: string) => void;
}
let _logger: Logger | undefined;
// ============================
// Initialization
// ============================
/**
* Initialize the time module. Called once during plugin register.
* Subsequent hot-reloads also go through here.
*/
export function initTimeModule(cfg: { timezone?: string }, logger?: Logger): void {
_resolvedTz = resolveTimeZone(cfg.timezone, logger);
_logger = logger;
_logger?.debug?.(`[time] Timezone resolved: "${_resolvedTz}"`);
}
/**
* Returns the currently active IANA timezone name (or offset string).
* Useful for diagnostics and prompt generation.
*/
export function getActiveTimeZone(): string {
return _resolvedTz;
}
/**
* @internal test-only reset to pre-init state.
* Avoids cross-test pollution when vitest runs multiple tests in the same process.
*/
export function _resetTimeModuleForTest(): void {
_resolvedTz = "UTC";
_logger = undefined;
}
// ============================
// A-type: UTC instants (for storage)
// ============================
/**
* Current time as UTC ISO 8601 string with "Z" suffix.
* Used for SQLite/TCVDB timestamps, cursors, and any machine-compared instants.
*/
export function nowInstantISO(): string {
return new Date().toISOString();
}
// ============================
// B-type: Local date/datetime (follows configured tz)
// ============================
/**
* Format a Date as "YYYY-MM-DD" in the configured timezone.
* Used for L0 JSONL shard filenames and cleaner day boundaries.
*/
export function formatLocalDate(d: Date = new Date()): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: _resolvedTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(d);
const year = parts.find((p) => p.type === "year")!.value;
const month = parts.find((p) => p.type === "month")!.value;
const day = parts.find((p) => p.type === "day")!.value;
return `${year}-${month}-${day}`;
}
/**
* Format a Date as "YYYY-MM-DD HH:mm:ss" in the configured timezone.
* Used for cleaner audit logs and human-readable local timestamps.
*/
export function formatLocalDateTime(d: Date = new Date()): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: _resolvedTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (type: string) => parts.find((p) => p.type === type)!.value;
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
}
/**
* Compute the start-of-day (00:00:00.000) in the configured timezone for a given date.
* Returns a UTC millisecond timestamp.
* Used by memory-cleaner for cutoff calculations.
*/
export function startOfLocalDay(d: Date = new Date()): number {
// Get the local date components in the configured timezone
const dateStr = formatLocalDate(d);
// Parse as midnight in the configured timezone
// Use a trick: format "YYYY-MM-DDT00:00:00" and find the UTC equivalent
const midnightLocal = new Date(`${dateStr}T00:00:00`);
// We need to find the UTC instant that corresponds to midnight in _resolvedTz.
// Approach: binary search isn't needed — we can use the timezone offset.
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: _resolvedTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
// Start with an estimate: the date at UTC midnight
const utcMidnight = new Date(`${dateStr}T00:00:00Z`);
// Check what local time that corresponds to
const localParts = formatter.formatToParts(utcMidnight);
const localHour = parseInt(localParts.find((p) => p.type === "hour")!.value, 10);
const localMinute = parseInt(localParts.find((p) => p.type === "minute")!.value, 10);
const localDay = localParts.find((p) => p.type === "day")!.value;
const targetDay = dateStr.slice(8, 10); // DD from YYYY-MM-DD
// The offset from UTC to local is: if UTC midnight shows as localHour:localMinute on localDay
// then local midnight = UTC midnight - (localHour*60 + localMinute) minutes
// But we need to handle day boundary crossings
let offsetMinutes = localHour * 60 + localMinute;
if (localDay !== targetDay) {
// Day wrapped — means local is behind UTC (negative offset zones)
// e.g. UTC midnight = previous day 19:00 in America/New_York
offsetMinutes = offsetMinutes - 24 * 60;
}
// Local midnight in UTC = UTC midnight - offset
return utcMidnight.getTime() - offsetMinutes * 60 * 1000;
}
// ============================
// C-type: LLM-facing timestamps (ISO 8601 with offset)
// ============================
/**
* Format a timestamp for LLM consumption: ISO 8601 with explicit UTC offset.
* Example: "2026-04-07T11:04:45+08:00"
*
* Handles:
* - Date objects
* - ISO 8601 strings (with or without "Z")
* - Unix millisecond timestamps (numbers)
*
* Old UTC data ("Z" suffix) is correctly converted to the configured timezone.
*/
export function formatForLLM(input: Date | string | number): string {
const d = input instanceof Date ? input : new Date(input);
if (isNaN(d.getTime())) {
return String(input); // pass-through for unparseable values
}
// Get components in the configured timezone
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: _resolvedTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (type: string) => parts.find((p) => p.type === type)!.value;
const dateTime = `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`;
// Compute UTC offset for this instant in the configured timezone
const offset = getUtcOffset(d);
return `${dateTime}${offset}`;
}
/**
* Generate a timezone declaration string for system prompts.
* Example: "All timestamps below are in Asia/Shanghai (UTC+08:00). When reasoning about time, use this timezone."
*/
export function describeTimeZoneForPrompt(): string {
const offset = getUtcOffset(new Date());
return `All timestamps below are in ${_resolvedTz} (UTC${offset}). When reasoning about "yesterday", "last week", or time differences, use this timezone.`;
}
// ============================
// Internal helpers
// ============================
/**
* Resolve the timezone configuration string to a validated timezone identifier.
*
* Accepts:
* - "system" or undefined process system timezone
* - IANA names: "Asia/Shanghai", "Europe/Berlin", "UTC"
* - UTC offset strings: "+08:00", "-05:30" (ECMA-402 2024)
*
* Invalid values fall back to system timezone with a warning.
*/
function resolveTimeZone(cfg: string | undefined, logger?: Logger): string {
if (!cfg || cfg === "system") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Node 22+ Intl natively supports IANA names and UTC offset strings
// per ECMA-402 2024 — no manual regex/Etc/GMT conversion needed.
if (validateTimeZone(cfg)) return cfg;
logger?.warn?.(`[time] Invalid timezone "${cfg}", falling back to system timezone`);
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/**
* Validate a timezone string using the Intl API.
* Works for IANA names and UTC offset strings ("+05:30", "-08:00").
*/
function validateTimeZone(tz: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Compute the UTC offset string (e.g. "+08:00", "-05:30", "+00:00")
* for a given instant in the configured timezone.
*/
function getUtcOffset(d: Date): string {
// Strategy: compare the "wall clock" time in the target tz vs UTC
// to derive the offset for this specific instant (handles DST).
const utcParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const localParts = new Intl.DateTimeFormat("en-CA", {
timeZone: _resolvedTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const toMinutes = (parts: Intl.DateTimeFormatPart[]) => {
const get = (type: string) => parseInt(parts.find((p) => p.type === type)!.value, 10);
const y = get("year"), mo = get("month"), day = get("day");
const h = get("hour"), mi = get("minute");
// Convert to a comparable minute-of-epoch (approximate, good enough for offset calc)
return ((y * 12 + mo) * 31 + day) * 24 * 60 + h * 60 + mi;
};
const diffMinutes = toMinutes(localParts) - toMinutes(utcParts);
const sign = diffMinutes >= 0 ? "+" : "-";
const abs = Math.abs(diffMinutes);
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
const mm = String(abs % 60).padStart(2, "0");
return `${sign}${hh}:${mm}`;
}