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
|
## [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 |
|
| 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` |
|
| `storeBackend` | `"sqlite"` | Storage backend: `sqlite` |
|
||||||
| `recall.strategy` | `"hybrid"` | Recall strategy: `keyword` / `embedding` / `hybrid` (RRF fusion, recommended) |
|
| `recall.strategy` | `"hybrid"` | Recall strategy: `keyword` / `embedding` / `hybrid` (RRF fusion, recommended) |
|
||||||
| `recall.maxResults` | `5` | Number of items returned per recall |
|
| `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` |
|
| `storeBackend` | `"sqlite"` | 存储后端:`sqlite` |
|
||||||
| `recall.strategy` | `"hybrid"` | 召回策略:`keyword` / `embedding` / `hybrid`(RRF 融合,推荐) |
|
| `recall.strategy` | `"hybrid"` | 召回策略:`keyword` / `embedding` / `hybrid`(RRF 融合,推荐) |
|
||||||
| `recall.maxResults` | `5` | 每次召回条数 |
|
| `recall.maxResults` | `5` | 每次召回条数 |
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { createRequire } from "node:module";
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { parseConfig } from "./src/config.js";
|
import { parseConfig } from "./src/config.js";
|
||||||
import type { MemoryTdaiConfig } 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 { registerOffload } from "./src/offload/index.js";
|
||||||
import {
|
import {
|
||||||
setPreferredEmbeddedAgentRuntime,
|
setPreferredEmbeddedAgentRuntime,
|
||||||
@@ -190,6 +191,9 @@ export default function register(api: OpenClawPluginApi) {
|
|||||||
throw err;
|
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)
|
// Hook policy auto-patch (v2026.4.24+ compat)
|
||||||
// ============================
|
// ============================
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "system",
|
||||||
|
"description": "时区配置:影响所有暴露给用户/LLM 的时间戳和本地日切片。可选值:\"system\"(跟随进程系统时区)、IANA 时区名(如 \"Asia/Shanghai\"、\"Europe/Berlin\")、UTC 偏移串(如 \"+08:00\"、\"-05:30\")。存储时间戳始终为 UTC,此配置仅影响展示层。"
|
||||||
|
},
|
||||||
"storeBackend": {
|
"storeBackend": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["sqlite", "tcvdb"],
|
"enum": ["sqlite", "tcvdb"],
|
||||||
|
|||||||
@@ -269,6 +269,15 @@ export interface OffloadConfig {
|
|||||||
|
|
||||||
/** Fully resolved plugin configuration (v3). */
|
/** Fully resolved plugin configuration (v3). */
|
||||||
export interface MemoryTdaiConfig {
|
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;
|
capture: CaptureConfig;
|
||||||
extraction: ExtractionConfig;
|
extraction: ExtractionConfig;
|
||||||
persona: PersonaConfig;
|
persona: PersonaConfig;
|
||||||
@@ -468,6 +477,7 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
timezone: str(c, "timezone") ?? "system",
|
||||||
capture: {
|
capture: {
|
||||||
enabled: bool(captureGroup, "enabled") ?? true,
|
enabled: bool(captureGroup, "enabled") ?? true,
|
||||||
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
|
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import path from "node:path";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
|
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
|
||||||
import type { Logger } from "../types.js";
|
import type { Logger } from "../types.js";
|
||||||
|
import { formatLocalDate } from "../../utils/time.js";
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Types
|
// Types
|
||||||
@@ -565,12 +566,4 @@ function extractUserAssistantMessages(messages: unknown[]): ConversationMessage[
|
|||||||
return result;
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { formatForLLM } from "../../utils/time.js";
|
||||||
import type { MemoryTdaiConfig } from "../../config.js";
|
import type { MemoryTdaiConfig } from "../../config.js";
|
||||||
import { readSceneIndex } from "../scene/scene-index.js";
|
import { readSceneIndex } from "../scene/scene-index.js";
|
||||||
import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.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")
|
* - 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.
|
* - Returns undefined for empty/invalid inputs.
|
||||||
*/
|
*/
|
||||||
function formatTimestamp(ts: string | undefined): string | undefined {
|
function formatTimestamp(ts: string | undefined): string | undefined {
|
||||||
if (!ts) return 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})?)?/);
|
const match = ts.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2})(?::\d{2})?)?/);
|
||||||
if (!match) return undefined;
|
if (match) {
|
||||||
const datePart = match[1];
|
|
||||||
const timePart = match[2];
|
const timePart = match[2];
|
||||||
if (!timePart || timePart === "00:00") {
|
if (!timePart || timePart === "00:00") {
|
||||||
return datePart;
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { formatForLLM } from "../../utils/time.js";
|
||||||
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
||||||
import { CheckpointManager } from "../../utils/checkpoint.js";
|
import { CheckpointManager } from "../../utils/checkpoint.js";
|
||||||
import { readSceneIndex } from "../scene/scene-index.js";
|
import { readSceneIndex } from "../scene/scene-index.js";
|
||||||
@@ -127,7 +128,7 @@ export class PersonaGenerator {
|
|||||||
// 6. Build prompt
|
// 6. Build prompt
|
||||||
const { systemPrompt, userPrompt } = buildPersonaPrompt({
|
const { systemPrompt, userPrompt } = buildPersonaPrompt({
|
||||||
mode,
|
mode,
|
||||||
currentTime: new Date().toISOString(),
|
currentTime: formatForLLM(new Date()),
|
||||||
totalProcessed: cp.total_processed,
|
totalProcessed: cp.total_processed,
|
||||||
sceneCount: index.length,
|
sceneCount: index.length,
|
||||||
changedSceneCount: changedScenes.length,
|
changedSceneCount: changedScenes.length,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConversationMessage } from "../conversation/l0-recorder.js";
|
import type { ConversationMessage } from "../conversation/l0-recorder.js";
|
||||||
|
import { formatForLLM, describeTimeZoneForPrompt } from "../../utils/time.js";
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// System Prompt
|
// System Prompt
|
||||||
@@ -120,15 +121,17 @@ export function formatExtractionPrompt(params: {
|
|||||||
|
|
||||||
const bgText = backgroundMessages.length > 0
|
const bgText = backgroundMessages.length > 0
|
||||||
? backgroundMessages
|
? 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")
|
.join("\n\n")
|
||||||
: "无";
|
: "无";
|
||||||
|
|
||||||
const newText = newMessages
|
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");
|
.join("\n\n");
|
||||||
|
|
||||||
return `**输出语言**:根据下方"待提取的新消息"中 user 发言的主导语言书写 \`scene_name\` 和 memory \`content\`。
|
return `**${describeTimeZoneForPrompt()}**
|
||||||
|
|
||||||
|
**输出语言**:根据下方"待提取的新消息"中 user 发言的主导语言书写 \`scene_name\` 和 memory \`content\`。
|
||||||
|
|
||||||
【上一个情境】:${previousSceneName}
|
【上一个情境】:${previousSceneName}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import crypto from "node:crypto";
|
|||||||
import type { IMemoryStore } from "../store/types.js";
|
import type { IMemoryStore } from "../store/types.js";
|
||||||
import type { EmbeddingService } from "../store/embedding.js";
|
import type { EmbeddingService } from "../store/embedding.js";
|
||||||
import type { Logger } from "../types.js";
|
import type { Logger } from "../types.js";
|
||||||
|
import { formatLocalDate } from "../../utils/time.js";
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Types
|
// Types
|
||||||
@@ -265,10 +266,3 @@ export async function writeMemory(params: {
|
|||||||
// ============================
|
// ============================
|
||||||
// Helpers
|
// 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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { formatForLLM } from "../../utils/time.js";
|
||||||
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
||||||
import { CheckpointManager } from "../../utils/checkpoint.js";
|
import { CheckpointManager } from "../../utils/checkpoint.js";
|
||||||
import { BackupManager } from "../../utils/backup.js";
|
import { BackupManager } from "../../utils/backup.js";
|
||||||
@@ -179,7 +180,7 @@ export class SceneExtractor {
|
|||||||
const memoriesJson = JSON.stringify(
|
const memoriesJson = JSON.stringify(
|
||||||
memories.map((m) => ({
|
memories.map((m) => ({
|
||||||
content: m.content,
|
content: m.content,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at ? formatForLLM(m.created_at) : m.created_at,
|
||||||
id: m.id ?? "",
|
id: m.id ?? "",
|
||||||
})),
|
})),
|
||||||
null,
|
null,
|
||||||
@@ -475,5 +476,5 @@ export class SceneExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(d: Date): string {
|
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 type { IMemoryStore } from "../core/store/types.js";
|
||||||
import { ManagedTimer } from "./managed-timer.js";
|
import { ManagedTimer } from "./managed-timer.js";
|
||||||
import type { Logger } from "../core/types.js";
|
import type { Logger } from "../core/types.js";
|
||||||
|
import { formatLocalDateTime, startOfLocalDay } from "./time.js";
|
||||||
|
|
||||||
export interface MemoryCleanerOptions {
|
export interface MemoryCleanerOptions {
|
||||||
baseDir: string;
|
baseDir: string;
|
||||||
@@ -314,18 +315,10 @@ function extractShardDateFromFileName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function localDayEndMs(year: number, month: number, day: number): number {
|
function localDayEndMs(year: number, month: number, day: number): number {
|
||||||
const end = new Date(year, month - 1, day, 23, 59, 59, 999);
|
// End of day = start of next day minus 1ms (in configured timezone)
|
||||||
return end.getTime();
|
const nextDay = new Date(Date.UTC(year, month - 1, day + 1));
|
||||||
}
|
const nextDayStartMs = startOfLocalDay(nextDay);
|
||||||
|
return nextDayStartMs - 1;
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUtcOffset(offsetMinutes: number): string {
|
function formatUtcOffset(offsetMinutes: number): string {
|
||||||
@@ -338,11 +331,10 @@ function formatUtcOffset(offsetMinutes: number): string {
|
|||||||
|
|
||||||
function computeCutoffMsByLocalDay(nowMs: number, retentionDays: number): number {
|
function computeCutoffMsByLocalDay(nowMs: number, retentionDays: number): number {
|
||||||
// 自然日策略,保留"今天 + 往前 retentionDays-1 天"
|
// 自然日策略,保留"今天 + 往前 retentionDays-1 天"
|
||||||
// 删除阈值为 keepStart 当天 00:00:00.000(本地时区)
|
// 删除阈值为 keepStart 当天 00:00:00.000(配置时区)
|
||||||
const now = new Date(nowMs);
|
const now = new Date(nowMs);
|
||||||
const keepStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
const todayStartMs = startOfLocalDay(now);
|
||||||
keepStart.setDate(keepStart.getDate() - (retentionDays - 1));
|
const cutoffMs = todayStartMs - (retentionDays - 1) * 24 * 60 * 60 * 1000;
|
||||||
const cutoffMs = keepStart.getTime();
|
|
||||||
|
|
||||||
// Sanity check: cutoff must be strictly in the past
|
// Sanity check: cutoff must be strictly in the past
|
||||||
if (cutoffMs >= nowMs) {
|
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