From 32ba4d303d94f774ecee52be446860ce77ee2635 Mon Sep 17 00:00:00 2001 From: Ruida Xu <36725184+Xuruida@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:44:01 +0800 Subject: [PATCH] feat(timezone): make all user/LLM-facing timestamps timezone-configurable (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 23 ++ README.md | 1 + README_CN.md | 1 + index.ts | 4 + openclaw.plugin.json | 5 + src/config.ts | 10 + src/core/conversation/l0-recorder.ts | 11 +- src/core/hooks/auto-recall.ts | 24 +- src/core/persona/persona-generator.ts | 3 +- src/core/prompts/l1-extraction.ts | 9 +- src/core/record/l1-writer.ts | 10 +- src/core/scene/scene-extractor.ts | 5 +- src/utils/memory-cleaner.ts | 24 +- src/utils/time.test.ts | 333 ++++++++++++++++++++++++++ src/utils/time.ts | 287 ++++++++++++++++++++++ 15 files changed, 702 insertions(+), 48 deletions(-) create mode 100644 src/utils/time.test.ts create mode 100644 src/utils/time.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d468679..0ad6ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### ✨ 新功能 diff --git a/README.md b/README.md index 46fb9bf..5b412cd 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README_CN.md b/README_CN.md index 15805f2..bb8db19 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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` | 每次召回条数 | diff --git a/index.ts b/index.ts index 4b37233..868a770 100644 --- a/index.ts +++ b/index.ts @@ -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) // ============================ diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 085effe..f0765a2 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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"], diff --git a/src/config.ts b/src/config.ts index 6805499..7587a5a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 | undefined): MemoryTda }; return { + timezone: str(c, "timezone") ?? "system", capture: { enabled: bool(captureGroup, "enabled") ?? true, excludeAgents: strArray(captureGroup, "excludeAgents") ?? [], diff --git a/src/core/conversation/l0-recorder.ts b/src/core/conversation/l0-recorder.ts index c9a75c1..50e904c 100644 --- a/src/core/conversation/l0-recorder.ts +++ b/src/core/conversation/l0-recorder.ts @@ -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}`; -} + diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index 036c1cd..23c9237 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -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]; - const timePart = match[2]; - if (!timePart || timePart === "00:00") { - return datePart; + if (match) { + const timePart = match[2]; + if (!timePart || timePart === "00:00") { + return match[1]; // date-only, no timezone conversion needed + } } - return `${datePart} ${timePart}`; + + return formatForLLM(ts); } /** diff --git a/src/core/persona/persona-generator.ts b/src/core/persona/persona-generator.ts index d0ae93c..cba0590 100644 --- a/src/core/persona/persona-generator.ts +++ b/src/core/persona/persona-generator.ts @@ -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, diff --git a/src/core/prompts/l1-extraction.ts b/src/core/prompts/l1-extraction.ts index 921507d..ec1f602 100644 --- a/src/core/prompts/l1-extraction.ts +++ b/src/core/prompts/l1-extraction.ts @@ -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} diff --git a/src/core/record/l1-writer.ts b/src/core/record/l1-writer.ts index 235c45a..9aeeb09 100644 --- a/src/core/record/l1-writer.ts +++ b/src/core/record/l1-writer.ts @@ -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 @@ -264,11 +265,4 @@ 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}`; -} \ No newline at end of file +// ============================ \ No newline at end of file diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts index 0751f9f..6d8a4b8 100644 --- a/src/core/scene/scene-extractor.ts +++ b/src/core/scene/scene-extractor.ts @@ -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); } diff --git a/src/utils/memory-cleaner.ts b/src/utils/memory-cleaner.ts index f76ccd9..509f4fb 100644 --- a/src/utils/memory-cleaner.ts +++ b/src/utils/memory-cleaner.ts @@ -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) { diff --git a/src/utils/time.test.ts b/src/utils/time.test.ts new file mode 100644 index 0000000..2963690 --- /dev/null +++ b/src/utils/time.test.ts @@ -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"); + }); + }); +}); diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..b495d1e --- /dev/null +++ b/src/utils/time.ts @@ -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}`; +}