mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 12:34:27 +00:00
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:
@@ -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
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` | 每次召回条数 |
|
||||
|
||||
@@ -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)
|
||||
// ============================
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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") ?? [],
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
// ============================
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user